Add time to voice message composer UI (#1720)

---------

Co-authored-by: ElementBot <benoitm+elementbot@element.io>
This commit is contained in:
jonnyandrew 2023-11-02 12:10:36 +00:00 committed by GitHub
parent b7e6375ac3
commit 3e58e03a10
10 changed files with 50 additions and 23 deletions

View file

@ -100,6 +100,10 @@ class VoiceMessageComposerPlayer @Inject constructor(
* The progress of this player between 0 and 1. * The progress of this player between 0 and 1.
*/ */
val progress: Float = val progress: Float =
if (duration <= currentPosition) 0f else currentPosition.toFloat() / duration.toFloat() if (duration == 0L)
0f
else
(currentPosition.toFloat() / duration.toFloat())
.coerceAtMost(1f) // Current position may exceed reported duration
} }
} }

View file

@ -50,6 +50,7 @@ import kotlinx.coroutines.launch
import timber.log.Timber import timber.log.Timber
import java.io.File import java.io.File
import javax.inject.Inject import javax.inject.Inject
import kotlin.time.Duration.Companion.milliseconds
@SingleIn(RoomScope::class) @SingleIn(RoomScope::class)
class VoiceMessageComposerPresenter @Inject constructor( class VoiceMessageComposerPresenter @Inject constructor(
@ -191,6 +192,7 @@ class VoiceMessageComposerPresenter @Inject constructor(
isSending = isSending, isSending = isSending,
isPlaying = isPlaying, isPlaying = isPlaying,
playbackProgress = playerState.progress, playbackProgress = playerState.progress,
time = playerState.currentPosition.milliseconds,
waveform = waveform, waveform = waveform,
) )
else -> VoiceMessageState.Idle else -> VoiceMessageState.Idle

View file

@ -50,13 +50,14 @@ import io.element.android.libraries.textcomposer.model.VoiceMessageState
import io.element.android.libraries.voicerecorder.test.FakeVoiceRecorder import io.element.android.libraries.voicerecorder.test.FakeVoiceRecorder
import io.element.android.services.analytics.test.FakeAnalyticsService import io.element.android.services.analytics.test.FakeAnalyticsService
import io.element.android.tests.testutils.WarmUpRule import io.element.android.tests.testutils.WarmUpRule
import kotlinx.collections.immutable.toPersistentList
import kotlinx.collections.immutable.toImmutableList import kotlinx.collections.immutable.toImmutableList
import kotlinx.collections.immutable.toPersistentList
import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.test.TestScope import kotlinx.coroutines.test.TestScope
import kotlinx.coroutines.test.runTest import kotlinx.coroutines.test.runTest
import org.junit.Rule import org.junit.Rule
import org.junit.Test import org.junit.Test
import kotlin.time.Duration
import kotlin.time.Duration.Companion.seconds import kotlin.time.Duration.Companion.seconds
class VoiceMessageComposerPresenterTest { class VoiceMessageComposerPresenterTest {
@ -195,7 +196,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, playbackProgress = 0.1f)) assertThat(it.voiceMessageState).isEqualTo(aPreviewState(isPlaying = true, playbackProgress = 0.1f, time = RECORDING_DURATION))
} }
voiceRecorder.assertCalls(started = 1, stopped = 1, deleted = 0) voiceRecorder.assertCalls(started = 1, stopped = 1, deleted = 0)
@ -214,7 +215,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, playbackProgress = 0.1f)) assertThat(it.voiceMessageState).isEqualTo(aPreviewState(isPlaying = false, playbackProgress = 0.1f, time = RECORDING_DURATION))
} }
voiceRecorder.assertCalls(started = 1, stopped = 1, deleted = 0) voiceRecorder.assertCalls(started = 1, stopped = 1, deleted = 0)
@ -251,7 +252,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, playbackProgress = 0.1f)) assertThat(voiceMessageState).isEqualTo(aPreviewState(isPlaying = false, playbackProgress = 0.1f, time = RECORDING_DURATION))
} }
val finalState = awaitItem() val finalState = awaitItem()
@ -321,9 +322,11 @@ 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))
awaitItem().eventSink(VoiceMessageComposerEvents.SendVoiceMessage) awaitItem().eventSink(VoiceMessageComposerEvents.SendVoiceMessage)
assertThat(awaitItem().voiceMessageState).isEqualTo(aPreviewState( assertThat(awaitItem().voiceMessageState).isEqualTo(
isSending = true, isPlaying = false, playbackProgress = 0.1f aPreviewState(
)) isSending = true, isPlaying = false, playbackProgress = 0.1f, time = RECORDING_DURATION
)
)
val finalState = awaitItem() val finalState = awaitItem()
assertThat(finalState.voiceMessageState).isEqualTo(VoiceMessageState.Idle) assertThat(finalState.voiceMessageState).isEqualTo(VoiceMessageState.Idle)
@ -570,7 +573,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(playbackProgress = 0.1f)) assertThat(voiceMessageState).isEqualTo(aPreviewState(playbackProgress = 0.1f, time = RECORDING_DURATION))
} }
false -> mostRecentState false -> mostRecentState
} }
@ -624,11 +627,13 @@ class VoiceMessageComposerPresenterTest {
isPlaying: Boolean = false, isPlaying: Boolean = false,
playbackProgress: Float = 0f, playbackProgress: Float = 0f,
isSending: Boolean = false, isSending: Boolean = false,
time: Duration = 0.seconds,
waveform: List<Float> = voiceRecorder.waveform, waveform: List<Float> = voiceRecorder.waveform,
) = VoiceMessageState.Preview( ) = VoiceMessageState.Preview(
isPlaying = isPlaying, isPlaying = isPlaying,
playbackProgress = playbackProgress, playbackProgress = playbackProgress,
isSending = isSending, isSending = isSending,
time = time,
waveform = waveform.toImmutableList(), waveform = waveform.toImmutableList(),
) )
} }

View file

@ -85,7 +85,6 @@ import io.element.android.wysiwyg.compose.RichTextEditor
import io.element.android.wysiwyg.compose.RichTextEditorState 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 uniffi.wysiwyg_composer.MenuAction import uniffi.wysiwyg_composer.MenuAction
import kotlin.time.Duration.Companion.seconds import kotlin.time.Duration.Companion.seconds
@ -211,6 +210,7 @@ fun TextComposer(
isPlaying = voiceMessageState.isPlaying, isPlaying = voiceMessageState.isPlaying,
waveform = voiceMessageState.waveform, waveform = voiceMessageState.waveform,
playbackProgress = voiceMessageState.playbackProgress, playbackProgress = voiceMessageState.playbackProgress,
time = voiceMessageState.time,
onPlayClick = onPlayVoiceMessageClicked, onPlayClick = onPlayVoiceMessageClicked,
onPauseClick = onPauseVoiceMessageClicked, onPauseClick = onPauseVoiceMessageClicked,
onSeek = onSeekVoiceMessage, onSeek = onSeekVoiceMessage,
@ -816,13 +816,14 @@ internal fun TextComposerVoicePreview() = ElementPreview {
enableVoiceMessages = true, enableVoiceMessages = true,
) )
PreviewColumn(items = persistentListOf({ PreviewColumn(items = persistentListOf({
VoicePreview(voiceMessageState = VoiceMessageState.Recording(61.seconds, List(100) { it.toFloat() / 100 }.toPersistentList())) VoicePreview(voiceMessageState = VoiceMessageState.Recording(61.seconds, createFakeWaveform()))
}, { }, {
VoicePreview( VoicePreview(
voiceMessageState = VoiceMessageState.Preview( voiceMessageState = VoiceMessageState.Preview(
isSending = false, isSending = false,
isPlaying = false, isPlaying = false,
waveform = createFakeWaveform(), waveform = createFakeWaveform(),
time = 0.seconds,
playbackProgress = 0.0f playbackProgress = 0.0f
) )
) )
@ -832,6 +833,7 @@ internal fun TextComposerVoicePreview() = ElementPreview {
isSending = false, isSending = false,
isPlaying = true, isPlaying = true,
waveform = createFakeWaveform(), waveform = createFakeWaveform(),
time = 3.seconds,
playbackProgress = 0.2f playbackProgress = 0.2f
) )
) )
@ -841,6 +843,7 @@ internal fun TextComposerVoicePreview() = ElementPreview {
isSending = true, isSending = true,
isPlaying = false, isPlaying = false,
waveform = createFakeWaveform(), waveform = createFakeWaveform(),
time = 61.seconds,
playbackProgress = 0.0f playbackProgress = 0.0f
) )
) )

View file

@ -34,6 +34,7 @@ import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.res.stringResource import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import io.element.android.libraries.designsystem.components.media.WaveformPlaybackView import io.element.android.libraries.designsystem.components.media.WaveformPlaybackView
import io.element.android.libraries.designsystem.components.media.createFakeWaveform import io.element.android.libraries.designsystem.components.media.createFakeWaveform
@ -42,16 +43,21 @@ import io.element.android.libraries.designsystem.preview.PreviewsDayNight
import io.element.android.libraries.designsystem.text.applyScaleUp import io.element.android.libraries.designsystem.text.applyScaleUp
import io.element.android.libraries.designsystem.theme.components.Icon import io.element.android.libraries.designsystem.theme.components.Icon
import io.element.android.libraries.designsystem.theme.components.IconButton import io.element.android.libraries.designsystem.theme.components.IconButton
import io.element.android.libraries.designsystem.theme.components.Text
import io.element.android.libraries.textcomposer.R import io.element.android.libraries.textcomposer.R
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
import io.element.android.libraries.ui.utils.time.formatShort
import kotlinx.collections.immutable.ImmutableList import kotlinx.collections.immutable.ImmutableList
import kotlin.time.Duration
import kotlin.time.Duration.Companion.seconds
@Composable @Composable
internal fun VoiceMessagePreview( internal fun VoiceMessagePreview(
isInteractive: Boolean, isInteractive: Boolean,
isPlaying: Boolean, isPlaying: Boolean,
waveform: ImmutableList<Float>, waveform: ImmutableList<Float>,
time: Duration,
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
playbackProgress: Float = 0f, playbackProgress: Float = 0f,
onPlayClick: () -> Unit = {}, onPlayClick: () -> Unit = {},
@ -85,7 +91,13 @@ internal fun VoiceMessagePreview(
Spacer(modifier = Modifier.width(8.dp)) Spacer(modifier = Modifier.width(8.dp))
// TODO Add timer UI Text(
text = time.formatShort(),
color = ElementTheme.materialColors.secondary,
style = ElementTheme.typography.fontBodySmMedium,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
)
Spacer(modifier = Modifier.width(12.dp)) Spacer(modifier = Modifier.width(12.dp))
@ -151,8 +163,8 @@ internal fun VoiceMessagePreviewPreview() = ElementPreview {
Column( Column(
verticalArrangement = Arrangement.spacedBy(8.dp) verticalArrangement = Arrangement.spacedBy(8.dp)
) { ) {
VoiceMessagePreview(isInteractive = true, isPlaying = true, waveform = createFakeWaveform()) VoiceMessagePreview(isInteractive = true, isPlaying = true, time = 2.seconds, playbackProgress = 0.2f, waveform = createFakeWaveform())
VoiceMessagePreview(isInteractive = true, isPlaying = false, waveform = createFakeWaveform()) VoiceMessagePreview(isInteractive = true, isPlaying = false, time = 0.seconds, playbackProgress = 0.0f, waveform = createFakeWaveform())
VoiceMessagePreview(isInteractive = false, isPlaying = false, waveform = createFakeWaveform()) VoiceMessagePreview(isInteractive = false, isPlaying = false, time = 789.seconds, playbackProgress = 0.0f, waveform = createFakeWaveform())
} }
} }

View file

@ -26,6 +26,7 @@ sealed class VoiceMessageState {
val isSending: Boolean, val isSending: Boolean,
val isPlaying: Boolean, val isPlaying: Boolean,
val playbackProgress: Float, val playbackProgress: Float,
val time: Duration,
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:8521c3615973ebf8b5d09a596a6122866648f45fa2015b3261dcb64505ebc41d oid sha256:1040e8767f3ce29d40842cb054dea64dc0d355b91ae86b1d25280b9308cdbda7
size 22459 size 24517

View file

@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1 version https://git-lfs.github.com/spec/v1
oid sha256:93a4628b19a5d8a5b1f064a0c5e6d8d90e2690481b10b34c1beca2cc84b3d34c oid sha256:162a6b2a725ecfda754f8fe8bc06da0bd6b7efd7eb217a2e77b1ece57f89d5d6
size 20848 size 22936

View file

@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1 version https://git-lfs.github.com/spec/v1
oid sha256:4008401117a064b6eed9cf06ada8c15273a52dd729ae0fe989d075808c928cb5 oid sha256:c692c6d70f847d37dce6c1cdbe630621cc6bdf37d48e24b4f95aa79d9422f15d
size 27376 size 28039

View file

@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1 version https://git-lfs.github.com/spec/v1
oid sha256:b03f6a351e4f6a22ca78b1f7b703568cef9855920ae03089c300a60c0fc593fb oid sha256:2f9b34504384622feed1348a00172d520123512c433973094634d159850a7653
size 26401 size 27124