diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/voicemessages/composer/VoiceMessageComposerPlayer.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/voicemessages/composer/VoiceMessageComposerPlayer.kt index 545a604af2..584ec96f2a 100644 --- a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/voicemessages/composer/VoiceMessageComposerPlayer.kt +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/voicemessages/composer/VoiceMessageComposerPlayer.kt @@ -17,96 +17,233 @@ package io.element.android.features.messages.impl.voicemessages.composer import io.element.android.libraries.mediaplayer.api.MediaPlayer +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Job +import kotlinx.coroutines.cancelAndJoin import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.combine import kotlinx.coroutines.flow.distinctUntilChanged import kotlinx.coroutines.flow.map +import kotlinx.coroutines.flow.scan +import kotlinx.coroutines.launch +import timber.log.Timber import javax.inject.Inject /** * A media player for the voice message composer. * * @param mediaPlayer The [MediaPlayer] to use. + * @param coroutineScope */ class VoiceMessageComposerPlayer @Inject constructor( private val mediaPlayer: MediaPlayer, + private val coroutineScope: CoroutineScope, ) { - private var lastPlayedMediaPath: String? = null - private val curPlayingMediaId - get() = mediaPlayer.state.value.mediaId + companion object { + const val MIME_TYPE = "audio/ogg" + } + + private var mediaPath: String? = null + + private var seekJob: Job? = null + private val seekingTo = MutableStateFlow(null) - val state: Flow = mediaPlayer.state.map { state -> - if (lastPlayedMediaPath == null || lastPlayedMediaPath != state.mediaId) { - return@map State.NotLoaded + val state: Flow = combine(mediaPlayer.state, seekingTo) { state, seekingTo -> + state to seekingTo + }.scan(InternalState.NotLoaded) { prevState, (state, seekingTo) -> + if (mediaPath == null || mediaPath != state.mediaId) { + return@scan InternalState.NotLoaded } - State( - isPlaying = state.isPlaying, + InternalState( + playState = calcPlayState(prevState.playState, seekingTo, state), currentPosition = state.currentPosition, - duration = state.duration ?: 0L, + duration = state.duration, + seekingTo = seekingTo, + ) + }.map { + State( + playState = it.playState, + currentPosition = it.currentPosition, + progress = calcProgress(it), ) }.distinctUntilChanged() + /** + * Set the voice message to be played. + */ + suspend fun setMedia(mediaPath: String) { + this.mediaPath = mediaPath + mediaPlayer.setMedia( + uri = mediaPath, + mediaId = mediaPath, + mimeType = MIME_TYPE, + ) + } + /** * Start playing from the current position. * - * @param mediaPath The path to the media to be played. - * @param mimeType The mime type of the media file. + * Call [setMedia] before calling this method. */ - suspend fun play(mediaPath: String, mimeType: String) { - if (mediaPath == curPlayingMediaId) { - mediaPlayer.play() - } else { - lastPlayedMediaPath = mediaPath - mediaPlayer.setMedia( - uri = mediaPath, - mediaId = mediaPath, - mimeType = mimeType, - ) - mediaPlayer.play() + suspend fun play() { + val mediaPath = this.mediaPath + if (mediaPath == null) { + Timber.e("Set media before playing") + return } + + mediaPlayer.ensureMediaReady(mediaPath) + + mediaPlayer.play() } /** * Pause playback. */ fun pause() { - if (lastPlayedMediaPath == curPlayingMediaId) { + if (mediaPath == mediaPlayer.state.value.mediaId) { mediaPlayer.pause() } } + /** + * Seek to a given position in the current media. + * + * Call [setMedia] before calling this method. + * + * @param position The position to seek to between 0 and 1. + */ + suspend fun seek(position: Float) { + val mediaPath = this.mediaPath + if (mediaPath == null) { + Timber.e("Set media before seeking") + return + } + + seekJob?.cancelAndJoin() + seekingTo.value = position + seekJob = coroutineScope.launch { + val mediaState = mediaPlayer.ensureMediaReady(mediaPath) + val duration = mediaState.duration ?: return@launch + val positionMs = (duration * position).toLong() + mediaPlayer.seekTo(positionMs) + }.apply { + invokeOnCompletion { + seekingTo.value = null + } + } + } + + private suspend fun MediaPlayer.ensureMediaReady(mediaPath: String): MediaPlayer.State { + val state = state.value + if (state.mediaId == mediaPath && state.isReady) { + return state + } + + return setMedia( + uri = mediaPath, + mediaId = mediaPath, + mimeType = MIME_TYPE, + ) + } + + private fun calcPlayState(prevPlayState: PlayState, seekingTo: Float?, state: MediaPlayer.State): PlayState { + if (state.mediaId == null || state.mediaId != mediaPath) { + return PlayState.Stopped + } + + // If we were stopped and the player didn't start playing or seeking, we are still stopped. + if (prevPlayState == PlayState.Stopped && !state.isPlaying && seekingTo == null) { + return PlayState.Stopped + } + + return if (state.isPlaying) { + PlayState.Playing + } else { + PlayState.Paused + } + } + + private fun calcProgress(state: InternalState): Float { + if (state.seekingTo != null) { + return state.seekingTo + } + + if (state.playState == PlayState.Stopped) { + return 0f + } + + if (state.duration == null) { + return 0f + } + + return (state.currentPosition.toFloat() / state.duration.toFloat()) + .coerceAtMost(1f) // Current position may exceed reported duration + } + + /** + * @property playState Whether this player is currently playing. See [PlayState]. + * @property currentPosition The elapsed time of this player in milliseconds. + * @property progress The progress of this player between 0 and 1. + */ data class State( + val playState: PlayState, + val currentPosition: Long, + val progress: Float, + ) { + + companion object { + val Initial = State( + playState = PlayState.Stopped, + currentPosition = 0L, + progress = 0f, + ) + } /** * Whether this player is currently playing. */ - val isPlaying: Boolean, + val isPlaying get() = this.playState == PlayState.Playing + + /** + * Whether this player is currently stopped. + */ + val isStopped get() = this.playState == PlayState.Stopped + } + + + enum class PlayState { /** - * The elapsed time of this player in milliseconds. + * The player is stopped, i.e. it has just been initialised. */ - val currentPosition: Long, + Stopped, + + /** + * The player is playing. + */ + Playing, + /** - * The duration of this player in milliseconds. + * The player has been paused. The player can also enter the paused state after seeking to a position. */ - val duration: Long, + Paused, + } + + private data class InternalState( + val playState: PlayState, + val currentPosition: Long, + val duration: Long?, + val seekingTo: Float?, ) { companion object { - val NotLoaded = State( - isPlaying = false, + val NotLoaded = InternalState( + playState = PlayState.Stopped, currentPosition = 0L, - duration = 0L, + duration = null, + seekingTo = null, ) } - - val isLoaded get() = this != NotLoaded - - /** - * The progress of this player between 0 and 1. - */ - val progress: Float = - if (duration == 0L) - 0f - else - (currentPosition.toFloat() / duration.toFloat()) - .coerceAtMost(1f) // Current position may exceed reported duration } } + diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/voicemessages/composer/VoiceMessageComposerPresenter.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/voicemessages/composer/VoiceMessageComposerPresenter.kt index bb642226a9..d39b065e5a 100644 --- a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/voicemessages/composer/VoiceMessageComposerPresenter.kt +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/voicemessages/composer/VoiceMessageComposerPresenter.kt @@ -18,6 +18,7 @@ package io.element.android.features.messages.impl.voicemessages.composer import android.Manifest import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.collectAsState import androidx.compose.runtime.derivedStateOf import androidx.compose.runtime.getValue @@ -69,13 +70,17 @@ class VoiceMessageComposerPresenter @Inject constructor( override fun present(): VoiceMessageComposerState { val localCoroutineScope = rememberCoroutineScope() val recorderState by voiceRecorder.state.collectAsState(initial = VoiceRecorderState.Idle) + val playerState by player.state.collectAsState(initial = VoiceMessageComposerPlayer.State.Initial) val keepScreenOn by remember { derivedStateOf { recorderState is VoiceRecorderState.Recording } } val permissionState = permissionsPresenter.present() var isSending by remember { mutableStateOf(false) } - val playerState by player.state.collectAsState(initial = VoiceMessageComposerPlayer.State.NotLoaded) - val playerTime by remember(playerState, recorderState) { derivedStateOf { displayTime(playerState, recorderState) } } - val waveform by remember(recorderState) { derivedStateOf { recorderState.finishedWaveform() } } + + LaunchedEffect(recorderState) { + val recording = recorderState as? VoiceRecorderState.Finished + ?: return@LaunchedEffect + player.setMedia(recording.file.path) + } val onLifecycleEvent = { event: Lifecycle.Event -> when (event) { @@ -115,27 +120,15 @@ class VoiceMessageComposerPresenter @Inject constructor( } } } - val onPlayerEvent = { event: VoiceMessagePlayerEvent -> - when (event) { - VoiceMessagePlayerEvent.Play -> - when (val recording = recorderState) { - is VoiceRecorderState.Finished -> - localCoroutineScope.launch { - player.play( - mediaPath = recording.file.path, - mimeType = recording.mimeType, - ) - } - else -> Timber.e("Voice message player event received but no file to play") - } - VoiceMessagePlayerEvent.Pause -> { - player.pause() - } - is VoiceMessagePlayerEvent.Seek -> { - // TODO implement seeking + val onPlayerEvent = { event: VoiceMessagePlayerEvent -> localCoroutineScope.launch { + localCoroutineScope.launch { + when (event) { + VoiceMessagePlayerEvent.Play -> player.play() + VoiceMessagePlayerEvent.Pause -> player.pause() + is VoiceMessagePlayerEvent.Seek -> player.seek(event.position) } } - } + } } val onAcceptPermissionsRationale = { permissionState.eventSink(PermissionsEvents.OpenSystemSettingAndCloseDialog) @@ -189,16 +182,14 @@ class VoiceMessageComposerPresenter @Inject constructor( voiceMessageState = when (val state = recorderState) { is VoiceRecorderState.Recording -> VoiceMessageState.Recording( duration = state.elapsedTime, - levels = state.levels.toPersistentList() - ) - is VoiceRecorderState.Finished -> VoiceMessageState.Preview( - isSending = isSending, - isPlaying = playerState.isPlaying, - showCursor = playerState.isLoaded && !isSending, - playbackProgress = playerState.progress, - time = playerTime, - waveform = waveform, + levels = state.levels.toPersistentList(), ) + is VoiceRecorderState.Finished -> + previewState( + playerState = playerState, + recorderState = recorderState, + isSending = isSending + ) else -> VoiceMessageState.Idle }, showPermissionRationaleDialog = permissionState.showDialog, @@ -207,6 +198,26 @@ class VoiceMessageComposerPresenter @Inject constructor( ) } + @Composable + private fun previewState( + playerState: VoiceMessageComposerPlayer.State, + recorderState: VoiceRecorderState, + isSending: Boolean, + ): VoiceMessageState { + val showCursor by remember(playerState.isStopped, isSending) { derivedStateOf { !playerState.isStopped && !isSending }} + val playerTime by remember(playerState, recorderState) { derivedStateOf { displayTime(playerState, recorderState) } } + val waveform by remember(recorderState) { derivedStateOf { recorderState.finishedWaveform() } } + + return VoiceMessageState.Preview( + isSending = isSending, + isPlaying = playerState.isPlaying, + showCursor = showCursor, + playbackProgress = playerState.progress, + time = playerTime, + waveform = waveform, + ) + } + private fun CoroutineScope.startRecording() = launch { try { voiceRecorder.startRecord() @@ -248,7 +259,7 @@ class VoiceMessageComposerPresenter @Inject constructor( } private fun AnalyticsService.captureComposerEvent() = - analyticsService.capture( + capture( Composer( inThread = messageComposerContext.composerMode.inThread, isEditing = messageComposerContext.composerMode.isEditing, @@ -273,7 +284,7 @@ private fun displayTime( playerState: VoiceMessageComposerPlayer.State, recording: VoiceRecorderState ): Duration = when { - playerState.isLoaded -> + !playerState.isStopped -> playerState.currentPosition.milliseconds recording is VoiceRecorderState.Finished -> recording.duration diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/voicemessages/timeline/VoiceMessagePlayer.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/voicemessages/timeline/VoiceMessagePlayer.kt index 67297fc3b3..f96342a308 100644 --- a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/voicemessages/timeline/VoiceMessagePlayer.kt +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/voicemessages/timeline/VoiceMessagePlayer.kt @@ -154,7 +154,7 @@ class DefaultVoiceMessagePlayer( mediaPlayer.setMedia( uri = mediaFile.path, mediaId = eventId.value, - mimeType = "audio/ogg" // Files in the voice cache have no extension so we need to set the mime type manually. + mimeType = "audio/ogg", // Files in the voice cache have no extension so we need to set the mime type manually. ) mediaPlayer.play() } diff --git a/features/messages/impl/src/test/kotlin/io/element/android/features/messages/MessagesPresenterTest.kt b/features/messages/impl/src/test/kotlin/io/element/android/features/messages/MessagesPresenterTest.kt index 640be695e9..2969f26580 100644 --- a/features/messages/impl/src/test/kotlin/io/element/android/features/messages/MessagesPresenterTest.kt +++ b/features/messages/impl/src/test/kotlin/io/element/android/features/messages/MessagesPresenterTest.kt @@ -641,7 +641,7 @@ class MessagesPresenterTest { FakeVoiceRecorder(), analyticsService, mediaSender, - player = VoiceMessageComposerPlayer(FakeMediaPlayer()), + player = VoiceMessageComposerPlayer(FakeMediaPlayer(), this), messageComposerContext = FakeMessageComposerContext(), permissionsPresenterFactory, ) diff --git a/features/messages/impl/src/test/kotlin/io/element/android/features/messages/voicemessages/composer/VoiceMessageComposerPresenterTest.kt b/features/messages/impl/src/test/kotlin/io/element/android/features/messages/voicemessages/composer/VoiceMessageComposerPresenterTest.kt index 598ceaf227..1a6ff988db 100644 --- a/features/messages/impl/src/test/kotlin/io/element/android/features/messages/voicemessages/composer/VoiceMessageComposerPresenterTest.kt +++ b/features/messages/impl/src/test/kotlin/io/element/android/features/messages/voicemessages/composer/VoiceMessageComposerPresenterTest.kt @@ -35,8 +35,8 @@ import io.element.android.features.messages.test.FakeMessageComposerContext import io.element.android.libraries.matrix.test.AN_EVENT_ID import io.element.android.libraries.matrix.test.A_MESSAGE import io.element.android.libraries.matrix.test.A_USER_NAME -import io.element.android.libraries.mediaplayer.test.FakeMediaPlayer import io.element.android.libraries.matrix.test.room.FakeMatrixRoom +import io.element.android.libraries.mediaplayer.test.FakeMediaPlayer import io.element.android.libraries.mediaupload.api.MediaSender import io.element.android.libraries.mediaupload.test.FakeMediaPreProcessor import io.element.android.libraries.permissions.api.PermissionsPresenter @@ -195,7 +195,6 @@ class VoiceMessageComposerPresenterTest { awaitItem().eventSink(VoiceMessageComposerEvents.RecordButtonEvent(PressEvent.PressStart)) awaitItem().eventSink(VoiceMessageComposerEvents.RecordButtonEvent(PressEvent.LongPressEnd)) awaitItem().eventSink(VoiceMessageComposerEvents.PlayerEvent(VoiceMessagePlayerEvent.Play)) - awaitItem().apply { assertThat(voiceMessageState).isEqualTo(aLoadedState()) } val finalState = awaitItem().also { assertThat(it.voiceMessageState).isEqualTo(aPlayingState()) } @@ -214,7 +213,6 @@ class VoiceMessageComposerPresenterTest { awaitItem().eventSink(VoiceMessageComposerEvents.RecordButtonEvent(PressEvent.PressStart)) awaitItem().eventSink(VoiceMessageComposerEvents.RecordButtonEvent(PressEvent.LongPressEnd)) awaitItem().eventSink(VoiceMessageComposerEvents.PlayerEvent(VoiceMessagePlayerEvent.Play)) - skipItems(1) // Loaded state awaitItem().eventSink(VoiceMessageComposerEvents.PlayerEvent(VoiceMessagePlayerEvent.Pause)) val finalState = awaitItem().also { assertThat(it.voiceMessageState).isEqualTo(aPausedState()) @@ -225,6 +223,33 @@ class VoiceMessageComposerPresenterTest { } } + @Test + fun `present - seek recording`() = runTest { + val presenter = createVoiceMessageComposerPresenter() + moleculeFlow(RecompositionMode.Immediate) { + presenter.present() + }.test { + awaitItem().eventSink(VoiceMessageComposerEvents.RecordButtonEvent(PressEvent.PressStart)) + awaitItem().eventSink(VoiceMessageComposerEvents.RecordButtonEvent(PressEvent.LongPressEnd)) + awaitItem().eventSink(VoiceMessageComposerEvents.PlayerEvent(VoiceMessagePlayerEvent.Seek(0.5f))) + awaitItem().apply { + assertThat(voiceMessageState).isEqualTo(aPreviewState(playbackProgress = 0.5f, time = 0.seconds, showCursor = true)) + } + awaitItem().apply { + assertThat(voiceMessageState).isEqualTo(aPreviewState(playbackProgress = 0.5f, time = 5.seconds, showCursor = true)) + eventSink(VoiceMessageComposerEvents.PlayerEvent(VoiceMessagePlayerEvent.Seek(0.2f))) + } + awaitItem().apply { + assertThat(voiceMessageState).isEqualTo(aPreviewState(playbackProgress = 0.2f, time = 5.seconds, showCursor = true)) + } + val finalState = awaitItem().apply { + assertThat(voiceMessageState).isEqualTo(aPreviewState(playbackProgress = 0.2f, time = 2.seconds, showCursor = true)) + } + + testPauseAndDestroy(finalState) + } + } + @Test fun `present - delete recording`() = runTest { val presenter = createVoiceMessageComposerPresenter() @@ -252,7 +277,6 @@ class VoiceMessageComposerPresenterTest { awaitItem().eventSink(VoiceMessageComposerEvents.RecordButtonEvent(PressEvent.PressStart)) awaitItem().eventSink(VoiceMessageComposerEvents.RecordButtonEvent(PressEvent.LongPressEnd)) awaitItem().eventSink(VoiceMessageComposerEvents.PlayerEvent(VoiceMessagePlayerEvent.Play)) - skipItems(1) // Loaded state awaitItem().eventSink(VoiceMessageComposerEvents.DeleteVoiceMessage) awaitItem().apply { assertThat(voiceMessageState).isEqualTo(aPausedState()) @@ -324,9 +348,9 @@ class VoiceMessageComposerPresenterTest { awaitItem().eventSink(VoiceMessageComposerEvents.RecordButtonEvent(PressEvent.PressStart)) awaitItem().eventSink(VoiceMessageComposerEvents.RecordButtonEvent(PressEvent.LongPressEnd)) awaitItem().eventSink(VoiceMessageComposerEvents.PlayerEvent(VoiceMessagePlayerEvent.Play)) - skipItems(1) // Loaded state awaitItem().eventSink(VoiceMessageComposerEvents.SendVoiceMessage) assertThat(awaitItem().voiceMessageState).isEqualTo(aPlayingState().toSendingState()) + skipItems(1) // Duplicate sending state val finalState = awaitItem() assertThat(finalState.voiceMessageState).isEqualTo(VoiceMessageState.Idle) @@ -603,7 +627,7 @@ class VoiceMessageComposerPresenterTest { voiceRecorder, analyticsService, mediaSender, - player = VoiceMessageComposerPlayer(FakeMediaPlayer()), + player = VoiceMessageComposerPlayer(FakeMediaPlayer(), this), messageComposerContext = messageComposerContext, FakePermissionsPresenterFactory(permissionsPresenter), ) @@ -639,14 +663,6 @@ class VoiceMessageComposerPresenterTest { waveform = waveform.toImmutableList(), ) - private fun aLoadedState() = - aPreviewState( - isPlaying = false, - playbackProgress = 0.0f, - showCursor = true, - time = 0.seconds, - ) - private fun aPlayingState() = aPreviewState( isPlaying = true, diff --git a/libraries/textcomposer/impl/src/main/kotlin/io/element/android/libraries/textcomposer/components/VoiceMessagePreview.kt b/libraries/textcomposer/impl/src/main/kotlin/io/element/android/libraries/textcomposer/components/VoiceMessagePreview.kt index 7197c07307..222ee2aeaf 100644 --- a/libraries/textcomposer/impl/src/main/kotlin/io/element/android/libraries/textcomposer/components/VoiceMessagePreview.kt +++ b/libraries/textcomposer/impl/src/main/kotlin/io/element/android/libraries/textcomposer/components/VoiceMessagePreview.kt @@ -108,7 +108,7 @@ internal fun VoiceMessagePreview( playbackProgress = playbackProgress, showCursor = showCursor, waveform = waveform, - seekEnabled = false, // TODO enable seeking + seekEnabled = true, onSeek = onSeek, ) } diff --git a/libraries/voicerecorder/api/build.gradle.kts b/libraries/voicerecorder/api/build.gradle.kts index bed69b7d28..f704935bbd 100644 --- a/libraries/voicerecorder/api/build.gradle.kts +++ b/libraries/voicerecorder/api/build.gradle.kts @@ -14,7 +14,7 @@ * limitations under the License. */ plugins { - id("io.element.android-library") + id("io.element.android-compose-library") alias(libs.plugins.anvil) } diff --git a/libraries/voicerecorder/api/src/main/kotlin/io/element/android/libraries/voicerecorder/api/VoiceRecorderState.kt b/libraries/voicerecorder/api/src/main/kotlin/io/element/android/libraries/voicerecorder/api/VoiceRecorderState.kt index 42035615d5..2bb4f575e3 100644 --- a/libraries/voicerecorder/api/src/main/kotlin/io/element/android/libraries/voicerecorder/api/VoiceRecorderState.kt +++ b/libraries/voicerecorder/api/src/main/kotlin/io/element/android/libraries/voicerecorder/api/VoiceRecorderState.kt @@ -16,9 +16,11 @@ package io.element.android.libraries.voicerecorder.api +import androidx.compose.runtime.Immutable import java.io.File import kotlin.time.Duration +@Immutable sealed interface VoiceRecorderState { /** * The recorder is idle and not recording.