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:
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user