mirror of
https://github.com/Novaloop-AG/element-x-ios.git
synced 2026-11-03 16:10:20 +00:00
Image viewer (#338)
* Resume other app's music when video playback finished * Remove old media player * Add `isModallyPresented` into the video player screen * Create image viewer screen * Add test screen identifier * Display image viewer when message tapped * Fix template script unit test path * Tweaks on scaling * Commit project file * Add changelog * Ignore safe areas on the file preview screen * Display images in preview * Remove image viewer screen Co-authored-by: Stefan Ceriu <stefanc@matrix.org>
This commit is contained in:
@@ -19,16 +19,11 @@ import SwiftUI
|
||||
import UIKit
|
||||
|
||||
struct FilePreviewScreen: View {
|
||||
@Environment(\.colorScheme) private var colorScheme
|
||||
|
||||
var counterColor: Color {
|
||||
colorScheme == .light ? .element.secondaryContent : .element.tertiaryContent
|
||||
}
|
||||
|
||||
@ObservedObject var context: FilePreviewViewModel.Context
|
||||
|
||||
var body: some View {
|
||||
PreviewController(fileURL: context.viewState.fileURL, title: context.viewState.title)
|
||||
.ignoresSafeArea()
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,56 +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
|
||||
|
||||
struct MediaPlayerCoordinatorParameters {
|
||||
let mediaURL: URL
|
||||
}
|
||||
|
||||
enum MediaPlayerCoordinatorAction {
|
||||
case cancel
|
||||
}
|
||||
|
||||
final class MediaPlayerCoordinator: CoordinatorProtocol {
|
||||
private let parameters: MediaPlayerCoordinatorParameters
|
||||
private var viewModel: MediaPlayerViewModelProtocol
|
||||
|
||||
var callback: ((MediaPlayerCoordinatorAction) -> Void)?
|
||||
|
||||
init(parameters: MediaPlayerCoordinatorParameters) {
|
||||
self.parameters = parameters
|
||||
|
||||
viewModel = MediaPlayerViewModel(mediaURL: parameters.mediaURL)
|
||||
}
|
||||
|
||||
// MARK: - Public
|
||||
|
||||
func start() {
|
||||
MXLog.debug("Did start.")
|
||||
viewModel.callback = { [weak self] action in
|
||||
guard let self else { return }
|
||||
MXLog.debug("MediaPlayerViewModel did complete with result: \(action).")
|
||||
switch action {
|
||||
case .cancel:
|
||||
self.callback?(.cancel)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func toPresentable() -> AnyView {
|
||||
AnyView(MediaPlayerScreen(context: viewModel.context))
|
||||
}
|
||||
}
|
||||
@@ -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 MediaPlayerViewModelType = StateStoreViewModel<MediaPlayerViewState, MediaPlayerViewAction>
|
||||
|
||||
class MediaPlayerViewModel: MediaPlayerViewModelType, MediaPlayerViewModelProtocol {
|
||||
var callback: ((MediaPlayerViewModelAction) -> Void)?
|
||||
|
||||
init(mediaURL: URL, autoplay: Bool = true) {
|
||||
super.init(initialViewState: MediaPlayerViewState(mediaURL: mediaURL,
|
||||
autoplay: autoplay))
|
||||
}
|
||||
|
||||
override func process(viewAction: MediaPlayerViewAction) 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 MediaPlayerViewModelProtocol {
|
||||
var callback: ((MediaPlayerViewModelAction) -> Void)? { get set }
|
||||
var context: MediaPlayerViewModelType.Context { get }
|
||||
}
|
||||
@@ -1,48 +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 MediaPlayerScreen: View {
|
||||
@ObservedObject var context: MediaPlayerViewModel.Context
|
||||
|
||||
var body: some View {
|
||||
VideoPlayer(player: player())
|
||||
.ignoresSafeArea()
|
||||
}
|
||||
|
||||
private func player() -> AVPlayer {
|
||||
let player = AVPlayer(url: context.viewState.mediaURL)
|
||||
if context.viewState.autoplay {
|
||||
player.play()
|
||||
}
|
||||
return player
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Previews
|
||||
|
||||
struct MediaPlayer_Previews: PreviewProvider {
|
||||
static var previews: some View {
|
||||
Group {
|
||||
let viewModel = MediaPlayerViewModel(mediaURL: URL(staticString: "https://storage.googleapis.com/gtv-videos-bucket/sample/BigBuckBunny.mp4"),
|
||||
autoplay: false)
|
||||
MediaPlayerScreen(context: viewModel.context)
|
||||
}
|
||||
.tint(.element.accent)
|
||||
}
|
||||
}
|
||||
+9
-12
@@ -14,17 +14,14 @@
|
||||
// limitations under the License.
|
||||
//
|
||||
|
||||
import Foundation
|
||||
import SwiftUI
|
||||
|
||||
enum MediaPlayerViewModelAction {
|
||||
case cancel
|
||||
}
|
||||
|
||||
struct MediaPlayerViewState: BindableState {
|
||||
let mediaURL: URL
|
||||
let autoplay: Bool
|
||||
}
|
||||
|
||||
enum MediaPlayerViewAction {
|
||||
case cancel
|
||||
struct ActivityCoordinator: CoordinatorProtocol {
|
||||
let items: [Any]
|
||||
|
||||
func toPresentable() -> AnyView {
|
||||
return AnyView(UIActivityViewControllerWrapper(activityItems: items)
|
||||
.presentationDetents([.medium])
|
||||
.ignoresSafeArea())
|
||||
}
|
||||
}
|
||||
@@ -66,13 +66,25 @@ final class RoomScreenCoordinator: CoordinatorProtocol {
|
||||
// MARK: - Private
|
||||
|
||||
private func displayVideo(for videoURL: URL) {
|
||||
let params = VideoPlayerCoordinatorParameters(videoURL: videoURL)
|
||||
let params = VideoPlayerCoordinatorParameters(videoURL: videoURL, isModallyPresented: false)
|
||||
let coordinator = VideoPlayerCoordinator(parameters: params)
|
||||
coordinator.callback = { [weak self] _ in
|
||||
self?.navigationController.pop()
|
||||
}
|
||||
|
||||
navigationController.push(coordinator)
|
||||
if params.isModallyPresented {
|
||||
coordinator.callback = { [weak self] _ in
|
||||
self?.navigationController.dismissSheet()
|
||||
}
|
||||
|
||||
let controller = NavigationController()
|
||||
controller.setRootCoordinator(coordinator)
|
||||
|
||||
navigationController.presentSheet(controller)
|
||||
} else {
|
||||
coordinator.callback = { [weak self] _ in
|
||||
self?.navigationController.pop()
|
||||
}
|
||||
|
||||
navigationController.push(coordinator)
|
||||
}
|
||||
}
|
||||
|
||||
private func displayFile(for fileURL: URL, with title: String?) {
|
||||
|
||||
@@ -19,6 +19,7 @@ import SwiftUI
|
||||
|
||||
struct VideoPlayerCoordinatorParameters {
|
||||
let videoURL: URL
|
||||
let isModallyPresented: Bool
|
||||
}
|
||||
|
||||
enum VideoPlayerCoordinatorAction {
|
||||
@@ -34,7 +35,8 @@ final class VideoPlayerCoordinator: CoordinatorProtocol {
|
||||
init(parameters: VideoPlayerCoordinatorParameters) {
|
||||
self.parameters = parameters
|
||||
|
||||
viewModel = VideoPlayerViewModel(videoURL: parameters.videoURL)
|
||||
viewModel = VideoPlayerViewModel(videoURL: parameters.videoURL,
|
||||
isModallyPresented: parameters.isModallyPresented)
|
||||
}
|
||||
|
||||
// MARK: - Public
|
||||
@@ -51,6 +53,10 @@ final class VideoPlayerCoordinator: CoordinatorProtocol {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func stop() {
|
||||
deconfigureAudioSession(.sharedInstance())
|
||||
}
|
||||
|
||||
func toPresentable() -> AnyView {
|
||||
AnyView(VideoPlayerScreen(context: viewModel.context))
|
||||
@@ -62,10 +68,18 @@ final class VideoPlayerCoordinator: CoordinatorProtocol {
|
||||
do {
|
||||
try session.setCategory(.playback,
|
||||
mode: .default,
|
||||
options: [.defaultToSpeaker, .allowBluetooth, .allowBluetoothA2DP])
|
||||
options: [.allowBluetooth, .allowBluetoothA2DP])
|
||||
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)")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -21,8 +21,9 @@ enum VideoPlayerViewModelAction {
|
||||
}
|
||||
|
||||
struct VideoPlayerViewState: BindableState {
|
||||
var videoURL: URL
|
||||
var autoplay: Bool
|
||||
let videoURL: URL
|
||||
let autoplay: Bool
|
||||
let isModallyPresented: Bool
|
||||
}
|
||||
|
||||
enum VideoPlayerViewAction {
|
||||
|
||||
@@ -21,9 +21,10 @@ typealias VideoPlayerViewModelType = StateStoreViewModel<VideoPlayerViewState, V
|
||||
class VideoPlayerViewModel: VideoPlayerViewModelType, VideoPlayerViewModelProtocol {
|
||||
var callback: ((VideoPlayerViewModelAction) -> Void)?
|
||||
|
||||
init(videoURL: URL, autoplay: Bool = true) {
|
||||
init(videoURL: URL, autoplay: Bool = true, isModallyPresented: Bool = true) {
|
||||
super.init(initialViewState: VideoPlayerViewState(videoURL: videoURL,
|
||||
autoplay: autoplay))
|
||||
autoplay: autoplay,
|
||||
isModallyPresented: isModallyPresented))
|
||||
}
|
||||
|
||||
override func process(viewAction: VideoPlayerViewAction) async {
|
||||
|
||||
@@ -22,25 +22,33 @@ struct VideoPlayerScreen: View {
|
||||
|
||||
var body: some View {
|
||||
VideoPlayer(player: player())
|
||||
.ignoresSafeArea()
|
||||
.background(Color.black.ignoresSafeArea())
|
||||
.navigationBarTitleDisplayMode(.inline)
|
||||
.navigationBarBackButtonHidden()
|
||||
.toolbar {
|
||||
ToolbarItem(placement: .navigationBarLeading) {
|
||||
Button {
|
||||
context.send(viewAction: .cancel)
|
||||
} label: {
|
||||
Image(systemName: "chevron.backward")
|
||||
.foregroundColor(.white)
|
||||
.fontWeight(.semibold)
|
||||
}
|
||||
.toolbar { toolbar }
|
||||
.onSwipeGesture(minimumDistance: 3.0, down: {
|
||||
if context.viewState.isModallyPresented {
|
||||
context.send(viewAction: .cancel)
|
||||
}
|
||||
}, right: {
|
||||
if !context.viewState.isModallyPresented {
|
||||
context.send(viewAction: .cancel)
|
||||
}
|
||||
}
|
||||
.onSwipeGesture(minimumDistance: 3.0, right: {
|
||||
context.send(viewAction: .cancel)
|
||||
})
|
||||
}
|
||||
|
||||
@ToolbarContentBuilder
|
||||
var toolbar: some ToolbarContent {
|
||||
ToolbarItem(placement: .cancellationAction) {
|
||||
Button { context.send(viewAction: .cancel) } label: {
|
||||
Image(systemName: context.viewState.isModallyPresented ? "xmark" : "chevron.backward")
|
||||
.foregroundColor(.white)
|
||||
.fontWeight(.semibold)
|
||||
}
|
||||
.accessibilityIdentifier("dismissButton")
|
||||
}
|
||||
}
|
||||
|
||||
private func player() -> AVPlayer {
|
||||
let player = AVPlayer(url: context.viewState.videoURL)
|
||||
if context.viewState.autoplay {
|
||||
|
||||
@@ -114,6 +114,16 @@ class RoomTimelineController: RoomTimelineControllerProtocol {
|
||||
}
|
||||
|
||||
switch timelineItem {
|
||||
case let item as ImageRoomTimelineItem:
|
||||
await loadFileForImageTimelineItem(item)
|
||||
guard let index = timelineItems.firstIndex(where: { $0.id == itemId }),
|
||||
let item = timelineItems[index] as? ImageRoomTimelineItem else {
|
||||
return .none
|
||||
}
|
||||
if let fileURL = item.cachedFileURL {
|
||||
return .displayFile(fileURL: fileURL, title: item.text)
|
||||
}
|
||||
return .none
|
||||
case let item as VideoRoomTimelineItem:
|
||||
await loadVideoForTimelineItem(item)
|
||||
guard let index = timelineItems.firstIndex(where: { $0.id == itemId }),
|
||||
@@ -349,6 +359,34 @@ class RoomTimelineController: RoomTimelineControllerProtocol {
|
||||
}
|
||||
}
|
||||
|
||||
private func loadFileForImageTimelineItem(_ timelineItem: ImageRoomTimelineItem) async {
|
||||
if timelineItem.cachedFileURL != nil {
|
||||
// already cached
|
||||
return
|
||||
}
|
||||
|
||||
guard let source = timelineItem.source else {
|
||||
return
|
||||
}
|
||||
|
||||
// This is not great. We could better estimate file extension from the mimetype.
|
||||
guard let fileExtension = timelineItem.text.split(separator: ".").last else {
|
||||
return
|
||||
}
|
||||
switch await mediaProvider.loadFileFromSource(source, fileExtension: String(fileExtension)) {
|
||||
case .success(let fileURL):
|
||||
guard let index = timelineItems.firstIndex(where: { $0.id == timelineItem.id }),
|
||||
var item = timelineItems[index] as? ImageRoomTimelineItem else {
|
||||
return
|
||||
}
|
||||
|
||||
item.cachedFileURL = fileURL
|
||||
timelineItems[index] = item
|
||||
case .failure:
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
private func loadFileForTimelineItem(_ timelineItem: FileRoomTimelineItem) async {
|
||||
if timelineItem.cachedFileURL != nil {
|
||||
// already cached
|
||||
|
||||
@@ -16,6 +16,7 @@
|
||||
|
||||
import Combine
|
||||
import Foundation
|
||||
import UIKit
|
||||
|
||||
enum RoomTimelineControllerCallback {
|
||||
case updatedTimelineItems
|
||||
|
||||
@@ -31,6 +31,7 @@ struct ImageRoomTimelineItem: EventBasedTimelineItemProtocol, Identifiable, Equa
|
||||
|
||||
let source: MediaSourceProxy?
|
||||
var image: UIImage?
|
||||
var cachedFileURL: URL?
|
||||
|
||||
var width: CGFloat?
|
||||
var height: CGFloat?
|
||||
|
||||
Reference in New Issue
Block a user