mirror of
https://github.com/Novaloop-AG/element-x-ios.git
synced 2026-11-03 16:10:20 +00:00
Allow to record a voice message (#1926)
This commit is contained in:
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user