Show voice message preview player progress (#1675)

* Show voice message preview player progress

* Update screenshots

* Fix test

* Some nits over mediaplayer stuff

---------

Co-authored-by: ElementBot <benoitm+elementbot@element.io>
Co-authored-by: Marco Romano <marcor@element.io>
This commit is contained in:
jonnyandrew 2023-10-27 21:43:52 +01:00 committed by GitHub
parent f7ed09eb82
commit 4dfe8121b4
13 changed files with 102 additions and 41 deletions

View file

@ -41,7 +41,8 @@ class VoiceMessageComposerPlayer @Inject constructor(
State( State(
isPlaying = state.isPlaying, isPlaying = state.isPlaying,
currentPosition = state.currentPosition currentPosition = state.currentPosition,
duration = state.duration,
) )
}.distinctUntilChanged() }.distinctUntilChanged()
@ -82,12 +83,23 @@ class VoiceMessageComposerPlayer @Inject constructor(
* The elapsed time of this player in milliseconds. * The elapsed time of this player in milliseconds.
*/ */
val currentPosition: Long, val currentPosition: Long,
/**
* The duration of this player in milliseconds.
*/
val duration: Long,
) { ) {
companion object { companion object {
val NotPlaying = State( val NotPlaying = State(
isPlaying = false, isPlaying = false,
currentPosition = 0L, 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()
} }
} }

View file

@ -185,6 +185,7 @@ class VoiceMessageComposerPresenter @Inject constructor(
is VoiceRecorderState.Finished -> VoiceMessageState.Preview( is VoiceRecorderState.Finished -> VoiceMessageState.Preview(
isSending = isSending, isSending = isSending,
isPlaying = isPlaying, isPlaying = isPlaying,
playbackProgress = playerState.progress,
waveform = waveform, waveform = waveform,
) )
else -> VoiceMessageState.Idle else -> VoiceMessageState.Idle

View file

@ -164,7 +164,7 @@ class VoiceMessageComposerPresenterTest {
awaitItem().eventSink(VoiceMessageComposerEvents.RecordButtonEvent(PressEvent.LongPressEnd)) awaitItem().eventSink(VoiceMessageComposerEvents.RecordButtonEvent(PressEvent.LongPressEnd))
awaitItem().eventSink(VoiceMessageComposerEvents.PlayerEvent(VoiceMessagePlayerEvent.Play)) awaitItem().eventSink(VoiceMessageComposerEvents.PlayerEvent(VoiceMessagePlayerEvent.Play))
val finalState = awaitItem().also { 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) 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.Play))
awaitItem().eventSink(VoiceMessageComposerEvents.PlayerEvent(VoiceMessagePlayerEvent.Pause)) awaitItem().eventSink(VoiceMessageComposerEvents.PlayerEvent(VoiceMessagePlayerEvent.Pause))
val finalState = awaitItem().also { 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) voiceRecorder.assertCalls(started = 1, stopped = 1, deleted = 0)
@ -220,7 +220,7 @@ class VoiceMessageComposerPresenterTest {
awaitItem().eventSink(VoiceMessageComposerEvents.PlayerEvent(VoiceMessagePlayerEvent.Play)) awaitItem().eventSink(VoiceMessageComposerEvents.PlayerEvent(VoiceMessagePlayerEvent.Play))
awaitItem().eventSink(VoiceMessageComposerEvents.DeleteVoiceMessage) awaitItem().eventSink(VoiceMessageComposerEvents.DeleteVoiceMessage)
awaitItem().apply { awaitItem().apply {
assertThat(voiceMessageState).isEqualTo(aPreviewState(isPlaying = false)) assertThat(voiceMessageState).isEqualTo(aPreviewState(isPlaying = false, playbackProgress = 0.1f))
} }
val finalState = awaitItem() val finalState = awaitItem()
@ -262,7 +262,7 @@ class VoiceMessageComposerPresenterTest {
awaitItem().eventSink(VoiceMessageComposerEvents.PlayerEvent(VoiceMessagePlayerEvent.Play)) awaitItem().eventSink(VoiceMessageComposerEvents.PlayerEvent(VoiceMessagePlayerEvent.Play))
awaitItem().eventSink(VoiceMessageComposerEvents.SendVoiceMessage) awaitItem().eventSink(VoiceMessageComposerEvents.SendVoiceMessage)
assertThat(awaitItem().voiceMessageState).isEqualTo(aPreviewState( assertThat(awaitItem().voiceMessageState).isEqualTo(aPreviewState(
isSending = true, isPlaying = false, isSending = true, isPlaying = false, playbackProgress = 0.1f
)) ))
val finalState = awaitItem() val finalState = awaitItem()
@ -510,7 +510,7 @@ class VoiceMessageComposerPresenterTest {
is VoiceMessageState.Preview -> when (state.isPlaying) { is VoiceMessageState.Preview -> when (state.isPlaying) {
// If the preview was playing, it pauses // If the preview was playing, it pauses
true -> awaitItem().apply { true -> awaitItem().apply {
assertThat(voiceMessageState).isEqualTo(aPreviewState()) assertThat(voiceMessageState).isEqualTo(aPreviewState(playbackProgress = 0.1f))
} }
false -> mostRecentState false -> mostRecentState
} }
@ -561,10 +561,12 @@ class VoiceMessageComposerPresenterTest {
private fun aPreviewState( private fun aPreviewState(
isPlaying: Boolean = false, isPlaying: Boolean = false,
playbackProgress: Float = 0f,
isSending: Boolean = false, isSending: Boolean = false,
waveform: List<Float> = voiceRecorder.waveform, waveform: List<Float> = voiceRecorder.waveform,
) = VoiceMessageState.Preview( ) = VoiceMessageState.Preview(
isPlaying = isPlaying, isPlaying = isPlaying,
playbackProgress = playbackProgress,
isSending = isSending, isSending = isSending,
waveform = waveform.toImmutableList(), waveform = waveform.toImmutableList(),
) )

View file

@ -60,6 +60,7 @@ private const val DEFAULT_GRAPHICS_LAYER_ALPHA: Float = 0.99F
* @param showCursor Whether to show the cursor or not. * @param showCursor Whether to show the cursor or not.
* @param waveform The waveform to display. Use [FakeWaveformFactory] to generate a fake waveform. * @param waveform The waveform to display. Use [FakeWaveformFactory] to generate a fake waveform.
* @param modifier The modifier to be applied to the view. * @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 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 brush The brush to use to draw the waveform.
* @param progressBrush The brush to use to draw the progress. * @param progressBrush The brush to use to draw the progress.
@ -74,6 +75,7 @@ fun WaveformPlaybackView(
showCursor: Boolean, showCursor: Boolean,
waveform: ImmutableList<Float>, waveform: ImmutableList<Float>,
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
seekEnabled: Boolean = true,
onSeek: (progress: Float) -> Unit = {}, onSeek: (progress: Float) -> Unit = {},
brush: Brush = SolidColor(ElementTheme.colors.iconQuaternary), brush: Brush = SolidColor(ElementTheme.colors.iconQuaternary),
progressBrush: Brush = SolidColor(ElementTheme.colors.iconSecondary), progressBrush: Brush = SolidColor(ElementTheme.colors.iconSecondary),
@ -106,28 +108,32 @@ fun WaveformPlaybackView(
modifier = Modifier modifier = Modifier
.fillMaxWidth() .fillMaxWidth()
.graphicsLayer(alpha = DEFAULT_GRAPHICS_LAYER_ALPHA) .graphicsLayer(alpha = DEFAULT_GRAPHICS_LAYER_ALPHA)
.pointerInteropFilter(requestDisallowInterceptTouchEvent = requestDisallowInterceptTouchEvent) { .let {
return@pointerInteropFilter when (it.action) { if (!seekEnabled) return@let it
MotionEvent.ACTION_DOWN -> {
if (it.x in 0F..canvasSizePx.width) { it.pointerInteropFilter(requestDisallowInterceptTouchEvent = requestDisallowInterceptTouchEvent) { e ->
requestDisallowInterceptTouchEvent.invoke(true) return@pointerInteropFilter when (e.action) {
seekProgress.value = it.x / canvasSizePx.width MotionEvent.ACTION_DOWN -> {
true if (e.x in 0F..canvasSizePx.width) {
} else false requestDisallowInterceptTouchEvent.invoke(true)
} seekProgress.value = e.x / canvasSizePx.width
MotionEvent.ACTION_MOVE -> { true
if (it.x in 0F..canvasSizePx.width) { } else false
seekProgress.value = it.x / canvasSizePx.width
} }
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) .then(modifier)

View file

@ -74,5 +74,9 @@ interface MediaPlayer : AutoCloseable {
* The current position of the player. * The current position of the player.
*/ */
val currentPosition: Long, val currentPosition: Long,
/**
* The duration of the current content.
*/
val duration: Long,
) )
} }

View file

@ -47,6 +47,7 @@ class MediaPlayerImpl @Inject constructor(
_state.update { _state.update {
it.copy( it.copy(
currentPosition = player.currentPosition, currentPosition = player.currentPosition,
duration = player.duration.coerceAtLeast(0),
isPlaying = isPlaying, isPlaying = isPlaying,
) )
} }
@ -61,6 +62,7 @@ class MediaPlayerImpl @Inject constructor(
_state.update { _state.update {
it.copy( it.copy(
currentPosition = player.currentPosition, currentPosition = player.currentPosition,
duration = player.duration.coerceAtLeast(0),
mediaId = mediaItem?.mediaId, mediaId = mediaItem?.mediaId,
) )
} }
@ -74,7 +76,7 @@ class MediaPlayerImpl @Inject constructor(
private val scope = CoroutineScope(Job() + Dispatchers.Main) private val scope = CoroutineScope(Job() + Dispatchers.Main)
private var job: Job? = null 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<MediaPlayer.State> = _state.asStateFlow() override val state: StateFlow<MediaPlayer.State> = _state.asStateFlow()

View file

@ -33,6 +33,7 @@ interface SimplePlayer {
fun addListener(listener: Listener) fun addListener(listener: Listener)
val currentPosition: Long val currentPosition: Long
val playbackState: Int val playbackState: Int
val duration: Long
fun clearMediaItems() fun clearMediaItems()
fun setMediaItem(mediaItem: MediaItem) fun setMediaItem(mediaItem: MediaItem)
fun getCurrentMediaItem(): MediaItem? fun getCurrentMediaItem(): MediaItem?
@ -73,6 +74,8 @@ class SimplePlayerImpl(
get() = p.currentPosition get() = p.currentPosition
override val playbackState: Int override val playbackState: Int
get() = p.playbackState get() = p.playbackState
override val duration: Long
get() = p.duration
override fun clearMediaItems() = p.clearMediaItems() override fun clearMediaItems() = p.clearMediaItems()

View file

@ -26,7 +26,12 @@ import kotlinx.coroutines.flow.update
* Fake implementation of [MediaPlayer] for testing purposes. * Fake implementation of [MediaPlayer] for testing purposes.
*/ */
class FakeMediaPlayer : MediaPlayer { 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<MediaPlayer.State> = _state.asStateFlow() override val state: StateFlow<MediaPlayer.State> = _state.asStateFlow()
@ -35,7 +40,8 @@ class FakeMediaPlayer : MediaPlayer {
it.copy( it.copy(
isPlaying = true, isPlaying = true,
mediaId = mediaId, 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 { _state.update {
it.copy( it.copy(
isPlaying = true, isPlaying = true,
currentPosition = it.currentPosition + 1000L, currentPosition = it.currentPosition + FAKE_PLAYED_DURATION_MS,
duration = FAKE_TOTAL_DURATION_MS,
) )
} }
} }

View file

@ -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.Message
import io.element.android.libraries.textcomposer.model.MessageComposerMode import io.element.android.libraries.textcomposer.model.MessageComposerMode
import io.element.android.libraries.textcomposer.model.PressEvent 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.Suggestion
import io.element.android.libraries.textcomposer.model.VoiceMessagePlayerEvent
import io.element.android.libraries.textcomposer.model.VoiceMessageState import io.element.android.libraries.textcomposer.model.VoiceMessageState
import io.element.android.libraries.theme.ElementTheme import io.element.android.libraries.theme.ElementTheme
import io.element.android.libraries.ui.strings.CommonStrings 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.ImmutableList
import kotlinx.collections.immutable.persistentListOf import kotlinx.collections.immutable.persistentListOf
import kotlinx.collections.immutable.toPersistentList import kotlinx.collections.immutable.toPersistentList
import kotlin.time.Duration.Companion.seconds
import uniffi.wysiwyg_composer.MenuAction import uniffi.wysiwyg_composer.MenuAction
import kotlin.time.Duration.Companion.seconds
@Composable @Composable
fun TextComposer( fun TextComposer(
@ -194,7 +194,7 @@ fun TextComposer(
when (voiceMessageState) { when (voiceMessageState) {
VoiceMessageState.Idle, VoiceMessageState.Idle,
is VoiceMessageState.Recording -> recordVoiceButton is VoiceMessageState.Recording -> recordVoiceButton
is VoiceMessageState.Preview -> when(voiceMessageState.isSending) { is VoiceMessageState.Preview -> when (voiceMessageState.isSending) {
true -> uploadVoiceProgress true -> uploadVoiceProgress
false -> sendVoiceButton false -> sendVoiceButton
} }
@ -210,6 +210,7 @@ fun TextComposer(
isInteractive = !voiceMessageState.isSending, isInteractive = !voiceMessageState.isSending,
isPlaying = voiceMessageState.isPlaying, isPlaying = voiceMessageState.isPlaying,
waveform = voiceMessageState.waveform, waveform = voiceMessageState.waveform,
playbackProgress = voiceMessageState.playbackProgress,
onPlayClick = onPlayVoiceMessageClicked, onPlayClick = onPlayVoiceMessageClicked,
onPauseClick = onPauseVoiceMessageClicked, onPauseClick = onPauseVoiceMessageClicked,
onSeek = onSeekVoiceMessage, onSeek = onSeekVoiceMessage,
@ -221,7 +222,7 @@ fun TextComposer(
} }
val voiceDeleteButton = @Composable { val voiceDeleteButton = @Composable {
if(voiceMessageState is VoiceMessageState.Preview) { if (voiceMessageState is VoiceMessageState.Preview) {
VoiceMessageDeleteButton(enabled = !voiceMessageState.isSending, onClick = onDeleteVoiceMessage) VoiceMessageDeleteButton(enabled = !voiceMessageState.isSending, onClick = onDeleteVoiceMessage)
} }
} }
@ -817,11 +818,32 @@ internal fun TextComposerVoicePreview() = ElementPreview {
PreviewColumn(items = persistentListOf({ PreviewColumn(items = persistentListOf({
VoicePreview(voiceMessageState = VoiceMessageState.Recording(61.seconds, List(100) { it.toFloat() / 100 }.toPersistentList())) 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
)
)
})) }))
} }

View file

@ -95,6 +95,7 @@ internal fun VoiceMessagePreview(
playbackProgress = playbackProgress, playbackProgress = playbackProgress,
showCursor = isInteractive, showCursor = isInteractive,
waveform = waveform, waveform = waveform,
seekEnabled = false, // TODO enable seeking
onSeek = onSeek, onSeek = onSeek,
) )
} }

View file

@ -25,6 +25,7 @@ sealed class VoiceMessageState {
data class Preview( data class Preview(
val isSending: Boolean, val isSending: Boolean,
val isPlaying: Boolean, val isPlaying: Boolean,
val playbackProgress: Float,
val waveform: ImmutableList<Float>, val waveform: ImmutableList<Float>,
): VoiceMessageState() ): VoiceMessageState()

View file

@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1 version https://git-lfs.github.com/spec/v1
oid sha256:d93f97e1ae36edc6375b00de4421166f8012ab16cc6629897b6a36b062cbbff6 oid sha256:e2fe52c3e34e58ab38f7fa0b4b2281bdd8ef269acb3426449faa93fe7b434feb
size 27255 size 27393

View file

@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1 version https://git-lfs.github.com/spec/v1
oid sha256:b34b9934ea8c06dc98b080d6dcd07c2c12d95c0841c1fd09e2ebc62bb8df2fce oid sha256:ffa7daeaf5e27170395307c87c68a7674fb115bb9806cfc0d438a905571ce37f
size 24904 size 25100