File messages on timeline (#311)

* 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

* Add file messages into the timeline

* Create file preview screen

* Display files in the preview screen

* Commit project file

* Update Rust SDK to 1.0.19-alpha

* Add changelog

* Bump the RustSDK to `v1.0.20-alpha`

* Configure audio session on video playback

Co-authored-by: Stefan Ceriu <stefanc@matrix.org>
This commit is contained in:
ismailgulek
2022-11-16 14:34:37 +03:00
committed by GitHub
parent ce6b0041d2
commit 287fa4cad5
30 changed files with 1019 additions and 61 deletions
@@ -0,0 +1,95 @@
//
// 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 FilePreviewCoordinatorParameters {
let fileURL: URL
let title: String?
}
enum FilePreviewCoordinatorAction {
case cancel
}
final class FilePreviewCoordinator: Coordinator, Presentable {
// MARK: - Properties
// MARK: Private
private let parameters: FilePreviewCoordinatorParameters
private let filePreviewHostingController: UIViewController
private var filePreviewViewModel: FilePreviewViewModelProtocol
private var indicatorPresenter: UserIndicatorTypePresenterProtocol
private var activityIndicator: UserIndicator?
// MARK: Public
// Must be used only internally
var childCoordinators: [Coordinator] = []
var callback: ((FilePreviewCoordinatorAction) -> Void)?
// MARK: - Setup
init(parameters: FilePreviewCoordinatorParameters) {
self.parameters = parameters
let viewModel = FilePreviewViewModel(fileURL: parameters.fileURL, title: parameters.title)
let view = FilePreviewScreen(context: viewModel.context)
filePreviewViewModel = viewModel
filePreviewHostingController = UIHostingController(rootView: view)
indicatorPresenter = UserIndicatorTypePresenter(presentingViewController: filePreviewHostingController)
}
// MARK: - Public
func start() {
MXLog.debug("Did start.")
filePreviewViewModel.callback = { [weak self] action in
guard let self else { return }
MXLog.debug("FilePreviewViewModel did complete with result: \(action).")
switch action {
case .cancel:
self.callback?(.cancel)
}
}
}
func toPresentable() -> UIViewController {
filePreviewHostingController
}
func stop() {
stopLoading()
}
// MARK: - Private
/// Show an activity indicator whilst loading.
/// - Parameters:
/// - label: The label to show on the indicator.
/// - isInteractionBlocking: Whether the indicator should block any user interaction.
private func startLoading(label: String = ElementL10n.loading, isInteractionBlocking: Bool = true) {
activityIndicator = indicatorPresenter.present(.loading(label: label, isInteractionBlocking: isInteractionBlocking))
}
/// Hide the currently displayed activity indicator.
private func stopLoading() {
activityIndicator = nil
}
}
@@ -0,0 +1,36 @@
//
// 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: - Coordinator
// MARK: View model
enum FilePreviewViewModelAction {
case cancel
}
// MARK: View
struct FilePreviewViewState: BindableState {
let fileURL: URL
let title: String?
}
enum FilePreviewViewAction {
case cancel
}
@@ -0,0 +1,44 @@
//
// 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 FilePreviewViewModelType = StateStoreViewModel<FilePreviewViewState, FilePreviewViewAction>
class FilePreviewViewModel: FilePreviewViewModelType, FilePreviewViewModelProtocol {
// MARK: - Properties
// MARK: Private
// MARK: Public
var callback: ((FilePreviewViewModelAction) -> Void)?
// MARK: - Setup
init(fileURL: URL, title: String? = nil) {
super.init(initialViewState: FilePreviewViewState(fileURL: fileURL, title: title))
}
// MARK: - Public
override func process(viewAction: FilePreviewViewAction) async {
switch viewAction {
case .cancel:
callback?(.cancel)
}
}
}
@@ -0,0 +1,23 @@
//
// 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 FilePreviewViewModelProtocol {
var callback: ((FilePreviewViewModelAction) -> Void)? { get set }
var context: FilePreviewViewModelType.Context { get }
}
@@ -0,0 +1,95 @@
//
// 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 QuickLook
import SwiftUI
import UIKit
struct FilePreviewScreen: View {
// MARK: Private
@Environment(\.colorScheme) private var colorScheme
var counterColor: Color {
colorScheme == .light ? .element.secondaryContent : .element.tertiaryContent
}
// MARK: Public
@ObservedObject var context: FilePreviewViewModel.Context
// MARK: Views
var body: some View {
PreviewController(fileURL: context.viewState.fileURL, title: context.viewState.title)
}
}
private struct PreviewController: UIViewControllerRepresentable {
let fileURL: URL
let title: String?
func makeUIViewController(context: Context) -> UINavigationController {
let controller = QLPreviewController()
controller.dataSource = context.coordinator
return UINavigationController(rootViewController: controller)
}
func updateUIViewController(_ uiViewController: UINavigationController, context: Context) { }
func makeCoordinator() -> Coordinator {
Coordinator(parent: self)
}
class Coordinator: QLPreviewControllerDataSource {
let parent: PreviewController
init(parent: PreviewController) {
self.parent = parent
}
func numberOfPreviewItems(in controller: QLPreviewController) -> Int {
1
}
func previewController(_ controller: QLPreviewController, previewItemAt index: Int) -> QLPreviewItem {
PreviewItem(previewItemURL: parent.fileURL, previewItemTitle: parent.title)
}
}
}
private class PreviewItem: NSObject, QLPreviewItem {
var previewItemURL: URL?
var previewItemTitle: String?
init(previewItemURL: URL?, previewItemTitle: String?) {
self.previewItemURL = previewItemURL
self.previewItemTitle = previewItemTitle
}
}
// MARK: - Previews
struct FilePreview_Previews: PreviewProvider {
static var previews: some View {
Group {
let upgradeViewModel = FilePreviewViewModel(fileURL: URL(staticString: "https://www.w3.org/WAI/ER/tests/xhtml/testfiles/resources/pdf/dummy.pdf"))
FilePreviewScreen(context: upgradeViewModel.context)
}
.tint(.element.accent)
}
}
@@ -0,0 +1,94 @@
//
// 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: Coordinator, Presentable {
// MARK: - Properties
// MARK: Private
private let parameters: MediaPlayerCoordinatorParameters
private let mediaPlayerHostingController: UIViewController
private var mediaPlayerViewModel: MediaPlayerViewModelProtocol
private var indicatorPresenter: UserIndicatorTypePresenterProtocol
private var activityIndicator: UserIndicator?
// MARK: Public
// Must be used only internally
var childCoordinators: [Coordinator] = []
var callback: ((MediaPlayerCoordinatorAction) -> Void)?
// MARK: - Setup
init(parameters: MediaPlayerCoordinatorParameters) {
self.parameters = parameters
let viewModel = MediaPlayerViewModel(mediaURL: parameters.mediaURL)
let view = MediaPlayerScreen(context: viewModel.context)
mediaPlayerViewModel = viewModel
mediaPlayerHostingController = UIHostingController(rootView: view)
indicatorPresenter = UserIndicatorTypePresenter(presentingViewController: mediaPlayerHostingController)
}
// MARK: - Public
func start() {
MXLog.debug("Did start.")
mediaPlayerViewModel.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() -> UIViewController {
mediaPlayerHostingController
}
func stop() {
stopLoading()
}
// MARK: - Private
/// Show an activity indicator whilst loading.
/// - Parameters:
/// - label: The label to show on the indicator.
/// - isInteractionBlocking: Whether the indicator should block any user interaction.
private func startLoading(label: String = ElementL10n.loading, isInteractionBlocking: Bool = true) {
activityIndicator = indicatorPresenter.present(.loading(label: label, isInteractionBlocking: isInteractionBlocking))
}
/// Hide the currently displayed activity indicator.
private func stopLoading() {
activityIndicator = nil
}
}
@@ -0,0 +1,36 @@
//
// 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: - Coordinator
// MARK: View model
enum MediaPlayerViewModelAction {
case cancel
}
// MARK: View
struct MediaPlayerViewState: BindableState {
let mediaURL: URL
let autoplay: Bool
}
enum MediaPlayerViewAction {
case cancel
}
@@ -0,0 +1,45 @@
//
// 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 {
// MARK: - Properties
// MARK: Private
// MARK: Public
var callback: ((MediaPlayerViewModelAction) -> Void)?
// MARK: - Setup
init(mediaURL: URL, autoplay: Bool = true) {
super.init(initialViewState: MediaPlayerViewState(mediaURL: mediaURL,
autoplay: autoplay))
}
// MARK: - Public
override func process(viewAction: MediaPlayerViewAction) async {
switch viewAction {
case .cancel:
callback?(.cancel)
}
}
}
@@ -0,0 +1,23 @@
//
// 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 }
}
@@ -0,0 +1,52 @@
//
// 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 {
// MARK: Public
@ObservedObject var context: MediaPlayerViewModel.Context
// MARK: Views
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)
}
}
@@ -65,6 +65,8 @@ final class RoomScreenCoordinator: Coordinator, Presentable {
switch result {
case .displayVideo(let videoURL):
self.displayVideo(for: videoURL)
case .displayFile(let fileURL, let title):
self.displayFile(for: fileURL, with: title)
}
}
}
@@ -94,4 +96,20 @@ final class RoomScreenCoordinator: Coordinator, Presentable {
self?.remove(childCoordinator: 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, weak coordinator] _ in
guard let self, let coordinator = coordinator else { return }
self.navigationRouter.popModule(animated: true)
self.remove(childCoordinator: coordinator)
}
add(childCoordinator: coordinator)
coordinator.start()
navigationRouter.push(coordinator) { [weak self] in
self?.remove(childCoordinator: coordinator)
}
}
}
@@ -19,6 +19,7 @@ import UIKit
enum RoomScreenViewModelAction {
case displayVideo(videoURL: URL)
case displayFile(fileURL: URL, title: String?)
}
enum RoomScreenComposerMode: Equatable {
@@ -126,10 +126,11 @@ class RoomScreenViewModel: RoomScreenViewModelType, RoomScreenViewModelProtocol
switch action {
case .displayVideo(let videoURL):
callback?(.displayVideo(videoURL: videoURL))
case .displayFile(let fileURL, let title):
callback?(.displayFile(fileURL: fileURL, title: title))
case .none:
break
}
state.showLoading = false
}
@@ -0,0 +1,81 @@
//
// 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
import SwiftUI
struct FileRoomTimelineView: View {
let timelineItem: FileRoomTimelineItem
var body: some View {
TimelineStyler(timelineItem: timelineItem) {
HStack {
Image(systemName: "doc.text.fill")
.foregroundColor(.element.primaryContent)
FormattedBodyText(text: timelineItem.text)
}
.padding(.vertical, 12)
.padding(.horizontal, 6)
}
.id(timelineItem.id)
}
}
struct FileRoomTimelineView_Previews: PreviewProvider {
static var previews: some View {
body.preferredColorScheme(.light)
body.preferredColorScheme(.dark)
body.preferredColorScheme(.light)
.timelineStyle(.plain)
body.preferredColorScheme(.dark)
.timelineStyle(.plain)
}
@ViewBuilder
static var body: some View {
VStack(spacing: 20.0) {
FileRoomTimelineView(timelineItem: FileRoomTimelineItem(id: UUID().uuidString,
text: "document.pdf",
timestamp: "Now",
inGroupState: .single,
isOutgoing: false,
isEditable: false,
senderId: "Bob",
source: nil,
thumbnailSource: nil))
FileRoomTimelineView(timelineItem: FileRoomTimelineItem(id: UUID().uuidString,
text: "document.docx",
timestamp: "Now",
inGroupState: .single,
isOutgoing: false,
isEditable: false,
senderId: "Bob",
source: nil,
thumbnailSource: nil))
FileRoomTimelineView(timelineItem: FileRoomTimelineItem(id: UUID().uuidString,
text: "document.txt",
timestamp: "Now",
inGroupState: .single,
isOutgoing: false,
isEditable: false,
senderId: "Bob",
source: nil,
thumbnailSource: nil))
}
}
}
@@ -14,6 +14,7 @@
// limitations under the License.
//
import AVKit
import SwiftUI
struct VideoPlayerCoordinatorParameters {
@@ -59,6 +60,9 @@ final class VideoPlayerCoordinator: Coordinator, Presentable {
func start() {
MXLog.debug("Did start.")
configureAudioSession(.sharedInstance())
videoPlayerViewModel.callback = { [weak self] action in
guard let self else { return }
MXLog.debug("VideoPlayerViewModel did complete with result: \(action).")
@@ -78,6 +82,17 @@ final class VideoPlayerCoordinator: Coordinator, Presentable {
}
// MARK: - Private
private func configureAudioSession(_ session: AVAudioSession) {
do {
try session.setCategory(.playback,
mode: .default,
options: [.defaultToSpeaker, .allowBluetooth, .allowBluetoothA2DP])
try session.setActive(true)
} catch {
MXLog.debug("Configure audio session failed: \(error)")
}
}
/// Show an activity indicator whilst loading.
/// - Parameters: