mirror of
https://github.com/Novaloop-AG/element-x-ios.git
synced 2026-11-04 01:20:12 +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,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)")
|
||||
|
||||
Reference in New Issue
Block a user