Add voice message playback from the timeline (#1844)

This commit is contained in:
Nicolas Mauri
2023-10-04 18:32:45 +02:00
committed by GitHub
parent dce94e7d7d
commit 82abd0aaf3
44 changed files with 1214 additions and 310 deletions
@@ -320,6 +320,8 @@ class RoomFlowCoordinator: FlowCoordinatorProtocol {
let userID = userSession.clientProxy.userID
let mediaPlayerProvider = MediaPlayerProvider(mediaProvider: userSession.mediaProvider)
let timelineItemFactory = RoomTimelineItemFactory(userID: userID,
mediaProvider: userSession.mediaProvider,
attributedStringBuilder: AttributedStringBuilder(permalinkBaseURL: appSettings.permalinkBaseURL,
@@ -327,9 +329,13 @@ class RoomFlowCoordinator: FlowCoordinatorProtocol {
stateEventStringBuilder: RoomStateEventStringBuilder(userID: userID),
appSettings: appSettings)
let voiceMesssageMediaManager = VoiceMessageMediaManager(mediaProvider: userSession.mediaProvider)
let timelineController = roomTimelineControllerFactory.buildRoomTimelineController(roomProxy: roomProxy,
timelineItemFactory: timelineItemFactory,
mediaProvider: userSession.mediaProvider)
mediaProvider: userSession.mediaProvider,
mediaPlayerProvider: mediaPlayerProvider,
voiceMessageMediaManager: voiceMesssageMediaManager)
self.timelineController = timelineController
analytics.trackViewRoom(isDM: roomProxy.isDirect, isSpace: roomProxy.isSpace)
+2
View File
@@ -63,6 +63,8 @@ internal enum Asset {
internal static let locationPin = ImageAsset(name: "images/location-pin")
internal static let locationPointerFull = ImageAsset(name: "images/location-pointer-full")
internal static let locationPointer = ImageAsset(name: "images/location-pointer")
internal static let mediaPause = ImageAsset(name: "images/media-pause")
internal static let mediaPlay = ImageAsset(name: "images/media-play")
internal static let addReaction = ImageAsset(name: "images/add-reaction")
internal static let copy = ImageAsset(name: "images/copy")
internal static let editOutline = ImageAsset(name: "images/edit-outline")
@@ -109,8 +109,8 @@ struct RoomScreenViewState: BindableState {
/// A closure providing the actions to show when long pressing on an item in the timeline.
var timelineItemMenuActionProvider: (@MainActor (_ itemId: TimelineItemIdentifier) -> TimelineItemMenuActions?)?
/// A closure providing the associated audio playback view state for an item in the timeline.
var audioPlaybackViewStateProvider: (@MainActor (_ itemId: TimelineItemIdentifier) -> VoiceRoomPlaybackViewState?)?
/// A closure providing the associated audio player state for an item in the timeline.
var audioPlayerStateProvider: (@MainActor (_ itemId: TimelineItemIdentifier) -> AudioPlayerState?)?
}
struct RoomScreenViewStateBindings {
@@ -77,10 +77,12 @@ class RoomScreenViewModel: RoomScreenViewModelType, RoomScreenViewModelProtocol
return self.timelineItemMenuActionsForItemId(itemId)
}
state.audioPlaybackViewStateProvider = { [weak self] itemId -> VoiceRoomPlaybackViewState? in
guard let self else { return nil }
state.audioPlayerStateProvider = { [weak self] itemId -> AudioPlayerState? in
guard let self else {
return nil
}
return self.audioPlaybackViewState(for: itemId)
return self.audioPlayerState(for: itemId)
}
buildTimelineViews()
@@ -883,8 +885,8 @@ class RoomScreenViewModel: RoomScreenViewModelType, RoomScreenViewModelProtocol
// MARK: - Audio
private func audioPlaybackViewState(for itemID: TimelineItemIdentifier) -> VoiceRoomPlaybackViewState? {
timelineController.playbackViewState(for: itemID)
private func audioPlayerState(for itemID: TimelineItemIdentifier) -> AudioPlayerState {
timelineController.audioPlayerState(for: itemID)
}
}
@@ -491,21 +491,21 @@ struct TimelineItemBubbledStylerView_Previews: PreviewProvider, TestablePreview
replyDetails: .loaded(sender: .init(id: "", displayName: "Alice"),
contentType: .text(.init(body: "Short")))))
VoiceRoomTimelineView(timelineItem: .init(id: .init(timelineID: ""),
timestamp: "10:42",
isOutgoing: true,
isEditable: false,
canBeRepliedTo: true,
isThreaded: true,
sender: .init(id: ""),
content: .init(body: "audio.ogg",
duration: 100,
waveform: Waveform.mockWaveform,
source: nil,
contentType: nil),
replyDetails: .loaded(sender: .init(id: "", displayName: "Alice"),
contentType: .text(.init(body: "Short")))),
playbackViewState: VoiceRoomPlaybackViewState(duration: 10, waveform: Waveform.mockWaveform))
VoiceMessageRoomTimelineView(timelineItem: .init(id: .init(timelineID: ""),
timestamp: "10:42",
isOutgoing: true,
isEditable: false,
canBeRepliedTo: true,
isThreaded: true,
sender: .init(id: ""),
content: .init(body: "audio.ogg",
duration: 100,
waveform: Waveform.mockWaveform,
source: nil,
contentType: nil),
replyDetails: .loaded(sender: .init(id: "", displayName: "Alice"),
contentType: .text(.init(body: "Short")))),
playerState: AudioPlayerState(duration: 10, waveform: Waveform.mockWaveform))
}
.environmentObject(viewModel.context)
}
@@ -218,21 +218,21 @@ struct TimelineItemPlainStylerView_Previews: PreviewProvider, TestablePreview {
geoURI: .init(latitude: 41.902782, longitude: 12.496366), description: nil),
replyDetails: .loaded(sender: .init(id: "", displayName: "Alice"),
contentType: .text(.init(body: "Short")))))
VoiceRoomTimelineView(timelineItem: .init(id: .init(timelineID: ""),
timestamp: "10:42",
isOutgoing: true,
isEditable: false,
canBeRepliedTo: true,
isThreaded: true,
sender: .init(id: ""),
content: .init(body: "audio.ogg",
duration: 100,
waveform: Waveform.mockWaveform,
source: nil,
contentType: nil),
replyDetails: .loaded(sender: .init(id: "", displayName: "Alice"),
contentType: .text(.init(body: "Short")))),
playbackViewState: VoiceRoomPlaybackViewState(duration: 10, waveform: Waveform.mockWaveform))
VoiceMessageRoomTimelineView(timelineItem: .init(id: .init(timelineID: ""),
timestamp: "10:42",
isOutgoing: true,
isEditable: false,
canBeRepliedTo: true,
isThreaded: true,
sender: .init(id: ""),
content: .init(body: "audio.ogg",
duration: 100,
waveform: Waveform.mockWaveform,
source: nil,
contentType: nil),
replyDetails: .loaded(sender: .init(id: "", displayName: "Alice"),
contentType: .text(.init(body: "Short")))),
playerState: AudioPlayerState(duration: 10, waveform: Waveform.mockWaveform))
}
.environmentObject(viewModel.context)
}
@@ -0,0 +1,43 @@
//
// Copyright 2023 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 AVFoundation
import Foundation
import SwiftOGG
enum AudioConverterError: Error {
case conversionFailed(Error?)
}
struct AudioConverter {
func convertToOpusOgg(sourceURL: URL, destinationURL: URL) throws {
do {
try OGGConverter.convertM4aFileToOpusOGG(src: sourceURL, dest: destinationURL)
} catch {
MXLog.error("failed to convert to OpusOgg: \(error)")
throw AudioConverterError.conversionFailed(error)
}
}
func convertToMPEG4AAC(sourceURL: URL, destinationURL: URL) throws {
do {
try OGGConverter.convertOpusOGGToM4aFile(src: sourceURL, dest: destinationURL)
} catch {
MXLog.error("failed to convert to MPEG4AAC: \(error)")
throw AudioConverterError.conversionFailed(error)
}
}
}
@@ -0,0 +1,223 @@
//
// Copyright 2023 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 AVFoundation
import Combine
import Foundation
import UIKit
private enum InternalAudioPlayerState {
case none
case loading
case readyToPlay
case playing
case paused
case stopped
case finishedPlaying
case error(Error)
}
enum AudioPlayerError: Error {
case genericError
case loadFileError
}
class AudioPlayer: NSObject, AudioPlayerProtocol {
var mediaSource: MediaSourceProxy?
private var playerItem: AVPlayerItem?
private var internalAudioPlayer: AVQueuePlayer?
private var cancellables = Set<AnyCancellable>()
private let actionsSubject: PassthroughSubject<AudioPlayerAction, Never> = .init()
var actions: AnyPublisher<AudioPlayerAction, Never> {
actionsSubject.eraseToAnyPublisher()
}
private var internalState = InternalAudioPlayerState.none
private var statusObserver: NSKeyValueObservation?
private var rateObserver: NSKeyValueObservation?
private var playToEndObserver: NSObjectProtocol?
private var appBackgroundObserver: NSObjectProtocol?
private(set) var url: URL?
var duration: TimeInterval {
abs(CMTimeGetSeconds(internalAudioPlayer?.currentItem?.duration ?? .zero))
}
var currentTime: TimeInterval {
let currentTime = abs(CMTimeGetSeconds(internalAudioPlayer?.currentTime() ?? .zero))
return currentTime.isFinite ? currentTime : .zero
}
var state: MediaPlayerState {
if case .loading = internalState {
return .loading
}
if case .stopped = internalState {
return .stopped
}
if case .playing = internalState {
return .playing
}
if case .paused = internalState {
return .paused
}
if case .error = internalState {
return .error
}
return .stopped
}
private var isStopped = true
deinit {
stop()
unloadContent()
}
func load(mediaSource: MediaSourceProxy, using url: URL) {
unloadContent()
setInternalState(.loading)
self.mediaSource = mediaSource
self.url = url
playerItem = AVPlayerItem(url: url)
internalAudioPlayer = AVQueuePlayer(playerItem: playerItem)
addObservers()
}
func play() {
isStopped = false
do {
try AVAudioSession.sharedInstance().setCategory(AVAudioSession.Category.playback)
try AVAudioSession.sharedInstance().setActive(true)
} catch {
MXLog.error("Could not redirect audio playback to speakers.")
}
internalAudioPlayer?.play()
}
func pause() {
guard case .playing = internalState else { return }
internalAudioPlayer?.pause()
}
func stop() {
guard !isStopped else { return }
isStopped = true
internalAudioPlayer?.pause()
internalAudioPlayer?.seek(to: .zero)
}
func seek(to progress: Double) async {
guard let internalAudioPlayer else { return }
let time = progress * duration
await internalAudioPlayer.seek(to: CMTime(seconds: time, preferredTimescale: 60000))
}
// MARK: - Private
private func unloadContent() {
mediaSource = nil
url = nil
internalAudioPlayer?.replaceCurrentItem(with: nil)
internalAudioPlayer = nil
playerItem = nil
removeObservers()
}
private func addObservers() {
guard let internalAudioPlayer, let playerItem else {
return
}
statusObserver = playerItem.observe(\.status, options: [.old, .new]) { [weak self] _, _ in
guard let self else { return }
switch playerItem.status {
case .failed:
self.setInternalState(.error(playerItem.error ?? AudioPlayerError.genericError))
case .readyToPlay:
guard state == .loading else { return }
self.setInternalState(.readyToPlay)
default:
break
}
}
rateObserver = internalAudioPlayer.observe(\.rate, options: [.old, .new]) { [weak self] _, _ in
guard let self else { return }
if internalAudioPlayer.rate == 0 {
if self.isStopped {
self.setInternalState(.stopped)
} else {
self.setInternalState(.paused)
}
} else {
self.setInternalState(.playing)
}
}
NotificationCenter.default.publisher(for: Notification.Name.AVPlayerItemDidPlayToEndTime)
.sink { [weak self] _ in
guard let self else { return }
self.setInternalState(.finishedPlaying)
}
.store(in: &cancellables)
// Pause playback uppon UIApplication.didBecomeActiveNotification notification
NotificationCenter.default.publisher(for: UIApplication.didEnterBackgroundNotification)
.sink { [weak self] _ in
guard let self else { return }
self.pause()
}
.store(in: &cancellables)
}
private func removeObservers() {
statusObserver?.invalidate()
rateObserver?.invalidate()
cancellables.removeAll()
}
private func setInternalState(_ state: InternalAudioPlayerState) {
internalState = state
switch state {
case .none:
break
case .loading:
actionsSubject.send(.didStartLoading)
case .readyToPlay:
actionsSubject.send(.didFinishLoading)
play()
case .playing:
actionsSubject.send(.didStartPlaying)
case .paused:
actionsSubject.send(.didPausePlaying)
case .stopped:
actionsSubject.send(.didStopPlaying)
case .finishedPlaying:
actionsSubject.send(.didFinishPlaying)
unloadContent()
case .error(let error):
MXLog.error("audio player did fail. \(error)")
actionsSubject.send(.didFailWithError(error: error))
}
}
}
@@ -0,0 +1,32 @@
//
// Copyright 2023 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 Combine
import Foundation
enum AudioPlayerAction {
case didStartLoading
case didFinishLoading
case didStartPlaying
case didPausePlaying
case didStopPlaying
case didFinishPlaying
case didFailWithError(error: Error)
}
protocol AudioPlayerProtocol: MediaPlayerProtocol {
var actions: AnyPublisher<AudioPlayerAction, Never> { get }
}
@@ -0,0 +1,141 @@
//
// Copyright 2023 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 Combine
import Foundation
import UIKit
enum AudioPlayerPlaybackState {
case loading
case readyToPlay
case playing
case stopped
case error
}
@MainActor
class AudioPlayerState: ObservableObject {
let duration: Double
let waveform: Waveform
@Published private(set) var playbackState: AudioPlayerPlaybackState
@Published private(set) var progress: Double
private var audioPlayer: AudioPlayerProtocol?
private var cancellables: Set<AnyCancellable> = []
private var cancellableTimer: AnyCancellable?
var isAttached: Bool {
audioPlayer != nil
}
init(duration: Double, waveform: Waveform? = nil, progress: Double = 0.0) {
self.duration = duration
self.waveform = waveform ?? Waveform(data: [])
self.progress = progress
playbackState = .stopped
}
func updateState(progress: Double) async {
let progress = max(0.0, min(progress, 1.0))
self.progress = progress
if let audioPlayer {
await audioPlayer.seek(to: progress)
}
}
func attachAudioPlayer(_ audioPlayer: AudioPlayerProtocol) {
if self.audioPlayer != nil {
detachAudioPlayer()
}
playbackState = .loading
self.audioPlayer = audioPlayer
subscribeToAudioPlayer(audioPlayer: audioPlayer)
}
func detachAudioPlayer() {
guard audioPlayer != nil else { return }
audioPlayer?.stop()
stopPublishProgress()
cancellables = []
audioPlayer = nil
playbackState = .stopped
}
func reportError(_ error: Error) {
playbackState = .error
}
// MARK: - Private
private func subscribeToAudioPlayer(audioPlayer: AudioPlayerProtocol) {
audioPlayer.actions
.receive(on: DispatchQueue.main)
.sink { [weak self] action in
guard let self else {
return
}
self.handleAudioPlayerAction(action)
}
.store(in: &cancellables)
}
private func handleAudioPlayerAction(_ action: AudioPlayerAction) {
switch action {
case .didStartLoading:
playbackState = .loading
case .didFinishLoading:
if let audioPlayer {
Task {
await restoreAudioPlayerState(audioPlayer: audioPlayer)
}
}
playbackState = .readyToPlay
case .didStartPlaying:
playbackState = .playing
startPublishProgress()
case .didPausePlaying, .didStopPlaying, .didFinishPlaying:
stopPublishProgress()
playbackState = .stopped
if case .didFinishPlaying = action {
progress = 0.0
}
case .didFailWithError:
stopPublishProgress()
}
}
private func startPublishProgress() {
cancellableTimer?.cancel()
cancellableTimer = Timer.publish(every: 0.2, on: .main, in: .default)
.autoconnect()
.receive(on: DispatchQueue.main)
.sink(receiveValue: { [weak self] _ in
guard let self else { return }
if let currentTime = self.audioPlayer?.currentTime, self.duration > 0 {
self.progress = currentTime / self.duration
}
})
}
private func stopPublishProgress() {
cancellableTimer?.cancel()
}
private func restoreAudioPlayerState(audioPlayer: AudioPlayerProtocol) async {
await audioPlayer.seek(to: progress)
}
}
@@ -0,0 +1,39 @@
//
// Copyright 2023 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 MediaPlayerState {
case loading
case playing
case paused
case stopped
case error
}
protocol MediaPlayerProtocol {
var mediaSource: MediaSourceProxy? { get }
var currentTime: TimeInterval { get }
var url: URL? { get }
var state: MediaPlayerState { get }
func load(mediaSource: MediaSourceProxy, using url: URL)
func play()
func pause()
func stop()
func seek(to progress: Double) async
}
@@ -0,0 +1,47 @@
//
// Copyright 2023 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
class MediaPlayerProvider: MediaPlayerProviderProtocol {
private let mediaProvider: MediaProviderProtocol
private var audioPlayer: AudioPlayerProtocol?
init(mediaProvider: MediaProviderProtocol) {
self.mediaProvider = mediaProvider
}
deinit {
audioPlayer = nil
}
func player(for mediaSource: MediaSourceProxy) -> MediaPlayerProtocol? {
guard let mimeType = mediaSource.mimeType else {
MXLog.error("Unknown mime type")
return nil
}
if mimeType.starts(with: "audio/") {
if audioPlayer == nil {
audioPlayer = AudioPlayer()
}
return audioPlayer
} else {
MXLog.error("Unsupported media type: \(mediaSource.mimeType ?? "unknown")")
return nil
}
}
}
@@ -0,0 +1,21 @@
//
// Copyright 2023 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
protocol MediaPlayerProviderProtocol {
func player(for mediaSource: MediaSourceProxy) async -> MediaPlayerProtocol?
}
@@ -82,10 +82,10 @@ class MockRoomTimelineController: RoomTimelineControllerProtocol {
func retryDecryption(for sessionID: String) async { }
func playbackViewState(for itemID: TimelineItemIdentifier) -> VoiceRoomPlaybackViewState? {
VoiceRoomPlaybackViewState(duration: 10.0,
waveform: nil,
progress: 0.0)
func audioPlayerState(for itemID: TimelineItemIdentifier) -> AudioPlayerState {
AudioPlayerState(duration: 10.0,
waveform: nil,
progress: 0.0)
}
func playPauseAudio(for itemID: TimelineItemIdentifier) async { }
@@ -19,7 +19,9 @@ import Foundation
struct MockRoomTimelineControllerFactory: RoomTimelineControllerFactoryProtocol {
func buildRoomTimelineController(roomProxy: RoomProxyProtocol,
timelineItemFactory: RoomTimelineItemFactoryProtocol,
mediaProvider: MediaProviderProtocol) -> RoomTimelineControllerProtocol {
mediaProvider: MediaProviderProtocol,
mediaPlayerProvider: MediaPlayerProviderProtocol,
voiceMessageMediaManager: VoiceMessageMediaManagerProtocol) -> RoomTimelineControllerProtocol {
let timelineController = MockRoomTimelineController()
timelineController.timelineItems = RoomTimelineItemFixtures.largeChunk
return timelineController
@@ -23,6 +23,8 @@ class RoomTimelineController: RoomTimelineControllerProtocol {
private let timelineProvider: RoomTimelineProviderProtocol
private let timelineItemFactory: RoomTimelineItemFactoryProtocol
private let mediaProvider: MediaProviderProtocol
private let mediaPlayerProvider: MediaPlayerProviderProtocol
private let voiceMessageMediaManager: VoiceMessageMediaManagerProtocol
private let appSettings: AppSettings
private let serialDispatchQueue: DispatchQueue
@@ -36,7 +38,7 @@ class RoomTimelineController: RoomTimelineControllerProtocol {
let callbacks = PassthroughSubject<RoomTimelineControllerCallback, Never>()
private(set) var timelineItems = [RoomTimelineItemProtocol]()
private var timelineAudioPlaybackViewStates = [TimelineItemIdentifier: VoiceRoomPlaybackViewState]()
private var timelineAudioPlayerStates = [TimelineItemIdentifier: AudioPlayerState]()
var roomID: String {
roomProxy.id
@@ -45,11 +47,15 @@ class RoomTimelineController: RoomTimelineControllerProtocol {
init(roomProxy: RoomProxyProtocol,
timelineItemFactory: RoomTimelineItemFactoryProtocol,
mediaProvider: MediaProviderProtocol,
mediaPlayerProvider: MediaPlayerProviderProtocol,
voiceMessageMediaManager: VoiceMessageMediaManagerProtocol,
appSettings: AppSettings) {
self.roomProxy = roomProxy
timelineProvider = roomProxy.timelineProvider
self.timelineItemFactory = timelineItemFactory
self.mediaProvider = mediaProvider
self.mediaPlayerProvider = mediaPlayerProvider
self.voiceMessageMediaManager = voiceMessageMediaManager
self.appSettings = appSettings
serialDispatchQueue = DispatchQueue(label: "io.element.elementx.roomtimelineprovider", qos: .utility)
@@ -222,32 +228,77 @@ class RoomTimelineController: RoomTimelineControllerProtocol {
await roomProxy.retryDecryption(for: sessionID)
}
func playbackViewState(for itemID: TimelineItemIdentifier) -> VoiceRoomPlaybackViewState? {
func audioPlayerState(for itemID: TimelineItemIdentifier) -> AudioPlayerState {
guard let timelineItem = timelineItems.firstUsingStableID(itemID) else {
MXLog.error("timelineItem not found")
return .none
fatalError("TimelineItem \(itemID) not found")
}
switch timelineItem {
case let item as VoiceRoomTimelineItem:
if let playbackViewState = timelineAudioPlaybackViewStates[itemID] {
return playbackViewState
guard let voiceMessageRoomTimelineItem = timelineItem as? VoiceMessageRoomTimelineItem else {
fatalError("Invalid TimelineItem type (expecting `VoiceMessageRoomTimelineItem` but found \(type(of: timelineItem)) instead")
}
if let playerState = timelineAudioPlayerStates[itemID] {
return playerState
}
let playerState = AudioPlayerState(duration: voiceMessageRoomTimelineItem.content.duration,
waveform: voiceMessageRoomTimelineItem.content.waveform)
timelineAudioPlayerStates[itemID] = playerState
return playerState
}
func playPauseAudio(for itemID: TimelineItemIdentifier) async {
guard let timelineItem = timelineItems.firstUsingStableID(itemID) else {
fatalError("TimelineItem \(itemID) not found")
}
guard let voiceMessageRoomTimelineItem = timelineItem as? VoiceMessageRoomTimelineItem else {
fatalError("Invalid TimelineItem type for itemID \(itemID) (expecting `VoiceMessageRoomTimelineItem` but found \(type(of: timelineItem)) instead")
}
guard let source = voiceMessageRoomTimelineItem.content.source else {
MXLog.error("Cannot start voice message playback, source is not defined for itemID \(itemID)")
return
}
guard let player = await mediaPlayerProvider.player(for: source) as? AudioPlayerProtocol else {
MXLog.error("Cannot play a voice message without an audio player")
return
}
let playerState = audioPlayerState(for: itemID)
guard player.mediaSource == source, player.state != .error else {
timelineAudioPlayerStates.forEach { itemID, playerState in
if itemID != timelineItem.id {
playerState.detachAudioPlayer()
}
}
let playbackViewState = VoiceRoomPlaybackViewState(duration: item.content.duration,
waveform: item.content.waveform)
timelineAudioPlaybackViewStates[itemID] = playbackViewState
return playbackViewState
default:
return .none
playerState.attachAudioPlayer(player)
// Load content
do {
let url = try await voiceMessageMediaManager.loadVoiceMessageFromSource(source, body: nil)
// Make sure that the player is still attached, as it may have been detached while waiting for the voice message to be loaded.
if playerState.isAttached {
player.load(mediaSource: source, using: url)
}
} catch {
MXLog.error("Failed to load voice message: \(error)")
playerState.reportError(error)
}
return
}
if player.state == .playing {
player.pause()
} else {
player.play()
}
}
func playPauseAudio(for itemID: TimelineItemIdentifier) async { }
func seekAudio(for itemID: TimelineItemIdentifier, progress: Double) async {
Task {
timelineAudioPlaybackViewStates[itemID]?.updateState(progress: progress)
}
await timelineAudioPlayerStates[itemID]?.updateState(progress: progress)
}
// MARK: - Private
@@ -19,10 +19,14 @@ import Foundation
struct RoomTimelineControllerFactory: RoomTimelineControllerFactoryProtocol {
func buildRoomTimelineController(roomProxy: RoomProxyProtocol,
timelineItemFactory: RoomTimelineItemFactoryProtocol,
mediaProvider: MediaProviderProtocol) -> RoomTimelineControllerProtocol {
mediaProvider: MediaProviderProtocol,
mediaPlayerProvider: MediaPlayerProviderProtocol,
voiceMessageMediaManager: VoiceMessageMediaManagerProtocol) -> RoomTimelineControllerProtocol {
RoomTimelineController(roomProxy: roomProxy,
timelineItemFactory: timelineItemFactory,
mediaProvider: mediaProvider,
mediaPlayerProvider: mediaPlayerProvider,
voiceMessageMediaManager: voiceMessageMediaManager,
appSettings: ServiceLocator.shared.settings)
}
}
@@ -20,5 +20,7 @@ import Foundation
protocol RoomTimelineControllerFactoryProtocol {
func buildRoomTimelineController(roomProxy: RoomProxyProtocol,
timelineItemFactory: RoomTimelineItemFactoryProtocol,
mediaProvider: MediaProviderProtocol) -> RoomTimelineControllerProtocol
mediaProvider: MediaProviderProtocol,
mediaPlayerProvider: MediaPlayerProviderProtocol,
voiceMessageMediaManager: VoiceMessageMediaManagerProtocol) -> RoomTimelineControllerProtocol
}
@@ -65,7 +65,7 @@ protocol RoomTimelineControllerProtocol {
func retryDecryption(for sessionID: String) async
func playbackViewState(for itemID: TimelineItemIdentifier) -> VoiceRoomPlaybackViewState?
func audioPlayerState(for itemID: TimelineItemIdentifier) -> AudioPlayerState
func playPauseAudio(for itemID: TimelineItemIdentifier) async
@@ -16,7 +16,7 @@
import Foundation
struct VoiceRoomTimelineItem: EventBasedMessageTimelineItemProtocol, Equatable {
struct VoiceMessageRoomTimelineItem: EventBasedMessageTimelineItemProtocol, Equatable {
let id: TimelineItemIdentifier
let timestamp: String
let isOutgoing: Bool
@@ -16,12 +16,17 @@
import SwiftUI
struct VoiceRoomPlaybackView: View {
@ObservedObject var playbackViewState: VoiceRoomPlaybackViewState
struct VoiceMessageRoomPlaybackView: View {
@ObservedObject var playerState: AudioPlayerState
let onPlayPause: () -> Void
let onSeek: (Double) -> Void
let onScrubbing: (Bool) -> Void
private let feedbackGenerator = UIImpactFeedbackGenerator(style: .heavy)
@State private var sendFeedback = false
@ScaledMetric private var waveformLineWidth = 2.0
@ScaledMetric private var waveformLinePadding = 2.0
private let waveformMaxWidth: CGFloat = 150
private let playPauseButtonSize = CGSize(width: 32, height: 32)
@@ -30,55 +35,18 @@ struct VoiceRoomPlaybackView: View {
dateFormatter.dateFormat = "m:ss"
return dateFormatter
}()
var onPlayPause: () -> Void = { }
var onSeek: (Double) -> Void = { _ in }
var onScrubbing: (Bool) -> Void = { _ in }
private enum DragState: Equatable {
case inactive
case pressing(progress: Double)
case dragging(progress: Double)
var progress: Double {
switch self {
case .inactive, .pressing:
return .zero
case .dragging(let progress):
return progress
}
}
var isActive: Bool {
switch self {
case .inactive:
return false
case .pressing, .dragging:
return true
}
}
var isDragging: Bool {
switch self {
case .inactive, .pressing:
return false
case .dragging:
return true
}
}
}
@GestureState private var dragState = DragState.inactive
@State private var tapProgress: Double = .zero
var timeLabelContent: String {
// Display the duration if progress is 0.0
let percent = playbackViewState.progress > 0.0 ? playbackViewState.progress : 1.0
return Self.elapsedTimeFormatter.string(from: Date(timeIntervalSinceReferenceDate: playbackViewState.duration * percent))
let percent = playerState.progress > 0.0 ? playerState.progress : 1.0
return Self.elapsedTimeFormatter.string(from: Date(timeIntervalSinceReferenceDate: playerState.duration * percent))
}
var showWaveformCursor: Bool {
playbackViewState.playing || dragState.isDragging
playerState.playbackState == .playing || dragState.isDragging
}
var body: some View {
@@ -90,28 +58,27 @@ struct VoiceRoomPlaybackView: View {
.foregroundColor(.compound.textSecondary)
.monospacedDigit()
}
.padding(.vertical, 6)
GeometryReader { geometry in
WaveformView(waveform: playbackViewState.waveform, progress: playbackViewState.progress, showCursor: showWaveformCursor)
WaveformView(lineWidth: waveformLineWidth, linePadding: waveformLinePadding, waveform: playerState.waveform, progress: playerState.progress, showCursor: showWaveformCursor)
// Add a gesture to drag the waveform
.gesture(SpatialTapGesture()
.simultaneously(with: LongPressGesture())
.sequenced(before: DragGesture(coordinateSpace: .local))
.sequenced(before: DragGesture(minimumDistance: waveformLinePadding, coordinateSpace: .local))
.updating($dragState) { value, state, _ in
switch value {
// (SpatialTap, LongPress) begins.
case .first(let spatialLongPress) where spatialLongPress.second == true:
case .first(let spatialLongPress) where spatialLongPress.second ?? false:
// Compute the progress with the spatialTap location
let progress = (spatialLongPress.first?.location ?? .zero).x / geometry.size.width
state = .pressing(progress: progress)
// Long press confirmed, dragging may begin.
case .second(let spatialLongPress, let drag) where spatialLongPress.second == true:
case .second(let spatialLongPress, let drag) where spatialLongPress.second ?? false:
var progress: Double = tapProgress
// Compute the progress with drag location
if let loc = drag?.location {
progress = loc.x / geometry.size.width
}
state = .dragging(progress: progress)
state = .dragging(progress: progress, distance: geometry.size.width)
// Dragging ended or the long press cancelled.
default:
state = .inactive
@@ -129,18 +96,21 @@ struct VoiceRoomPlaybackView: View {
onScrubbing(true)
feedbackGenerator.prepare()
sendFeedback = true
case .dragging(let progress):
case .dragging(let progress, let totalWidth):
if sendFeedback {
feedbackGenerator.impactOccurred()
sendFeedback = false
}
if abs(progress - playbackViewState.progress) > 0.01 {
let minimumProgress = waveformLinePadding / totalWidth
let deltaProgress = abs(progress - playerState.progress)
let deltaTime = playerState.duration * deltaProgress
if deltaProgress == 0 || deltaProgress >= minimumProgress || deltaTime >= 1.0 {
onSeek(max(0, min(progress, 1.0)))
}
}
}
.padding(.vertical, 2)
.padding(.horizontal, 8)
.padding(.leading, 2)
.padding(.trailing, 8)
}
@ViewBuilder
@@ -148,33 +118,73 @@ struct VoiceRoomPlaybackView: View {
Button {
onPlayPause()
} label: {
Image(systemName: playbackViewState.playing ? "pause.fill" : "play.fill")
.foregroundColor(.compound.iconSecondary)
.background(
Circle()
.frame(width: playPauseButtonSize.width,
height: playPauseButtonSize.height)
.foregroundColor(.compound.bgCanvasDefault)
)
.padding(.trailing, 7)
ZStack {
Circle()
.foregroundColor(.compound.bgCanvasDefault)
if playerState.playbackState == .loading {
ProgressView()
} else {
Image(asset: playerState.playbackState == .playing ? Asset.Images.mediaPause : Asset.Images.mediaPlay)
.offset(x: playerState.playbackState == .playing ? 0 : 2)
.aspectRatio(contentMode: .fit)
.foregroundColor(.compound.iconSecondary)
}
}
}
.disabled(playerState.playbackState == .loading)
.frame(width: playPauseButtonSize.width,
height: playPauseButtonSize.height)
}
}
private enum DragState: Equatable {
case inactive
case pressing(progress: Double)
case dragging(progress: Double, distance: Double)
var progress: Double {
switch self {
case .inactive, .pressing:
return .zero
case .dragging(let progress, _):
return progress
}
}
var isActive: Bool {
switch self {
case .inactive:
return false
case .pressing, .dragging:
return true
}
}
var isDragging: Bool {
switch self {
case .inactive, .pressing:
return false
case .dragging:
return true
}
}
}
struct VoiceRoomPlaybackView_Previews: PreviewProvider, TestablePreview {
struct VoiceMessageRoomPlaybackView_Previews: PreviewProvider, TestablePreview {
static let waveform = Waveform(data: [3, 127, 400, 266, 126, 122, 373, 251, 45, 112,
334, 205, 99, 138, 397, 354, 125, 361, 199, 51,
294, 131, 19, 2, 3, 3, 1, 2, 0, 0,
0, 0, 0, 0, 0, 3])
static let playbackViewState = VoiceRoomPlaybackViewState(duration: 10.0,
waveform: waveform,
progress: 0.3)
static let playerState = AudioPlayerState(duration: 10.0,
waveform: waveform,
progress: 0.3)
static var previews: some View {
VoiceRoomPlaybackView(playbackViewState: playbackViewState,
onPlayPause: { playbackViewState.updateState(playing: !playbackViewState.playing) },
onSeek: { playbackViewState.updateState(progress: $0) })
VoiceMessageRoomPlaybackView(playerState: playerState,
onPlayPause: { },
onSeek: { value in Task { await playerState.updateState(progress: value) } },
onScrubbing: { _ in })
.fixedSize(horizontal: false, vertical: true)
}
}
@@ -0,0 +1,99 @@
//
// Copyright 2023 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 VoiceMessageRoomTimelineView: View {
@EnvironmentObject private var context: RoomScreenViewModel.Context
private let timelineItem: VoiceMessageRoomTimelineItem
private let playerState: AudioPlayerState
@State private var resumePlaybackAfterScrubbing = false
init(timelineItem: VoiceMessageRoomTimelineItem, playerState: AudioPlayerState) {
self.timelineItem = timelineItem
self.playerState = playerState
}
var body: some View {
TimelineStyler(timelineItem: timelineItem) {
VoiceMessageRoomPlaybackView(playerState: playerState,
onPlayPause: onPlaybackPlayPause,
onSeek: { onPlaybackSeek($0) },
onScrubbing: { onPlaybackScrubbing($0) })
.fixedSize(horizontal: false, vertical: true)
}
}
private func onPlaybackPlayPause() {
context.send(viewAction: .playPauseAudio(itemID: timelineItem.id))
}
private func onPlaybackSeek(_ progress: Double) {
context.send(viewAction: .seekAudio(itemID: timelineItem.id, progress: progress))
}
private func onPlaybackScrubbing(_ dragging: Bool) {
if dragging {
if playerState.playbackState == .playing {
resumePlaybackAfterScrubbing = true
context.send(viewAction: .playPauseAudio(itemID: timelineItem.id))
}
context.send(viewAction: .disableLongPress(itemID: timelineItem.id))
} else {
context.send(viewAction: .enableLongPress(itemID: timelineItem.id))
if resumePlaybackAfterScrubbing {
context.send(viewAction: .playPauseAudio(itemID: timelineItem.id))
resumePlaybackAfterScrubbing = false
}
}
}
}
struct VoiceMessageRoomTimelineView_Previews: PreviewProvider, TestablePreview {
static let viewModel = RoomScreenViewModel.mock
static let voiceRoomTimelineItem = VoiceMessageRoomTimelineItem(id: .random,
timestamp: "Now",
isOutgoing: false,
isEditable: false,
canBeRepliedTo: true,
isThreaded: false,
sender: .init(id: "Bob"),
content: .init(body: "audio.ogg",
duration: 300,
waveform: Waveform.mockWaveform,
source: nil,
contentType: nil))
static let playerState = AudioPlayerState(duration: 10.0,
waveform: Waveform.mockWaveform,
progress: 0.4)
static var previews: some View {
body.environmentObject(viewModel.context)
.previewDisplayName("Bubble")
body
.environment(\.timelineStyle, .plain)
.environmentObject(viewModel.context)
.previewDisplayName("Plain")
}
static var body: some View {
VoiceMessageRoomTimelineView(timelineItem: voiceRoomTimelineItem, playerState: playerState)
.fixedSize(horizontal: false, vertical: true)
}
}
@@ -1,43 +0,0 @@
//
// Copyright 2023 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 Combine
import Foundation
@MainActor
class VoiceRoomPlaybackViewState: ObservableObject {
let duration: Double
let waveform: Waveform
@Published private(set) var loading: Bool
@Published private(set) var playing: Bool
@Published private(set) var progress: Double
init(duration: Double = 0.0, waveform: Waveform? = nil, progress: Double = 0.0) {
self.duration = duration
self.waveform = waveform ?? Waveform(data: [])
self.progress = progress
loading = false
playing = false
}
func updateState(progress: Double) {
self.progress = max(0.0, min(progress, 1.0))
}
func updateState(playing: Bool) {
self.playing = playing
}
}
@@ -1,93 +0,0 @@
//
// Copyright 2023 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 VoiceRoomTimelineView: View {
@EnvironmentObject private var context: RoomScreenViewModel.Context
let timelineItem: VoiceRoomTimelineItem
let playbackViewState: VoiceRoomPlaybackViewState
init(timelineItem: VoiceRoomTimelineItem, playbackViewState: VoiceRoomPlaybackViewState?) {
self.timelineItem = timelineItem
if playbackViewState == nil {
MXLog.error("[VoiceRoomTimelineView] Voice audio playback state is missing")
}
self.playbackViewState = playbackViewState ?? VoiceRoomPlaybackViewState()
}
var body: some View {
TimelineStyler(timelineItem: timelineItem) {
VoiceRoomPlaybackView(playbackViewState: playbackViewState,
onPlayPause: onPlaybackPlayPause,
onSeek: onPlaybackSeek(_:),
onScrubbing: onPlaybackScrubbing(_:))
.fixedSize(horizontal: false, vertical: true)
}
}
private func onPlaybackPlayPause() {
context.send(viewAction: .playPauseAudio(itemID: timelineItem.id))
}
private func onPlaybackSeek(_ progress: Double) {
context.send(viewAction: .seekAudio(itemID: timelineItem.id, progress: progress))
}
private func onPlaybackScrubbing(_ dragging: Bool) {
if dragging {
context.send(viewAction: .disableLongPress(itemID: timelineItem.id))
} else {
context.send(viewAction: .enableLongPress(itemID: timelineItem.id))
}
}
}
struct VoiceRoomTimelineView_Previews: PreviewProvider, TestablePreview {
static let viewModel = RoomScreenViewModel.mock
static let voiceRoomTimelineItem = VoiceRoomTimelineItem(id: .random,
timestamp: "Now",
isOutgoing: false,
isEditable: false,
canBeRepliedTo: true,
isThreaded: false,
sender: .init(id: "Bob"),
content: .init(body: "audio.ogg",
duration: 300,
waveform: Waveform.mockWaveform,
source: nil,
contentType: nil))
static let playbackViewState = VoiceRoomPlaybackViewState(duration: 10.0,
waveform: Waveform.mockWaveform,
progress: 0.4)
static var previews: some View {
body.environmentObject(viewModel.context)
.previewDisplayName("Bubble")
body
.environment(\.timelineStyle, .plain)
.environmentObject(viewModel.context)
.previewDisplayName("Plain")
}
static var body: some View {
VoiceRoomTimelineView(timelineItem: voiceRoomTimelineItem, playbackViewState: playbackViewState)
.fixedSize(horizontal: false, vertical: true)
}
}
@@ -40,8 +40,8 @@ extension Waveform {
}
struct WaveformView: View {
private let lineWidth: CGFloat = 2
private let linePadding: CGFloat = 2
var lineWidth: CGFloat = 2
var linePadding: CGFloat = 2
var waveform: Waveform
private let minimumGraphAmplitude: CGFloat = 1
var progress: CGFloat = 0.0
@@ -276,19 +276,19 @@ struct RoomTimelineItemFactory: RoomTimelineItemFactoryProtocol {
_ messageContent: AudioMessageContent,
_ isOutgoing: Bool,
_ isThreaded: Bool) -> RoomTimelineItemProtocol {
VoiceRoomTimelineItem(id: eventItemProxy.id,
timestamp: eventItemProxy.timestamp.formatted(date: .omitted, time: .shortened),
isOutgoing: isOutgoing,
isEditable: eventItemProxy.isEditable,
canBeRepliedTo: eventItemProxy.canBeRepliedTo,
isThreaded: isThreaded,
sender: eventItemProxy.sender,
content: buildAudioTimelineItemContent(messageContent),
replyDetails: buildReplyToDetailsFrom(details: messageTimelineItem.inReplyTo()),
properties: RoomTimelineItemProperties(isEdited: messageTimelineItem.isEdited(),
reactions: aggregateReactions(eventItemProxy.reactions),
deliveryStatus: eventItemProxy.deliveryStatus,
orderedReadReceipts: orderReadReceipts(eventItemProxy.readReceipts)))
VoiceMessageRoomTimelineItem(id: eventItemProxy.id,
timestamp: eventItemProxy.timestamp.formatted(date: .omitted, time: .shortened),
isOutgoing: isOutgoing,
isEditable: eventItemProxy.isEditable,
canBeRepliedTo: eventItemProxy.canBeRepliedTo,
isThreaded: isThreaded,
sender: eventItemProxy.sender,
content: buildAudioTimelineItemContent(messageContent),
replyDetails: buildReplyToDetailsFrom(details: messageTimelineItem.inReplyTo()),
properties: RoomTimelineItemProperties(isEdited: messageTimelineItem.isEdited(),
reactions: aggregateReactions(eventItemProxy.reactions),
deliveryStatus: eventItemProxy.deliveryStatus,
orderedReadReceipts: orderReadReceipts(eventItemProxy.readReceipts)))
}
private func buildFileTimelineItem(for eventItemProxy: EventTimelineItemProxy,
@@ -75,7 +75,7 @@ struct RoomTimelineItemView: View {
case .poll(let item):
PollRoomTimelineView(timelineItem: item)
case .voice(let item):
VoiceRoomTimelineView(timelineItem: item, playbackViewState: context.viewState.audioPlaybackViewStateProvider?(item.id))
VoiceMessageRoomTimelineView(timelineItem: item, playerState: context.viewState.audioPlayerStateProvider?(item.id) ?? AudioPlayerState(duration: 0))
}
}
@@ -69,7 +69,7 @@ enum RoomTimelineItemType: Equatable {
case group(CollapsibleTimelineItem)
case location(LocationRoomTimelineItem)
case poll(PollRoomTimelineItem)
case voice(VoiceRoomTimelineItem)
case voice(VoiceMessageRoomTimelineItem)
init(item: RoomTimelineItemProtocol) {
switch item {
@@ -113,7 +113,7 @@ enum RoomTimelineItemType: Equatable {
self = .location(item)
case let item as PollRoomTimelineItem:
self = .poll(item)
case let item as VoiceRoomTimelineItem:
case let item as VoiceMessageRoomTimelineItem:
self = .voice(item)
default:
fatalError("Unknown timeline item")
@@ -0,0 +1,67 @@
//
// Copyright 2023 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
class VoiceMessageCache {
var temporaryFilesFolderURL: URL {
FileManager.default.temporaryDirectory.appendingPathComponent("media/voice-message")
}
func cacheURL(for mediaSource: MediaSourceProxy, replacingExtension newExtension: String? = nil) -> URL {
var newURL = temporaryFilesFolderURL.appendingPathComponent(mediaSource.url.lastPathComponent)
if let newExtension {
newURL = newURL.deletingPathExtension().appendingPathExtension(newExtension)
}
return newURL
}
func fileURL(for mediaSource: MediaSourceProxy, withExtension fileExtension: String? = nil) -> URL? {
var url = temporaryFilesFolderURL.appendingPathComponent(mediaSource.url.lastPathComponent)
if let fileExtension {
url = url.deletingPathExtension().appendingPathExtension(fileExtension)
}
return FileManager.default.fileExists(atPath: url.path()) ? url : nil
}
func cache(mediaSource: MediaSourceProxy, using fileURL: URL) throws -> URL {
setupTemporaryFilesFolder()
let url = cacheURL(for: mediaSource)
try? FileManager.default.removeItem(at: url)
try FileManager.default.copyItem(at: fileURL, to: url)
return url
}
func clearCache() {
if FileManager.default.fileExists(atPath: temporaryFilesFolderURL.path) {
do {
try FileManager.default.removeItem(at: temporaryFilesFolderURL)
} catch {
MXLog.error("Failed clearing cached disk files", context: error)
}
}
}
// MARK: - Private
private func setupTemporaryFilesFolder() {
do {
try FileManager.default.createDirectoryIfNeeded(at: temporaryFilesFolderURL, withIntermediateDirectories: true)
} catch {
MXLog.error("Failed to setup audio cache manager.")
}
}
}
@@ -0,0 +1,67 @@
//
// Copyright 2023 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 VoiceMessageMediaManagerError: Error {
case unsupportedMimeTye
}
class VoiceMessageMediaManager: VoiceMessageMediaManagerProtocol {
private let mediaProvider: MediaProviderProtocol
private let cache: VoiceMessageCache
private let supportedVoiceMessageMimeType = "audio/ogg"
/// Preferred audio file extension after conversion
private let preferredAudioExtension = "m4a"
init(mediaProvider: MediaProviderProtocol) {
self.mediaProvider = mediaProvider
cache = VoiceMessageCache()
}
deinit {
cache.clearCache()
}
func loadVoiceMessageFromSource(_ source: MediaSourceProxy, body: String?) async throws -> URL {
guard let mimeType = source.mimeType, mimeType == supportedVoiceMessageMimeType else {
throw VoiceMessageMediaManagerError.unsupportedMimeTye
}
// Do we already have a converted version?
if let fileURL = cache.fileURL(for: source, withExtension: preferredAudioExtension) {
return fileURL
}
// Otherwise, load the file from source
guard case .success(let fileHandle) = await mediaProvider.loadFileFromSource(source, body: body) else {
throw MediaProviderError.failedRetrievingFile
}
let fileURL = try cache.cache(mediaSource: source, using: fileHandle.url)
// Convert from ogg
let audioConverter = AudioConverter()
let convertedFileURL = cache.cacheURL(for: source, replacingExtension: preferredAudioExtension)
try audioConverter.convertToMPEG4AAC(sourceURL: fileURL, destinationURL: convertedFileURL)
// we don't need the original file anymore
try? FileManager.default.removeItem(at: fileURL)
return convertedFileURL
}
}
@@ -0,0 +1,21 @@
//
// Copyright 2023 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
protocol VoiceMessageMediaManagerProtocol {
func loadVoiceMessageFromSource(_ source: MediaSourceProxy, body: String?) async throws -> URL
}