mirror of
https://github.com/Novaloop-AG/element-x-ios.git
synced 2026-11-03 06:55:59 +00:00
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:
@@ -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")
|
||||
|
||||
Reference in New Issue
Block a user