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 36dddb7d0d..b166dcd78a 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 @@ -41,7 +41,8 @@ class VoiceMessageComposerPlayer @Inject constructor( State( isPlaying = state.isPlaying, - currentPosition = state.currentPosition + currentPosition = state.currentPosition, + duration = state.duration, ) }.distinctUntilChanged() @@ -82,12 +83,23 @@ class VoiceMessageComposerPlayer @Inject constructor( * The elapsed time of this player in milliseconds. */ val currentPosition: Long, + /** + * The duration of this player in milliseconds. + */ + val duration: Long, ) { companion object { val NotPlaying = State( isPlaying = false, currentPosition = 0L, + duration = 0L, ) } + + /** + * The progress of this player between 0 and 1. + */ + val progress: Float = + if (duration <= currentPosition) 0f else currentPosition.toFloat() / duration.toFloat() } } 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 ff3e2df722..c8edec15a6 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 @@ -185,6 +185,7 @@ class VoiceMessageComposerPresenter @Inject constructor( is VoiceRecorderState.Finished -> VoiceMessageState.Preview( isSending = isSending, isPlaying = isPlaying, + playbackProgress = playerState.progress, waveform = waveform, ) else -> VoiceMessageState.Idle 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 4cffe65036..c0d1723c36 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 @@ -164,7 +164,7 @@ class VoiceMessageComposerPresenterTest { awaitItem().eventSink(VoiceMessageComposerEvents.RecordButtonEvent(PressEvent.LongPressEnd)) awaitItem().eventSink(VoiceMessageComposerEvents.PlayerEvent(VoiceMessagePlayerEvent.Play)) val finalState = awaitItem().also { - assertThat(it.voiceMessageState).isEqualTo(aPreviewState(isPlaying = true)) + assertThat(it.voiceMessageState).isEqualTo(aPreviewState(isPlaying = true, playbackProgress = 0.1f)) } voiceRecorder.assertCalls(started = 1, stopped = 1, deleted = 0) @@ -183,7 +183,7 @@ class VoiceMessageComposerPresenterTest { awaitItem().eventSink(VoiceMessageComposerEvents.PlayerEvent(VoiceMessagePlayerEvent.Play)) awaitItem().eventSink(VoiceMessageComposerEvents.PlayerEvent(VoiceMessagePlayerEvent.Pause)) val finalState = awaitItem().also { - assertThat(it.voiceMessageState).isEqualTo(aPreviewState(isPlaying = false)) + assertThat(it.voiceMessageState).isEqualTo(aPreviewState(isPlaying = false, playbackProgress = 0.1f)) } voiceRecorder.assertCalls(started = 1, stopped = 1, deleted = 0) @@ -220,7 +220,7 @@ class VoiceMessageComposerPresenterTest { awaitItem().eventSink(VoiceMessageComposerEvents.PlayerEvent(VoiceMessagePlayerEvent.Play)) awaitItem().eventSink(VoiceMessageComposerEvents.DeleteVoiceMessage) awaitItem().apply { - assertThat(voiceMessageState).isEqualTo(aPreviewState(isPlaying = false)) + assertThat(voiceMessageState).isEqualTo(aPreviewState(isPlaying = false, playbackProgress = 0.1f)) } val finalState = awaitItem() @@ -262,7 +262,7 @@ class VoiceMessageComposerPresenterTest { awaitItem().eventSink(VoiceMessageComposerEvents.PlayerEvent(VoiceMessagePlayerEvent.Play)) awaitItem().eventSink(VoiceMessageComposerEvents.SendVoiceMessage) assertThat(awaitItem().voiceMessageState).isEqualTo(aPreviewState( - isSending = true, isPlaying = false, + isSending = true, isPlaying = false, playbackProgress = 0.1f )) val finalState = awaitItem() @@ -510,7 +510,7 @@ class VoiceMessageComposerPresenterTest { is VoiceMessageState.Preview -> when (state.isPlaying) { // If the preview was playing, it pauses true -> awaitItem().apply { - assertThat(voiceMessageState).isEqualTo(aPreviewState()) + assertThat(voiceMessageState).isEqualTo(aPreviewState(playbackProgress = 0.1f)) } false -> mostRecentState } @@ -561,10 +561,12 @@ class VoiceMessageComposerPresenterTest { private fun aPreviewState( isPlaying: Boolean = false, + playbackProgress: Float = 0f, isSending: Boolean = false, waveform: List = voiceRecorder.waveform, ) = VoiceMessageState.Preview( isPlaying = isPlaying, + playbackProgress = playbackProgress, isSending = isSending, waveform = waveform.toImmutableList(), ) diff --git a/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/components/media/WaveformPlaybackView.kt b/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/components/media/WaveformPlaybackView.kt index 70a506cffa..bae772c0f2 100644 --- a/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/components/media/WaveformPlaybackView.kt +++ b/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/components/media/WaveformPlaybackView.kt @@ -60,6 +60,7 @@ private const val DEFAULT_GRAPHICS_LAYER_ALPHA: Float = 0.99F * @param showCursor Whether to show the cursor or not. * @param waveform The waveform to display. Use [FakeWaveformFactory] to generate a fake waveform. * @param modifier The modifier to be applied to the view. + * @param seekEnabled Whether the user can seek the waveform or not. * @param onSeek Callback when the user seeks the waveform. Called with a value between 0 and 1. * @param brush The brush to use to draw the waveform. * @param progressBrush The brush to use to draw the progress. @@ -74,6 +75,7 @@ fun WaveformPlaybackView( showCursor: Boolean, waveform: ImmutableList, modifier: Modifier = Modifier, + seekEnabled: Boolean = true, onSeek: (progress: Float) -> Unit = {}, brush: Brush = SolidColor(ElementTheme.colors.iconQuaternary), progressBrush: Brush = SolidColor(ElementTheme.colors.iconSecondary), @@ -106,28 +108,32 @@ fun WaveformPlaybackView( modifier = Modifier .fillMaxWidth() .graphicsLayer(alpha = DEFAULT_GRAPHICS_LAYER_ALPHA) - .pointerInteropFilter(requestDisallowInterceptTouchEvent = requestDisallowInterceptTouchEvent) { - return@pointerInteropFilter when (it.action) { - MotionEvent.ACTION_DOWN -> { - if (it.x in 0F..canvasSizePx.width) { - requestDisallowInterceptTouchEvent.invoke(true) - seekProgress.value = it.x / canvasSizePx.width - true - } else false - } - MotionEvent.ACTION_MOVE -> { - if (it.x in 0F..canvasSizePx.width) { - seekProgress.value = it.x / canvasSizePx.width + .let { + if (!seekEnabled) return@let it + + it.pointerInteropFilter(requestDisallowInterceptTouchEvent = requestDisallowInterceptTouchEvent) { e -> + return@pointerInteropFilter when (e.action) { + MotionEvent.ACTION_DOWN -> { + if (e.x in 0F..canvasSizePx.width) { + requestDisallowInterceptTouchEvent.invoke(true) + seekProgress.value = e.x / canvasSizePx.width + true + } else false } - true + MotionEvent.ACTION_MOVE -> { + if (e.x in 0F..canvasSizePx.width) { + seekProgress.value = e.x / canvasSizePx.width + } + true + } + MotionEvent.ACTION_UP -> { + requestDisallowInterceptTouchEvent.invoke(false) + seekProgress.value?.let(onSeek) + seekProgress.value = null + true + } + else -> false } - MotionEvent.ACTION_UP -> { - requestDisallowInterceptTouchEvent.invoke(false) - seekProgress.value?.let(onSeek) - seekProgress.value = null - true - } - else -> false } } .then(modifier) diff --git a/libraries/mediaplayer/api/src/main/kotlin/io/element/android/libraries/mediaplayer/api/MediaPlayer.kt b/libraries/mediaplayer/api/src/main/kotlin/io/element/android/libraries/mediaplayer/api/MediaPlayer.kt index 2c72cbf54a..9c74e27daa 100644 --- a/libraries/mediaplayer/api/src/main/kotlin/io/element/android/libraries/mediaplayer/api/MediaPlayer.kt +++ b/libraries/mediaplayer/api/src/main/kotlin/io/element/android/libraries/mediaplayer/api/MediaPlayer.kt @@ -74,5 +74,9 @@ interface MediaPlayer : AutoCloseable { * The current position of the player. */ val currentPosition: Long, + /** + * The duration of the current content. + */ + val duration: Long, ) } diff --git a/libraries/mediaplayer/impl/src/main/kotlin/io/element/android/libraries/mediaplayer/impl/MediaPlayerImpl.kt b/libraries/mediaplayer/impl/src/main/kotlin/io/element/android/libraries/mediaplayer/impl/MediaPlayerImpl.kt index 88e592a9d8..0e46ad0fee 100644 --- a/libraries/mediaplayer/impl/src/main/kotlin/io/element/android/libraries/mediaplayer/impl/MediaPlayerImpl.kt +++ b/libraries/mediaplayer/impl/src/main/kotlin/io/element/android/libraries/mediaplayer/impl/MediaPlayerImpl.kt @@ -47,6 +47,7 @@ class MediaPlayerImpl @Inject constructor( _state.update { it.copy( currentPosition = player.currentPosition, + duration = player.duration.coerceAtLeast(0), isPlaying = isPlaying, ) } @@ -61,6 +62,7 @@ class MediaPlayerImpl @Inject constructor( _state.update { it.copy( currentPosition = player.currentPosition, + duration = player.duration.coerceAtLeast(0), mediaId = mediaItem?.mediaId, ) } @@ -74,7 +76,7 @@ class MediaPlayerImpl @Inject constructor( private val scope = CoroutineScope(Job() + Dispatchers.Main) private var job: Job? = null - private val _state = MutableStateFlow(MediaPlayer.State(false, null, 0L)) + private val _state = MutableStateFlow(MediaPlayer.State(false, null, 0L, 0L)) override val state: StateFlow = _state.asStateFlow() diff --git a/libraries/mediaplayer/impl/src/main/kotlin/io/element/android/libraries/mediaplayer/impl/SimplePlayer.kt b/libraries/mediaplayer/impl/src/main/kotlin/io/element/android/libraries/mediaplayer/impl/SimplePlayer.kt index 79a51973e6..8ae4325e64 100644 --- a/libraries/mediaplayer/impl/src/main/kotlin/io/element/android/libraries/mediaplayer/impl/SimplePlayer.kt +++ b/libraries/mediaplayer/impl/src/main/kotlin/io/element/android/libraries/mediaplayer/impl/SimplePlayer.kt @@ -33,6 +33,7 @@ interface SimplePlayer { fun addListener(listener: Listener) val currentPosition: Long val playbackState: Int + val duration: Long fun clearMediaItems() fun setMediaItem(mediaItem: MediaItem) fun getCurrentMediaItem(): MediaItem? @@ -73,6 +74,8 @@ class SimplePlayerImpl( get() = p.currentPosition override val playbackState: Int get() = p.playbackState + override val duration: Long + get() = p.duration override fun clearMediaItems() = p.clearMediaItems() diff --git a/libraries/mediaplayer/test/src/main/kotlin/io/element/android/libraries/mediaplayer/test/FakeMediaPlayer.kt b/libraries/mediaplayer/test/src/main/kotlin/io/element/android/libraries/mediaplayer/test/FakeMediaPlayer.kt index efa5bb1fea..c94261f27e 100644 --- a/libraries/mediaplayer/test/src/main/kotlin/io/element/android/libraries/mediaplayer/test/FakeMediaPlayer.kt +++ b/libraries/mediaplayer/test/src/main/kotlin/io/element/android/libraries/mediaplayer/test/FakeMediaPlayer.kt @@ -26,7 +26,12 @@ import kotlinx.coroutines.flow.update * Fake implementation of [MediaPlayer] for testing purposes. */ class FakeMediaPlayer : MediaPlayer { - private val _state = MutableStateFlow(MediaPlayer.State(false, null, 0L)) + companion object { + private const val FAKE_TOTAL_DURATION_MS = 10_000L + private const val FAKE_PLAYED_DURATION_MS = 1000L + } + + private val _state = MutableStateFlow(MediaPlayer.State(false, null, 0L, 0L)) override val state: StateFlow = _state.asStateFlow() @@ -35,7 +40,8 @@ class FakeMediaPlayer : MediaPlayer { it.copy( isPlaying = true, mediaId = mediaId, - currentPosition = it.currentPosition + 1000L, + currentPosition = it.currentPosition + FAKE_PLAYED_DURATION_MS, + duration = FAKE_TOTAL_DURATION_MS, ) } } @@ -44,7 +50,8 @@ class FakeMediaPlayer : MediaPlayer { _state.update { it.copy( isPlaying = true, - currentPosition = it.currentPosition + 1000L, + currentPosition = it.currentPosition + FAKE_PLAYED_DURATION_MS, + duration = FAKE_TOTAL_DURATION_MS, ) } } diff --git a/libraries/textcomposer/impl/src/main/kotlin/io/element/android/libraries/textcomposer/TextComposer.kt b/libraries/textcomposer/impl/src/main/kotlin/io/element/android/libraries/textcomposer/TextComposer.kt index 5c55dbb517..a9236c6bfd 100644 --- a/libraries/textcomposer/impl/src/main/kotlin/io/element/android/libraries/textcomposer/TextComposer.kt +++ b/libraries/textcomposer/impl/src/main/kotlin/io/element/android/libraries/textcomposer/TextComposer.kt @@ -76,8 +76,8 @@ import io.element.android.libraries.textcomposer.components.textInputRoundedCorn import io.element.android.libraries.textcomposer.model.Message import io.element.android.libraries.textcomposer.model.MessageComposerMode import io.element.android.libraries.textcomposer.model.PressEvent -import io.element.android.libraries.textcomposer.model.VoiceMessagePlayerEvent import io.element.android.libraries.textcomposer.model.Suggestion +import io.element.android.libraries.textcomposer.model.VoiceMessagePlayerEvent import io.element.android.libraries.textcomposer.model.VoiceMessageState import io.element.android.libraries.theme.ElementTheme import io.element.android.libraries.ui.strings.CommonStrings @@ -86,8 +86,8 @@ import io.element.android.wysiwyg.compose.RichTextEditorState import kotlinx.collections.immutable.ImmutableList import kotlinx.collections.immutable.persistentListOf import kotlinx.collections.immutable.toPersistentList -import kotlin.time.Duration.Companion.seconds import uniffi.wysiwyg_composer.MenuAction +import kotlin.time.Duration.Companion.seconds @Composable fun TextComposer( @@ -194,7 +194,7 @@ fun TextComposer( when (voiceMessageState) { VoiceMessageState.Idle, is VoiceMessageState.Recording -> recordVoiceButton - is VoiceMessageState.Preview -> when(voiceMessageState.isSending) { + is VoiceMessageState.Preview -> when (voiceMessageState.isSending) { true -> uploadVoiceProgress false -> sendVoiceButton } @@ -210,6 +210,7 @@ fun TextComposer( isInteractive = !voiceMessageState.isSending, isPlaying = voiceMessageState.isPlaying, waveform = voiceMessageState.waveform, + playbackProgress = voiceMessageState.playbackProgress, onPlayClick = onPlayVoiceMessageClicked, onPauseClick = onPauseVoiceMessageClicked, onSeek = onSeekVoiceMessage, @@ -221,7 +222,7 @@ fun TextComposer( } val voiceDeleteButton = @Composable { - if(voiceMessageState is VoiceMessageState.Preview) { + if (voiceMessageState is VoiceMessageState.Preview) { VoiceMessageDeleteButton(enabled = !voiceMessageState.isSending, onClick = onDeleteVoiceMessage) } } @@ -817,11 +818,32 @@ internal fun TextComposerVoicePreview() = ElementPreview { PreviewColumn(items = persistentListOf({ VoicePreview(voiceMessageState = VoiceMessageState.Recording(61.seconds, List(100) { it.toFloat() / 100 }.toPersistentList())) }, { - VoicePreview(voiceMessageState = VoiceMessageState.Preview(isSending = false, isPlaying = false, waveform = createFakeWaveform())) + VoicePreview( + voiceMessageState = VoiceMessageState.Preview( + isSending = false, + isPlaying = false, + waveform = createFakeWaveform(), + playbackProgress = 0.0f + ) + ) }, { - VoicePreview(voiceMessageState = VoiceMessageState.Preview(isSending = false, isPlaying = true, waveform = createFakeWaveform())) + VoicePreview( + voiceMessageState = VoiceMessageState.Preview( + isSending = false, + isPlaying = true, + waveform = createFakeWaveform(), + playbackProgress = 0.2f + ) + ) }, { - VoicePreview(voiceMessageState = VoiceMessageState.Preview(isSending = true, isPlaying = false, waveform = createFakeWaveform())) + VoicePreview( + voiceMessageState = VoiceMessageState.Preview( + isSending = true, + isPlaying = false, + waveform = createFakeWaveform(), + playbackProgress = 0.0f + ) + ) })) } 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 8375a43642..5a4c1bd49e 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 @@ -95,6 +95,7 @@ internal fun VoiceMessagePreview( playbackProgress = playbackProgress, showCursor = isInteractive, waveform = waveform, + seekEnabled = false, // TODO enable seeking onSeek = onSeek, ) } diff --git a/libraries/textcomposer/impl/src/main/kotlin/io/element/android/libraries/textcomposer/model/VoiceMessageState.kt b/libraries/textcomposer/impl/src/main/kotlin/io/element/android/libraries/textcomposer/model/VoiceMessageState.kt index 5a3e030c56..b3798be203 100644 --- a/libraries/textcomposer/impl/src/main/kotlin/io/element/android/libraries/textcomposer/model/VoiceMessageState.kt +++ b/libraries/textcomposer/impl/src/main/kotlin/io/element/android/libraries/textcomposer/model/VoiceMessageState.kt @@ -25,6 +25,7 @@ sealed class VoiceMessageState { data class Preview( val isSending: Boolean, val isPlaying: Boolean, + val playbackProgress: Float, val waveform: ImmutableList, ): VoiceMessageState() diff --git a/tests/uitests/src/test/snapshots/images/ui_S_t[l.textcomposer_null_TextComposerVoice-D-4_4_null,NEXUS_5,1.0,en].png b/tests/uitests/src/test/snapshots/images/ui_S_t[l.textcomposer_null_TextComposerVoice-D-4_4_null,NEXUS_5,1.0,en].png index 889aa92b7d..433aeb1bca 100644 --- a/tests/uitests/src/test/snapshots/images/ui_S_t[l.textcomposer_null_TextComposerVoice-D-4_4_null,NEXUS_5,1.0,en].png +++ b/tests/uitests/src/test/snapshots/images/ui_S_t[l.textcomposer_null_TextComposerVoice-D-4_4_null,NEXUS_5,1.0,en].png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:d93f97e1ae36edc6375b00de4421166f8012ab16cc6629897b6a36b062cbbff6 -size 27255 +oid sha256:e2fe52c3e34e58ab38f7fa0b4b2281bdd8ef269acb3426449faa93fe7b434feb +size 27393 diff --git a/tests/uitests/src/test/snapshots/images/ui_S_t[l.textcomposer_null_TextComposerVoice-N-4_5_null,NEXUS_5,1.0,en].png b/tests/uitests/src/test/snapshots/images/ui_S_t[l.textcomposer_null_TextComposerVoice-N-4_5_null,NEXUS_5,1.0,en].png index ffcc9c6d02..7bd842a1c4 100644 --- a/tests/uitests/src/test/snapshots/images/ui_S_t[l.textcomposer_null_TextComposerVoice-N-4_5_null,NEXUS_5,1.0,en].png +++ b/tests/uitests/src/test/snapshots/images/ui_S_t[l.textcomposer_null_TextComposerVoice-N-4_5_null,NEXUS_5,1.0,en].png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:b34b9934ea8c06dc98b080d6dcd07c2c12d95c0841c1fd09e2ebc62bb8df2fce -size 24904 +oid sha256:ffa7daeaf5e27170395307c87c68a7674fb115bb9806cfc0d438a905571ce37f +size 25100