Display duration of recorded voice message (#1733)

---------

Co-authored-by: ElementBot <benoitm+elementbot@element.io>
This commit is contained in:
jonnyandrew
2023-11-03 12:59:36 +00:00
committed by GitHub
parent 0e89080a50
commit 413ec4b5db
12 changed files with 119 additions and 40 deletions

View File

@@ -36,7 +36,7 @@ class VoiceMessageComposerPlayer @Inject constructor(
val state: Flow<State> = mediaPlayer.state.map { state ->
if (lastPlayedMediaPath == null || lastPlayedMediaPath != state.mediaId) {
return@map State.NotPlaying
return@map State.NotLoaded
}
State(
@@ -89,13 +89,15 @@ class VoiceMessageComposerPlayer @Inject constructor(
val duration: Long,
) {
companion object {
val NotPlaying = State(
val NotLoaded = State(
isPlaying = false,
currentPosition = 0L,
duration = 0L,
)
}
val isLoaded get() = this != NotLoaded
/**
* The progress of this player between 0 and 1.
*/

View File

@@ -42,14 +42,15 @@ import io.element.android.libraries.textcomposer.model.VoiceMessageState
import io.element.android.libraries.voicerecorder.api.VoiceRecorder
import io.element.android.libraries.voicerecorder.api.VoiceRecorderState
import io.element.android.services.analytics.api.AnalyticsService
import kotlinx.collections.immutable.toPersistentList
import kotlinx.collections.immutable.ImmutableList
import kotlinx.collections.immutable.toImmutableList
import kotlinx.collections.immutable.toPersistentList
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.launch
import timber.log.Timber
import java.io.File
import javax.inject.Inject
import kotlin.time.Duration
import kotlin.time.Duration.Companion.milliseconds
@SingleIn(RoomScope::class)
@@ -72,8 +73,8 @@ class VoiceMessageComposerPresenter @Inject constructor(
val permissionState = permissionsPresenter.present()
var isSending by remember { mutableStateOf(false) }
val playerState by player.state.collectAsState(initial = VoiceMessageComposerPlayer.State.NotPlaying)
val isPlaying by remember(playerState.isPlaying) { derivedStateOf { playerState.isPlaying } }
val playerState by player.state.collectAsState(initial = VoiceMessageComposerPlayer.State.NotLoaded)
val playerTime by remember(playerState, recorderState) { derivedStateOf { displayTime(playerState, recorderState) } }
val waveform by remember(recorderState) { derivedStateOf { recorderState.finishedWaveform() } }
val onLifecycleEvent = { event: Lifecycle.Event ->
@@ -190,9 +191,10 @@ class VoiceMessageComposerPresenter @Inject constructor(
)
is VoiceRecorderState.Finished -> VoiceMessageState.Preview(
isSending = isSending,
isPlaying = isPlaying,
isPlaying = playerState.isPlaying,
showCursor = playerState.isLoaded && !isSending,
playbackProgress = playerState.progress,
time = playerState.currentPosition.milliseconds,
time = playerTime,
waveform = waveform,
)
else -> VoiceMessageState.Idle
@@ -259,3 +261,20 @@ private fun VoiceRecorderState.finishedWaveform(): ImmutableList<Float> =
?.waveform
.orEmpty()
.toImmutableList()
/**
* The time to display depending on the player state.
*
* Either the current position or total duration.
*/
private fun displayTime(
playerState: VoiceMessageComposerPlayer.State,
recording: VoiceRecorderState
): Duration = when {
playerState.isLoaded ->
playerState.currentPosition.milliseconds
recording is VoiceRecorderState.Finished ->
recording.duration
else ->
0.milliseconds
}

View File

@@ -179,7 +179,7 @@ class VoiceMessageComposerPresenterTest {
}
// Nothing should happen
assertThat(finalState.voiceMessageState).isEqualTo(VoiceMessageState.Recording(RECORDING_DURATION, RECORDING_STATE.levels))
assertThat(finalState.voiceMessageState).isEqualTo(RECORDING_STATE)
voiceRecorder.assertCalls(started = 1, stopped = 0, deleted = 0)
testPauseAndDestroy(finalState)
@@ -196,7 +196,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, playbackProgress = 0.1f, time = RECORDING_DURATION))
assertThat(it.voiceMessageState).isEqualTo(aPlayingState())
}
voiceRecorder.assertCalls(started = 1, stopped = 1, deleted = 0)
@@ -215,7 +215,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, playbackProgress = 0.1f, time = RECORDING_DURATION))
assertThat(it.voiceMessageState).isEqualTo(aPausedState())
}
voiceRecorder.assertCalls(started = 1, stopped = 1, deleted = 0)
@@ -252,7 +252,7 @@ class VoiceMessageComposerPresenterTest {
awaitItem().eventSink(VoiceMessageComposerEvents.PlayerEvent(VoiceMessagePlayerEvent.Play))
awaitItem().eventSink(VoiceMessageComposerEvents.DeleteVoiceMessage)
awaitItem().apply {
assertThat(voiceMessageState).isEqualTo(aPreviewState(isPlaying = false, playbackProgress = 0.1f, time = RECORDING_DURATION))
assertThat(voiceMessageState).isEqualTo(aPausedState())
}
val finalState = awaitItem()
@@ -272,7 +272,7 @@ class VoiceMessageComposerPresenterTest {
awaitItem().eventSink(VoiceMessageComposerEvents.RecordButtonEvent(PressEvent.PressStart))
awaitItem().eventSink(VoiceMessageComposerEvents.RecordButtonEvent(PressEvent.LongPressEnd))
awaitItem().eventSink(VoiceMessageComposerEvents.SendVoiceMessage)
assertThat(awaitItem().voiceMessageState).isEqualTo(aPreviewState(isSending = true))
assertThat(awaitItem().voiceMessageState).isEqualTo(aPreviewState().toSendingState())
val finalState = awaitItem()
assertThat(finalState.voiceMessageState).isEqualTo(VoiceMessageState.Idle)
@@ -322,11 +322,7 @@ class VoiceMessageComposerPresenterTest {
awaitItem().eventSink(VoiceMessageComposerEvents.RecordButtonEvent(PressEvent.LongPressEnd))
awaitItem().eventSink(VoiceMessageComposerEvents.PlayerEvent(VoiceMessagePlayerEvent.Play))
awaitItem().eventSink(VoiceMessageComposerEvents.SendVoiceMessage)
assertThat(awaitItem().voiceMessageState).isEqualTo(
aPreviewState(
isSending = true, isPlaying = false, playbackProgress = 0.1f, time = RECORDING_DURATION
)
)
assertThat(awaitItem().voiceMessageState).isEqualTo(aPlayingState().toSendingState())
val finalState = awaitItem()
assertThat(finalState.voiceMessageState).isEqualTo(VoiceMessageState.Idle)
@@ -349,7 +345,7 @@ class VoiceMessageComposerPresenterTest {
eventSink(VoiceMessageComposerEvents.SendVoiceMessage)
eventSink(VoiceMessageComposerEvents.SendVoiceMessage)
}
assertThat(awaitItem().voiceMessageState).isEqualTo(aPreviewState(isSending = true))
assertThat(awaitItem().voiceMessageState).isEqualTo(aPreviewState().toSendingState())
val finalState = awaitItem()
assertThat(finalState.voiceMessageState).isEqualTo(VoiceMessageState.Idle)
@@ -398,7 +394,7 @@ class VoiceMessageComposerPresenterTest {
val previewState = awaitItem()
previewState.eventSink(VoiceMessageComposerEvents.SendVoiceMessage)
assertThat(awaitItem().voiceMessageState).isEqualTo(aPreviewState(isSending = true))
assertThat(awaitItem().voiceMessageState).isEqualTo(aPreviewState().toSendingState())
ensureAllEventsConsumed()
assertThat(previewState.voiceMessageState).isEqualTo(aPreviewState())
@@ -573,7 +569,7 @@ class VoiceMessageComposerPresenterTest {
is VoiceMessageState.Preview -> when (state.isPlaying) {
// If the preview was playing, it pauses
true -> awaitItem().apply {
assertThat(voiceMessageState).isEqualTo(aPreviewState(playbackProgress = 0.1f, time = RECORDING_DURATION))
assertThat(voiceMessageState).isEqualTo(aPausedState())
}
false -> mostRecentState
}
@@ -627,15 +623,37 @@ class VoiceMessageComposerPresenterTest {
isPlaying: Boolean = false,
playbackProgress: Float = 0f,
isSending: Boolean = false,
time: Duration = 0.seconds,
time: Duration = RECORDING_DURATION,
showCursor: Boolean = false,
waveform: List<Float> = voiceRecorder.waveform,
) = VoiceMessageState.Preview(
isPlaying = isPlaying,
playbackProgress = playbackProgress,
isSending = isSending,
time = time,
showCursor = showCursor,
waveform = waveform.toImmutableList(),
)
private fun aPlayingState() =
aPreviewState(
isPlaying = true,
playbackProgress = 0.1f,
showCursor = true,
time = RECORDING_DURATION,
)
private fun aPausedState() =
aPlayingState()
.copy(isPlaying = false)
private fun VoiceMessageState.Preview.toSendingState() =
copy(
isPlaying = false,
isSending = true,
showCursor = false,
time = RECORDING_DURATION,
)
}
private fun aReplyMode() = MessageComposerMode.Reply(A_USER_NAME, null, false, AN_EVENT_ID, A_MESSAGE)

View File

@@ -207,6 +207,7 @@ fun TextComposer(
VoiceMessagePreview(
isInteractive = !voiceMessageState.isSending,
isPlaying = voiceMessageState.isPlaying,
showCursor = voiceMessageState.showCursor,
waveform = voiceMessageState.waveform,
playbackProgress = voiceMessageState.playbackProgress,
time = voiceMessageState.time,
@@ -816,6 +817,7 @@ internal fun TextComposerVoicePreview() = ElementPreview {
voiceMessageState = VoiceMessageState.Preview(
isSending = false,
isPlaying = false,
showCursor = false,
waveform = createFakeWaveform(),
time = 0.seconds,
playbackProgress = 0.0f
@@ -826,6 +828,7 @@ internal fun TextComposerVoicePreview() = ElementPreview {
voiceMessageState = VoiceMessageState.Preview(
isSending = false,
isPlaying = true,
showCursor = true,
waveform = createFakeWaveform(),
time = 3.seconds,
playbackProgress = 0.2f
@@ -836,6 +839,7 @@ internal fun TextComposerVoicePreview() = ElementPreview {
voiceMessageState = VoiceMessageState.Preview(
isSending = true,
isPlaying = false,
showCursor = false,
waveform = createFakeWaveform(),
time = 61.seconds,
playbackProgress = 0.0f

View File

@@ -55,6 +55,7 @@ import kotlin.time.Duration.Companion.seconds
internal fun VoiceMessagePreview(
isInteractive: Boolean,
isPlaying: Boolean,
showCursor: Boolean,
waveform: ImmutableList<Float>,
time: Duration,
modifier: Modifier = Modifier,
@@ -105,7 +106,7 @@ internal fun VoiceMessagePreview(
.weight(1f)
.height(26.dp),
playbackProgress = playbackProgress,
showCursor = isInteractive,
showCursor = showCursor,
waveform = waveform,
seekEnabled = false, // TODO enable seeking
onSeek = onSeek,
@@ -162,8 +163,29 @@ internal fun VoiceMessagePreviewPreview() = ElementPreview {
Column(
verticalArrangement = Arrangement.spacedBy(8.dp)
) {
VoiceMessagePreview(isInteractive = true, isPlaying = true, time = 2.seconds, playbackProgress = 0.2f, waveform = createFakeWaveform())
VoiceMessagePreview(isInteractive = true, isPlaying = false, time = 0.seconds, playbackProgress = 0.0f, waveform = createFakeWaveform())
VoiceMessagePreview(isInteractive = false, isPlaying = false, time = 789.seconds, playbackProgress = 0.0f, waveform = createFakeWaveform())
VoiceMessagePreview(
isInteractive = true,
isPlaying = true,
time = 2.seconds,
playbackProgress = 0.2f,
showCursor = true,
waveform = createFakeWaveform()
)
VoiceMessagePreview(
isInteractive = true,
isPlaying = false,
time = 0.seconds,
playbackProgress = 0.0f,
showCursor = true,
waveform = createFakeWaveform()
)
VoiceMessagePreview(
isInteractive = false,
isPlaying = false,
time = 789.seconds,
playbackProgress = 0.0f,
showCursor = false,
waveform = createFakeWaveform()
)
}
}

View File

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

View File

@@ -39,10 +39,12 @@ sealed class VoiceRecorderState {
* @property file The recorded file.
* @property mimeType The mime type of the file.
* @property waveform The waveform of the recording.
* @property duration The total time spent recording.
*/
data class Finished(
val file: File,
val mimeType: String,
val waveform: List<Float>,
val duration: Duration,
) : VoiceRecorderState()
}

View File

@@ -45,6 +45,7 @@ import timber.log.Timber
import java.io.File
import java.util.UUID
import javax.inject.Inject
import kotlin.time.Duration.Companion.milliseconds
import kotlin.time.Duration.Companion.minutes
import kotlin.time.TimeSource
@@ -93,7 +94,7 @@ class VoiceRecorderImpl @Inject constructor(
val elapsedTime = startedAt.elapsedNow()
if (elapsedTime >= 30.minutes) {
if (elapsedTime > 30.minutes) {
Timber.w("Voice message time limit reached")
stopRecord(false)
return@record
@@ -145,11 +146,15 @@ class VoiceRecorderImpl @Inject constructor(
_state.emit(
when (val file = outputFile) {
null -> VoiceRecorderState.Idle
else -> VoiceRecorderState.Finished(
file = file,
mimeType = fileConfig.mimeType,
waveform = levels.resample(100),
)
else -> {
val duration = (state.value as? VoiceRecorderState.Recording)?.elapsedTime
VoiceRecorderState.Finished(
file = file,
mimeType = fileConfig.mimeType,
waveform = levels.resample(100),
duration = duration ?: 0.milliseconds
)
}
}
)
}

View File

@@ -37,6 +37,7 @@ import kotlinx.coroutines.test.runTest
import org.junit.BeforeClass
import org.junit.Test
import java.io.File
import kotlin.time.Duration.Companion.milliseconds
import kotlin.time.Duration.Companion.minutes
import kotlin.time.Duration.Companion.seconds
import kotlin.time.TestTimeSource
@@ -76,34 +77,38 @@ class VoiceRecorderImplTest {
voiceRecorder.startRecord()
assertThat(awaitItem()).isEqualTo(VoiceRecorderState.Recording(0.minutes, listOf(1.0f)))
timeSource += 29.minutes
assertThat(awaitItem()).isEqualTo(VoiceRecorderState.Recording(29.minutes, listOf()))
timeSource += 1.minutes
timeSource += 30.minutes
assertThat(awaitItem()).isEqualTo(VoiceRecorderState.Recording(30.minutes, listOf()))
timeSource += 1.milliseconds
assertThat(awaitItem()).isEqualTo(
VoiceRecorderState.Finished(
file = File(FILE_PATH),
mimeType = "audio/ogg",
waveform = List(100) { 1f },
duration = 30.minutes,
)
)
}
}
@Test
fun `when stopped, it provides a file`() = runTest {
fun `when stopped, it provides a file and duration`() = runTest {
val voiceRecorder = createVoiceRecorder()
voiceRecorder.state.test {
assertThat(awaitItem()).isEqualTo(VoiceRecorderState.Idle)
voiceRecorder.startRecord()
skipItems(3)
skipItems(1)
timeSource += 5.seconds
skipItems(2)
voiceRecorder.stopRecord()
assertThat(awaitItem()).isEqualTo(
VoiceRecorderState.Finished(
file = File(FILE_PATH),
mimeType = "audio/ogg",
waveform = List(100) { 1f },
duration = 5.seconds,
)
)
assertThat(fakeFileSystem.files[File(FILE_PATH)]).isEqualTo(ENCODED_DATA)

View File

@@ -74,6 +74,7 @@ class FakeVoiceRecorder(
else -> VoiceRecorderState.Finished(
file = curRecording!!,
mimeType = "audio/ogg",
duration = recordingDuration,
waveform = waveform,
)
}