Enable seeking a recorded voice message (#1758)

This commit is contained in:
jonnyandrew
2023-11-10 09:18:01 +00:00
committed by GitHub
parent 941c196dcf
commit 7a4adf3e28
8 changed files with 261 additions and 95 deletions

View File

@@ -17,96 +17,233 @@
package io.element.android.features.messages.impl.voicemessages.composer
import io.element.android.libraries.mediaplayer.api.MediaPlayer
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Job
import kotlinx.coroutines.cancelAndJoin
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.distinctUntilChanged
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.scan
import kotlinx.coroutines.launch
import timber.log.Timber
import javax.inject.Inject
/**
* A media player for the voice message composer.
*
* @param mediaPlayer The [MediaPlayer] to use.
* @param coroutineScope
*/
class VoiceMessageComposerPlayer @Inject constructor(
private val mediaPlayer: MediaPlayer,
private val coroutineScope: CoroutineScope,
) {
private var lastPlayedMediaPath: String? = null
private val curPlayingMediaId
get() = mediaPlayer.state.value.mediaId
companion object {
const val MIME_TYPE = "audio/ogg"
}
val state: Flow<State> = mediaPlayer.state.map { state ->
if (lastPlayedMediaPath == null || lastPlayedMediaPath != state.mediaId) {
return@map State.NotLoaded
private var mediaPath: String? = null
private var seekJob: Job? = null
private val seekingTo = MutableStateFlow<Float?>(null)
val state: Flow<State> = combine(mediaPlayer.state, seekingTo) { state, seekingTo ->
state to seekingTo
}.scan(InternalState.NotLoaded) { prevState, (state, seekingTo) ->
if (mediaPath == null || mediaPath != state.mediaId) {
return@scan InternalState.NotLoaded
}
State(
isPlaying = state.isPlaying,
InternalState(
playState = calcPlayState(prevState.playState, seekingTo, state),
currentPosition = state.currentPosition,
duration = state.duration ?: 0L,
duration = state.duration,
seekingTo = seekingTo,
)
}.map {
State(
playState = it.playState,
currentPosition = it.currentPosition,
progress = calcProgress(it),
)
}.distinctUntilChanged()
/**
* Set the voice message to be played.
*/
suspend fun setMedia(mediaPath: String) {
this.mediaPath = mediaPath
mediaPlayer.setMedia(
uri = mediaPath,
mediaId = mediaPath,
mimeType = MIME_TYPE,
)
}
/**
* Start playing from the current position.
*
* @param mediaPath The path to the media to be played.
* @param mimeType The mime type of the media file.
* Call [setMedia] before calling this method.
*/
suspend fun play(mediaPath: String, mimeType: String) {
if (mediaPath == curPlayingMediaId) {
mediaPlayer.play()
} else {
lastPlayedMediaPath = mediaPath
mediaPlayer.setMedia(
uri = mediaPath,
mediaId = mediaPath,
mimeType = mimeType,
)
mediaPlayer.play()
suspend fun play() {
val mediaPath = this.mediaPath
if (mediaPath == null) {
Timber.e("Set media before playing")
return
}
mediaPlayer.ensureMediaReady(mediaPath)
mediaPlayer.play()
}
/**
* Pause playback.
*/
fun pause() {
if (lastPlayedMediaPath == curPlayingMediaId) {
if (mediaPath == mediaPlayer.state.value.mediaId) {
mediaPlayer.pause()
}
}
/**
* Seek to a given position in the current media.
*
* Call [setMedia] before calling this method.
*
* @param position The position to seek to between 0 and 1.
*/
suspend fun seek(position: Float) {
val mediaPath = this.mediaPath
if (mediaPath == null) {
Timber.e("Set media before seeking")
return
}
seekJob?.cancelAndJoin()
seekingTo.value = position
seekJob = coroutineScope.launch {
val mediaState = mediaPlayer.ensureMediaReady(mediaPath)
val duration = mediaState.duration ?: return@launch
val positionMs = (duration * position).toLong()
mediaPlayer.seekTo(positionMs)
}.apply {
invokeOnCompletion {
seekingTo.value = null
}
}
}
private suspend fun MediaPlayer.ensureMediaReady(mediaPath: String): MediaPlayer.State {
val state = state.value
if (state.mediaId == mediaPath && state.isReady) {
return state
}
return setMedia(
uri = mediaPath,
mediaId = mediaPath,
mimeType = MIME_TYPE,
)
}
private fun calcPlayState(prevPlayState: PlayState, seekingTo: Float?, state: MediaPlayer.State): PlayState {
if (state.mediaId == null || state.mediaId != mediaPath) {
return PlayState.Stopped
}
// If we were stopped and the player didn't start playing or seeking, we are still stopped.
if (prevPlayState == PlayState.Stopped && !state.isPlaying && seekingTo == null) {
return PlayState.Stopped
}
return if (state.isPlaying) {
PlayState.Playing
} else {
PlayState.Paused
}
}
private fun calcProgress(state: InternalState): Float {
if (state.seekingTo != null) {
return state.seekingTo
}
if (state.playState == PlayState.Stopped) {
return 0f
}
if (state.duration == null) {
return 0f
}
return (state.currentPosition.toFloat() / state.duration.toFloat())
.coerceAtMost(1f) // Current position may exceed reported duration
}
/**
* @property playState Whether this player is currently playing. See [PlayState].
* @property currentPosition The elapsed time of this player in milliseconds.
* @property progress The progress of this player between 0 and 1.
*/
data class State(
val playState: PlayState,
val currentPosition: Long,
val progress: Float,
) {
companion object {
val Initial = State(
playState = PlayState.Stopped,
currentPosition = 0L,
progress = 0f,
)
}
/**
* Whether this player is currently playing.
*/
val isPlaying: Boolean,
val isPlaying get() = this.playState == PlayState.Playing
/**
* The elapsed time of this player in milliseconds.
* Whether this player is currently stopped.
*/
val isStopped get() = this.playState == PlayState.Stopped
}
enum class PlayState {
/**
* The player is stopped, i.e. it has just been initialised.
*/
Stopped,
/**
* The player is playing.
*/
Playing,
/**
* The player has been paused. The player can also enter the paused state after seeking to a position.
*/
Paused,
}
private data class InternalState(
val playState: PlayState,
val currentPosition: Long,
/**
* The duration of this player in milliseconds.
*/
val duration: Long,
val duration: Long?,
val seekingTo: Float?,
) {
companion object {
val NotLoaded = State(
isPlaying = false,
val NotLoaded = InternalState(
playState = PlayState.Stopped,
currentPosition = 0L,
duration = 0L,
duration = null,
seekingTo = null,
)
}
val isLoaded get() = this != NotLoaded
/**
* The progress of this player between 0 and 1.
*/
val progress: Float =
if (duration == 0L)
0f
else
(currentPosition.toFloat() / duration.toFloat())
.coerceAtMost(1f) // Current position may exceed reported duration
}
}

View File

@@ -18,6 +18,7 @@ package io.element.android.features.messages.impl.voicemessages.composer
import android.Manifest
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.derivedStateOf
import androidx.compose.runtime.getValue
@@ -69,13 +70,17 @@ class VoiceMessageComposerPresenter @Inject constructor(
override fun present(): VoiceMessageComposerState {
val localCoroutineScope = rememberCoroutineScope()
val recorderState by voiceRecorder.state.collectAsState(initial = VoiceRecorderState.Idle)
val playerState by player.state.collectAsState(initial = VoiceMessageComposerPlayer.State.Initial)
val keepScreenOn by remember { derivedStateOf { recorderState is VoiceRecorderState.Recording } }
val permissionState = permissionsPresenter.present()
var isSending by remember { mutableStateOf(false) }
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() } }
LaunchedEffect(recorderState) {
val recording = recorderState as? VoiceRecorderState.Finished
?: return@LaunchedEffect
player.setMedia(recording.file.path)
}
val onLifecycleEvent = { event: Lifecycle.Event ->
when (event) {
@@ -115,27 +120,15 @@ class VoiceMessageComposerPresenter @Inject constructor(
}
}
}
val onPlayerEvent = { event: VoiceMessagePlayerEvent ->
when (event) {
VoiceMessagePlayerEvent.Play ->
when (val recording = recorderState) {
is VoiceRecorderState.Finished ->
localCoroutineScope.launch {
player.play(
mediaPath = recording.file.path,
mimeType = recording.mimeType,
)
}
else -> Timber.e("Voice message player event received but no file to play")
}
VoiceMessagePlayerEvent.Pause -> {
player.pause()
}
is VoiceMessagePlayerEvent.Seek -> {
// TODO implement seeking
val onPlayerEvent = { event: VoiceMessagePlayerEvent -> localCoroutineScope.launch {
localCoroutineScope.launch {
when (event) {
VoiceMessagePlayerEvent.Play -> player.play()
VoiceMessagePlayerEvent.Pause -> player.pause()
is VoiceMessagePlayerEvent.Seek -> player.seek(event.position)
}
}
}
} }
val onAcceptPermissionsRationale = {
permissionState.eventSink(PermissionsEvents.OpenSystemSettingAndCloseDialog)
@@ -189,16 +182,14 @@ class VoiceMessageComposerPresenter @Inject constructor(
voiceMessageState = when (val state = recorderState) {
is VoiceRecorderState.Recording -> VoiceMessageState.Recording(
duration = state.elapsedTime,
levels = state.levels.toPersistentList()
)
is VoiceRecorderState.Finished -> VoiceMessageState.Preview(
isSending = isSending,
isPlaying = playerState.isPlaying,
showCursor = playerState.isLoaded && !isSending,
playbackProgress = playerState.progress,
time = playerTime,
waveform = waveform,
levels = state.levels.toPersistentList(),
)
is VoiceRecorderState.Finished ->
previewState(
playerState = playerState,
recorderState = recorderState,
isSending = isSending
)
else -> VoiceMessageState.Idle
},
showPermissionRationaleDialog = permissionState.showDialog,
@@ -207,6 +198,26 @@ class VoiceMessageComposerPresenter @Inject constructor(
)
}
@Composable
private fun previewState(
playerState: VoiceMessageComposerPlayer.State,
recorderState: VoiceRecorderState,
isSending: Boolean,
): VoiceMessageState {
val showCursor by remember(playerState.isStopped, isSending) { derivedStateOf { !playerState.isStopped && !isSending }}
val playerTime by remember(playerState, recorderState) { derivedStateOf { displayTime(playerState, recorderState) } }
val waveform by remember(recorderState) { derivedStateOf { recorderState.finishedWaveform() } }
return VoiceMessageState.Preview(
isSending = isSending,
isPlaying = playerState.isPlaying,
showCursor = showCursor,
playbackProgress = playerState.progress,
time = playerTime,
waveform = waveform,
)
}
private fun CoroutineScope.startRecording() = launch {
try {
voiceRecorder.startRecord()
@@ -248,7 +259,7 @@ class VoiceMessageComposerPresenter @Inject constructor(
}
private fun AnalyticsService.captureComposerEvent() =
analyticsService.capture(
capture(
Composer(
inThread = messageComposerContext.composerMode.inThread,
isEditing = messageComposerContext.composerMode.isEditing,
@@ -273,7 +284,7 @@ private fun displayTime(
playerState: VoiceMessageComposerPlayer.State,
recording: VoiceRecorderState
): Duration = when {
playerState.isLoaded ->
!playerState.isStopped ->
playerState.currentPosition.milliseconds
recording is VoiceRecorderState.Finished ->
recording.duration

View File

@@ -154,7 +154,7 @@ class DefaultVoiceMessagePlayer(
mediaPlayer.setMedia(
uri = mediaFile.path,
mediaId = eventId.value,
mimeType = "audio/ogg" // Files in the voice cache have no extension so we need to set the mime type manually.
mimeType = "audio/ogg", // Files in the voice cache have no extension so we need to set the mime type manually.
)
mediaPlayer.play()
}

View File

@@ -641,7 +641,7 @@ class MessagesPresenterTest {
FakeVoiceRecorder(),
analyticsService,
mediaSender,
player = VoiceMessageComposerPlayer(FakeMediaPlayer()),
player = VoiceMessageComposerPlayer(FakeMediaPlayer(), this),
messageComposerContext = FakeMessageComposerContext(),
permissionsPresenterFactory,
)

View File

@@ -35,8 +35,8 @@ import io.element.android.features.messages.test.FakeMessageComposerContext
import io.element.android.libraries.matrix.test.AN_EVENT_ID
import io.element.android.libraries.matrix.test.A_MESSAGE
import io.element.android.libraries.matrix.test.A_USER_NAME
import io.element.android.libraries.mediaplayer.test.FakeMediaPlayer
import io.element.android.libraries.matrix.test.room.FakeMatrixRoom
import io.element.android.libraries.mediaplayer.test.FakeMediaPlayer
import io.element.android.libraries.mediaupload.api.MediaSender
import io.element.android.libraries.mediaupload.test.FakeMediaPreProcessor
import io.element.android.libraries.permissions.api.PermissionsPresenter
@@ -195,7 +195,6 @@ class VoiceMessageComposerPresenterTest {
awaitItem().eventSink(VoiceMessageComposerEvents.RecordButtonEvent(PressEvent.PressStart))
awaitItem().eventSink(VoiceMessageComposerEvents.RecordButtonEvent(PressEvent.LongPressEnd))
awaitItem().eventSink(VoiceMessageComposerEvents.PlayerEvent(VoiceMessagePlayerEvent.Play))
awaitItem().apply { assertThat(voiceMessageState).isEqualTo(aLoadedState()) }
val finalState = awaitItem().also {
assertThat(it.voiceMessageState).isEqualTo(aPlayingState())
}
@@ -214,7 +213,6 @@ class VoiceMessageComposerPresenterTest {
awaitItem().eventSink(VoiceMessageComposerEvents.RecordButtonEvent(PressEvent.PressStart))
awaitItem().eventSink(VoiceMessageComposerEvents.RecordButtonEvent(PressEvent.LongPressEnd))
awaitItem().eventSink(VoiceMessageComposerEvents.PlayerEvent(VoiceMessagePlayerEvent.Play))
skipItems(1) // Loaded state
awaitItem().eventSink(VoiceMessageComposerEvents.PlayerEvent(VoiceMessagePlayerEvent.Pause))
val finalState = awaitItem().also {
assertThat(it.voiceMessageState).isEqualTo(aPausedState())
@@ -225,6 +223,33 @@ class VoiceMessageComposerPresenterTest {
}
}
@Test
fun `present - seek recording`() = runTest {
val presenter = createVoiceMessageComposerPresenter()
moleculeFlow(RecompositionMode.Immediate) {
presenter.present()
}.test {
awaitItem().eventSink(VoiceMessageComposerEvents.RecordButtonEvent(PressEvent.PressStart))
awaitItem().eventSink(VoiceMessageComposerEvents.RecordButtonEvent(PressEvent.LongPressEnd))
awaitItem().eventSink(VoiceMessageComposerEvents.PlayerEvent(VoiceMessagePlayerEvent.Seek(0.5f)))
awaitItem().apply {
assertThat(voiceMessageState).isEqualTo(aPreviewState(playbackProgress = 0.5f, time = 0.seconds, showCursor = true))
}
awaitItem().apply {
assertThat(voiceMessageState).isEqualTo(aPreviewState(playbackProgress = 0.5f, time = 5.seconds, showCursor = true))
eventSink(VoiceMessageComposerEvents.PlayerEvent(VoiceMessagePlayerEvent.Seek(0.2f)))
}
awaitItem().apply {
assertThat(voiceMessageState).isEqualTo(aPreviewState(playbackProgress = 0.2f, time = 5.seconds, showCursor = true))
}
val finalState = awaitItem().apply {
assertThat(voiceMessageState).isEqualTo(aPreviewState(playbackProgress = 0.2f, time = 2.seconds, showCursor = true))
}
testPauseAndDestroy(finalState)
}
}
@Test
fun `present - delete recording`() = runTest {
val presenter = createVoiceMessageComposerPresenter()
@@ -252,7 +277,6 @@ class VoiceMessageComposerPresenterTest {
awaitItem().eventSink(VoiceMessageComposerEvents.RecordButtonEvent(PressEvent.PressStart))
awaitItem().eventSink(VoiceMessageComposerEvents.RecordButtonEvent(PressEvent.LongPressEnd))
awaitItem().eventSink(VoiceMessageComposerEvents.PlayerEvent(VoiceMessagePlayerEvent.Play))
skipItems(1) // Loaded state
awaitItem().eventSink(VoiceMessageComposerEvents.DeleteVoiceMessage)
awaitItem().apply {
assertThat(voiceMessageState).isEqualTo(aPausedState())
@@ -324,9 +348,9 @@ class VoiceMessageComposerPresenterTest {
awaitItem().eventSink(VoiceMessageComposerEvents.RecordButtonEvent(PressEvent.PressStart))
awaitItem().eventSink(VoiceMessageComposerEvents.RecordButtonEvent(PressEvent.LongPressEnd))
awaitItem().eventSink(VoiceMessageComposerEvents.PlayerEvent(VoiceMessagePlayerEvent.Play))
skipItems(1) // Loaded state
awaitItem().eventSink(VoiceMessageComposerEvents.SendVoiceMessage)
assertThat(awaitItem().voiceMessageState).isEqualTo(aPlayingState().toSendingState())
skipItems(1) // Duplicate sending state
val finalState = awaitItem()
assertThat(finalState.voiceMessageState).isEqualTo(VoiceMessageState.Idle)
@@ -603,7 +627,7 @@ class VoiceMessageComposerPresenterTest {
voiceRecorder,
analyticsService,
mediaSender,
player = VoiceMessageComposerPlayer(FakeMediaPlayer()),
player = VoiceMessageComposerPlayer(FakeMediaPlayer(), this),
messageComposerContext = messageComposerContext,
FakePermissionsPresenterFactory(permissionsPresenter),
)
@@ -639,14 +663,6 @@ class VoiceMessageComposerPresenterTest {
waveform = waveform.toImmutableList(),
)
private fun aLoadedState() =
aPreviewState(
isPlaying = false,
playbackProgress = 0.0f,
showCursor = true,
time = 0.seconds,
)
private fun aPlayingState() =
aPreviewState(
isPlaying = true,

View File

@@ -108,7 +108,7 @@ internal fun VoiceMessagePreview(
playbackProgress = playbackProgress,
showCursor = showCursor,
waveform = waveform,
seekEnabled = false, // TODO enable seeking
seekEnabled = true,
onSeek = onSeek,
)
}

View File

@@ -14,7 +14,7 @@
* limitations under the License.
*/
plugins {
id("io.element.android-library")
id("io.element.android-compose-library")
alias(libs.plugins.anvil)
}

View File

@@ -16,9 +16,11 @@
package io.element.android.libraries.voicerecorder.api
import androidx.compose.runtime.Immutable
import java.io.File
import kotlin.time.Duration
@Immutable
sealed interface VoiceRecorderState {
/**
* The recorder is idle and not recording.