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,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)")