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:
ismailgulek
2022-11-28 14:19:40 +03:00
committed by GitHub
parent 9589c689c3
commit 232430ac95
18 changed files with 143 additions and 284 deletions
@@ -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)
}
}
@@ -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?