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:
@@ -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()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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<Float> = voiceRecorder.waveform,
|
||||
) = VoiceMessageState.Preview(
|
||||
isPlaying = isPlaying,
|
||||
playbackProgress = playbackProgress,
|
||||
isSending = isSending,
|
||||
waveform = waveform.toImmutableList(),
|
||||
)
|
||||
|
||||
@@ -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<Float>,
|
||||
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)
|
||||
|
||||
@@ -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,
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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<MediaPlayer.State> = _state.asStateFlow()
|
||||
|
||||
|
||||
@@ -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()
|
||||
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
)
|
||||
)
|
||||
}))
|
||||
}
|
||||
|
||||
|
||||
@@ -95,6 +95,7 @@ internal fun VoiceMessagePreview(
|
||||
playbackProgress = playbackProgress,
|
||||
showCursor = isInteractive,
|
||||
waveform = waveform,
|
||||
seekEnabled = false, // TODO enable seeking
|
||||
onSeek = onSeek,
|
||||
)
|
||||
}
|
||||
|
||||
@@ -25,6 +25,7 @@ sealed class VoiceMessageState {
|
||||
data class Preview(
|
||||
val isSending: Boolean,
|
||||
val isPlaying: Boolean,
|
||||
val playbackProgress: Float,
|
||||
val waveform: ImmutableList<Float>,
|
||||
): VoiceMessageState()
|
||||
|
||||
|
||||
Binary file not shown.
Binary file not shown.
Reference in New Issue
Block a user