Display duration of recorded voice message (#1733)
--------- Co-authored-by: ElementBot <benoitm+elementbot@element.io>
This commit is contained in:
@@ -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.
|
||||
*/
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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()
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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>,
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
|
||||
@@ -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
|
||||
)
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -74,6 +74,7 @@ class FakeVoiceRecorder(
|
||||
else -> VoiceRecorderState.Finished(
|
||||
file = curRecording!!,
|
||||
mimeType = "audio/ogg",
|
||||
duration = recordingDuration,
|
||||
waveform = waveform,
|
||||
)
|
||||
}
|
||||
|
||||
Binary file not shown.
Binary file not shown.
Reference in New Issue
Block a user