Video playback (#308)

* Create media player screen

* Introduce `FileCache` to cache message attachments

* Add file loading functionality into the media provider

* Process tap action on timeline items

* Pass item taps to view model

* Navigate to media player on view model callback

* Commit project file

* Add changelog

* Rename media to video

* Add a loader when large videos being processed

* Add back button explicitly on video screen, fixes for light scheme

* Handle right swipe to dismiss video
This commit is contained in:
ismailgulek
2022-11-14 11:58:18 +03:00
committed by GitHub
parent 8f1e8e6c2a
commit 2e56fe9380
27 changed files with 820 additions and 39 deletions
@@ -0,0 +1,34 @@
//
// Copyright 2022 New Vector Ltd
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
//
import Foundation
import SwiftUI
extension View {
func onSwipeGesture(minimumDistance: CGFloat,
up: @escaping (() -> Void) = { },
down: @escaping (() -> Void) = { },
left: @escaping (() -> Void) = { },
right: @escaping (() -> Void) = { }) -> some View {
gesture(DragGesture(minimumDistance: minimumDistance, coordinateSpace: .local)
.onEnded { value in
if value.translation.width < 0 { left() }
if value.translation.width > 0 { right() }
if value.translation.height < 0 { up() }
if value.translation.height > 0 { down() }
})
}
}
@@ -17,6 +17,7 @@
import SwiftUI
struct RoomScreenCoordinatorParameters {
let navigationRouter: NavigationRouterType
let timelineController: RoomTimelineControllerProtocol
let mediaProvider: MediaProviderProtocol
let roomName: String?
@@ -31,6 +32,7 @@ final class RoomScreenCoordinator: Coordinator, Presentable {
private let parameters: RoomScreenCoordinatorParameters
private let roomScreenHostingController: UIViewController
private var roomScreenViewModel: RoomScreenViewModelProtocol
private var navigationRouter: NavigationRouterType { parameters.navigationRouter }
// MARK: Public
@@ -55,7 +57,17 @@ final class RoomScreenCoordinator: Coordinator, Presentable {
// MARK: - Public
func start() { }
func start() {
MXLog.debug("Did start.")
roomScreenViewModel.callback = { [weak self] result in
guard let self else { return }
MXLog.debug("RoomScreenViewModel did complete with result: \(result).")
switch result {
case .displayVideo(let videoURL):
self.displayVideo(for: videoURL)
}
}
}
func toPresentable() -> UIViewController {
roomScreenHostingController
@@ -64,4 +76,22 @@ final class RoomScreenCoordinator: Coordinator, Presentable {
func stop() {
roomScreenViewModel.stop()
}
// MARK: - Private
private func displayVideo(for videoURL: URL) {
let params = VideoPlayerCoordinatorParameters(videoURL: videoURL)
let coordinator = VideoPlayerCoordinator(parameters: params)
coordinator.callback = { [weak self, weak coordinator] _ in
guard let self, let coordinator = coordinator else { return }
self.navigationRouter.popModule(animated: true)
self.remove(childCoordinator: coordinator)
}
add(childCoordinator: coordinator)
coordinator.start()
navigationRouter.push(coordinator) { [weak self] in
self?.remove(childCoordinator: coordinator)
}
}
}
@@ -17,7 +17,9 @@
import Foundation
import UIKit
enum RoomScreenViewModelAction { }
enum RoomScreenViewModelAction {
case displayVideo(videoURL: URL)
}
enum RoomScreenComposerMode: Equatable {
case `default`
@@ -29,6 +31,7 @@ enum RoomScreenViewAction {
case loadPreviousPage
case itemAppeared(id: String)
case itemDisappeared(id: String)
case itemTapped(id: String)
case linkClicked(url: URL)
case sendMessage
case sendReaction(key: String, eventID: String)
@@ -42,6 +45,7 @@ struct RoomScreenViewState: BindableState {
var roomAvatar: UIImage?
var items: [RoomTimelineViewProvider] = []
var isBackPaginating = false
var showLoading = false
var bindings: RoomScreenViewStateBindings
var contextMenuBuilder: (@MainActor (_ itemId: String) -> TimelineItemContextMenu)?
@@ -81,14 +81,12 @@ class RoomScreenViewModel: RoomScreenViewModelType, RoomScreenViewModelProtocol
}
// MARK: - Public
var callback: ((RoomScreenViewModelAction) -> Void)?
override func process(viewAction: RoomScreenViewAction) async {
switch viewAction {
case .loadPreviousPage:
guard !state.isBackPaginating else {
return
}
switch await timelineController.paginateBackwards(Constants.backPaginationPageSize) {
default:
#warning("Treat errors")
@@ -97,6 +95,8 @@ class RoomScreenViewModel: RoomScreenViewModelType, RoomScreenViewModelProtocol
await timelineController.processItemAppearance(id)
case .itemDisappeared(let id):
await timelineController.processItemDisappearance(id)
case .itemTapped(let id):
await itemTapped(with: id)
case .linkClicked(let url):
MXLog.warning("Link clicked: \(url)")
case .sendMessage:
@@ -118,6 +118,20 @@ class RoomScreenViewModel: RoomScreenViewModelType, RoomScreenViewModelProtocol
}
// MARK: - Private
private func itemTapped(with itemId: String) async {
state.showLoading = true
let action = await timelineController.processItemTap(itemId)
switch action {
case .displayVideo(let videoURL):
callback?(.displayVideo(videoURL: videoURL))
case .none:
break
}
state.showLoading = false
}
private func buildTimelineViews() {
let stateItems = timelineController.timelineItems.map { item in
@@ -18,6 +18,7 @@ import Foundation
@MainActor
protocol RoomScreenViewModelProtocol {
var callback: ((RoomScreenViewModelAction) -> Void)? { get set }
var context: RoomScreenViewModelType.Context { get }
func stop()
@@ -20,30 +20,41 @@ struct RoomScreen: View {
@ObservedObject var context: RoomScreenViewModel.Context
var body: some View {
VStack(spacing: 0.0) {
TimelineView()
.environmentObject(context)
MessageComposer(text: $context.composerText,
focused: $context.composerFocused,
sendingDisabled: context.viewState.sendButtonDisabled,
type: context.viewState.composerMode) {
sendMessage()
} replyCancellationAction: {
context.send(viewAction: .cancelReply)
} editCancellationAction: {
context.send(viewAction: .cancelEdit)
ZStack {
VStack(spacing: 0.0) {
TimelineView()
.environmentObject(context)
MessageComposer(text: $context.composerText,
focused: $context.composerFocused,
sendingDisabled: context.viewState.sendButtonDisabled,
type: context.viewState.composerMode) {
sendMessage()
} replyCancellationAction: {
context.send(viewAction: .cancelReply)
} editCancellationAction: {
context.send(viewAction: .cancelEdit)
}
.padding()
}
.padding()
}
.navigationBarTitleDisplayMode(.inline)
.toolbar {
ToolbarItem(placement: .navigationBarLeading) {
RoomHeaderView(context: context)
.navigationBarTitleDisplayMode(.inline)
.toolbar {
ToolbarItem(placement: .navigationBarLeading) {
RoomHeaderView(context: context)
}
}
.alert(item: $context.alertInfo) { $0.alert }
.sheet(item: $context.debugInfo) { DebugScreen(info: $0) }
if context.viewState.showLoading {
ProgressView()
.progressViewStyle(.circular)
.tint(.element.primaryContent)
.padding(16)
.background(Color.element.quinaryContent)
.cornerRadius(8)
}
}
.alert(item: $context.alertInfo) { $0.alert }
.sheet(item: $context.debugInfo) { DebugScreen(info: $0) }
}
private func sendMessage() {
@@ -76,6 +76,9 @@ struct TimelineItemList: View {
context.send(viewAction: .linkClicked(url: url))
return .systemAction
})
.onTapGesture {
context.send(viewAction: .itemTapped(id: item.id))
}
}
}
}
@@ -173,6 +176,9 @@ struct TimelineItemList: View {
}
private func requestBackPagination() {
guard !context.viewState.isBackPaginating else {
return
}
context.send(viewAction: .loadPreviousPage)
}
@@ -0,0 +1,94 @@
//
// Copyright 2022 New Vector Ltd
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
//
import SwiftUI
struct VideoPlayerCoordinatorParameters {
let videoURL: URL
}
enum VideoPlayerCoordinatorAction {
case cancel
}
final class VideoPlayerCoordinator: Coordinator, Presentable {
// MARK: - Properties
// MARK: Private
private let parameters: VideoPlayerCoordinatorParameters
private let videoPlayerHostingController: UIViewController
private var videoPlayerViewModel: VideoPlayerViewModelProtocol
private var indicatorPresenter: UserIndicatorTypePresenterProtocol
private var activityIndicator: UserIndicator?
// MARK: Public
// Must be used only internally
var childCoordinators: [Coordinator] = []
var callback: ((VideoPlayerCoordinatorAction) -> Void)?
// MARK: - Setup
init(parameters: VideoPlayerCoordinatorParameters) {
self.parameters = parameters
let viewModel = VideoPlayerViewModel(videoURL: parameters.videoURL)
let view = VideoPlayerScreen(context: viewModel.context)
videoPlayerViewModel = viewModel
videoPlayerHostingController = UIHostingController(rootView: view)
indicatorPresenter = UserIndicatorTypePresenter(presentingViewController: videoPlayerHostingController)
}
// MARK: - Public
func start() {
MXLog.debug("Did start.")
videoPlayerViewModel.callback = { [weak self] action in
guard let self else { return }
MXLog.debug("VideoPlayerViewModel did complete with result: \(action).")
switch action {
case .cancel:
self.callback?(.cancel)
}
}
}
func toPresentable() -> UIViewController {
videoPlayerHostingController
}
func stop() {
stopLoading()
}
// MARK: - Private
/// Show an activity indicator whilst loading.
/// - Parameters:
/// - label: The label to show on the indicator.
/// - isInteractionBlocking: Whether the indicator should block any user interaction.
private func startLoading(label: String = ElementL10n.loading, isInteractionBlocking: Bool = true) {
activityIndicator = indicatorPresenter.present(.loading(label: label, isInteractionBlocking: isInteractionBlocking))
}
/// Hide the currently displayed activity indicator.
private func stopLoading() {
activityIndicator = nil
}
}
@@ -0,0 +1,36 @@
//
// Copyright 2022 New Vector Ltd
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
//
import Foundation
// MARK: - Coordinator
// MARK: View model
enum VideoPlayerViewModelAction {
case cancel
}
// MARK: View
struct VideoPlayerViewState: BindableState {
var videoURL: URL
var autoplay: Bool
}
enum VideoPlayerViewAction {
case cancel
}
@@ -0,0 +1,45 @@
//
// Copyright 2022 New Vector Ltd
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
//
import SwiftUI
typealias VideoPlayerViewModelType = StateStoreViewModel<VideoPlayerViewState, VideoPlayerViewAction>
class VideoPlayerViewModel: VideoPlayerViewModelType, VideoPlayerViewModelProtocol {
// MARK: - Properties
// MARK: Private
// MARK: Public
var callback: ((VideoPlayerViewModelAction) -> Void)?
// MARK: - Setup
init(videoURL: URL, autoplay: Bool = true) {
super.init(initialViewState: VideoPlayerViewState(videoURL: videoURL,
autoplay: autoplay))
}
// MARK: - Public
override func process(viewAction: VideoPlayerViewAction) async {
switch viewAction {
case .cancel:
callback?(.cancel)
}
}
}
@@ -0,0 +1,23 @@
//
// Copyright 2022 New Vector Ltd
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
//
import Foundation
@MainActor
protocol VideoPlayerViewModelProtocol {
var callback: ((VideoPlayerViewModelAction) -> Void)? { get set }
var context: VideoPlayerViewModelType.Context { get }
}
@@ -0,0 +1,68 @@
//
// Copyright 2022 New Vector Ltd
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
//
import AVKit
import SwiftUI
struct VideoPlayerScreen: View {
// MARK: Public
@ObservedObject var context: VideoPlayerViewModel.Context
// MARK: Views
var body: some View {
VideoPlayer(player: player())
.ignoresSafeArea()
.navigationBarTitleDisplayMode(.inline)
.navigationBarBackButtonHidden()
.toolbar {
ToolbarItem(placement: .navigationBarLeading) {
Button {
context.send(viewAction: .cancel)
} label: {
Image(systemName: "chevron.backward")
.foregroundColor(.white)
.fontWeight(.semibold)
}
}
}
.onSwipeGesture(minimumDistance: 3.0, right: {
context.send(viewAction: .cancel)
})
}
private func player() -> AVPlayer {
let player = AVPlayer(url: context.viewState.videoURL)
if context.viewState.autoplay {
player.play()
}
return player
}
}
// MARK: - Previews
struct VideoPlayer_Previews: PreviewProvider {
static var previews: some View {
Group {
let viewModel = VideoPlayerViewModel(videoURL: URL(staticString: "https://storage.googleapis.com/gtv-videos-bucket/sample/BigBuckBunny.mp4"),
autoplay: false)
VideoPlayerScreen(context: viewModel.context)
}
.tint(.element.accent)
}
}
@@ -0,0 +1,89 @@
//
// Copyright 2022 New Vector Ltd
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
//
import Foundation
// MARK: - FileCacheProtocol
protocol FileCacheProtocol {
func file(forKey key: String, fileExtension: String) -> URL?
func store(_ data: Data, with fileExtension: String, forKey key: String) throws -> URL
func remove(forKey key: String, fileExtension: String) throws
func removeAll() throws
}
// MARK: - FileCache
/// Implementation of `FileCacheProtocol` under `FileManager.default.temporaryDirectory`.
class FileCache {
private let fileManager = FileManager.default
private let folder: URL
/// Default instance. Uses `FileCache` as the folder name.
static let `default` = FileCache(folderName: "FileCache")
init(folderName: String) {
folder = fileManager.temporaryDirectory.appending(path: folderName, directoryHint: .isDirectory)
}
// MARK: Private
private func filePath(forKey key: String, fileExtension: String) -> URL {
folder.appending(path: key, directoryHint: .notDirectory).appendingPathExtension(fileExtension)
}
private func folderExists() -> Bool {
var isDirectory: ObjCBool = false
guard fileManager.fileExists(atPath: folder.path(), isDirectory: &isDirectory) else {
return false
}
return isDirectory.boolValue
}
private func createFolderIfNeeded() throws {
guard !folderExists() else {
return
}
try fileManager.createDirectory(at: folder, withIntermediateDirectories: true)
}
}
// MARK: - FileCacheProtocol
extension FileCache: FileCacheProtocol {
func file(forKey key: String, fileExtension: String) -> URL? {
let url = filePath(forKey: key, fileExtension: fileExtension)
return fileManager.isReadableFile(atPath: url.path()) ? url : nil
}
func store(_ data: Data, with fileExtension: String, forKey key: String) throws -> URL {
try createFolderIfNeeded()
let url = filePath(forKey: key, fileExtension: fileExtension)
try data.write(to: url)
return url
}
func remove(forKey key: String, fileExtension: String) throws {
try fileManager.removeItem(at: filePath(forKey: key, fileExtension: fileExtension))
}
func removeAll() throws {
guard folderExists() else {
return
}
try fileManager.removeItem(at: folder)
}
}
@@ -20,13 +20,16 @@ import UIKit
struct MediaProvider: MediaProviderProtocol {
private let clientProxy: ClientProxyProtocol
private let imageCache: Kingfisher.ImageCache
private let fileCache: FileCache
private let backgroundTaskService: BackgroundTaskServiceProtocol
init(clientProxy: ClientProxyProtocol,
imageCache: Kingfisher.ImageCache,
fileCache: FileCache,
backgroundTaskService: BackgroundTaskServiceProtocol) {
self.clientProxy = clientProxy
self.imageCache = imageCache
self.fileCache = fileCache
self.backgroundTaskService = backgroundTaskService
}
@@ -88,6 +91,51 @@ struct MediaProvider: MediaProviderProtocol {
return .failure(.failedRetrievingImage)
}
}
func fileFromSource(_ source: MediaSource?, fileExtension: String) -> URL? {
guard let source else {
return nil
}
let cacheKey = fileCacheKeyForURLString(source.underlyingSource.url())
return fileCache.file(forKey: cacheKey, fileExtension: fileExtension)
}
@discardableResult func loadFileFromSource(_ source: MediaSource, fileExtension: String) async -> Result<URL, MediaProviderError> {
if let url = fileFromSource(source, fileExtension: fileExtension) {
return .success(url)
}
let loadFileBgTask = await backgroundTaskService.startBackgroundTask(withName: "LoadFile: \(source.url.hashValue)")
defer {
loadFileBgTask?.stop()
}
let cacheKey = fileCacheKeyForURLString(source.url)
do {
let data = try await clientProxy.loadMediaContentForSource(source.underlyingSource)
let url = try fileCache.store(data, with: fileExtension, forKey: cacheKey)
return .success(url)
} catch {
MXLog.error("Failed retrieving file with error: \(error)")
return .failure(.failedRetrievingImage)
}
}
func fileFromURLString(_ urlString: String?, fileExtension: String) -> URL? {
guard let urlString else {
return nil
}
return fileFromSource(MediaSource(source: clientProxy.mediaSourceForURLString(urlString)),
fileExtension: fileExtension)
}
func loadFileFromURLString(_ urlString: String, fileExtension: String) async -> Result<URL, MediaProviderError> {
await loadFileFromSource(MediaSource(source: clientProxy.mediaSourceForURLString(urlString)),
fileExtension: fileExtension)
}
// MARK: - Private
@@ -98,6 +146,13 @@ struct MediaProvider: MediaProviderProtocol {
return urlString
}
}
private func fileCacheKeyForURLString(_ urlString: String) -> String {
guard let component = urlString.split(separator: "/").last else {
return urlString
}
return String(component)
}
}
private extension ImageCache {
@@ -19,6 +19,7 @@ import UIKit
enum MediaProviderError: Error {
case failedRetrievingImage
case failedRetrievingFile
case invalidImageData
}
@@ -30,6 +31,14 @@ protocol MediaProviderProtocol {
func imageFromURLString(_ urlString: String?, avatarSize: AvatarSize?) -> UIImage?
@discardableResult func loadImageFromURLString(_ urlString: String, avatarSize: AvatarSize?) async -> Result<UIImage, MediaProviderError>
func fileFromSource(_ source: MediaSource?, fileExtension: String) -> URL?
@discardableResult func loadFileFromSource(_ source: MediaSource, fileExtension: String) async -> Result<URL, MediaProviderError>
func fileFromURLString(_ urlString: String?, fileExtension: String) -> URL?
@discardableResult func loadFileFromURLString(_ urlString: String, fileExtension: String) async -> Result<URL, MediaProviderError>
}
extension MediaProviderProtocol {
@@ -49,4 +49,20 @@ struct MockMediaProvider: MediaProviderProtocol {
return .success(image)
}
func fileFromSource(_ source: MediaSource?, fileExtension: String) -> URL? {
nil
}
@discardableResult func loadFileFromSource(_ source: MediaSource, fileExtension: String) async -> Result<URL, MediaProviderError> {
.failure(.failedRetrievingFile)
}
func fileFromURLString(_ urlString: String?, fileExtension: String) -> URL? {
nil
}
@discardableResult func loadFileFromURLString(_ urlString: String, fileExtension: String) async -> Result<URL, MediaProviderError> {
.failure(.failedRetrievingFile)
}
}
@@ -109,6 +109,8 @@ class MockRoomTimelineController: RoomTimelineControllerProtocol {
func processItemAppearance(_ itemId: String) async { }
func processItemDisappearance(_ itemId: String) async { }
func processItemTap(_ itemId: String) async -> RoomTimelineControllerAction { .none }
func sendMessage(_ message: String) async { }
@@ -98,15 +98,36 @@ class RoomTimelineController: RoomTimelineControllerProtocol {
switch timelineItem {
case let item as ImageRoomTimelineItem:
await loadImageForImageTimelineItem(item)
await loadThumbnailForImageTimelineItem(item)
case let item as VideoRoomTimelineItem:
await loadImageForVideoTimelineItem(item)
await loadThumbnailForVideoTimelineItem(item)
default:
break
}
}
func processItemDisappearance(_ itemId: String) { }
func processItemTap(_ itemId: String) async -> RoomTimelineControllerAction {
guard let timelineItem = timelineItems.first(where: { $0.id == itemId }) else {
return .none
}
switch timelineItem {
case let item as VideoRoomTimelineItem:
await loadVideoForTimelineItem(item)
guard let index = timelineItems.firstIndex(where: { $0.id == itemId }),
let item = timelineItems[index] as? VideoRoomTimelineItem else {
return .none
}
if let videoURL = item.cachedVideoURL {
return .displayVideo(videoURL: videoURL)
}
return .none
default:
return .none
}
}
func sendMessage(_ message: String) async {
switch await timelineProvider.sendMessage(message) {
@@ -244,7 +265,7 @@ class RoomTimelineController: RoomTimelineControllerProtocol {
return .middle
}
private func loadImageForImageTimelineItem(_ timelineItem: ImageRoomTimelineItem) async {
private func loadThumbnailForImageTimelineItem(_ timelineItem: ImageRoomTimelineItem) async {
if timelineItem.image != nil {
return
}
@@ -268,7 +289,7 @@ class RoomTimelineController: RoomTimelineControllerProtocol {
}
}
private func loadImageForVideoTimelineItem(_ timelineItem: VideoRoomTimelineItem) async {
private func loadThumbnailForVideoTimelineItem(_ timelineItem: VideoRoomTimelineItem) async {
if timelineItem.image != nil {
return
}
@@ -291,6 +312,32 @@ class RoomTimelineController: RoomTimelineControllerProtocol {
break
}
}
private func loadVideoForTimelineItem(_ timelineItem: VideoRoomTimelineItem) async {
if timelineItem.cachedVideoURL != nil {
// already cached
return
}
guard let source = timelineItem.source else {
return
}
// This is not great. We could better estimate file extension from the mimetype.
let fileExtension = String(timelineItem.text.split(separator: ".").last ?? "mp4")
switch await mediaProvider.loadFileFromSource(source, fileExtension: fileExtension) {
case .success(let fileURL):
guard let index = timelineItems.firstIndex(where: { $0.id == timelineItem.id }),
var item = timelineItems[index] as? VideoRoomTimelineItem else {
return
}
item.cachedVideoURL = fileURL
timelineItems[index] = item
case .failure:
break
}
}
private func loadUserAvatarForTimelineItem(_ timelineItem: EventBasedTimelineItemProtocol) async {
if timelineItem.shouldShowSenderDetails == false {
@@ -24,6 +24,11 @@ enum RoomTimelineControllerCallback {
case finishedBackPaginating
}
enum RoomTimelineControllerAction {
case displayVideo(videoURL: URL)
case none
}
enum RoomTimelineControllerError: Error {
case generic
}
@@ -38,6 +43,8 @@ protocol RoomTimelineControllerProtocol {
func processItemAppearance(_ itemId: String) async
func processItemDisappearance(_ itemId: String) async
func processItemTap(_ itemId: String) async -> RoomTimelineControllerAction
func paginateBackwards(_ count: UInt) async -> Result<Void, RoomTimelineControllerError>
@@ -33,6 +33,7 @@ struct VideoRoomTimelineItem: EventBasedTimelineItemProtocol, Identifiable, Equa
let source: MediaSource?
let thumbnailSource: MediaSource?
var image: UIImage?
var cachedVideoURL: URL?
var width: CGFloat?
var height: CGFloat?
@@ -158,13 +158,15 @@ class UserSessionFlowCoordinator: Coordinator {
mediaProvider: userSession.mediaProvider,
roomProxy: roomProxy)
let parameters = RoomScreenCoordinatorParameters(timelineController: timelineController,
let parameters = RoomScreenCoordinatorParameters(navigationRouter: navigationRouter,
timelineController: timelineController,
mediaProvider: userSession.mediaProvider,
roomName: roomProxy.displayName ?? roomProxy.name,
roomAvatarUrl: roomProxy.avatarURL)
let coordinator = RoomScreenCoordinator(parameters: parameters)
add(childCoordinator: coordinator)
coordinator.start()
navigationRouter.push(coordinator) { [weak self] in
guard let self else { return }
self.stateMachine.processEvent(.dismissedRoomScreen)
@@ -60,6 +60,7 @@ class UserSessionStore: UserSessionStoreProtocol {
return .success(UserSession(clientProxy: clientProxy,
mediaProvider: MediaProvider(clientProxy: clientProxy,
imageCache: .onlyInMemory,
fileCache: .default,
backgroundTaskService: backgroundTaskService)))
case .failure(let error):
MXLog.error("Failed restoring login with error: \(error)")
@@ -78,6 +79,7 @@ class UserSessionStore: UserSessionStoreProtocol {
return .success(UserSession(clientProxy: clientProxy,
mediaProvider: MediaProvider(clientProxy: clientProxy,
imageCache: .onlyInMemory,
fileCache: .default,
backgroundTaskService: backgroundTaskService)))
case .failure(let error):
MXLog.error("Failed creating user session with error: \(error)")
@@ -115,13 +115,15 @@ class MockScreen: Identifiable {
case .splash:
return SplashScreenCoordinator()
case .roomPlainNoAvatar:
let parameters = RoomScreenCoordinatorParameters(timelineController: MockRoomTimelineController(),
let parameters = RoomScreenCoordinatorParameters(navigationRouter: navigationRouter,
timelineController: MockRoomTimelineController(),
mediaProvider: MockMediaProvider(),
roomName: "Some room name",
roomAvatarUrl: nil)
return RoomScreenCoordinator(parameters: parameters)
case .roomEncryptedWithAvatar:
let parameters = RoomScreenCoordinatorParameters(timelineController: MockRoomTimelineController(),
let parameters = RoomScreenCoordinatorParameters(navigationRouter: navigationRouter,
timelineController: MockRoomTimelineController(),
mediaProvider: MockMediaProvider(),
roomName: "Some room name",
roomAvatarUrl: "mock_url")