mirror of
https://github.com/Novaloop-AG/element-x-ios.git
synced 2026-11-05 03:48:27 +00:00
Allow to record a voice message (#1926)
This commit is contained in:
@@ -334,7 +334,7 @@ class RoomFlowCoordinator: FlowCoordinatorProtocol {
|
||||
|
||||
let userID = userSession.clientProxy.userID
|
||||
|
||||
let mediaPlayerProvider = MediaPlayerProvider(mediaProvider: userSession.mediaProvider)
|
||||
let mediaPlayerProvider = MediaPlayerProvider()
|
||||
|
||||
let timelineItemFactory = RoomTimelineItemFactory(userID: userID,
|
||||
mediaProvider: userSession.mediaProvider,
|
||||
@@ -357,6 +357,7 @@ class RoomFlowCoordinator: FlowCoordinatorProtocol {
|
||||
let parameters = RoomScreenCoordinatorParameters(roomProxy: roomProxy,
|
||||
timelineController: timelineController,
|
||||
mediaProvider: userSession.mediaProvider,
|
||||
mediaPlayerProvider: mediaPlayerProvider,
|
||||
emojiProvider: emojiProvider,
|
||||
completionSuggestionService: completionSuggestionService,
|
||||
appSettings: appSettings)
|
||||
|
||||
@@ -323,19 +323,19 @@ class AudioPlayerMock: AudioPlayerProtocol {
|
||||
|
||||
//MARK: - load
|
||||
|
||||
var loadMediaSourceUsingCallsCount = 0
|
||||
var loadMediaSourceUsingCalled: Bool {
|
||||
return loadMediaSourceUsingCallsCount > 0
|
||||
var loadMediaSourceUsingAutoplayCallsCount = 0
|
||||
var loadMediaSourceUsingAutoplayCalled: Bool {
|
||||
return loadMediaSourceUsingAutoplayCallsCount > 0
|
||||
}
|
||||
var loadMediaSourceUsingReceivedArguments: (mediaSource: MediaSourceProxy, url: URL)?
|
||||
var loadMediaSourceUsingReceivedInvocations: [(mediaSource: MediaSourceProxy, url: URL)] = []
|
||||
var loadMediaSourceUsingClosure: ((MediaSourceProxy, URL) -> Void)?
|
||||
var loadMediaSourceUsingAutoplayReceivedArguments: (mediaSource: MediaSourceProxy, url: URL, autoplay: Bool)?
|
||||
var loadMediaSourceUsingAutoplayReceivedInvocations: [(mediaSource: MediaSourceProxy, url: URL, autoplay: Bool)] = []
|
||||
var loadMediaSourceUsingAutoplayClosure: ((MediaSourceProxy, URL, Bool) -> Void)?
|
||||
|
||||
func load(mediaSource: MediaSourceProxy, using url: URL) {
|
||||
loadMediaSourceUsingCallsCount += 1
|
||||
loadMediaSourceUsingReceivedArguments = (mediaSource: mediaSource, url: url)
|
||||
loadMediaSourceUsingReceivedInvocations.append((mediaSource: mediaSource, url: url))
|
||||
loadMediaSourceUsingClosure?(mediaSource, url)
|
||||
func load(mediaSource: MediaSourceProxy, using url: URL, autoplay: Bool) {
|
||||
loadMediaSourceUsingAutoplayCallsCount += 1
|
||||
loadMediaSourceUsingAutoplayReceivedArguments = (mediaSource: mediaSource, url: url, autoplay: autoplay)
|
||||
loadMediaSourceUsingAutoplayReceivedInvocations.append((mediaSource: mediaSource, url: url, autoplay: autoplay))
|
||||
loadMediaSourceUsingAutoplayClosure?(mediaSource, url, autoplay)
|
||||
}
|
||||
//MARK: - play
|
||||
|
||||
@@ -408,21 +408,26 @@ class AudioRecorderMock: AudioRecorderProtocol {
|
||||
var underlyingIsRecording: Bool!
|
||||
var url: URL?
|
||||
|
||||
//MARK: - recordWithOutputURL
|
||||
//MARK: - record
|
||||
|
||||
var recordWithOutputURLCallsCount = 0
|
||||
var recordWithOutputURLCalled: Bool {
|
||||
return recordWithOutputURLCallsCount > 0
|
||||
var recordWithCallsCount = 0
|
||||
var recordWithCalled: Bool {
|
||||
return recordWithCallsCount > 0
|
||||
}
|
||||
var recordWithOutputURLReceivedUrl: URL?
|
||||
var recordWithOutputURLReceivedInvocations: [URL] = []
|
||||
var recordWithOutputURLClosure: ((URL) -> Void)?
|
||||
var recordWithReceivedRecordID: AudioRecordingIdentifier?
|
||||
var recordWithReceivedInvocations: [AudioRecordingIdentifier] = []
|
||||
var recordWithReturnValue: Result<Void, AudioRecorderError>!
|
||||
var recordWithClosure: ((AudioRecordingIdentifier) async -> Result<Void, AudioRecorderError>)?
|
||||
|
||||
func recordWithOutputURL(_ url: URL) {
|
||||
recordWithOutputURLCallsCount += 1
|
||||
recordWithOutputURLReceivedUrl = url
|
||||
recordWithOutputURLReceivedInvocations.append(url)
|
||||
recordWithOutputURLClosure?(url)
|
||||
func record(with recordID: AudioRecordingIdentifier) async -> Result<Void, AudioRecorderError> {
|
||||
recordWithCallsCount += 1
|
||||
recordWithReceivedRecordID = recordID
|
||||
recordWithReceivedInvocations.append(recordID)
|
||||
if let recordWithClosure = recordWithClosure {
|
||||
return await recordWithClosure(recordID)
|
||||
} else {
|
||||
return recordWithReturnValue
|
||||
}
|
||||
}
|
||||
//MARK: - stopRecording
|
||||
|
||||
@@ -430,11 +435,23 @@ class AudioRecorderMock: AudioRecorderProtocol {
|
||||
var stopRecordingCalled: Bool {
|
||||
return stopRecordingCallsCount > 0
|
||||
}
|
||||
var stopRecordingClosure: (() -> Void)?
|
||||
var stopRecordingClosure: (() async -> Void)?
|
||||
|
||||
func stopRecording() {
|
||||
func stopRecording() async {
|
||||
stopRecordingCallsCount += 1
|
||||
stopRecordingClosure?()
|
||||
await stopRecordingClosure?()
|
||||
}
|
||||
//MARK: - deleteRecording
|
||||
|
||||
var deleteRecordingCallsCount = 0
|
||||
var deleteRecordingCalled: Bool {
|
||||
return deleteRecordingCallsCount > 0
|
||||
}
|
||||
var deleteRecordingClosure: (() -> Void)?
|
||||
|
||||
func deleteRecording() {
|
||||
deleteRecordingCallsCount += 1
|
||||
deleteRecordingClosure?()
|
||||
}
|
||||
//MARK: - averagePowerForChannelNumber
|
||||
|
||||
@@ -807,19 +824,19 @@ class MediaPlayerMock: MediaPlayerProtocol {
|
||||
|
||||
//MARK: - load
|
||||
|
||||
var loadMediaSourceUsingCallsCount = 0
|
||||
var loadMediaSourceUsingCalled: Bool {
|
||||
return loadMediaSourceUsingCallsCount > 0
|
||||
var loadMediaSourceUsingAutoplayCallsCount = 0
|
||||
var loadMediaSourceUsingAutoplayCalled: Bool {
|
||||
return loadMediaSourceUsingAutoplayCallsCount > 0
|
||||
}
|
||||
var loadMediaSourceUsingReceivedArguments: (mediaSource: MediaSourceProxy, url: URL)?
|
||||
var loadMediaSourceUsingReceivedInvocations: [(mediaSource: MediaSourceProxy, url: URL)] = []
|
||||
var loadMediaSourceUsingClosure: ((MediaSourceProxy, URL) -> Void)?
|
||||
var loadMediaSourceUsingAutoplayReceivedArguments: (mediaSource: MediaSourceProxy, url: URL, autoplay: Bool)?
|
||||
var loadMediaSourceUsingAutoplayReceivedInvocations: [(mediaSource: MediaSourceProxy, url: URL, autoplay: Bool)] = []
|
||||
var loadMediaSourceUsingAutoplayClosure: ((MediaSourceProxy, URL, Bool) -> Void)?
|
||||
|
||||
func load(mediaSource: MediaSourceProxy, using url: URL) {
|
||||
loadMediaSourceUsingCallsCount += 1
|
||||
loadMediaSourceUsingReceivedArguments = (mediaSource: mediaSource, url: url)
|
||||
loadMediaSourceUsingReceivedInvocations.append((mediaSource: mediaSource, url: url))
|
||||
loadMediaSourceUsingClosure?(mediaSource, url)
|
||||
func load(mediaSource: MediaSourceProxy, using url: URL, autoplay: Bool) {
|
||||
loadMediaSourceUsingAutoplayCallsCount += 1
|
||||
loadMediaSourceUsingAutoplayReceivedArguments = (mediaSource: mediaSource, url: url, autoplay: autoplay)
|
||||
loadMediaSourceUsingAutoplayReceivedInvocations.append((mediaSource: mediaSource, url: url, autoplay: autoplay))
|
||||
loadMediaSourceUsingAutoplayClosure?(mediaSource, url, autoplay)
|
||||
}
|
||||
//MARK: - play
|
||||
|
||||
@@ -874,6 +891,99 @@ class MediaPlayerMock: MediaPlayerProtocol {
|
||||
await seekToClosure?(progress)
|
||||
}
|
||||
}
|
||||
class MediaPlayerProviderMock: MediaPlayerProviderProtocol {
|
||||
|
||||
//MARK: - player
|
||||
|
||||
var playerForCallsCount = 0
|
||||
var playerForCalled: Bool {
|
||||
return playerForCallsCount > 0
|
||||
}
|
||||
var playerForReceivedMediaSource: MediaSourceProxy?
|
||||
var playerForReceivedInvocations: [MediaSourceProxy] = []
|
||||
var playerForReturnValue: Result<MediaPlayerProtocol, MediaPlayerProviderError>!
|
||||
var playerForClosure: ((MediaSourceProxy) -> Result<MediaPlayerProtocol, MediaPlayerProviderError>)?
|
||||
|
||||
func player(for mediaSource: MediaSourceProxy) -> Result<MediaPlayerProtocol, MediaPlayerProviderError> {
|
||||
playerForCallsCount += 1
|
||||
playerForReceivedMediaSource = mediaSource
|
||||
playerForReceivedInvocations.append(mediaSource)
|
||||
if let playerForClosure = playerForClosure {
|
||||
return playerForClosure(mediaSource)
|
||||
} else {
|
||||
return playerForReturnValue
|
||||
}
|
||||
}
|
||||
//MARK: - playerState
|
||||
|
||||
var playerStateForCallsCount = 0
|
||||
var playerStateForCalled: Bool {
|
||||
return playerStateForCallsCount > 0
|
||||
}
|
||||
var playerStateForReceivedId: AudioPlayerStateIdentifier?
|
||||
var playerStateForReceivedInvocations: [AudioPlayerStateIdentifier] = []
|
||||
var playerStateForReturnValue: AudioPlayerState?
|
||||
var playerStateForClosure: ((AudioPlayerStateIdentifier) -> AudioPlayerState?)?
|
||||
|
||||
func playerState(for id: AudioPlayerStateIdentifier) -> AudioPlayerState? {
|
||||
playerStateForCallsCount += 1
|
||||
playerStateForReceivedId = id
|
||||
playerStateForReceivedInvocations.append(id)
|
||||
if let playerStateForClosure = playerStateForClosure {
|
||||
return playerStateForClosure(id)
|
||||
} else {
|
||||
return playerStateForReturnValue
|
||||
}
|
||||
}
|
||||
//MARK: - register
|
||||
|
||||
var registerAudioPlayerStateCallsCount = 0
|
||||
var registerAudioPlayerStateCalled: Bool {
|
||||
return registerAudioPlayerStateCallsCount > 0
|
||||
}
|
||||
var registerAudioPlayerStateReceivedAudioPlayerState: AudioPlayerState?
|
||||
var registerAudioPlayerStateReceivedInvocations: [AudioPlayerState] = []
|
||||
var registerAudioPlayerStateClosure: ((AudioPlayerState) -> Void)?
|
||||
|
||||
func register(audioPlayerState: AudioPlayerState) {
|
||||
registerAudioPlayerStateCallsCount += 1
|
||||
registerAudioPlayerStateReceivedAudioPlayerState = audioPlayerState
|
||||
registerAudioPlayerStateReceivedInvocations.append(audioPlayerState)
|
||||
registerAudioPlayerStateClosure?(audioPlayerState)
|
||||
}
|
||||
//MARK: - unregister
|
||||
|
||||
var unregisterAudioPlayerStateCallsCount = 0
|
||||
var unregisterAudioPlayerStateCalled: Bool {
|
||||
return unregisterAudioPlayerStateCallsCount > 0
|
||||
}
|
||||
var unregisterAudioPlayerStateReceivedAudioPlayerState: AudioPlayerState?
|
||||
var unregisterAudioPlayerStateReceivedInvocations: [AudioPlayerState] = []
|
||||
var unregisterAudioPlayerStateClosure: ((AudioPlayerState) -> Void)?
|
||||
|
||||
func unregister(audioPlayerState: AudioPlayerState) {
|
||||
unregisterAudioPlayerStateCallsCount += 1
|
||||
unregisterAudioPlayerStateReceivedAudioPlayerState = audioPlayerState
|
||||
unregisterAudioPlayerStateReceivedInvocations.append(audioPlayerState)
|
||||
unregisterAudioPlayerStateClosure?(audioPlayerState)
|
||||
}
|
||||
//MARK: - detachAllStates
|
||||
|
||||
var detachAllStatesExceptCallsCount = 0
|
||||
var detachAllStatesExceptCalled: Bool {
|
||||
return detachAllStatesExceptCallsCount > 0
|
||||
}
|
||||
var detachAllStatesExceptReceivedException: AudioPlayerState?
|
||||
var detachAllStatesExceptReceivedInvocations: [AudioPlayerState?] = []
|
||||
var detachAllStatesExceptClosure: ((AudioPlayerState?) async -> Void)?
|
||||
|
||||
func detachAllStates(except exception: AudioPlayerState?) async {
|
||||
detachAllStatesExceptCallsCount += 1
|
||||
detachAllStatesExceptReceivedException = exception
|
||||
detachAllStatesExceptReceivedInvocations.append(exception)
|
||||
await detachAllStatesExceptClosure?(exception)
|
||||
}
|
||||
}
|
||||
class NotificationCenterMock: NotificationCenterProtocol {
|
||||
|
||||
//MARK: - post
|
||||
@@ -1775,6 +1885,23 @@ class RoomProxyMock: RoomProxyProtocol {
|
||||
return sendLocationBodyGeoURIDescriptionZoomLevelAssetTypeReturnValue
|
||||
}
|
||||
}
|
||||
//MARK: - sendVoiceMessage
|
||||
|
||||
var sendVoiceMessageUrlAudioInfoWaveformProgressSubjectRequestHandleCallsCount = 0
|
||||
var sendVoiceMessageUrlAudioInfoWaveformProgressSubjectRequestHandleCalled: Bool {
|
||||
return sendVoiceMessageUrlAudioInfoWaveformProgressSubjectRequestHandleCallsCount > 0
|
||||
}
|
||||
var sendVoiceMessageUrlAudioInfoWaveformProgressSubjectRequestHandleReturnValue: Result<Void, RoomProxyError>!
|
||||
var sendVoiceMessageUrlAudioInfoWaveformProgressSubjectRequestHandleClosure: ((URL, AudioInfo, [UInt16], CurrentValueSubject<Double, Never>?, @MainActor (SendAttachmentJoinHandleProtocol) -> Void) async -> Result<Void, RoomProxyError>)?
|
||||
|
||||
func sendVoiceMessage(url: URL, audioInfo: AudioInfo, waveform: [UInt16], progressSubject: CurrentValueSubject<Double, Never>?, requestHandle: @MainActor (SendAttachmentJoinHandleProtocol) -> Void) async -> Result<Void, RoomProxyError> {
|
||||
sendVoiceMessageUrlAudioInfoWaveformProgressSubjectRequestHandleCallsCount += 1
|
||||
if let sendVoiceMessageUrlAudioInfoWaveformProgressSubjectRequestHandleClosure = sendVoiceMessageUrlAudioInfoWaveformProgressSubjectRequestHandleClosure {
|
||||
return await sendVoiceMessageUrlAudioInfoWaveformProgressSubjectRequestHandleClosure(url, audioInfo, waveform, progressSubject, requestHandle)
|
||||
} else {
|
||||
return sendVoiceMessageUrlAudioInfoWaveformProgressSubjectRequestHandleReturnValue
|
||||
}
|
||||
}
|
||||
//MARK: - retrySend
|
||||
|
||||
var retrySendTransactionIDCallsCount = 0
|
||||
@@ -2715,25 +2842,21 @@ class VoiceMessageCacheMock: VoiceMessageCacheProtocol {
|
||||
}
|
||||
//MARK: - cache
|
||||
|
||||
var cacheMediaSourceUsingMoveThrowableError: Error?
|
||||
var cacheMediaSourceUsingMoveCallsCount = 0
|
||||
var cacheMediaSourceUsingMoveCalled: Bool {
|
||||
return cacheMediaSourceUsingMoveCallsCount > 0
|
||||
}
|
||||
var cacheMediaSourceUsingMoveReceivedArguments: (mediaSource: MediaSourceProxy, fileURL: URL, move: Bool)?
|
||||
var cacheMediaSourceUsingMoveReceivedInvocations: [(mediaSource: MediaSourceProxy, fileURL: URL, move: Bool)] = []
|
||||
var cacheMediaSourceUsingMoveReturnValue: URL!
|
||||
var cacheMediaSourceUsingMoveClosure: ((MediaSourceProxy, URL, Bool) throws -> URL)?
|
||||
var cacheMediaSourceUsingMoveReturnValue: Result<URL, VoiceMessageCacheError>!
|
||||
var cacheMediaSourceUsingMoveClosure: ((MediaSourceProxy, URL, Bool) -> Result<URL, VoiceMessageCacheError>)?
|
||||
|
||||
func cache(mediaSource: MediaSourceProxy, using fileURL: URL, move: Bool) throws -> URL {
|
||||
if let error = cacheMediaSourceUsingMoveThrowableError {
|
||||
throw error
|
||||
}
|
||||
func cache(mediaSource: MediaSourceProxy, using fileURL: URL, move: Bool) -> Result<URL, VoiceMessageCacheError> {
|
||||
cacheMediaSourceUsingMoveCallsCount += 1
|
||||
cacheMediaSourceUsingMoveReceivedArguments = (mediaSource: mediaSource, fileURL: fileURL, move: move)
|
||||
cacheMediaSourceUsingMoveReceivedInvocations.append((mediaSource: mediaSource, fileURL: fileURL, move: move))
|
||||
if let cacheMediaSourceUsingMoveClosure = cacheMediaSourceUsingMoveClosure {
|
||||
return try cacheMediaSourceUsingMoveClosure(mediaSource, fileURL, move)
|
||||
return cacheMediaSourceUsingMoveClosure(mediaSource, fileURL, move)
|
||||
} else {
|
||||
return cacheMediaSourceUsingMoveReturnValue
|
||||
}
|
||||
@@ -2779,4 +2902,172 @@ class VoiceMessageMediaManagerMock: VoiceMessageMediaManagerProtocol {
|
||||
}
|
||||
}
|
||||
}
|
||||
class VoiceMessageRecorderMock: VoiceMessageRecorderProtocol {
|
||||
var audioRecorder: AudioRecorderProtocol {
|
||||
get { return underlyingAudioRecorder }
|
||||
set(value) { underlyingAudioRecorder = value }
|
||||
}
|
||||
var underlyingAudioRecorder: AudioRecorderProtocol!
|
||||
var previewAudioPlayerState: AudioPlayerState?
|
||||
var recordingURL: URL?
|
||||
var recordingDuration: TimeInterval {
|
||||
get { return underlyingRecordingDuration }
|
||||
set(value) { underlyingRecordingDuration = value }
|
||||
}
|
||||
var underlyingRecordingDuration: TimeInterval!
|
||||
|
||||
//MARK: - startRecording
|
||||
|
||||
var startRecordingCallsCount = 0
|
||||
var startRecordingCalled: Bool {
|
||||
return startRecordingCallsCount > 0
|
||||
}
|
||||
var startRecordingReturnValue: Result<Void, VoiceMessageRecorderError>!
|
||||
var startRecordingClosure: (() async -> Result<Void, VoiceMessageRecorderError>)?
|
||||
|
||||
func startRecording() async -> Result<Void, VoiceMessageRecorderError> {
|
||||
startRecordingCallsCount += 1
|
||||
if let startRecordingClosure = startRecordingClosure {
|
||||
return await startRecordingClosure()
|
||||
} else {
|
||||
return startRecordingReturnValue
|
||||
}
|
||||
}
|
||||
//MARK: - stopRecording
|
||||
|
||||
var stopRecordingCallsCount = 0
|
||||
var stopRecordingCalled: Bool {
|
||||
return stopRecordingCallsCount > 0
|
||||
}
|
||||
var stopRecordingReturnValue: Result<Void, VoiceMessageRecorderError>!
|
||||
var stopRecordingClosure: (() async -> Result<Void, VoiceMessageRecorderError>)?
|
||||
|
||||
func stopRecording() async -> Result<Void, VoiceMessageRecorderError> {
|
||||
stopRecordingCallsCount += 1
|
||||
if let stopRecordingClosure = stopRecordingClosure {
|
||||
return await stopRecordingClosure()
|
||||
} else {
|
||||
return stopRecordingReturnValue
|
||||
}
|
||||
}
|
||||
//MARK: - cancelRecording
|
||||
|
||||
var cancelRecordingCallsCount = 0
|
||||
var cancelRecordingCalled: Bool {
|
||||
return cancelRecordingCallsCount > 0
|
||||
}
|
||||
var cancelRecordingClosure: (() async -> Void)?
|
||||
|
||||
func cancelRecording() async {
|
||||
cancelRecordingCallsCount += 1
|
||||
await cancelRecordingClosure?()
|
||||
}
|
||||
//MARK: - startPlayback
|
||||
|
||||
var startPlaybackCallsCount = 0
|
||||
var startPlaybackCalled: Bool {
|
||||
return startPlaybackCallsCount > 0
|
||||
}
|
||||
var startPlaybackReturnValue: Result<Void, VoiceMessageRecorderError>!
|
||||
var startPlaybackClosure: (() async -> Result<Void, VoiceMessageRecorderError>)?
|
||||
|
||||
func startPlayback() async -> Result<Void, VoiceMessageRecorderError> {
|
||||
startPlaybackCallsCount += 1
|
||||
if let startPlaybackClosure = startPlaybackClosure {
|
||||
return await startPlaybackClosure()
|
||||
} else {
|
||||
return startPlaybackReturnValue
|
||||
}
|
||||
}
|
||||
//MARK: - pausePlayback
|
||||
|
||||
var pausePlaybackCallsCount = 0
|
||||
var pausePlaybackCalled: Bool {
|
||||
return pausePlaybackCallsCount > 0
|
||||
}
|
||||
var pausePlaybackClosure: (() -> Void)?
|
||||
|
||||
func pausePlayback() {
|
||||
pausePlaybackCallsCount += 1
|
||||
pausePlaybackClosure?()
|
||||
}
|
||||
//MARK: - stopPlayback
|
||||
|
||||
var stopPlaybackCallsCount = 0
|
||||
var stopPlaybackCalled: Bool {
|
||||
return stopPlaybackCallsCount > 0
|
||||
}
|
||||
var stopPlaybackClosure: (() async -> Void)?
|
||||
|
||||
func stopPlayback() async {
|
||||
stopPlaybackCallsCount += 1
|
||||
await stopPlaybackClosure?()
|
||||
}
|
||||
//MARK: - seekPlayback
|
||||
|
||||
var seekPlaybackToCallsCount = 0
|
||||
var seekPlaybackToCalled: Bool {
|
||||
return seekPlaybackToCallsCount > 0
|
||||
}
|
||||
var seekPlaybackToReceivedProgress: Double?
|
||||
var seekPlaybackToReceivedInvocations: [Double] = []
|
||||
var seekPlaybackToClosure: ((Double) async -> Void)?
|
||||
|
||||
func seekPlayback(to progress: Double) async {
|
||||
seekPlaybackToCallsCount += 1
|
||||
seekPlaybackToReceivedProgress = progress
|
||||
seekPlaybackToReceivedInvocations.append(progress)
|
||||
await seekPlaybackToClosure?(progress)
|
||||
}
|
||||
//MARK: - deleteRecording
|
||||
|
||||
var deleteRecordingCallsCount = 0
|
||||
var deleteRecordingCalled: Bool {
|
||||
return deleteRecordingCallsCount > 0
|
||||
}
|
||||
var deleteRecordingClosure: (() async -> Void)?
|
||||
|
||||
func deleteRecording() async {
|
||||
deleteRecordingCallsCount += 1
|
||||
await deleteRecordingClosure?()
|
||||
}
|
||||
//MARK: - buildRecordingWaveform
|
||||
|
||||
var buildRecordingWaveformCallsCount = 0
|
||||
var buildRecordingWaveformCalled: Bool {
|
||||
return buildRecordingWaveformCallsCount > 0
|
||||
}
|
||||
var buildRecordingWaveformReturnValue: Result<[UInt16], VoiceMessageRecorderError>!
|
||||
var buildRecordingWaveformClosure: (() async -> Result<[UInt16], VoiceMessageRecorderError>)?
|
||||
|
||||
func buildRecordingWaveform() async -> Result<[UInt16], VoiceMessageRecorderError> {
|
||||
buildRecordingWaveformCallsCount += 1
|
||||
if let buildRecordingWaveformClosure = buildRecordingWaveformClosure {
|
||||
return await buildRecordingWaveformClosure()
|
||||
} else {
|
||||
return buildRecordingWaveformReturnValue
|
||||
}
|
||||
}
|
||||
//MARK: - sendVoiceMessage
|
||||
|
||||
var sendVoiceMessageInRoomAudioConverterCallsCount = 0
|
||||
var sendVoiceMessageInRoomAudioConverterCalled: Bool {
|
||||
return sendVoiceMessageInRoomAudioConverterCallsCount > 0
|
||||
}
|
||||
var sendVoiceMessageInRoomAudioConverterReceivedArguments: (roomProxy: RoomProxyProtocol, audioConverter: AudioConverterProtocol)?
|
||||
var sendVoiceMessageInRoomAudioConverterReceivedInvocations: [(roomProxy: RoomProxyProtocol, audioConverter: AudioConverterProtocol)] = []
|
||||
var sendVoiceMessageInRoomAudioConverterReturnValue: Result<Void, VoiceMessageRecorderError>!
|
||||
var sendVoiceMessageInRoomAudioConverterClosure: ((RoomProxyProtocol, AudioConverterProtocol) async -> Result<Void, VoiceMessageRecorderError>)?
|
||||
|
||||
func sendVoiceMessage(inRoom roomProxy: RoomProxyProtocol, audioConverter: AudioConverterProtocol) async -> Result<Void, VoiceMessageRecorderError> {
|
||||
sendVoiceMessageInRoomAudioConverterCallsCount += 1
|
||||
sendVoiceMessageInRoomAudioConverterReceivedArguments = (roomProxy: roomProxy, audioConverter: audioConverter)
|
||||
sendVoiceMessageInRoomAudioConverterReceivedInvocations.append((roomProxy: roomProxy, audioConverter: audioConverter))
|
||||
if let sendVoiceMessageInRoomAudioConverterClosure = sendVoiceMessageInRoomAudioConverterClosure {
|
||||
return await sendVoiceMessageInRoomAudioConverterClosure(roomProxy, audioConverter)
|
||||
} else {
|
||||
return sendVoiceMessageInRoomAudioConverterReturnValue
|
||||
}
|
||||
}
|
||||
}
|
||||
// swiftlint:enable all
|
||||
|
||||
@@ -0,0 +1,24 @@
|
||||
//
|
||||
// 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 WaveformSource: Equatable {
|
||||
/// File URL of the source audio file
|
||||
case url(URL)
|
||||
/// Array of small number of pre-computed samples
|
||||
case data([Float])
|
||||
}
|
||||
@@ -0,0 +1,98 @@
|
||||
//
|
||||
// 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 DSWaveformImageViews
|
||||
import Foundation
|
||||
import SwiftUI
|
||||
|
||||
struct WaveformViewDragGestureModifier: ViewModifier {
|
||||
@GestureState private var dragGestureState = WaveformViewDragState.inactive
|
||||
@Binding var dragState: WaveformViewDragState
|
||||
|
||||
let minimumDragDistance: Double
|
||||
|
||||
func body(content: Content) -> some View {
|
||||
GeometryReader { geometry in
|
||||
content
|
||||
.gesture(SpatialTapGesture()
|
||||
.simultaneously(with: LongPressGesture())
|
||||
.sequenced(before: DragGesture(minimumDistance: minimumDragDistance, coordinateSpace: .local))
|
||||
.updating($dragGestureState) { value, state, _ in
|
||||
switch value {
|
||||
// (SpatialTap, LongPress) begins.
|
||||
case .first(let spatialLongPress):
|
||||
// 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 ?? false:
|
||||
var progress: Double = dragState.progress
|
||||
// Compute the progress with drag location
|
||||
if let location = drag?.location {
|
||||
progress = location.x / geometry.size.width
|
||||
}
|
||||
state = .dragging(progress: progress)
|
||||
// Dragging ended or the long press cancelled.
|
||||
default:
|
||||
state = .inactive
|
||||
}
|
||||
})
|
||||
}
|
||||
.onChange(of: dragGestureState) { value in
|
||||
dragState = value
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
extension View {
|
||||
func waveformDragGesture(_ dragState: Binding<WaveformViewDragState>, minimumDragDistance: Double = 0) -> some View {
|
||||
modifier(WaveformViewDragGestureModifier(dragState: dragState,
|
||||
minimumDragDistance: minimumDragDistance))
|
||||
}
|
||||
}
|
||||
|
||||
enum WaveformViewDragState: Equatable {
|
||||
case inactive
|
||||
case pressing(progress: Double)
|
||||
case dragging(progress: Double)
|
||||
|
||||
var progress: Double {
|
||||
switch self {
|
||||
case .inactive:
|
||||
return .zero
|
||||
case .pressing(let progress), .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
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -31,9 +31,13 @@ enum ComposerToolbarViewModelAction {
|
||||
case composerModeChanged(mode: RoomScreenComposerMode)
|
||||
case composerFocusedChanged(isFocused: Bool)
|
||||
|
||||
case startRecordingVoiceMessage
|
||||
case stopRecordingVoiceMessage
|
||||
case deleteRecordedVoiceMessage
|
||||
case startVoiceMessageRecording
|
||||
case stopVoiceMessageRecording
|
||||
case cancelVoiceMessageRecording
|
||||
case deleteVoiceMessageRecording
|
||||
case startVoiceMessagePlayback
|
||||
case pauseVoiceMessagePlayback
|
||||
case seekVoiceMessagePlayback(progress: Double)
|
||||
case sendVoiceMessage
|
||||
}
|
||||
|
||||
@@ -51,9 +55,13 @@ enum ComposerToolbarViewAction {
|
||||
case enableTextFormatting
|
||||
case composerAction(action: ComposerAction)
|
||||
case selectedSuggestion(_ suggestion: SuggestionItem)
|
||||
case startRecordingVoiceMessage
|
||||
case stopRecordingVoiceMessage
|
||||
case deleteRecordedVoiceMessage
|
||||
case startVoiceMessageRecording
|
||||
case stopVoiceMessageRecording
|
||||
case cancelVoiceMessageRecording
|
||||
case deleteVoiceMessageRecording
|
||||
case startVoiceMessagePlayback
|
||||
case pauseVoiceMessagePlayback
|
||||
case seekVoiceMessagePlayback(progress: Double)
|
||||
}
|
||||
|
||||
struct ComposerToolbarViewState: BindableState {
|
||||
|
||||
@@ -47,7 +47,7 @@ final class ComposerToolbarViewModel: ComposerToolbarViewModelType, ComposerTool
|
||||
|
||||
super.init(initialViewState: ComposerToolbarViewState(areSuggestionsEnabled: completionSuggestionService.areSuggestionsEnabled,
|
||||
enableVoiceMessageComposer: appSettings.voiceMessageEnabled,
|
||||
audioPlayerState: .init(duration: 0),
|
||||
audioPlayerState: .init(id: .recorderPreview, duration: 0),
|
||||
audioRecorderState: .init(),
|
||||
bindings: .init()),
|
||||
imageProvider: mediaProvider)
|
||||
@@ -144,13 +144,21 @@ final class ComposerToolbarViewModel: ComposerToolbarViewModelType, ComposerTool
|
||||
}
|
||||
case .selectedSuggestion(let suggestion):
|
||||
handleSuggestion(suggestion)
|
||||
case .startRecordingVoiceMessage:
|
||||
case .startVoiceMessageRecording:
|
||||
state.bindings.composerActionsEnabled = false
|
||||
actionsSubject.send(.startRecordingVoiceMessage)
|
||||
case .stopRecordingVoiceMessage:
|
||||
actionsSubject.send(.stopRecordingVoiceMessage)
|
||||
case .deleteRecordedVoiceMessage:
|
||||
actionsSubject.send(.deleteRecordedVoiceMessage)
|
||||
actionsSubject.send(.startVoiceMessageRecording)
|
||||
case .stopVoiceMessageRecording:
|
||||
actionsSubject.send(.stopVoiceMessageRecording)
|
||||
case .cancelVoiceMessageRecording:
|
||||
actionsSubject.send(.cancelVoiceMessageRecording)
|
||||
case .deleteVoiceMessageRecording:
|
||||
actionsSubject.send(.deleteVoiceMessageRecording)
|
||||
case .startVoiceMessagePlayback:
|
||||
actionsSubject.send(.startVoiceMessagePlayback)
|
||||
case .pauseVoiceMessagePlayback:
|
||||
actionsSubject.send(.pauseVoiceMessagePlayback)
|
||||
case .seekVoiceMessagePlayback(let progress):
|
||||
actionsSubject.send(.seekVoiceMessagePlayback(progress: progress))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -224,7 +232,7 @@ final class ComposerToolbarViewModel: ComposerToolbarViewModelType, ComposerTool
|
||||
case .recordVoiceMessage(let audioRecorderState):
|
||||
state.bindings.composerFocused = false
|
||||
state.audioRecorderState = audioRecorderState
|
||||
case .previewVoiceMessage(let audioPlayerState):
|
||||
case .previewVoiceMessage(let audioPlayerState, _):
|
||||
state.audioPlayerState = audioPlayerState
|
||||
case .edit, .reply:
|
||||
// Focus composer when switching to reply/edit
|
||||
|
||||
@@ -28,9 +28,13 @@ struct ComposerToolbar: View {
|
||||
@ScaledMetric private var trashButtonIconSize = 24
|
||||
@ScaledMetric(relativeTo: .title) private var closeRTEButtonSize = 30
|
||||
|
||||
@State private var voiceMessageRecordingStartTime: Date?
|
||||
@State private var showVoiceMessageRecordingTooltip = false
|
||||
@ScaledMetric private var voiceMessageTooltipPointerHeight = 6
|
||||
|
||||
private let voiceMessageMinimumRecordingDuration = 1.0
|
||||
private let voiceMessageTooltipDuration = 1.0
|
||||
|
||||
@State private var frame: CGRect = .zero
|
||||
|
||||
var body: some View {
|
||||
@@ -51,8 +55,10 @@ struct ComposerToolbar: View {
|
||||
}
|
||||
}
|
||||
.overlay(alignment: .bottomTrailing) {
|
||||
voiceMessageRecordingButtonTooltipView
|
||||
.offset(y: -frame.height - voiceMessageTooltipPointerHeight)
|
||||
if showVoiceMessageRecordingTooltip {
|
||||
voiceMessageRecordingButtonTooltipView
|
||||
.offset(y: -frame.height - voiceMessageTooltipPointerHeight)
|
||||
}
|
||||
}
|
||||
.alert(item: $context.alertInfo)
|
||||
}
|
||||
@@ -69,9 +75,9 @@ struct ComposerToolbar: View {
|
||||
case .recordVoiceMessage(let state) where context.viewState.enableVoiceMessageComposer:
|
||||
VoiceMessageRecordingComposer(recorderState: state)
|
||||
.padding(.leading, 12)
|
||||
case .previewVoiceMessage(let state) where context.viewState.enableVoiceMessageComposer:
|
||||
case .previewVoiceMessage(let state, let waveform) where context.viewState.enableVoiceMessageComposer:
|
||||
voiceMessageTrashButton
|
||||
VoiceMessagePreviewComposer(playerState: state)
|
||||
voiceMessagePreviewComposer(audioPlayerState: state, waveform: waveform)
|
||||
default:
|
||||
if !context.composerActionsEnabled {
|
||||
RoomAttachmentPicker(context: context)
|
||||
@@ -96,6 +102,7 @@ struct ComposerToolbar: View {
|
||||
}
|
||||
}
|
||||
}
|
||||
.animation(.elementDefault, value: context.viewState.composerMode)
|
||||
}
|
||||
|
||||
private var bottomBar: some View {
|
||||
@@ -216,17 +223,26 @@ struct ComposerToolbar: View {
|
||||
// MARK: - Voice message
|
||||
|
||||
private var voiceMessageRecordingButton: some View {
|
||||
VoiceMessageRecordingButton(showRecordTooltip: $showVoiceMessageRecordingTooltip, startRecording: {
|
||||
context.send(viewAction: .startRecordingVoiceMessage)
|
||||
}, stopRecording: {
|
||||
context.send(viewAction: .stopRecordingVoiceMessage)
|
||||
})
|
||||
VoiceMessageRecordingButton {
|
||||
showVoiceMessageRecordingTooltip = false
|
||||
voiceMessageRecordingStartTime = Date.now
|
||||
context.send(viewAction: .startVoiceMessageRecording)
|
||||
} stopRecording: {
|
||||
if let voiceMessageRecordingStartTime, Date.now.timeIntervalSince(voiceMessageRecordingStartTime) < voiceMessageMinimumRecordingDuration {
|
||||
context.send(viewAction: .cancelVoiceMessageRecording)
|
||||
withAnimation {
|
||||
showVoiceMessageRecordingTooltip = true
|
||||
}
|
||||
} else {
|
||||
context.send(viewAction: .stopVoiceMessageRecording)
|
||||
}
|
||||
}
|
||||
.padding(4)
|
||||
}
|
||||
|
||||
private var voiceMessageTrashButton: some View {
|
||||
Button {
|
||||
context.send(viewAction: .deleteRecordedVoiceMessage)
|
||||
context.send(viewAction: .deleteVoiceMessageRecording)
|
||||
} label: {
|
||||
CompoundIcon(\.delete)
|
||||
.font(.compound.bodyLG)
|
||||
@@ -241,8 +257,23 @@ struct ComposerToolbar: View {
|
||||
private var voiceMessageRecordingButtonTooltipView: some View {
|
||||
VoiceMessageRecordingButtonTooltipView(text: L10n.screenRoomVoiceMessageTooltip, pointerHeight: voiceMessageTooltipPointerHeight)
|
||||
.allowsHitTesting(false)
|
||||
.opacity(showVoiceMessageRecordingTooltip ? 1.0 : 0.0)
|
||||
.animation(.elementDefault, value: showVoiceMessageRecordingTooltip)
|
||||
.onAppear {
|
||||
DispatchQueue.main.asyncAfter(deadline: .now() + voiceMessageTooltipDuration) {
|
||||
withAnimation {
|
||||
showVoiceMessageRecordingTooltip = false
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func voiceMessagePreviewComposer(audioPlayerState: AudioPlayerState, waveform: WaveformSource) -> some View {
|
||||
VoiceMessagePreviewComposer(playerState: audioPlayerState, waveform: waveform) {
|
||||
context.send(viewAction: .startVoiceMessagePlayback)
|
||||
} onPause: {
|
||||
context.send(viewAction: .pauseVoiceMessagePlayback)
|
||||
} onSeek: { progress in
|
||||
context.send(viewAction: .seekVoiceMessagePlayback(progress: progress))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -333,13 +364,14 @@ extension ComposerToolbar {
|
||||
|
||||
static func voiceMessagePreviewMock(recording: Bool) -> ComposerToolbar {
|
||||
let wysiwygViewModel = WysiwygComposerViewModel()
|
||||
let waveformData: [Float] = Array(repeating: 1.0, count: 1000)
|
||||
var composerViewModel: ComposerToolbarViewModel {
|
||||
let model = ComposerToolbarViewModel(wysiwygViewModel: wysiwygViewModel,
|
||||
completionSuggestionService: CompletionSuggestionServiceMock(configuration: .init()),
|
||||
mediaProvider: MockMediaProvider(),
|
||||
appSettings: ServiceLocator.shared.settings,
|
||||
mentionDisplayHelper: ComposerMentionDisplayHelper.mock)
|
||||
model.state.composerMode = .previewVoiceMessage(state: AudioPlayerState(duration: 10.0))
|
||||
model.state.composerMode = .previewVoiceMessage(state: AudioPlayerState(id: .recorderPreview, duration: 10.0), waveform: .data(waveformData))
|
||||
model.state.enableVoiceMessageComposer = true
|
||||
return model
|
||||
}
|
||||
|
||||
@@ -14,49 +14,161 @@
|
||||
// limitations under the License.
|
||||
//
|
||||
|
||||
import DSWaveformImage
|
||||
import DSWaveformImageViews
|
||||
import Foundation
|
||||
import SwiftUI
|
||||
|
||||
struct VoiceMessagePreviewComposer: View {
|
||||
@ObservedObject var playerState: AudioPlayerState
|
||||
|
||||
let waveform: WaveformSource
|
||||
@ScaledMetric private var waveformLineWidth = 2.0
|
||||
@ScaledMetric private var waveformLinePadding = 2.0
|
||||
@State private var resumePlaybackAfterScrubbing = false
|
||||
|
||||
let onPlay: () -> Void
|
||||
let onPause: () -> Void
|
||||
let onSeek: (Double) -> Void
|
||||
|
||||
var body: some View {
|
||||
VoiceMessageRoomPlaybackView(playerState: playerState,
|
||||
onPlayPause: onPlaybackPlayPause,
|
||||
onSeek: { onPlaybackSeek($0) },
|
||||
onScrubbing: { onPlaybackScrubbing($0) })
|
||||
.padding(.vertical, 4.0)
|
||||
.padding(.horizontal, 6.0)
|
||||
.background {
|
||||
let roundedRectangle = RoundedRectangle(cornerRadius: 12)
|
||||
ZStack {
|
||||
roundedRectangle
|
||||
.fill(Color.compound.bgSubtleSecondary)
|
||||
roundedRectangle
|
||||
.stroke(Color.compound._borderTextFieldFocused, lineWidth: 0.5)
|
||||
}
|
||||
}
|
||||
.frame(minHeight: 42)
|
||||
.fixedSize(horizontal: false, vertical: true)
|
||||
@ScaledMetric private var playPauseButtonSize = 32
|
||||
@ScaledMetric private var playPauseImagePadding = 8
|
||||
@State var dragState: WaveformViewDragState = .inactive
|
||||
|
||||
private static let elapsedTimeFormatter: DateFormatter = {
|
||||
let dateFormatter = DateFormatter()
|
||||
dateFormatter.dateFormat = "mm:ss"
|
||||
return dateFormatter
|
||||
}()
|
||||
|
||||
var timeLabelContent: String {
|
||||
// Display the duration if progress is 0.0
|
||||
let percent = playerState.progress > 0.0 ? playerState.progress : 1.0
|
||||
// If the duration is greater or equal 10 minutes, use the long format
|
||||
let elapsed = Date(timeIntervalSinceReferenceDate: playerState.duration * percent)
|
||||
return Self.elapsedTimeFormatter.string(from: elapsed)
|
||||
}
|
||||
|
||||
private func onPlaybackPlayPause() { }
|
||||
var showWaveformCursor: Bool {
|
||||
playerState.playbackState == .playing || dragState.isDragging
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
HStack {
|
||||
HStack {
|
||||
playPauseButton
|
||||
Text(timeLabelContent)
|
||||
.lineLimit(1)
|
||||
.font(.compound.bodySMSemibold)
|
||||
.foregroundColor(.compound.textSecondary)
|
||||
.monospacedDigit()
|
||||
.fixedSize(horizontal: true, vertical: true)
|
||||
}
|
||||
waveformView
|
||||
.waveformDragGesture($dragState)
|
||||
.onChange(of: dragState) { dragState in
|
||||
switch dragState {
|
||||
case .inactive:
|
||||
onScrubbing(false)
|
||||
case .pressing(let progress):
|
||||
onScrubbing(true)
|
||||
onSeek(max(0, min(progress, 1.0)))
|
||||
case .dragging(let progress):
|
||||
onSeek(max(0, min(progress, 1.0)))
|
||||
}
|
||||
self.dragState = dragState
|
||||
}
|
||||
}
|
||||
.padding(.vertical, 4.0)
|
||||
.padding(.horizontal, 6.0)
|
||||
.background {
|
||||
let roundedRectangle = RoundedRectangle(cornerRadius: 12)
|
||||
ZStack {
|
||||
roundedRectangle
|
||||
.fill(Color.compound.bgSubtleSecondary)
|
||||
roundedRectangle
|
||||
.stroke(Color.compound._borderTextFieldFocused, lineWidth: 0.5)
|
||||
}
|
||||
}
|
||||
.frame(minHeight: 42)
|
||||
.fixedSize(horizontal: false, vertical: true)
|
||||
}
|
||||
|
||||
private func onPlaybackSeek(_ progress: Double) { }
|
||||
@ViewBuilder
|
||||
private var waveformView: some View {
|
||||
let configuration: Waveform.Configuration = .init(style: .striped(.init(color: .black, width: waveformLineWidth, spacing: waveformLinePadding)),
|
||||
verticalScalingFactor: 1.0)
|
||||
switch waveform {
|
||||
case .url(let url):
|
||||
WaveformView(audioURL: url,
|
||||
configuration: configuration)
|
||||
.progressMask(progress: playerState.progress)
|
||||
case .data(let array):
|
||||
WaveformLiveCanvas(samples: array,
|
||||
configuration: configuration)
|
||||
.progressMask(progress: playerState.progress)
|
||||
}
|
||||
}
|
||||
|
||||
@ViewBuilder
|
||||
private var playPauseButton: some View {
|
||||
Button {
|
||||
onPlayPause()
|
||||
} label: {
|
||||
ZStack {
|
||||
Circle()
|
||||
.foregroundColor(.compound.bgCanvasDefault)
|
||||
if playerState.playbackState == .loading {
|
||||
ProgressView()
|
||||
} else {
|
||||
Image(asset: playerState.playbackState == .playing ? Asset.Images.mediaPause : Asset.Images.mediaPlay)
|
||||
.resizable()
|
||||
.padding(playPauseImagePadding)
|
||||
.offset(x: playerState.playbackState == .playing ? 0 : 2)
|
||||
.aspectRatio(contentMode: .fit)
|
||||
.foregroundColor(.compound.iconSecondary)
|
||||
.accessibilityLabel(playerState.playbackState == .playing ? L10n.a11yPause : L10n.a11yPlay)
|
||||
}
|
||||
}
|
||||
}
|
||||
.disabled(playerState.playbackState == .loading)
|
||||
.frame(width: playPauseButtonSize,
|
||||
height: playPauseButtonSize)
|
||||
}
|
||||
|
||||
private func onPlaybackScrubbing(_ dragging: Bool) { }
|
||||
private func onPlayPause() {
|
||||
if playerState.playbackState == .playing {
|
||||
onPause()
|
||||
} else {
|
||||
onPlay()
|
||||
}
|
||||
}
|
||||
|
||||
private func onScrubbing(_ scrubbing: Bool) {
|
||||
if scrubbing {
|
||||
if playerState.playbackState == .playing {
|
||||
resumePlaybackAfterScrubbing = true
|
||||
onPause()
|
||||
}
|
||||
} else {
|
||||
if resumePlaybackAfterScrubbing {
|
||||
onPlay()
|
||||
resumePlaybackAfterScrubbing = false
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
struct VoiceMessagePreviewComposer_Previews: PreviewProvider, TestablePreview {
|
||||
static let playerState = AudioPlayerState(duration: 10.0,
|
||||
static let playerState = AudioPlayerState(id: .recorderPreview,
|
||||
duration: 10.0,
|
||||
waveform: EstimatedWaveform.mockWaveform,
|
||||
progress: 0.4)
|
||||
|
||||
static let waveformData: [Float] = Array(repeating: 1.0, count: 1000)
|
||||
|
||||
static var previews: some View {
|
||||
VStack {
|
||||
VoiceMessagePreviewComposer(playerState: playerState)
|
||||
VoiceMessagePreviewComposer(playerState: playerState, waveform: .data(waveformData), onPlay: { }, onPause: { }, onSeek: { _ in })
|
||||
.fixedSize(horizontal: false, vertical: true)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -19,14 +19,8 @@ import SwiftUI
|
||||
|
||||
struct VoiceMessageRecordingButton: View {
|
||||
@ScaledMetric private var buttonIconSize = 24
|
||||
@State private var longPressConfirmed = false
|
||||
@State private var buttonPressed = false
|
||||
@State private var longPressTask = VoiceMessageButtonTask()
|
||||
|
||||
private let feedbackGenerator = UIImpactFeedbackGenerator(style: .heavy)
|
||||
private let delayBeforeRecording = 500
|
||||
|
||||
@Binding var showRecordTooltip: Bool
|
||||
var startRecording: (() -> Void)?
|
||||
var stopRecording: (() -> Void)?
|
||||
|
||||
@@ -34,29 +28,16 @@ struct VoiceMessageRecordingButton: View {
|
||||
Button { } label: {
|
||||
voiceMessageButtonImage
|
||||
}
|
||||
.onLongPressGesture(perform: { }, onPressingChanged: { pressing in
|
||||
buttonPressed = pressing
|
||||
if pressing {
|
||||
showRecordTooltip = true
|
||||
feedbackGenerator.prepare()
|
||||
longPressTask.task = Task {
|
||||
try? await Task.sleep(for: .milliseconds(delayBeforeRecording))
|
||||
guard !Task.isCancelled else {
|
||||
return
|
||||
}
|
||||
feedbackGenerator.impactOccurred()
|
||||
showRecordTooltip = false
|
||||
longPressConfirmed = true
|
||||
startRecording?()
|
||||
}
|
||||
.onLongPressGesture { } onPressingChanged: { isPressing in
|
||||
buttonPressed = isPressing
|
||||
if isPressing {
|
||||
// Start recording
|
||||
startRecording?()
|
||||
} else {
|
||||
longPressTask.task?.cancel()
|
||||
showRecordTooltip = false
|
||||
guard longPressConfirmed else { return }
|
||||
longPressConfirmed = false
|
||||
// Stop recording
|
||||
stopRecording?()
|
||||
}
|
||||
})
|
||||
}
|
||||
.fixedSize()
|
||||
}
|
||||
|
||||
@@ -71,13 +52,9 @@ struct VoiceMessageRecordingButton: View {
|
||||
}
|
||||
}
|
||||
|
||||
private class VoiceMessageButtonTask {
|
||||
@CancellableTask var task: Task<Void, Never>?
|
||||
}
|
||||
|
||||
struct VoiceMessageRecordingButton_Previews: PreviewProvider, TestablePreview {
|
||||
static var previews: some View {
|
||||
VoiceMessageRecordingButton(showRecordTooltip: .constant(false))
|
||||
VoiceMessageRecordingButton()
|
||||
.fixedSize(horizontal: true, vertical: true)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -15,6 +15,8 @@
|
||||
//
|
||||
|
||||
import Compound
|
||||
import DSWaveformImage
|
||||
import DSWaveformImageViews
|
||||
import Foundation
|
||||
import SwiftUI
|
||||
|
||||
@@ -30,10 +32,15 @@ struct VoiceMessageRecordingView: View {
|
||||
return dateFormatter
|
||||
}()
|
||||
|
||||
var timeLabelContent: String {
|
||||
private var timeLabelContent: String {
|
||||
Self.elapsedTimeFormatter.string(from: Date(timeIntervalSinceReferenceDate: recorderState.duration))
|
||||
}
|
||||
|
||||
private var configuration: Waveform.Configuration {
|
||||
.init(style: .striped(.init(color: .compound.iconSecondary, width: waveformLineWidth, spacing: waveformLinePadding)),
|
||||
verticalScalingFactor: 1.0)
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
HStack {
|
||||
Circle()
|
||||
@@ -45,7 +52,9 @@ struct VoiceMessageRecordingView: View {
|
||||
.foregroundColor(.compound.textSecondary)
|
||||
.monospacedDigit()
|
||||
.fixedSize()
|
||||
EstimatedWaveformView(lineWidth: waveformLineWidth, linePadding: waveformLinePadding, waveform: recorderState.waveform, progress: 0)
|
||||
|
||||
WaveformLiveCanvas(samples: recorderState.waveformSamples,
|
||||
configuration: configuration)
|
||||
}
|
||||
.padding(.leading, 2)
|
||||
.padding(.trailing, 8)
|
||||
@@ -53,11 +62,6 @@ struct VoiceMessageRecordingView: View {
|
||||
}
|
||||
|
||||
struct VoiceMessageRecordingView_Previews: PreviewProvider, TestablePreview {
|
||||
static let waveform = EstimatedWaveform(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 recorderState = AudioRecorderState()
|
||||
|
||||
static var previews: some View {
|
||||
|
||||
@@ -23,6 +23,7 @@ struct RoomScreenCoordinatorParameters {
|
||||
let roomProxy: RoomProxyProtocol
|
||||
let timelineController: RoomTimelineControllerProtocol
|
||||
let mediaProvider: MediaProviderProtocol
|
||||
let mediaPlayerProvider: MediaPlayerProviderProtocol
|
||||
let emojiProvider: EmojiProviderProtocol
|
||||
let completionSuggestionService: CompletionSuggestionServiceProtocol
|
||||
let appSettings: AppSettings
|
||||
@@ -60,6 +61,7 @@ final class RoomScreenCoordinator: CoordinatorProtocol {
|
||||
|
||||
viewModel = RoomScreenViewModel(timelineController: parameters.timelineController,
|
||||
mediaProvider: parameters.mediaProvider,
|
||||
mediaPlayerProvider: parameters.mediaPlayerProvider,
|
||||
roomProxy: parameters.roomProxy,
|
||||
appSettings: parameters.appSettings,
|
||||
analytics: ServiceLocator.shared.analytics,
|
||||
|
||||
@@ -42,7 +42,7 @@ enum RoomScreenComposerMode: Equatable {
|
||||
case reply(itemID: TimelineItemIdentifier, replyDetails: TimelineItemReplyDetails, isThread: Bool)
|
||||
case edit(originalItemId: TimelineItemIdentifier)
|
||||
case recordVoiceMessage(state: AudioRecorderState)
|
||||
case previewVoiceMessage(state: AudioPlayerState)
|
||||
case previewVoiceMessage(state: AudioPlayerState, waveform: WaveformSource)
|
||||
|
||||
var isEdit: Bool {
|
||||
switch self {
|
||||
|
||||
@@ -34,16 +34,16 @@ class RoomScreenViewModel: RoomScreenViewModelType, RoomScreenViewModelProtocol
|
||||
private let analytics: AnalyticsService
|
||||
private unowned let userIndicatorController: UserIndicatorControllerProtocol
|
||||
private let notificationCenterProtocol: NotificationCenterProtocol
|
||||
private let voiceMessageRecorder: VoiceMessageRecorderProtocol
|
||||
private let composerFocusedSubject = PassthroughSubject<Bool, Never>()
|
||||
|
||||
private let mediaPlayerProvider: MediaPlayerProviderProtocol
|
||||
private let actionsSubject: PassthroughSubject<RoomScreenViewModelAction, Never> = .init()
|
||||
|
||||
private var canCurrentUserRedact = false
|
||||
|
||||
private var paginateBackwardsTask: Task<Void, Never>?
|
||||
|
||||
init(timelineController: RoomTimelineControllerProtocol,
|
||||
mediaProvider: MediaProviderProtocol,
|
||||
mediaPlayerProvider: MediaPlayerProviderProtocol,
|
||||
roomProxy: RoomProxyProtocol,
|
||||
appSettings: AppSettings,
|
||||
analytics: AnalyticsService,
|
||||
@@ -55,6 +55,8 @@ class RoomScreenViewModel: RoomScreenViewModelType, RoomScreenViewModelProtocol
|
||||
self.analytics = analytics
|
||||
self.userIndicatorController = userIndicatorController
|
||||
self.notificationCenterProtocol = notificationCenterProtocol
|
||||
self.mediaPlayerProvider = mediaPlayerProvider
|
||||
voiceMessageRecorder = VoiceMessageRecorder(audioRecorder: AudioRecorder(), mediaPlayerProvider: mediaPlayerProvider)
|
||||
|
||||
super.init(initialViewState: RoomScreenViewState(roomID: timelineController.roomID,
|
||||
roomTitle: roomProxy.roomTitle,
|
||||
@@ -194,15 +196,28 @@ class RoomScreenViewModel: RoomScreenViewModelType, RoomScreenViewModelProtocol
|
||||
trackComposerMode(mode)
|
||||
case .composerFocusedChanged(isFocused: let isFocused):
|
||||
composerFocusedSubject.send(isFocused)
|
||||
case .startRecordingVoiceMessage:
|
||||
timelineController.pauseAudio()
|
||||
startRecordingVoiceMessage()
|
||||
case .stopRecordingVoiceMessage:
|
||||
stopRecordingVoiceMessage()
|
||||
case .deleteRecordedVoiceMessage:
|
||||
deleteCurrentVoiceMessage()
|
||||
case .startVoiceMessageRecording:
|
||||
Task {
|
||||
await mediaPlayerProvider.detachAllStates(except: nil)
|
||||
await startRecordingVoiceMessage()
|
||||
}
|
||||
case .stopVoiceMessageRecording:
|
||||
Task { await stopRecordingVoiceMessage() }
|
||||
case .cancelVoiceMessageRecording:
|
||||
Task { await cancelRecordingVoiceMessage() }
|
||||
case .deleteVoiceMessageRecording:
|
||||
Task { await deleteCurrentVoiceMessage() }
|
||||
case .sendVoiceMessage:
|
||||
Task { await sendCurrentVoiceMessage() }
|
||||
case .startVoiceMessagePlayback:
|
||||
Task {
|
||||
await mediaPlayerProvider.detachAllStates(except: voiceMessageRecorder.previewAudioPlayerState)
|
||||
await startPlayingRecordedVoiceMessage()
|
||||
}
|
||||
case .pauseVoiceMessagePlayback:
|
||||
pausePlayingRecordedVoiceMessage()
|
||||
case .seekVoiceMessagePlayback(let progress):
|
||||
Task { await seekRecordedVoiceMessage(to: progress) }
|
||||
}
|
||||
}
|
||||
|
||||
@@ -922,30 +937,89 @@ class RoomScreenViewModel: RoomScreenViewModelType, RoomScreenViewModelProtocol
|
||||
|
||||
// MARK: - Voice message
|
||||
|
||||
private func startRecordingVoiceMessage() {
|
||||
// Partially implemented
|
||||
|
||||
private func stopVoiceMessageRecorder() async {
|
||||
_ = await voiceMessageRecorder.stopRecording()
|
||||
await voiceMessageRecorder.stopPlayback()
|
||||
}
|
||||
|
||||
private func startRecordingVoiceMessage() async {
|
||||
let audioRecordState = AudioRecorderState()
|
||||
actionsSubject.send(.composer(action: .setMode(mode: .recordVoiceMessage(state: audioRecordState))))
|
||||
audioRecordState.attachAudioRecorder(voiceMessageRecorder.audioRecorder)
|
||||
|
||||
switch await voiceMessageRecorder.startRecording() {
|
||||
case .success:
|
||||
actionsSubject.send(.composer(action: .setMode(mode: .recordVoiceMessage(state: audioRecordState))))
|
||||
case .failure(let error):
|
||||
switch error {
|
||||
case .audioRecorderError(.recordPermissionNotGranted):
|
||||
state.bindings.confirmationAlertInfo = .init(id: .init(),
|
||||
title: "",
|
||||
message: L10n.dialogPermissionMicrophone,
|
||||
primaryButton: .init(title: L10n.actionOpenSettings, action: { [weak self] in self?.openSystemSettings() }),
|
||||
secondaryButton: .init(title: L10n.actionNotNow, role: .cancel, action: nil))
|
||||
default:
|
||||
MXLog.error("failed to start voice message recording: \(error)")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func stopRecordingVoiceMessage() {
|
||||
// Partially implemented
|
||||
private func stopRecordingVoiceMessage() async {
|
||||
if case .failure(let error) = await voiceMessageRecorder.stopRecording() {
|
||||
MXLog.error("failed to stop the recording", context: error)
|
||||
return
|
||||
}
|
||||
|
||||
let audioPlayerState = AudioPlayerState(duration: 0)
|
||||
actionsSubject.send(.composer(action: .setMode(mode: .previewVoiceMessage(state: audioPlayerState))))
|
||||
guard let audioPlayerState = voiceMessageRecorder.previewAudioPlayerState else {
|
||||
MXLog.error("the recorder preview is missing after the recording has been stopped")
|
||||
return
|
||||
}
|
||||
|
||||
guard let recordingURL = voiceMessageRecorder.recordingURL else {
|
||||
MXLog.error("the recording URL is missing after the recording has been stopped")
|
||||
return
|
||||
}
|
||||
|
||||
mediaPlayerProvider.register(audioPlayerState: audioPlayerState)
|
||||
actionsSubject.send(.composer(action: .setMode(mode: .previewVoiceMessage(state: audioPlayerState, waveform: .url(recordingURL)))))
|
||||
}
|
||||
|
||||
private func deleteCurrentVoiceMessage() {
|
||||
// Partially implemented
|
||||
|
||||
private func cancelRecordingVoiceMessage() async {
|
||||
await voiceMessageRecorder.cancelRecording()
|
||||
actionsSubject.send(.composer(action: .setMode(mode: .default)))
|
||||
}
|
||||
|
||||
private func deleteCurrentVoiceMessage() async {
|
||||
await voiceMessageRecorder.deleteRecording()
|
||||
actionsSubject.send(.composer(action: .setMode(mode: .default)))
|
||||
}
|
||||
|
||||
private func sendCurrentVoiceMessage() async {
|
||||
// Partially implemented
|
||||
|
||||
actionsSubject.send(.composer(action: .setMode(mode: .default)))
|
||||
await voiceMessageRecorder.stopPlayback()
|
||||
switch await voiceMessageRecorder.sendVoiceMessage(inRoom: roomProxy, audioConverter: AudioConverter()) {
|
||||
case .success:
|
||||
await deleteCurrentVoiceMessage()
|
||||
case .failure(let error):
|
||||
MXLog.error("failed to send the voice message", context: error)
|
||||
}
|
||||
}
|
||||
|
||||
private func startPlayingRecordedVoiceMessage() async {
|
||||
if case .failure(let error) = await voiceMessageRecorder.startPlayback() {
|
||||
MXLog.error("failed to play recorded voice message", context: error)
|
||||
}
|
||||
}
|
||||
|
||||
private func pausePlayingRecordedVoiceMessage() {
|
||||
voiceMessageRecorder.pausePlayback()
|
||||
}
|
||||
|
||||
private func seekRecordedVoiceMessage(to progress: Double) async {
|
||||
await voiceMessageRecorder.seekPlayback(to: progress)
|
||||
}
|
||||
|
||||
private func openSystemSettings() {
|
||||
guard let url = URL(string: UIApplication.openSettingsURLString) else { return }
|
||||
UIApplication.shared.open(url)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -974,6 +1048,7 @@ extension RoomScreenViewModel.Context {
|
||||
extension RoomScreenViewModel {
|
||||
static let mock = RoomScreenViewModel(timelineController: MockRoomTimelineController(),
|
||||
mediaProvider: MockMediaProvider(),
|
||||
mediaPlayerProvider: MediaPlayerProviderMock(),
|
||||
roomProxy: RoomProxyMock(with: .init(displayName: "Preview room")),
|
||||
appSettings: ServiceLocator.shared.settings,
|
||||
analytics: ServiceLocator.shared.analytics,
|
||||
|
||||
@@ -57,6 +57,7 @@ struct RoomHeaderView_Previews: PreviewProvider, TestablePreview {
|
||||
static var bodyPlain: some View {
|
||||
let viewModel = RoomScreenViewModel(timelineController: MockRoomTimelineController(),
|
||||
mediaProvider: MockMediaProvider(),
|
||||
mediaPlayerProvider: MediaPlayerProviderMock(),
|
||||
roomProxy: RoomProxyMock(with: .init(displayName: "Some Room name", avatarURL: URL.picturesDirectory)),
|
||||
appSettings: ServiceLocator.shared.settings,
|
||||
analytics: ServiceLocator.shared.analytics,
|
||||
@@ -71,6 +72,7 @@ struct RoomHeaderView_Previews: PreviewProvider, TestablePreview {
|
||||
static var bodyEncrypted: some View {
|
||||
let viewModel = RoomScreenViewModel(timelineController: MockRoomTimelineController(),
|
||||
mediaProvider: MockMediaProvider(),
|
||||
mediaPlayerProvider: MediaPlayerProviderMock(),
|
||||
roomProxy: RoomProxyMock(with: .init(displayName: "Some Room name")),
|
||||
appSettings: ServiceLocator.shared.settings,
|
||||
analytics: ServiceLocator.shared.analytics,
|
||||
|
||||
@@ -193,6 +193,7 @@ struct RoomScreen: View {
|
||||
struct RoomScreen_Previews: PreviewProvider, TestablePreview {
|
||||
static let viewModel = RoomScreenViewModel(timelineController: MockRoomTimelineController(),
|
||||
mediaProvider: MockMediaProvider(),
|
||||
mediaPlayerProvider: MediaPlayerProviderMock(),
|
||||
roomProxy: RoomProxyMock(with: .init(displayName: "Preview room", isCallOngoing: true)),
|
||||
appSettings: ServiceLocator.shared.settings,
|
||||
analytics: ServiceLocator.shared.analytics,
|
||||
|
||||
@@ -505,7 +505,7 @@ struct TimelineItemBubbledStylerView_Previews: PreviewProvider, TestablePreview
|
||||
contentType: nil),
|
||||
replyDetails: .loaded(sender: .init(id: "", displayName: "Alice"),
|
||||
contentType: .text(.init(body: "Short")))),
|
||||
playerState: AudioPlayerState(duration: 10, waveform: EstimatedWaveform.mockWaveform))
|
||||
playerState: AudioPlayerState(id: .timelineItemIdentifier(.random), duration: 10, waveform: EstimatedWaveform.mockWaveform))
|
||||
}
|
||||
.environmentObject(viewModel.context)
|
||||
}
|
||||
|
||||
@@ -232,7 +232,7 @@ struct TimelineItemPlainStylerView_Previews: PreviewProvider, TestablePreview {
|
||||
contentType: nil),
|
||||
replyDetails: .loaded(sender: .init(id: "", displayName: "Alice"),
|
||||
contentType: .text(.init(body: "Short")))),
|
||||
playerState: AudioPlayerState(duration: 10, waveform: EstimatedWaveform.mockWaveform))
|
||||
playerState: AudioPlayerState(id: .timelineItemIdentifier(.init(timelineID: "")), duration: 10, waveform: EstimatedWaveform.mockWaveform))
|
||||
}
|
||||
.environmentObject(viewModel.context)
|
||||
}
|
||||
|
||||
@@ -60,6 +60,7 @@ struct TimelineReadReceiptsView_Previews: PreviewProvider, TestablePreview {
|
||||
|
||||
static let viewModel = RoomScreenViewModel(timelineController: MockRoomTimelineController(),
|
||||
mediaProvider: MockMediaProvider(),
|
||||
mediaPlayerProvider: MediaPlayerProviderMock(),
|
||||
roomProxy: RoomProxyMock(with: .init(displayName: "Test",
|
||||
members: members)),
|
||||
appSettings: ServiceLocator.shared.settings,
|
||||
|
||||
@@ -82,6 +82,7 @@ struct UITimelineView: UIViewControllerRepresentable {
|
||||
struct UITimelineView_Previews: PreviewProvider, TestablePreview {
|
||||
static let viewModel = RoomScreenViewModel(timelineController: MockRoomTimelineController(),
|
||||
mediaProvider: MockMediaProvider(),
|
||||
mediaPlayerProvider: MediaPlayerProviderMock(),
|
||||
roomProxy: RoomProxyMock(with: .init(displayName: "Preview room")),
|
||||
appSettings: ServiceLocator.shared.settings,
|
||||
analytics: ServiceLocator.shared.analytics,
|
||||
|
||||
@@ -170,6 +170,7 @@ struct TimelineView: View {
|
||||
struct TimelineView_Previews: PreviewProvider, TestablePreview {
|
||||
static let viewModel = RoomScreenViewModel(timelineController: MockRoomTimelineController(),
|
||||
mediaProvider: MockMediaProvider(),
|
||||
mediaPlayerProvider: MediaPlayerProviderMock(),
|
||||
roomProxy: RoomProxyMock(with: .init(displayName: "Preview room")),
|
||||
appSettings: ServiceLocator.shared.settings,
|
||||
analytics: ServiceLocator.shared.analytics,
|
||||
|
||||
@@ -30,7 +30,6 @@ enum AudioConverterPreferredFileExtension: String {
|
||||
struct AudioConverter: AudioConverterProtocol {
|
||||
func convertToOpusOgg(sourceURL: URL, destinationURL: URL) throws {
|
||||
do {
|
||||
MXLog.debug("converting \(sourceURL) to \(destinationURL)")
|
||||
try OGGConverter.convertM4aFileToOpusOGG(src: sourceURL, dest: destinationURL)
|
||||
} catch {
|
||||
MXLog.error("failed to convert to OpusOgg: \(error)")
|
||||
|
||||
@@ -30,11 +30,6 @@ private enum InternalAudioPlayerState {
|
||||
case error(Error)
|
||||
}
|
||||
|
||||
enum AudioPlayerError: Error {
|
||||
case genericError
|
||||
case loadFileError
|
||||
}
|
||||
|
||||
class AudioPlayer: NSObject, AudioPlayerProtocol {
|
||||
var mediaSource: MediaSourceProxy?
|
||||
|
||||
@@ -53,6 +48,7 @@ class AudioPlayer: NSObject, AudioPlayerProtocol {
|
||||
private var rateObserver: NSKeyValueObservation?
|
||||
private var playToEndObserver: NSObjectProtocol?
|
||||
private var appBackgroundObserver: NSObjectProtocol?
|
||||
private var autoplay = false
|
||||
|
||||
private(set) var url: URL?
|
||||
|
||||
@@ -91,11 +87,12 @@ class AudioPlayer: NSObject, AudioPlayerProtocol {
|
||||
unloadContent()
|
||||
}
|
||||
|
||||
func load(mediaSource: MediaSourceProxy, using url: URL) {
|
||||
func load(mediaSource: MediaSourceProxy, using url: URL, autoplay: Bool) {
|
||||
unloadContent()
|
||||
setInternalState(.loading)
|
||||
self.mediaSource = mediaSource
|
||||
self.url = url
|
||||
self.autoplay = autoplay
|
||||
playerItem = AVPlayerItem(url: url)
|
||||
internalAudioPlayer = AVQueuePlayer(playerItem: playerItem)
|
||||
addObservers()
|
||||
@@ -122,6 +119,7 @@ class AudioPlayer: NSObject, AudioPlayerProtocol {
|
||||
isStopped = true
|
||||
internalAudioPlayer?.pause()
|
||||
internalAudioPlayer?.seek(to: .zero)
|
||||
try? AVAudioSession.sharedInstance().setActive(false)
|
||||
}
|
||||
|
||||
func seek(to progress: Double) async {
|
||||
@@ -205,7 +203,10 @@ class AudioPlayer: NSObject, AudioPlayerProtocol {
|
||||
actionsSubject.send(.didStartLoading)
|
||||
case .readyToPlay:
|
||||
actionsSubject.send(.didFinishLoading)
|
||||
play()
|
||||
if autoplay {
|
||||
autoplay = false
|
||||
play()
|
||||
}
|
||||
case .playing:
|
||||
actionsSubject.send(.didStartPlaying)
|
||||
case .paused:
|
||||
|
||||
@@ -17,6 +17,11 @@
|
||||
import Combine
|
||||
import Foundation
|
||||
|
||||
enum AudioPlayerError: Error {
|
||||
case genericError
|
||||
case loadFileError
|
||||
}
|
||||
|
||||
enum AudioPlayerAction {
|
||||
case didStartLoading
|
||||
case didFinishLoading
|
||||
|
||||
@@ -26,9 +26,14 @@ enum AudioPlayerPlaybackState {
|
||||
case error
|
||||
}
|
||||
|
||||
enum AudioPlayerStateIdentifier {
|
||||
case timelineItemIdentifier(TimelineItemIdentifier)
|
||||
case recorderPreview
|
||||
}
|
||||
|
||||
@MainActor
|
||||
class AudioPlayerState: ObservableObject, Identifiable {
|
||||
let id = UUID()
|
||||
let id: AudioPlayerStateIdentifier
|
||||
let duration: Double
|
||||
let waveform: EstimatedWaveform
|
||||
@Published private(set) var playbackState: AudioPlayerPlaybackState
|
||||
@@ -50,7 +55,8 @@ class AudioPlayerState: ObservableObject, Identifiable {
|
||||
displayLink != nil
|
||||
}
|
||||
|
||||
init(duration: Double, waveform: EstimatedWaveform? = nil, progress: Double = 0.0) {
|
||||
init(id: AudioPlayerStateIdentifier, duration: Double, waveform: EstimatedWaveform? = nil, progress: Double = 0.0) {
|
||||
self.id = id
|
||||
self.duration = duration
|
||||
self.waveform = waveform ?? EstimatedWaveform(data: [])
|
||||
self.progress = progress
|
||||
|
||||
@@ -17,16 +17,14 @@
|
||||
import AVFoundation
|
||||
import Combine
|
||||
import Foundation
|
||||
|
||||
enum AudioRecorderError: Error {
|
||||
case genericError
|
||||
}
|
||||
import UIKit
|
||||
|
||||
class AudioRecorder: NSObject, AudioRecorderProtocol, AVAudioRecorderDelegate {
|
||||
private let silenceThreshold: Float = -50.0
|
||||
|
||||
private var audioRecorder: AVAudioRecorder?
|
||||
|
||||
private var cancellables = Set<AnyCancellable>()
|
||||
private let actionsSubject: PassthroughSubject<AudioRecorderAction, Never> = .init()
|
||||
var actions: AnyPublisher<AudioRecorderAction, Never> {
|
||||
actionsSubject.eraseToAnyPublisher()
|
||||
@@ -44,7 +42,11 @@ class AudioRecorder: NSObject, AudioRecorderProtocol, AVAudioRecorderDelegate {
|
||||
audioRecorder?.isRecording ?? false
|
||||
}
|
||||
|
||||
func recordWithOutputURL(_ url: URL) {
|
||||
func record(with recordID: AudioRecordingIdentifier) async -> Result<Void, AudioRecorderError> {
|
||||
guard await requestRecordPermission() else {
|
||||
return .failure(.recordPermissionNotGranted)
|
||||
}
|
||||
|
||||
let settings = [AVFormatIDKey: Int(kAudioFormatMPEG4AAC),
|
||||
AVSampleRateKey: 48000,
|
||||
AVEncoderBitRateKey: 128_000,
|
||||
@@ -54,6 +56,7 @@ class AudioRecorder: NSObject, AudioRecorderProtocol, AVAudioRecorderDelegate {
|
||||
do {
|
||||
try AVAudioSession.sharedInstance().setCategory(.playAndRecord, mode: .default)
|
||||
try AVAudioSession.sharedInstance().setActive(true)
|
||||
let url = URL.temporaryDirectory.appendingPathComponent("voice-message-\(recordID.identifier).m4a")
|
||||
audioRecorder = try AVAudioRecorder(url: url, settings: settings)
|
||||
audioRecorder?.delegate = self
|
||||
audioRecorder?.isMeteringEnabled = true
|
||||
@@ -63,25 +66,26 @@ class AudioRecorder: NSObject, AudioRecorderProtocol, AVAudioRecorderDelegate {
|
||||
MXLog.error("audio recording failed: \(error)")
|
||||
actionsSubject.send(.didFailWithError(error: error))
|
||||
}
|
||||
}
|
||||
|
||||
func stopRecording() {
|
||||
audioRecorder?.stop()
|
||||
|
||||
do {
|
||||
try AVAudioSession.sharedInstance().setActive(false)
|
||||
} catch {
|
||||
actionsSubject.send(.didFailWithError(error: error))
|
||||
}
|
||||
return .success(())
|
||||
}
|
||||
|
||||
func stopRecording() async {
|
||||
guard let audioRecorder, audioRecorder.isRecording else {
|
||||
return
|
||||
}
|
||||
audioRecorder.stop()
|
||||
}
|
||||
|
||||
func deleteRecording() {
|
||||
audioRecorder?.deleteRecording()
|
||||
}
|
||||
|
||||
func peakPowerForChannelNumber(_ channelNumber: Int) -> Float {
|
||||
guard isRecording, let audioRecorder else {
|
||||
return 0.0
|
||||
}
|
||||
|
||||
audioRecorder.updateMeters()
|
||||
|
||||
return normalizedPowerLevelFromDecibels(audioRecorder.peakPower(forChannel: channelNumber))
|
||||
}
|
||||
|
||||
@@ -91,13 +95,37 @@ class AudioRecorder: NSObject, AudioRecorderProtocol, AVAudioRecorderDelegate {
|
||||
}
|
||||
|
||||
audioRecorder.updateMeters()
|
||||
|
||||
return normalizedPowerLevelFromDecibels(audioRecorder.averagePower(forChannel: channelNumber))
|
||||
}
|
||||
|
||||
// MARK: - Private
|
||||
|
||||
private func addObservers() {
|
||||
// Stop recording uppon UIApplication.didEnterBackgroundNotification notification
|
||||
NotificationCenter.default.publisher(for: UIApplication.didEnterBackgroundNotification)
|
||||
.sink { [weak self] _ in
|
||||
guard let self else { return }
|
||||
Task { await self.stopRecording() }
|
||||
}
|
||||
.store(in: &cancellables)
|
||||
}
|
||||
|
||||
private func removeObservers() {
|
||||
cancellables.removeAll()
|
||||
}
|
||||
|
||||
private func requestRecordPermission() async -> Bool {
|
||||
await withCheckedContinuation { continuation in
|
||||
AVAudioSession.sharedInstance().requestRecordPermission { granted in
|
||||
continuation.resume(returning: granted)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - AVAudioRecorderDelegate
|
||||
|
||||
func audioRecorderDidFinishRecording(_ recorder: AVAudioRecorder, successfully success: Bool) {
|
||||
try? AVAudioSession.sharedInstance().setActive(false)
|
||||
if success {
|
||||
actionsSubject.send(.didStopRecording)
|
||||
} else {
|
||||
@@ -106,6 +134,7 @@ class AudioRecorder: NSObject, AudioRecorderProtocol, AVAudioRecorderDelegate {
|
||||
}
|
||||
|
||||
func audioRecorderEncodeErrorDidOccur(_ recorder: AVAudioRecorder, error: Error?) {
|
||||
try? AVAudioSession.sharedInstance().setActive(false)
|
||||
actionsSubject.send(.didFailWithError(error: error ?? AudioRecorderError.genericError))
|
||||
}
|
||||
|
||||
|
||||
@@ -17,6 +17,24 @@
|
||||
import Combine
|
||||
import Foundation
|
||||
|
||||
enum AudioRecordingIdentifier {
|
||||
case uuid(UUID)
|
||||
}
|
||||
|
||||
extension AudioRecordingIdentifier {
|
||||
var identifier: String {
|
||||
switch self {
|
||||
case .uuid(let uuid):
|
||||
return uuid.uuidString
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
enum AudioRecorderError: Error {
|
||||
case genericError
|
||||
case recordPermissionNotGranted
|
||||
}
|
||||
|
||||
enum AudioRecorderAction {
|
||||
case didStartRecording
|
||||
case didStopRecording
|
||||
@@ -29,8 +47,9 @@ protocol AudioRecorderProtocol: AnyObject {
|
||||
var isRecording: Bool { get }
|
||||
var url: URL? { get }
|
||||
|
||||
func recordWithOutputURL(_ url: URL)
|
||||
func stopRecording()
|
||||
func record(with recordID: AudioRecordingIdentifier) async -> Result<Void, AudioRecorderError>
|
||||
func stopRecording() async
|
||||
func deleteRecording()
|
||||
func averagePowerForChannelNumber(_ channelNumber: Int) -> Float
|
||||
}
|
||||
|
||||
|
||||
@@ -30,25 +30,27 @@ class AudioRecorderState: ObservableObject, Identifiable {
|
||||
|
||||
@Published private(set) var recordingState: AudioRecorderRecordingState = .stopped
|
||||
@Published private(set) var duration = 0.0
|
||||
@Published private(set) var waveform = EstimatedWaveform(data: Array(repeating: 0, count: 100))
|
||||
@Published private(set) var waveformSamples: [Float] = []
|
||||
|
||||
private weak var audioRecorder: AudioRecorderProtocol?
|
||||
private var cancellables: Set<AnyCancellable> = []
|
||||
private var displayLink: CADisplayLink?
|
||||
|
||||
func attachAudioRecorder(_ audioRecorder: AudioRecorderProtocol) {
|
||||
if self.audioRecorder != nil {
|
||||
detachAudioRecorder()
|
||||
}
|
||||
recordingState = .stopped
|
||||
self.audioRecorder = audioRecorder
|
||||
subscribeToAudioRecorder(audioRecorder)
|
||||
if audioRecorder.isRecording {
|
||||
startPublishUpdates()
|
||||
}
|
||||
}
|
||||
|
||||
func detachAudioRecorder() {
|
||||
guard audioRecorder != nil else { return }
|
||||
audioRecorder?.stopRecording()
|
||||
func detachAudioRecorder() async {
|
||||
if let audioRecorder, audioRecorder.isRecording {
|
||||
await audioRecorder.stopRecording()
|
||||
}
|
||||
stopPublishUpdates()
|
||||
waveformSamples = []
|
||||
cancellables = []
|
||||
audioRecorder = nil
|
||||
recordingState = .stopped
|
||||
@@ -91,7 +93,7 @@ class AudioRecorderState: ObservableObject, Identifiable {
|
||||
stopPublishUpdates()
|
||||
}
|
||||
displayLink = CADisplayLink(target: self, selector: #selector(publishUpdate))
|
||||
displayLink?.preferredFrameRateRange = .init(minimum: 10, maximum: 20)
|
||||
displayLink?.preferredFrameRateRange = .init(minimum: 30, maximum: 60)
|
||||
displayLink?.add(to: .current, forMode: .common)
|
||||
}
|
||||
|
||||
@@ -99,6 +101,9 @@ class AudioRecorderState: ObservableObject, Identifiable {
|
||||
if let currentTime = audioRecorder?.currentTime {
|
||||
duration = currentTime
|
||||
}
|
||||
if let sample = audioRecorder?.averagePowerForChannelNumber(0) {
|
||||
waveformSamples.append(sample)
|
||||
}
|
||||
}
|
||||
|
||||
private func stopPublishUpdates() {
|
||||
|
||||
@@ -31,7 +31,7 @@ protocol MediaPlayerProtocol: AnyObject {
|
||||
var url: URL? { get }
|
||||
var state: MediaPlayerState { get }
|
||||
|
||||
func load(mediaSource: MediaSourceProxy, using url: URL)
|
||||
func load(mediaSource: MediaSourceProxy, using url: URL, autoplay: Bool)
|
||||
func play()
|
||||
func pause()
|
||||
func stop()
|
||||
|
||||
@@ -17,31 +17,72 @@
|
||||
import Foundation
|
||||
|
||||
class MediaPlayerProvider: MediaPlayerProviderProtocol {
|
||||
private let mediaProvider: MediaProviderProtocol
|
||||
private var audioPlayer: AudioPlayerProtocol?
|
||||
|
||||
init(mediaProvider: MediaProviderProtocol) {
|
||||
self.mediaProvider = mediaProvider
|
||||
}
|
||||
private lazy var audioPlayer = AudioPlayer()
|
||||
private var audioPlayerStates: [String: AudioPlayerState] = [:]
|
||||
|
||||
deinit {
|
||||
audioPlayer = nil
|
||||
audioPlayerStates = [:]
|
||||
}
|
||||
|
||||
func player(for mediaSource: MediaSourceProxy) -> MediaPlayerProtocol? {
|
||||
func player(for mediaSource: MediaSourceProxy) -> Result<MediaPlayerProtocol, MediaPlayerProviderError> {
|
||||
guard let mimeType = mediaSource.mimeType else {
|
||||
MXLog.error("Unknown mime type")
|
||||
return nil
|
||||
return .failure(.unsupportedMediaType)
|
||||
}
|
||||
|
||||
if mimeType.starts(with: "audio/") {
|
||||
if audioPlayer == nil {
|
||||
audioPlayer = AudioPlayer()
|
||||
}
|
||||
return audioPlayer
|
||||
return .success(audioPlayer)
|
||||
} else {
|
||||
MXLog.error("Unsupported media type: \(mediaSource.mimeType ?? "unknown")")
|
||||
return .failure(.unsupportedMediaType)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - AudioPlayer
|
||||
|
||||
func playerState(for id: AudioPlayerStateIdentifier) -> AudioPlayerState? {
|
||||
guard let audioPlayerStateID = audioPlayerStateID(for: id) else {
|
||||
MXLog.error("Failed to build an ID using: \(id)")
|
||||
return nil
|
||||
}
|
||||
return audioPlayerStates[audioPlayerStateID]
|
||||
}
|
||||
|
||||
@MainActor
|
||||
func register(audioPlayerState: AudioPlayerState) {
|
||||
guard let audioPlayerStateID = audioPlayerStateID(for: audioPlayerState.id) else {
|
||||
MXLog.error("Failed to build a key to register this audioPlayerState: \(audioPlayerState)")
|
||||
return
|
||||
}
|
||||
audioPlayerStates[audioPlayerStateID] = audioPlayerState
|
||||
}
|
||||
|
||||
@MainActor
|
||||
func unregister(audioPlayerState: AudioPlayerState) {
|
||||
guard let audioPlayerStateID = audioPlayerStateID(for: audioPlayerState.id) else {
|
||||
MXLog.error("Failed to build a key to register this audioPlayerState: \(audioPlayerState)")
|
||||
return
|
||||
}
|
||||
audioPlayerStates[audioPlayerStateID] = nil
|
||||
}
|
||||
|
||||
func detachAllStates(except exception: AudioPlayerState?) async {
|
||||
for key in audioPlayerStates.keys {
|
||||
if let exception, key == audioPlayerStateID(for: exception.id) {
|
||||
continue
|
||||
}
|
||||
await audioPlayerStates[key]?.detachAudioPlayer()
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Private
|
||||
|
||||
private func audioPlayerStateID(for identifier: AudioPlayerStateIdentifier) -> String? {
|
||||
switch identifier {
|
||||
case .timelineItemIdentifier(let timelineItemIdentifier):
|
||||
return timelineItemIdentifier.eventID
|
||||
case .recorderPreview:
|
||||
return "recorderPreviewAudioPlayerState"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -16,6 +16,18 @@
|
||||
|
||||
import Foundation
|
||||
|
||||
protocol MediaPlayerProviderProtocol {
|
||||
func player(for mediaSource: MediaSourceProxy) async -> MediaPlayerProtocol?
|
||||
enum MediaPlayerProviderError: Error {
|
||||
case unsupportedMediaType
|
||||
}
|
||||
|
||||
protocol MediaPlayerProviderProtocol {
|
||||
func player(for mediaSource: MediaSourceProxy) -> Result<MediaPlayerProtocol, MediaPlayerProviderError>
|
||||
|
||||
func playerState(for id: AudioPlayerStateIdentifier) -> AudioPlayerState?
|
||||
func register(audioPlayerState: AudioPlayerState)
|
||||
func unregister(audioPlayerState: AudioPlayerState)
|
||||
func detachAllStates(except exception: AudioPlayerState?) async
|
||||
}
|
||||
|
||||
// sourcery: AutoMockable
|
||||
extension MediaPlayerProviderProtocol { }
|
||||
|
||||
@@ -375,6 +375,31 @@ class RoomProxy: RoomProxyProtocol {
|
||||
return .success(())
|
||||
}
|
||||
|
||||
func sendVoiceMessage(url: URL,
|
||||
audioInfo: AudioInfo,
|
||||
waveform: [UInt16],
|
||||
progressSubject: CurrentValueSubject<Double, Never>?,
|
||||
requestHandle: @MainActor (SendAttachmentJoinHandleProtocol) -> Void) async -> Result<Void, RoomProxyError> {
|
||||
sendMessageBackgroundTask = await backgroundTaskService.startBackgroundTask(withName: backgroundTaskName, isReusable: true)
|
||||
defer {
|
||||
sendMessageBackgroundTask?.stop()
|
||||
}
|
||||
|
||||
let handle = room.sendVoiceMessage(url: url.path(percentEncoded: false), audioInfo: audioInfo, waveform: waveform, progressWatcher: UploadProgressListener { progress in
|
||||
progressSubject?.send(progress)
|
||||
})
|
||||
|
||||
await requestHandle(handle)
|
||||
|
||||
do {
|
||||
try await handle.join()
|
||||
} catch {
|
||||
return .failure(.failedSendingMedia)
|
||||
}
|
||||
|
||||
return .success(())
|
||||
}
|
||||
|
||||
func sendFile(url: URL,
|
||||
fileInfo: FileInfo,
|
||||
progressSubject: CurrentValueSubject<Double, Never>?,
|
||||
|
||||
@@ -131,6 +131,12 @@ protocol RoomProxyProtocol {
|
||||
zoomLevel: UInt8?,
|
||||
assetType: AssetType?) async -> Result<Void, RoomProxyError>
|
||||
|
||||
func sendVoiceMessage(url: URL,
|
||||
audioInfo: AudioInfo,
|
||||
waveform: [UInt16],
|
||||
progressSubject: CurrentValueSubject<Double, Never>?,
|
||||
requestHandle: @MainActor (SendAttachmentJoinHandleProtocol) -> Void) async -> Result<Void, RoomProxyError>
|
||||
|
||||
/// Retries sending a failed message given its transaction ID
|
||||
func retrySend(transactionID: String) async
|
||||
|
||||
|
||||
+2
-1
@@ -89,7 +89,8 @@ class MockRoomTimelineController: RoomTimelineControllerProtocol {
|
||||
func retryDecryption(for sessionID: String) async { }
|
||||
|
||||
func audioPlayerState(for itemID: TimelineItemIdentifier) -> AudioPlayerState {
|
||||
AudioPlayerState(duration: 10.0,
|
||||
AudioPlayerState(id: .timelineItemIdentifier(itemID),
|
||||
duration: 10.0,
|
||||
waveform: nil,
|
||||
progress: 0.0)
|
||||
}
|
||||
|
||||
+26
-30
@@ -38,7 +38,6 @@ class RoomTimelineController: RoomTimelineControllerProtocol {
|
||||
let callbacks = PassthroughSubject<RoomTimelineControllerCallback, Never>()
|
||||
|
||||
private(set) var timelineItems = [RoomTimelineItemProtocol]()
|
||||
private var timelineAudioPlayerStates = [TimelineItemIdentifier: AudioPlayerState]()
|
||||
|
||||
var roomID: String {
|
||||
roomProxy.id
|
||||
@@ -249,13 +248,14 @@ class RoomTimelineController: RoomTimelineControllerProtocol {
|
||||
fatalError("Invalid TimelineItem type (expecting `VoiceMessageRoomTimelineItem` but found \(type(of: timelineItem)) instead")
|
||||
}
|
||||
|
||||
if let playerState = timelineAudioPlayerStates[itemID] {
|
||||
if let playerState = mediaPlayerProvider.playerState(for: .timelineItemIdentifier(itemID)) {
|
||||
return playerState
|
||||
}
|
||||
|
||||
let playerState = AudioPlayerState(duration: voiceMessageRoomTimelineItem.content.duration,
|
||||
let playerState = AudioPlayerState(id: .timelineItemIdentifier(itemID),
|
||||
duration: voiceMessageRoomTimelineItem.content.duration,
|
||||
waveform: voiceMessageRoomTimelineItem.content.waveform)
|
||||
timelineAudioPlayerStates[itemID] = playerState
|
||||
mediaPlayerProvider.register(audioPlayerState: playerState)
|
||||
return playerState
|
||||
}
|
||||
|
||||
@@ -273,52 +273,48 @@ class RoomTimelineController: RoomTimelineControllerProtocol {
|
||||
return
|
||||
}
|
||||
|
||||
guard let player = await mediaPlayerProvider.player(for: source) as? AudioPlayerProtocol else {
|
||||
guard case .success(let mediaPlayer) = mediaPlayerProvider.player(for: source), let audioPlayer = mediaPlayer as? AudioPlayerProtocol else {
|
||||
MXLog.error("Cannot play a voice message without an audio player")
|
||||
return
|
||||
}
|
||||
|
||||
let playerState = audioPlayerState(for: itemID)
|
||||
let audioPlayerState = audioPlayerState(for: itemID)
|
||||
|
||||
guard player.mediaSource == source, player.state != .error else {
|
||||
timelineAudioPlayerStates.forEach { itemID, playerState in
|
||||
if itemID != timelineItem.id {
|
||||
playerState.detachAudioPlayer()
|
||||
}
|
||||
}
|
||||
playerState.attachAudioPlayer(player)
|
||||
guard audioPlayer.mediaSource == source, audioPlayer.state != .error else {
|
||||
audioPlayer.stop()
|
||||
|
||||
await mediaPlayerProvider.detachAllStates(except: audioPlayerState)
|
||||
|
||||
audioPlayerState.attachAudioPlayer(audioPlayer)
|
||||
|
||||
// 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)
|
||||
if audioPlayerState.isAttached {
|
||||
audioPlayer.load(mediaSource: source, using: url, autoplay: true)
|
||||
}
|
||||
} catch {
|
||||
MXLog.error("Failed to load voice message: \(error)")
|
||||
playerState.reportError(error)
|
||||
audioPlayerState.reportError(error)
|
||||
}
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
if player.state == .playing {
|
||||
player.pause()
|
||||
if audioPlayer.state == .playing {
|
||||
audioPlayer.pause()
|
||||
} else {
|
||||
player.play()
|
||||
audioPlayer.play()
|
||||
}
|
||||
}
|
||||
|
||||
func pauseAudio() {
|
||||
timelineAudioPlayerStates.forEach { _, playerState in
|
||||
playerState.detachAudioPlayer()
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
func seekAudio(for itemID: TimelineItemIdentifier, progress: Double) async {
|
||||
await timelineAudioPlayerStates[itemID]?.updateState(progress: progress)
|
||||
guard let playerState = mediaPlayerProvider.playerState(for: .timelineItemIdentifier(itemID)) else {
|
||||
return
|
||||
}
|
||||
await playerState.updateState(progress: progress)
|
||||
}
|
||||
|
||||
// MARK: - Private
|
||||
@@ -402,11 +398,11 @@ class RoomTimelineController: RoomTimelineControllerProtocol {
|
||||
|
||||
// Stops the audio player when a voice message is redacted.
|
||||
if timelineItem is RedactedRoomTimelineItem {
|
||||
guard let audioState = timelineAudioPlayerStates[timelineItem.id] else {
|
||||
guard let playerState = mediaPlayerProvider.playerState(for: .timelineItemIdentifier(timelineItem.id)) else {
|
||||
continue
|
||||
}
|
||||
audioState.detachAudioPlayer()
|
||||
timelineAudioPlayerStates.removeValue(forKey: timelineItem.id)
|
||||
playerState.detachAudioPlayer()
|
||||
mediaPlayerProvider.unregister(audioPlayerState: playerState)
|
||||
}
|
||||
|
||||
newTimelineItems.append(timelineItem)
|
||||
|
||||
+1
-3
@@ -74,9 +74,7 @@ protocol RoomTimelineControllerProtocol {
|
||||
func audioPlayerState(for itemID: TimelineItemIdentifier) -> AudioPlayerState
|
||||
|
||||
func playPauseAudio(for itemID: TimelineItemIdentifier) async
|
||||
|
||||
func pauseAudio()
|
||||
|
||||
|
||||
func seekAudio(for itemID: TimelineItemIdentifier, progress: Double) async
|
||||
}
|
||||
|
||||
|
||||
+5
-29
@@ -45,8 +45,7 @@ struct VoiceMessageRoomPlaybackView: View {
|
||||
return dateFormatter
|
||||
}()
|
||||
|
||||
@GestureState private var dragState = DragState.inactive
|
||||
@State private var tapProgress: Double = .zero
|
||||
@State var dragState: WaveformViewDragState = .inactive
|
||||
|
||||
var timeLabelContent: String {
|
||||
// Display the duration if progress is 0.0
|
||||
@@ -85,38 +84,14 @@ struct VoiceMessageRoomPlaybackView: View {
|
||||
.frame(width: waveformLineWidth, height: geometry.size.height)
|
||||
.opacity(showWaveformCursor ? 1 : 0)
|
||||
}
|
||||
// Add a gesture to drag the waveform
|
||||
.gesture(SpatialTapGesture()
|
||||
.simultaneously(with: LongPressGesture())
|
||||
.sequenced(before: DragGesture(minimumDistance: waveformLinePadding, coordinateSpace: .local))
|
||||
.updating($dragState) { value, state, _ in
|
||||
switch value {
|
||||
// (SpatialTap, LongPress) begins.
|
||||
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 ?? 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)
|
||||
// Dragging ended or the long press cancelled.
|
||||
default:
|
||||
state = .inactive
|
||||
}
|
||||
})
|
||||
.waveformDragGesture($dragState)
|
||||
}
|
||||
}
|
||||
.onChange(of: dragState) { newDragState in
|
||||
switch newDragState {
|
||||
case .inactive:
|
||||
onScrubbing(false)
|
||||
case .pressing(let progress):
|
||||
tapProgress = progress
|
||||
case .pressing:
|
||||
onScrubbing(true)
|
||||
feedbackGenerator.prepare()
|
||||
sendFeedback = true
|
||||
@@ -218,7 +193,8 @@ struct VoiceMessageRoomPlaybackView_Previews: PreviewProvider, TestablePreview {
|
||||
294, 131, 19, 2, 3, 3, 1, 2, 0, 0,
|
||||
0, 0, 0, 0, 0, 3])
|
||||
|
||||
static var playerState = AudioPlayerState(duration: 10.0,
|
||||
static var playerState = AudioPlayerState(id: .timelineItemIdentifier(.random),
|
||||
duration: 10.0,
|
||||
waveform: waveform,
|
||||
progress: 0.3)
|
||||
|
||||
|
||||
+4
-3
@@ -65,8 +65,8 @@ struct VoiceMessageRoomTimelineView: View {
|
||||
|
||||
struct VoiceMessageRoomTimelineView_Previews: PreviewProvider, TestablePreview {
|
||||
static let viewModel = RoomScreenViewModel.mock
|
||||
|
||||
static let voiceRoomTimelineItem = VoiceMessageRoomTimelineItem(id: .random,
|
||||
static let timelineItemIdentifier = TimelineItemIdentifier.random
|
||||
static let voiceRoomTimelineItem = VoiceMessageRoomTimelineItem(id: timelineItemIdentifier,
|
||||
timestamp: "Now",
|
||||
isOutgoing: false,
|
||||
isEditable: false,
|
||||
@@ -79,7 +79,8 @@ struct VoiceMessageRoomTimelineView_Previews: PreviewProvider, TestablePreview {
|
||||
source: nil,
|
||||
contentType: nil))
|
||||
|
||||
static let playerState = AudioPlayerState(duration: 10.0,
|
||||
static let playerState = AudioPlayerState(id: .timelineItemIdentifier(timelineItemIdentifier),
|
||||
duration: 10.0,
|
||||
waveform: EstimatedWaveform.mockWaveform,
|
||||
progress: 0.4)
|
||||
|
||||
|
||||
@@ -75,7 +75,7 @@ struct RoomTimelineItemView: View {
|
||||
case .poll(let item):
|
||||
PollRoomTimelineView(timelineItem: item)
|
||||
case .voice(let item):
|
||||
VoiceMessageRoomTimelineView(timelineItem: item, playerState: context.viewState.audioPlayerStateProvider?(item.id) ?? AudioPlayerState(duration: 0))
|
||||
VoiceMessageRoomTimelineView(timelineItem: item, playerState: context.viewState.audioPlayerStateProvider?(item.id) ?? AudioPlayerState(id: .timelineItemIdentifier(item.id), duration: 0))
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -16,36 +16,31 @@
|
||||
|
||||
import Foundation
|
||||
|
||||
enum VoiceMessageCacheError: Error {
|
||||
case invalidFileExtension
|
||||
}
|
||||
|
||||
class VoiceMessageCache: VoiceMessageCacheProtocol {
|
||||
private let preferredFileExtension = "m4a"
|
||||
private var temporaryFilesFolderURL: URL {
|
||||
FileManager.default.temporaryDirectory.appendingPathComponent("media/voice-message")
|
||||
}
|
||||
|
||||
|
||||
func fileURL(for mediaSource: MediaSourceProxy) -> URL? {
|
||||
let url = cacheURL(for: mediaSource)
|
||||
return FileManager.default.fileExists(atPath: url.path()) ? url : nil
|
||||
}
|
||||
|
||||
func cache(mediaSource: MediaSourceProxy, using fileURL: URL, move: Bool = false) throws -> URL {
|
||||
guard fileURL.pathExtension == preferredFileExtension else {
|
||||
throw VoiceMessageCacheError.invalidFileExtension
|
||||
}
|
||||
setupTemporaryFilesFolder()
|
||||
let url = cacheURL(for: mediaSource)
|
||||
try? FileManager.default.removeItem(at: url)
|
||||
if move {
|
||||
try FileManager.default.moveItem(at: fileURL, to: url)
|
||||
} else {
|
||||
try FileManager.default.copyItem(at: fileURL, to: url)
|
||||
}
|
||||
return url
|
||||
}
|
||||
|
||||
func cache(mediaSource: MediaSourceProxy, using fileURL: URL, move: Bool = false) -> Result<URL, VoiceMessageCacheError> {
|
||||
guard fileURL.pathExtension == preferredFileExtension else {
|
||||
return .failure(.invalidFileExtension)
|
||||
}
|
||||
let url = cacheURL(for: mediaSource)
|
||||
do {
|
||||
try cacheFile(source: fileURL, destination: url, move: move)
|
||||
} catch {
|
||||
MXLog.error("Failed storing file in cache", context: error)
|
||||
return .failure(.failedStoringFileInCache)
|
||||
}
|
||||
return .success(url)
|
||||
}
|
||||
|
||||
func clearCache() {
|
||||
if FileManager.default.fileExists(atPath: temporaryFilesFolderURL.path) {
|
||||
do {
|
||||
@@ -66,6 +61,16 @@ class VoiceMessageCache: VoiceMessageCacheProtocol {
|
||||
}
|
||||
}
|
||||
|
||||
private func cacheFile(source: URL, destination: URL, move: Bool) throws {
|
||||
setupTemporaryFilesFolder()
|
||||
try? FileManager.default.removeItem(at: destination)
|
||||
if move {
|
||||
try FileManager.default.moveItem(at: source, to: destination)
|
||||
} else {
|
||||
try FileManager.default.copyItem(at: source, to: destination)
|
||||
}
|
||||
}
|
||||
|
||||
private func cacheURL(for mediaSource: MediaSourceProxy) -> URL {
|
||||
temporaryFilesFolderURL.appendingPathComponent(mediaSource.url.lastPathComponent).appendingPathExtension(preferredFileExtension)
|
||||
}
|
||||
|
||||
@@ -16,9 +16,26 @@
|
||||
|
||||
import Foundation
|
||||
|
||||
enum VoiceMessageCacheError: Error {
|
||||
case invalidFileExtension
|
||||
case failedStoringFileInCache
|
||||
}
|
||||
|
||||
protocol VoiceMessageCacheProtocol {
|
||||
/// Returns the URL of the cached audio file for a given media source
|
||||
/// - Parameter mediaSource: the media source
|
||||
/// - Returns: the URL of the cached audio file or nil if the file doesn't exist
|
||||
func fileURL(for mediaSource: MediaSourceProxy) -> URL?
|
||||
func cache(mediaSource: MediaSourceProxy, using fileURL: URL, move: Bool) throws -> URL
|
||||
|
||||
/// Adds a file in the cache
|
||||
/// - Parameters:
|
||||
/// - mediaSource: the media source
|
||||
/// - fileURL: the source file
|
||||
/// - move: wheter to move or copy the source file
|
||||
/// - Returns: the cached URL
|
||||
func cache(mediaSource: MediaSourceProxy, using fileURL: URL, move: Bool) -> Result<URL, VoiceMessageCacheError>
|
||||
|
||||
/// Clears the cache
|
||||
func clearCache()
|
||||
}
|
||||
|
||||
|
||||
@@ -80,7 +80,13 @@ class VoiceMessageMediaManager: VoiceMessageMediaManagerProtocol {
|
||||
try audioConverter.convertToMPEG4AAC(sourceURL: fileHandle.url, destinationURL: convertedFileURL)
|
||||
|
||||
// Cache the file and return the url
|
||||
return try voiceMessageCache.cache(mediaSource: source, using: convertedFileURL, move: true)
|
||||
let result = voiceMessageCache.cache(mediaSource: source, using: convertedFileURL, move: true)
|
||||
switch result {
|
||||
case .success(let url):
|
||||
return url
|
||||
case .failure(let error):
|
||||
throw error
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,198 @@
|
||||
//
|
||||
// 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 DSWaveformImage
|
||||
import Foundation
|
||||
import MatrixRustSDK
|
||||
|
||||
class VoiceMessageRecorder: VoiceMessageRecorderProtocol {
|
||||
let audioRecorder: AudioRecorderProtocol
|
||||
private let audioConverter: AudioConverterProtocol
|
||||
private let voiceMessageCache: VoiceMessageCacheProtocol
|
||||
private let mediaPlayerProvider: MediaPlayerProviderProtocol
|
||||
|
||||
private let mp4accMimeType = "audio/m4a"
|
||||
private let waveformSamplesCount = 100
|
||||
|
||||
private(set) var recordingURL: URL?
|
||||
private(set) var recordingDuration: TimeInterval = 0.0
|
||||
|
||||
private(set) var previewAudioPlayerState: AudioPlayerState?
|
||||
private(set) var previewAudioPlayer: AudioPlayerProtocol?
|
||||
|
||||
init(audioRecorder: AudioRecorderProtocol = AudioRecorder(),
|
||||
mediaPlayerProvider: MediaPlayerProviderProtocol,
|
||||
audioConverter: AudioConverterProtocol = AudioConverter(),
|
||||
voiceMessageCache: VoiceMessageCacheProtocol = VoiceMessageCache()) {
|
||||
self.audioRecorder = audioRecorder
|
||||
self.mediaPlayerProvider = mediaPlayerProvider
|
||||
self.audioConverter = audioConverter
|
||||
self.voiceMessageCache = voiceMessageCache
|
||||
}
|
||||
|
||||
// MARK: - Recording
|
||||
|
||||
func startRecording() async -> Result<Void, VoiceMessageRecorderError> {
|
||||
await stopPlayback()
|
||||
recordingURL = nil
|
||||
switch await audioRecorder.record(with: .uuid(UUID())) {
|
||||
case .failure(let error):
|
||||
return .failure(.audioRecorderError(error))
|
||||
case .success:
|
||||
recordingURL = audioRecorder.url
|
||||
return .success(())
|
||||
}
|
||||
}
|
||||
|
||||
func stopRecording() async -> Result<Void, VoiceMessageRecorderError> {
|
||||
recordingDuration = audioRecorder.currentTime
|
||||
await audioRecorder.stopRecording()
|
||||
guard case .success = await finalizeRecording() else {
|
||||
return .failure(.previewNotAvailable)
|
||||
}
|
||||
return .success(())
|
||||
}
|
||||
|
||||
func cancelRecording() async {
|
||||
await audioRecorder.stopRecording()
|
||||
audioRecorder.deleteRecording()
|
||||
recordingURL = nil
|
||||
previewAudioPlayerState = nil
|
||||
}
|
||||
|
||||
func deleteRecording() async {
|
||||
await stopPlayback()
|
||||
audioRecorder.deleteRecording()
|
||||
previewAudioPlayerState = nil
|
||||
recordingURL = nil
|
||||
}
|
||||
|
||||
// MARK: - Preview
|
||||
|
||||
func startPlayback() async -> Result<Void, VoiceMessageRecorderError> {
|
||||
guard let previewAudioPlayerState, let url = recordingURL else {
|
||||
return .failure(.previewNotAvailable)
|
||||
}
|
||||
|
||||
guard let audioPlayer = previewAudioPlayer else {
|
||||
return .failure(.previewNotAvailable)
|
||||
}
|
||||
|
||||
if audioPlayer.url == url {
|
||||
audioPlayer.play()
|
||||
return .success(())
|
||||
}
|
||||
|
||||
await previewAudioPlayerState.attachAudioPlayer(audioPlayer)
|
||||
let pendingMediaSource = MediaSourceProxy(url: url, mimeType: mp4accMimeType)
|
||||
audioPlayer.load(mediaSource: pendingMediaSource, using: url, autoplay: true)
|
||||
return .success(())
|
||||
}
|
||||
|
||||
func pausePlayback() {
|
||||
previewAudioPlayer?.pause()
|
||||
}
|
||||
|
||||
func stopPlayback() async {
|
||||
guard let previewAudioPlayerState else {
|
||||
return
|
||||
}
|
||||
await previewAudioPlayerState.detachAudioPlayer()
|
||||
previewAudioPlayer?.stop()
|
||||
}
|
||||
|
||||
func seekPlayback(to progress: Double) async {
|
||||
await previewAudioPlayerState?.updateState(progress: progress)
|
||||
}
|
||||
|
||||
func buildRecordingWaveform() async -> Result<[UInt16], VoiceMessageRecorderError> {
|
||||
guard let url = recordingURL else {
|
||||
return .failure(.missingRecordingFile)
|
||||
}
|
||||
// build the waveform
|
||||
var waveformData: [UInt16] = []
|
||||
let analyzer = WaveformAnalyzer()
|
||||
do {
|
||||
let samples = try await analyzer.samples(fromAudioAt: url, count: 100)
|
||||
// linearly normalized to [0, 1] (1 -> -50 dB)
|
||||
waveformData = samples.map { UInt16(max(0, (1 - $0) * 1024)) }
|
||||
} catch {
|
||||
MXLog.error("Waveform analysis failed: \(error)")
|
||||
}
|
||||
return .success(waveformData)
|
||||
}
|
||||
|
||||
func sendVoiceMessage(inRoom roomProxy: RoomProxyProtocol, audioConverter: AudioConverterProtocol) async -> Result<Void, VoiceMessageRecorderError> {
|
||||
guard let url = recordingURL else {
|
||||
return .failure(VoiceMessageRecorderError.missingRecordingFile)
|
||||
}
|
||||
|
||||
// convert the file
|
||||
let sourceFilename = url.deletingPathExtension().lastPathComponent
|
||||
let oggFile = URL.temporaryDirectory.appendingPathComponent(sourceFilename).appendingPathExtension("ogg")
|
||||
do {
|
||||
try audioConverter.convertToOpusOgg(sourceURL: url, destinationURL: oggFile)
|
||||
} catch {
|
||||
return .failure(.failedSendingVoiceMessage)
|
||||
}
|
||||
|
||||
// send it
|
||||
let size: UInt64
|
||||
do {
|
||||
size = try UInt64(FileManager.default.sizeForItem(at: oggFile))
|
||||
} catch {
|
||||
MXLog.error("Failed to get the recording file size", context: error)
|
||||
return .failure(.failedSendingVoiceMessage)
|
||||
}
|
||||
let audioInfo = AudioInfo(duration: recordingDuration, size: size, mimetype: "audio/ogg")
|
||||
guard case .success(let waveform) = await buildRecordingWaveform() else {
|
||||
return .failure(.failedSendingVoiceMessage)
|
||||
}
|
||||
|
||||
let result = await roomProxy.sendVoiceMessage(url: oggFile,
|
||||
audioInfo: audioInfo,
|
||||
waveform: waveform,
|
||||
progressSubject: nil) { _ in }
|
||||
// delete the temporary file
|
||||
try? FileManager.default.removeItem(at: oggFile)
|
||||
|
||||
if case .failure(let error) = result {
|
||||
MXLog.error("Failed to send the voice message.", context: error)
|
||||
return .failure(.failedSendingVoiceMessage)
|
||||
}
|
||||
|
||||
return .success(())
|
||||
}
|
||||
|
||||
// MARK: - Private
|
||||
|
||||
private func finalizeRecording() async -> Result<Void, VoiceMessageRecorderError> {
|
||||
guard let url = recordingURL else {
|
||||
return .failure(.previewNotAvailable)
|
||||
}
|
||||
|
||||
// Build the preview audio player state
|
||||
previewAudioPlayerState = await AudioPlayerState(id: .recorderPreview, duration: recordingDuration, waveform: EstimatedWaveform(data: []))
|
||||
|
||||
// Build the preview audio player
|
||||
let mediaSource = MediaSourceProxy(url: url, mimeType: mp4accMimeType)
|
||||
guard case .success(let mediaPlayer) = mediaPlayerProvider.player(for: mediaSource), let audioPlayer = mediaPlayer as? AudioPlayerProtocol else {
|
||||
return .failure(.previewNotAvailable)
|
||||
}
|
||||
previewAudioPlayer = audioPlayer
|
||||
return .success(())
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
|
||||
enum VoiceMessageRecorderError: Error {
|
||||
case genericError
|
||||
case missingRecordingFile
|
||||
case previewNotAvailable
|
||||
case audioRecorderError(AudioRecorderError)
|
||||
case failedSendingVoiceMessage
|
||||
}
|
||||
|
||||
protocol VoiceMessageRecorderProtocol {
|
||||
var audioRecorder: AudioRecorderProtocol { get }
|
||||
var previewAudioPlayerState: AudioPlayerState? { get }
|
||||
var recordingURL: URL? { get }
|
||||
var recordingDuration: TimeInterval { get }
|
||||
|
||||
func startRecording() async -> Result<Void, VoiceMessageRecorderError>
|
||||
func stopRecording() async -> Result<Void, VoiceMessageRecorderError>
|
||||
func cancelRecording() async
|
||||
func startPlayback() async -> Result<Void, VoiceMessageRecorderError>
|
||||
func pausePlayback()
|
||||
func stopPlayback() async
|
||||
func seekPlayback(to progress: Double) async
|
||||
func deleteRecording() async
|
||||
|
||||
func buildRecordingWaveform() async -> Result<[UInt16], VoiceMessageRecorderError>
|
||||
func sendVoiceMessage(inRoom roomProxy: RoomProxyProtocol, audioConverter: AudioConverterProtocol) async -> Result<Void, VoiceMessageRecorderError>
|
||||
}
|
||||
|
||||
// sourcery: AutoMockable
|
||||
extension VoiceMessageRecorderProtocol { }
|
||||
@@ -260,6 +260,7 @@ class MockScreen: Identifiable {
|
||||
let parameters = RoomScreenCoordinatorParameters(roomProxy: RoomProxyMock(with: .init(displayName: "Some room name", avatarURL: nil)),
|
||||
timelineController: MockRoomTimelineController(),
|
||||
mediaProvider: MockMediaProvider(),
|
||||
mediaPlayerProvider: MediaPlayerProviderMock(),
|
||||
emojiProvider: EmojiProvider(),
|
||||
completionSuggestionService: CompletionSuggestionServiceMock(configuration: .init()),
|
||||
appSettings: ServiceLocator.shared.settings)
|
||||
@@ -271,6 +272,7 @@ class MockScreen: Identifiable {
|
||||
let parameters = RoomScreenCoordinatorParameters(roomProxy: RoomProxyMock(with: .init(displayName: "Some room name", avatarURL: URL.picturesDirectory)),
|
||||
timelineController: MockRoomTimelineController(),
|
||||
mediaProvider: MockMediaProvider(),
|
||||
mediaPlayerProvider: MediaPlayerProviderMock(),
|
||||
emojiProvider: EmojiProvider(),
|
||||
completionSuggestionService: CompletionSuggestionServiceMock(configuration: .init()),
|
||||
appSettings: ServiceLocator.shared.settings)
|
||||
@@ -284,6 +286,7 @@ class MockScreen: Identifiable {
|
||||
let parameters = RoomScreenCoordinatorParameters(roomProxy: RoomProxyMock(with: .init(displayName: "New room", avatarURL: URL.picturesDirectory)),
|
||||
timelineController: timelineController,
|
||||
mediaProvider: MockMediaProvider(),
|
||||
mediaPlayerProvider: MediaPlayerProviderMock(),
|
||||
emojiProvider: EmojiProvider(),
|
||||
completionSuggestionService: CompletionSuggestionServiceMock(configuration: .init()),
|
||||
appSettings: ServiceLocator.shared.settings)
|
||||
@@ -297,6 +300,7 @@ class MockScreen: Identifiable {
|
||||
let parameters = RoomScreenCoordinatorParameters(roomProxy: RoomProxyMock(with: .init(displayName: "New room", avatarURL: URL.picturesDirectory)),
|
||||
timelineController: timelineController,
|
||||
mediaProvider: MockMediaProvider(),
|
||||
mediaPlayerProvider: MediaPlayerProviderMock(),
|
||||
emojiProvider: EmojiProvider(),
|
||||
completionSuggestionService: CompletionSuggestionServiceMock(configuration: .init()),
|
||||
appSettings: ServiceLocator.shared.settings)
|
||||
@@ -311,6 +315,7 @@ class MockScreen: Identifiable {
|
||||
let parameters = RoomScreenCoordinatorParameters(roomProxy: RoomProxyMock(with: .init(displayName: "New room", avatarURL: URL.picturesDirectory)),
|
||||
timelineController: timelineController,
|
||||
mediaProvider: MockMediaProvider(),
|
||||
mediaPlayerProvider: MediaPlayerProviderMock(),
|
||||
emojiProvider: EmojiProvider(),
|
||||
completionSuggestionService: CompletionSuggestionServiceMock(configuration: .init()),
|
||||
appSettings: ServiceLocator.shared.settings)
|
||||
@@ -327,6 +332,7 @@ class MockScreen: Identifiable {
|
||||
let parameters = RoomScreenCoordinatorParameters(roomProxy: RoomProxyMock(with: .init(displayName: "Small timeline", avatarURL: URL.picturesDirectory)),
|
||||
timelineController: timelineController,
|
||||
mediaProvider: MockMediaProvider(),
|
||||
mediaPlayerProvider: MediaPlayerProviderMock(),
|
||||
emojiProvider: EmojiProvider(),
|
||||
completionSuggestionService: CompletionSuggestionServiceMock(configuration: .init()),
|
||||
appSettings: ServiceLocator.shared.settings)
|
||||
@@ -343,6 +349,7 @@ class MockScreen: Identifiable {
|
||||
let parameters = RoomScreenCoordinatorParameters(roomProxy: RoomProxyMock(with: .init(displayName: "Small timeline, paginating", avatarURL: URL.picturesDirectory)),
|
||||
timelineController: timelineController,
|
||||
mediaProvider: MockMediaProvider(),
|
||||
mediaPlayerProvider: MediaPlayerProviderMock(),
|
||||
emojiProvider: EmojiProvider(),
|
||||
completionSuggestionService: CompletionSuggestionServiceMock(configuration: .init()),
|
||||
appSettings: ServiceLocator.shared.settings)
|
||||
@@ -359,6 +366,7 @@ class MockScreen: Identifiable {
|
||||
let parameters = RoomScreenCoordinatorParameters(roomProxy: RoomProxyMock(with: .init(displayName: "Large timeline", avatarURL: URL.picturesDirectory)),
|
||||
timelineController: timelineController,
|
||||
mediaProvider: MockMediaProvider(),
|
||||
mediaPlayerProvider: MediaPlayerProviderMock(),
|
||||
emojiProvider: EmojiProvider(),
|
||||
completionSuggestionService: CompletionSuggestionServiceMock(configuration: .init()),
|
||||
appSettings: ServiceLocator.shared.settings)
|
||||
@@ -376,6 +384,7 @@ class MockScreen: Identifiable {
|
||||
let parameters = RoomScreenCoordinatorParameters(roomProxy: RoomProxyMock(with: .init(displayName: "Large timeline", avatarURL: URL.picturesDirectory)),
|
||||
timelineController: timelineController,
|
||||
mediaProvider: MockMediaProvider(),
|
||||
mediaPlayerProvider: MediaPlayerProviderMock(),
|
||||
emojiProvider: EmojiProvider(),
|
||||
completionSuggestionService: CompletionSuggestionServiceMock(configuration: .init()),
|
||||
appSettings: ServiceLocator.shared.settings)
|
||||
@@ -392,6 +401,7 @@ class MockScreen: Identifiable {
|
||||
let parameters = RoomScreenCoordinatorParameters(roomProxy: RoomProxyMock(with: .init(displayName: "Large timeline", avatarURL: URL.picturesDirectory)),
|
||||
timelineController: timelineController,
|
||||
mediaProvider: MockMediaProvider(),
|
||||
mediaPlayerProvider: MediaPlayerProviderMock(),
|
||||
emojiProvider: EmojiProvider(),
|
||||
completionSuggestionService: CompletionSuggestionServiceMock(configuration: .init()),
|
||||
appSettings: ServiceLocator.shared.settings)
|
||||
@@ -408,6 +418,7 @@ class MockScreen: Identifiable {
|
||||
let parameters = RoomScreenCoordinatorParameters(roomProxy: RoomProxyMock(with: .init(displayName: "Polls timeline", avatarURL: URL.picturesDirectory)),
|
||||
timelineController: timelineController,
|
||||
mediaProvider: MockMediaProvider(),
|
||||
mediaPlayerProvider: MediaPlayerProviderMock(),
|
||||
emojiProvider: EmojiProvider(),
|
||||
completionSuggestionService: CompletionSuggestionServiceMock(configuration: .init()),
|
||||
appSettings: ServiceLocator.shared.settings)
|
||||
@@ -424,6 +435,7 @@ class MockScreen: Identifiable {
|
||||
let parameters = RoomScreenCoordinatorParameters(roomProxy: RoomProxyMock(with: .init(displayName: "Polls timeline", avatarURL: URL.picturesDirectory)),
|
||||
timelineController: timelineController,
|
||||
mediaProvider: MockMediaProvider(),
|
||||
mediaPlayerProvider: MediaPlayerProviderMock(),
|
||||
emojiProvider: EmojiProvider(),
|
||||
completionSuggestionService: CompletionSuggestionServiceMock(configuration: .init()),
|
||||
appSettings: ServiceLocator.shared.settings)
|
||||
@@ -440,6 +452,7 @@ class MockScreen: Identifiable {
|
||||
let parameters = RoomScreenCoordinatorParameters(roomProxy: RoomProxyMock(with: .init(displayName: "Polls timeline", avatarURL: URL.picturesDirectory)),
|
||||
timelineController: timelineController,
|
||||
mediaProvider: MockMediaProvider(),
|
||||
mediaPlayerProvider: MediaPlayerProviderMock(),
|
||||
emojiProvider: EmojiProvider(),
|
||||
completionSuggestionService: CompletionSuggestionServiceMock(configuration: .init()),
|
||||
appSettings: ServiceLocator.shared.settings)
|
||||
|
||||
Reference in New Issue
Block a user