Skip to content

Commit

Permalink
Show voice message preview player progress (#1675)
Browse files Browse the repository at this point in the history
* 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>
  • Loading branch information
3 people authored Oct 27, 2023
1 parent 21499a2 commit 8121d1a
Show file tree
Hide file tree
Showing 13 changed files with 101 additions and 40 deletions.
Original file line number Diff line number Diff line change
Expand Up @@ -41,7 +41,8 @@ class VoiceMessageComposerPlayer @Inject constructor(

State(
isPlaying = state.isPlaying,
currentPosition = state.currentPosition
currentPosition = state.currentPosition,
duration = state.duration,
)
}.distinctUntilChanged()

Expand Down Expand Up @@ -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()
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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)

Expand All @@ -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)

Expand Down Expand Up @@ -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()
Expand Down Expand Up @@ -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()
Expand Down Expand Up @@ -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
}
Expand Down Expand Up @@ -561,10 +561,12 @@ class VoiceMessageComposerPresenterTest {

private fun aPreviewState(
isPlaying: Boolean = false,
playbackProgress: Float = 0f,
isSending: Boolean = false,
waveform: List<Float> = voiceRecorder.waveform,
) = VoiceMessageState.Preview(
isPlaying = isPlaying,
playbackProgress = playbackProgress,
isSending = isSending,
waveform = waveform.toImmutableList(),
)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand All @@ -74,6 +75,7 @@ fun WaveformPlaybackView(
showCursor: Boolean,
waveform: ImmutableList<Float>,
modifier: Modifier = Modifier,
seekEnabled: Boolean = true,
onSeek: (progress: Float) -> Unit = {},
brush: Brush = SolidColor(ElementTheme.colors.iconQuaternary),
progressBrush: Brush = SolidColor(ElementTheme.colors.iconSecondary),
Expand Down Expand Up @@ -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
.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
}
MotionEvent.ACTION_MOVE -> {
if (e.x in 0F..canvasSizePx.width) {
seekProgress.value = e.x / canvasSizePx.width
}
true
} else false
}
MotionEvent.ACTION_MOVE -> {
if (it.x in 0F..canvasSizePx.width) {
seekProgress.value = it.x / canvasSizePx.width
}
true
}
MotionEvent.ACTION_UP -> {
requestDisallowInterceptTouchEvent.invoke(false)
seekProgress.value?.let(onSeek)
seekProgress.value = null
true
MotionEvent.ACTION_UP -> {
requestDisallowInterceptTouchEvent.invoke(false)
seekProgress.value?.let(onSeek)
seekProgress.value = null
true
}
else -> false
}
else -> false
}
}
.then(modifier)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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,
)
}
Original file line number Diff line number Diff line change
Expand Up @@ -47,6 +47,7 @@ class MediaPlayerImpl @Inject constructor(
_state.update {
it.copy(
currentPosition = player.currentPosition,
duration = player.duration.coerceAtLeast(0),
isPlaying = isPlaying,
)
}
Expand All @@ -61,6 +62,7 @@ class MediaPlayerImpl @Inject constructor(
_state.update {
it.copy(
currentPosition = player.currentPosition,
duration = player.duration.coerceAtLeast(0),
mediaId = mediaItem?.mediaId,
)
}
Expand All @@ -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<MediaPlayer.State> = _state.asStateFlow()

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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?
Expand Down Expand Up @@ -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()

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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<MediaPlayer.State> = _state.asStateFlow()

Expand All @@ -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,
)
}
}
Expand All @@ -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,
)
}
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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(
Expand Down Expand Up @@ -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
}
Expand All @@ -210,6 +210,7 @@ fun TextComposer(
isInteractive = !voiceMessageState.isSending,
isPlaying = voiceMessageState.isPlaying,
waveform = voiceMessageState.waveform,
playbackProgress = voiceMessageState.playbackProgress,
onPlayClick = onPlayVoiceMessageClicked,
onPauseClick = onPauseVoiceMessageClicked,
onSeek = onSeekVoiceMessage,
Expand All @@ -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)
}
}
Expand Down Expand Up @@ -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
)
)
}))
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -95,6 +95,7 @@ internal fun VoiceMessagePreview(
playbackProgress = playbackProgress,
showCursor = isInteractive,
waveform = waveform,
seekEnabled = false, // TODO enable seeking
onSeek = onSeek,
)
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,7 @@ sealed class VoiceMessageState {
data class Preview(
val isSending: Boolean,
val isPlaying: Boolean,
val playbackProgress: Float,
val waveform: ImmutableList<Float>,
): VoiceMessageState()

Expand Down
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.

0 comments on commit 8121d1a

Please sign in to comment.