mirror of
https://github.com/Novaloop-AG/element-x-ios.git
synced 2026-11-03 16:10:20 +00:00
QuickLook media. (#447)
* Use QL previews for video and present full screen. * Use URL(staticString:) in more places. * Fix DesignKit issues.
This commit is contained in:
@@ -90,6 +90,28 @@ class NavigationSplitCoordinator: CoordinatorProtocol, ObservableObject, CustomS
|
||||
sheetModule?.coordinator
|
||||
}
|
||||
|
||||
@Published fileprivate var fullScreenCoverModule: NavigationModule? {
|
||||
didSet {
|
||||
if let oldValue {
|
||||
logPresentationChange("Remove fullscreen cover", oldValue)
|
||||
oldValue.coordinator.stop()
|
||||
oldValue.dismissalCallback?()
|
||||
}
|
||||
|
||||
if let fullScreenCoverModule {
|
||||
logPresentationChange("Set fullscreen cover", fullScreenCoverModule)
|
||||
fullScreenCoverModule.coordinator.start()
|
||||
}
|
||||
|
||||
updateCompactLayoutComponents()
|
||||
}
|
||||
}
|
||||
|
||||
/// The currently displayed fullscreen cover coordinator
|
||||
var fullScreenCoverCoordinator: (any CoordinatorProtocol)? {
|
||||
fullScreenCoverModule?.coordinator
|
||||
}
|
||||
|
||||
@Published fileprivate var compactLayoutRootModule: NavigationModule?
|
||||
|
||||
var compactLayoutRootCoordinator: (any CoordinatorProtocol)? {
|
||||
@@ -148,6 +170,19 @@ class NavigationSplitCoordinator: CoordinatorProtocol, ObservableObject, CustomS
|
||||
|
||||
sheetModule = NavigationModule(coordinator, dismissalCallback: dismissalCallback)
|
||||
}
|
||||
|
||||
/// Present a fullscreen cover on top of the split view
|
||||
/// - Parameters:
|
||||
/// - coordinator: the coordinator to display
|
||||
/// - dismissalCallback: called when the fullscreen cover has been dismissed, programatically or otherwise
|
||||
func setFullScreenCoverCoordinator(_ coordinator: (any CoordinatorProtocol)?, dismissalCallback: (() -> Void)? = nil) {
|
||||
guard let coordinator else {
|
||||
fullScreenCoverModule = nil
|
||||
return
|
||||
}
|
||||
|
||||
fullScreenCoverModule = NavigationModule(coordinator, dismissalCallback: dismissalCallback)
|
||||
}
|
||||
|
||||
// MARK: - CoordinatorProtocol
|
||||
|
||||
@@ -301,6 +336,11 @@ private struct NavigationSplitCoordinatorView: View {
|
||||
// through the NavigationSplitCoordinator as well.
|
||||
.sheet(item: $navigationSplitCoordinator.sheetModule) { module in
|
||||
module.coordinator.toPresentable()
|
||||
.tint(.element.accent)
|
||||
}
|
||||
.fullScreenCover(item: $navigationSplitCoordinator.fullScreenCoverModule) { module in
|
||||
module.coordinator.toPresentable()
|
||||
.tint(.element.accent)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -389,6 +429,31 @@ class NavigationStackCoordinator: ObservableObject, CoordinatorProtocol, CustomS
|
||||
return sheetModule?.coordinator
|
||||
}
|
||||
|
||||
@Published fileprivate var fullScreenCoverModule: NavigationModule? {
|
||||
didSet {
|
||||
if let oldValue {
|
||||
logPresentationChange("Remove fullscreen cover", oldValue)
|
||||
oldValue.coordinator.stop()
|
||||
oldValue.dismissalCallback?()
|
||||
}
|
||||
|
||||
if let fullScreenCoverModule {
|
||||
logPresentationChange("Set fullscreen cover", fullScreenCoverModule)
|
||||
fullScreenCoverModule.coordinator.start()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// The currently presented fullscreen cover coordinator
|
||||
// Fullscreen covers will be presented through the NavigationSplitCoordinator if provided
|
||||
var fullScreenCoverCoordinator: (any CoordinatorProtocol)? {
|
||||
if let navigationSplitCoordinator {
|
||||
return navigationSplitCoordinator.fullScreenCoverCoordinator
|
||||
}
|
||||
|
||||
return fullScreenCoverModule?.coordinator
|
||||
}
|
||||
|
||||
@Published fileprivate var stackModules = [NavigationModule]() {
|
||||
didSet {
|
||||
let diffs = stackModules.difference(from: oldValue)
|
||||
@@ -488,6 +553,25 @@ class NavigationStackCoordinator: ObservableObject, CoordinatorProtocol, CustomS
|
||||
sheetModule = NavigationModule(coordinator, dismissalCallback: dismissalCallback)
|
||||
}
|
||||
|
||||
/// Present a fullscreen cover on top of the stack. If this NavigationStackCoordinator is embedded within a NavigationSplitCoordinator
|
||||
/// then the presentation will be proxied to the split
|
||||
/// - Parameters:
|
||||
/// - coordinator: the coordinator to display
|
||||
/// - dismissalCallback: called when the fullscreen cover has been dismissed, programatically or otherwise
|
||||
func setFullScreenCoverCoordinator(_ coordinator: (any CoordinatorProtocol)?, dismissalCallback: (() -> Void)? = nil) {
|
||||
if let navigationSplitCoordinator {
|
||||
navigationSplitCoordinator.setFullScreenCoverCoordinator(coordinator, dismissalCallback: dismissalCallback)
|
||||
return
|
||||
}
|
||||
|
||||
guard let coordinator else {
|
||||
fullScreenCoverModule = nil
|
||||
return
|
||||
}
|
||||
|
||||
fullScreenCoverModule = NavigationModule(coordinator, dismissalCallback: dismissalCallback)
|
||||
}
|
||||
|
||||
// MARK: - CoordinatorProtocol
|
||||
|
||||
func toPresentable() -> AnyView {
|
||||
@@ -524,6 +608,11 @@ private struct NavigationStackCoordinatorView: View {
|
||||
}
|
||||
.sheet(item: $navigationStackCoordinator.sheetModule) { module in
|
||||
module.coordinator.toPresentable()
|
||||
.tint(.element.accent)
|
||||
}
|
||||
.fullScreenCover(item: $navigationStackCoordinator.fullScreenCoverModule) { module in
|
||||
module.coordinator.toPresentable()
|
||||
.tint(.element.accent)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -64,10 +64,7 @@ extension LoginHomeserver {
|
||||
|
||||
/// A mock homeserver that supports only supports authentication via a single SSO provider.
|
||||
static var mockOIDC: LoginHomeserver {
|
||||
guard let issuerURL = URL(string: "https://auth.company.com") else {
|
||||
fatalError("This shoud never fail parsing")
|
||||
}
|
||||
|
||||
let issuerURL = URL(staticString: "https://auth.company.com")
|
||||
return LoginHomeserver(address: "company.com", loginMode: .oidc(issuerURL))
|
||||
}
|
||||
|
||||
|
||||
@@ -22,19 +22,27 @@ struct FilePreviewScreen: View {
|
||||
@ObservedObject var context: FilePreviewViewModel.Context
|
||||
|
||||
var body: some View {
|
||||
PreviewController(fileURL: context.viewState.fileURL, title: context.viewState.title)
|
||||
.ignoresSafeArea(.all, edges: [.horizontal, .bottom])
|
||||
.navigationTitle(ElementL10n.attachmentTypeFile)
|
||||
PreviewView(context: context,
|
||||
fileURL: context.viewState.fileURL,
|
||||
title: context.viewState.title)
|
||||
.ignoresSafeArea()
|
||||
}
|
||||
}
|
||||
|
||||
private struct PreviewController: UIViewControllerRepresentable {
|
||||
private struct PreviewView: UIViewControllerRepresentable {
|
||||
let context: FilePreviewViewModel.Context
|
||||
let fileURL: URL
|
||||
let title: String?
|
||||
|
||||
func makeUIViewController(context: Context) -> UINavigationController {
|
||||
let controller = QLPreviewController()
|
||||
controller.dataSource = context.coordinator
|
||||
|
||||
let doneButton = UIBarButtonItem(title: "Done",
|
||||
style: .done,
|
||||
target: context.coordinator,
|
||||
action: #selector(Coordinator.done))
|
||||
controller.navigationItem.rightBarButtonItem = doneButton
|
||||
|
||||
return UINavigationController(rootViewController: controller)
|
||||
}
|
||||
@@ -42,22 +50,28 @@ private struct PreviewController: UIViewControllerRepresentable {
|
||||
func updateUIViewController(_ uiViewController: UINavigationController, context: Context) { }
|
||||
|
||||
func makeCoordinator() -> Coordinator {
|
||||
Coordinator(parent: self)
|
||||
Coordinator(view: self)
|
||||
}
|
||||
|
||||
class Coordinator: NSObject, QLPreviewControllerDataSource {
|
||||
let view: PreviewView
|
||||
|
||||
class Coordinator: QLPreviewControllerDataSource {
|
||||
let parent: PreviewController
|
||||
|
||||
init(parent: PreviewController) {
|
||||
self.parent = parent
|
||||
init(view: PreviewView) {
|
||||
self.view = view
|
||||
}
|
||||
|
||||
@objc func done() {
|
||||
Task { await view.context.send(viewAction: .cancel) }
|
||||
}
|
||||
|
||||
// MARK: - QLPreviewControllerDataSource
|
||||
|
||||
func numberOfPreviewItems(in controller: QLPreviewController) -> Int {
|
||||
1
|
||||
}
|
||||
|
||||
func previewController(_ controller: QLPreviewController, previewItemAt index: Int) -> QLPreviewItem {
|
||||
PreviewItem(previewItemURL: parent.fileURL, previewItemTitle: parent.title)
|
||||
PreviewItem(previewItemURL: view.fileURL, previewItemTitle: view.title)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -55,9 +55,7 @@ final class RoomScreenCoordinator: CoordinatorProtocol {
|
||||
switch result {
|
||||
case .displayRoomDetails:
|
||||
self.displayRoomDetails()
|
||||
case .displayVideo(let videoURL):
|
||||
self.displayVideo(for: videoURL)
|
||||
case .displayFile(let fileURL, let title):
|
||||
case .displayVideo(let fileURL, let title), .displayFile(let fileURL, let title):
|
||||
self.displayFile(for: fileURL, with: title)
|
||||
case .displayEmojiPicker(let itemId):
|
||||
self.displayEmojiPickerScreen(for: itemId)
|
||||
@@ -81,25 +79,14 @@ final class RoomScreenCoordinator: CoordinatorProtocol {
|
||||
|
||||
// MARK: - Private
|
||||
|
||||
private func displayVideo(for videoURL: URL) {
|
||||
let params = VideoPlayerCoordinatorParameters(videoURL: videoURL)
|
||||
let coordinator = VideoPlayerCoordinator(parameters: params)
|
||||
|
||||
coordinator.callback = { [weak self] _ in
|
||||
self?.navigationStackCoordinator.pop()
|
||||
}
|
||||
|
||||
navigationStackCoordinator.push(coordinator)
|
||||
}
|
||||
|
||||
private func displayFile(for fileURL: URL, with title: String?) {
|
||||
let params = FilePreviewCoordinatorParameters(fileURL: fileURL, title: title)
|
||||
let coordinator = FilePreviewCoordinator(parameters: params)
|
||||
coordinator.callback = { [weak self] _ in
|
||||
self?.navigationStackCoordinator.pop()
|
||||
self?.navigationStackCoordinator.setFullScreenCoverCoordinator(nil)
|
||||
}
|
||||
|
||||
navigationStackCoordinator.push(coordinator)
|
||||
navigationStackCoordinator.setFullScreenCoverCoordinator(coordinator)
|
||||
}
|
||||
|
||||
private func displayEmojiPickerScreen(for itemId: String) {
|
||||
|
||||
@@ -19,7 +19,7 @@ import UIKit
|
||||
|
||||
enum RoomScreenViewModelAction {
|
||||
case displayRoomDetails
|
||||
case displayVideo(videoURL: URL)
|
||||
case displayVideo(videoURL: URL, title: String?)
|
||||
case displayFile(fileURL: URL, title: String?)
|
||||
case displayEmojiPicker(itemId: String)
|
||||
}
|
||||
|
||||
@@ -140,8 +140,8 @@ class RoomScreenViewModel: RoomScreenViewModelType, RoomScreenViewModelProtocol
|
||||
let action = await timelineController.processItemTap(itemId)
|
||||
|
||||
switch action {
|
||||
case .displayVideo(let videoURL):
|
||||
callback?(.displayVideo(videoURL: videoURL))
|
||||
case .displayVideo(let videoURL, let title):
|
||||
callback?(.displayVideo(videoURL: videoURL, title: title))
|
||||
case .displayFile(let fileURL, let title):
|
||||
callback?(.displayFile(fileURL: fileURL, title: title))
|
||||
case .none:
|
||||
|
||||
@@ -1,83 +0,0 @@
|
||||
//
|
||||
// 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 VideoPlayerCoordinatorParameters {
|
||||
let videoURL: URL
|
||||
}
|
||||
|
||||
enum VideoPlayerCoordinatorAction {
|
||||
case cancel
|
||||
}
|
||||
|
||||
final class VideoPlayerCoordinator: CoordinatorProtocol {
|
||||
private let parameters: VideoPlayerCoordinatorParameters
|
||||
private var viewModel: VideoPlayerViewModelProtocol
|
||||
|
||||
var callback: ((VideoPlayerCoordinatorAction) -> Void)?
|
||||
|
||||
init(parameters: VideoPlayerCoordinatorParameters) {
|
||||
self.parameters = parameters
|
||||
|
||||
viewModel = VideoPlayerViewModel(videoURL: parameters.videoURL,
|
||||
autoplay: UIApplication.shared.applicationState == .active)
|
||||
}
|
||||
|
||||
// MARK: - Public
|
||||
|
||||
func start() {
|
||||
configureAudioSession(.sharedInstance())
|
||||
|
||||
viewModel.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 stop() {
|
||||
deconfigureAudioSession(.sharedInstance())
|
||||
}
|
||||
|
||||
func toPresentable() -> AnyView {
|
||||
AnyView(VideoPlayerScreen(context: viewModel.context))
|
||||
}
|
||||
|
||||
// MARK: - Private
|
||||
|
||||
private func configureAudioSession(_ session: AVAudioSession) {
|
||||
do {
|
||||
try session.setCategory(.playback,
|
||||
mode: .default)
|
||||
try session.setActive(true)
|
||||
} catch {
|
||||
MXLog.debug("Configure audio session failed: \(error)")
|
||||
}
|
||||
}
|
||||
|
||||
private func deconfigureAudioSession(_ session: AVAudioSession) {
|
||||
do {
|
||||
try session.setActive(false, options: .notifyOthersOnDeactivation)
|
||||
} catch {
|
||||
MXLog.debug("Deconfigure audio session failed: \(error)")
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,30 +0,0 @@
|
||||
//
|
||||
// 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
|
||||
|
||||
enum VideoPlayerViewModelAction {
|
||||
case cancel
|
||||
}
|
||||
|
||||
struct VideoPlayerViewState: BindableState {
|
||||
let videoURL: URL
|
||||
let autoplay: Bool
|
||||
}
|
||||
|
||||
enum VideoPlayerViewAction {
|
||||
case cancel
|
||||
}
|
||||
@@ -1,35 +0,0 @@
|
||||
//
|
||||
// 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 {
|
||||
var callback: ((VideoPlayerViewModelAction) -> Void)?
|
||||
|
||||
init(videoURL: URL, autoplay: Bool = true) {
|
||||
super.init(initialViewState: VideoPlayerViewState(videoURL: videoURL,
|
||||
autoplay: autoplay))
|
||||
}
|
||||
|
||||
override func process(viewAction: VideoPlayerViewAction) async {
|
||||
switch viewAction {
|
||||
case .cancel:
|
||||
callback?(.cancel)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,23 +0,0 @@
|
||||
//
|
||||
// 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 }
|
||||
}
|
||||
@@ -1,49 +0,0 @@
|
||||
//
|
||||
// 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 {
|
||||
@ObservedObject var context: VideoPlayerViewModel.Context
|
||||
|
||||
var body: some View {
|
||||
VideoPlayer(player: player())
|
||||
.ignoresSafeArea(.all, edges: [.horizontal, .bottom])
|
||||
.navigationTitle(ElementL10n.a11yVideo)
|
||||
}
|
||||
|
||||
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)
|
||||
}
|
||||
}
|
||||
@@ -127,7 +127,7 @@ class RoomTimelineController: RoomTimelineControllerProtocol {
|
||||
return .none
|
||||
}
|
||||
if let videoURL = item.cachedVideoURL {
|
||||
return .displayVideo(videoURL: videoURL)
|
||||
return .displayVideo(videoURL: videoURL, title: item.text)
|
||||
}
|
||||
return .none
|
||||
case let item as FileRoomTimelineItem:
|
||||
|
||||
+1
-1
@@ -26,7 +26,7 @@ enum RoomTimelineControllerCallback {
|
||||
}
|
||||
|
||||
enum RoomTimelineControllerAction {
|
||||
case displayVideo(videoURL: URL)
|
||||
case displayVideo(videoURL: URL, title: String?)
|
||||
case displayFile(fileURL: URL, title: String?)
|
||||
case none
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user