Voice message scrubbing improvements (#1847)

- Voice messages can be scrubbed (i.e. seeked to) even when they have not been played yet..
- The progress bar is displayed also when paused.
- Multiple voice messages can keep their state when paused.
- Tries to adhere as much as possible at the detailed "green cursor" behavior in the story (but might not be 100% compliant).

Story: https://github.com/vector-im/element-meta/issues/2113
This commit is contained in:
Marco Romano 2023-11-21 20:48:08 +01:00 committed by GitHub
parent 0649cf1706
commit de646e4e5a
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
11 changed files with 381 additions and 208 deletions

View file

@ -96,7 +96,7 @@ fun TimelineItemVoiceView(
Spacer(Modifier.width(8.dp))
val context = LocalContext.current
WaveformPlaybackView(
showCursor = state.button == VoiceMessageState.Button.Pause,
showCursor = state.showCursor,
playbackProgress = state.progress,
waveform = content.waveform,
modifier = Modifier

View file

@ -22,8 +22,11 @@ import io.element.android.libraries.matrix.api.core.EventId
import io.element.android.libraries.matrix.api.media.MediaSource
import io.element.android.libraries.mediaplayer.api.MediaPlayer
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.update
import java.io.File
import javax.inject.Inject
/**
@ -60,14 +63,18 @@ interface VoiceMessagePlayer {
val state: Flow<State>
/**
* Starts playing from the beginning
* acquiring control of the underlying [MediaPlayer].
* If already in control of the underlying [MediaPlayer], starts playing from the
* current position.
* Acquires control of the underlying [MediaPlayer] and prepares it
* to play the media file.
*
* Will suspend whilst the media file is being downloaded.
* Will suspend whilst the media file is being downloaded and/or
* the underlying [MediaPlayer] is loading the media file.
*/
suspend fun play(): Result<Unit>
suspend fun prepare(): Result<Unit>
/**
* Play the media.
*/
fun play()
/**
* Pause playback.
@ -75,28 +82,33 @@ interface VoiceMessagePlayer {
fun pause()
/**
* Seek to a specific position acquiring control of the
* underlying [MediaPlayer] if needed.
*
* Will suspend whilst the media file is being downloaded.
* Seek to a specific position.
*
* @param positionMs The position in milliseconds.
*/
suspend fun seekTo(positionMs: Long): Result<Unit>
fun seekTo(positionMs: Long)
data class State(
/**
* Whether the player is ready to play.
*/
val isReady: Boolean,
/**
* Whether this player is currently playing.
*/
val isPlaying: Boolean,
/**
* Whether this player has control of the underlying [MediaPlayer].
* Whether the player has reached the end of the media.
*/
val isMyMedia: Boolean,
val isEnded: Boolean,
/**
* The elapsed time of this player in milliseconds.
*/
val currentPosition: Long,
/**
* The duration of the current content, if available.
*/
val duration: Long?,
)
}
@ -140,50 +152,84 @@ class DefaultVoiceMessagePlayer(
body = body
)
override val state: Flow<VoiceMessagePlayer.State> = mediaPlayer.state.map { state ->
private var internalState = MutableStateFlow(
VoiceMessagePlayer.State(
isPlaying = state.mediaId.isMyTrack() && state.isPlaying,
isMyMedia = state.mediaId.isMyTrack(),
currentPosition = if (state.mediaId.isMyTrack()) state.currentPosition else 0L
isReady = false,
isPlaying = false,
isEnded = false,
currentPosition = 0L,
duration = null
)
)
override val state: Flow<VoiceMessagePlayer.State> = combine(mediaPlayer.state, internalState) { mediaPlayerState, internalState ->
if (mediaPlayerState.isMyTrack) {
this.internalState.update {
it.copy(
isReady = mediaPlayerState.isReady,
isPlaying = mediaPlayerState.isPlaying,
isEnded = mediaPlayerState.isEnded,
currentPosition = mediaPlayerState.currentPosition,
duration = mediaPlayerState.duration,
)
}
} else {
this.internalState.update {
it.copy(
isReady = false,
isPlaying = false,
)
}
}
VoiceMessagePlayer.State(
isReady = internalState.isReady,
isPlaying = internalState.isPlaying,
isEnded = internalState.isEnded,
currentPosition = internalState.currentPosition,
duration = internalState.duration,
)
}.distinctUntilChanged()
override suspend fun play(): Result<Unit> = acquireControl {
mediaPlayer.play()
override suspend fun prepare(): Result<Unit> = if (eventId != null) {
repo.getMediaFile().mapCatching<Unit, File> { mediaFile ->
val state = internalState.value
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.
startPositionMs = if (state.isEnded) 0L else state.currentPosition,
)
}
} else {
Result.failure(IllegalStateException("Cannot acquireControl on a voice message with no eventId"))
}
override fun play() {
if (inControl()) {
mediaPlayer.play()
}
}
override fun pause() {
ifInControl {
if (inControl()) {
mediaPlayer.pause()
}
}
override suspend fun seekTo(positionMs: Long): Result<Unit> = acquireControl {
mediaPlayer.seekTo(positionMs)
}
private fun String?.isMyTrack(): Boolean = if (eventId == null) false else this == eventId.value
private inline fun ifInControl(block: () -> Unit) {
if (inControl()) block()
}
private fun inControl(): Boolean = mediaPlayer.state.value.mediaId.isMyTrack()
private suspend inline fun acquireControl(onReady: (state: MediaPlayer.State) -> Unit): Result<Unit> = if (inControl()) {
onReady(mediaPlayer.state.value)
Result.success(Unit)
} else {
if (eventId != null) {
repo.getMediaFile().mapCatching { mediaFile ->
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.
).let(onReady)
}
override fun seekTo(positionMs: Long) {
if (inControl()) {
mediaPlayer.seekTo(positionMs)
} else {
Result.failure(IllegalStateException("Cannot acquireControl on a voice message with no eventId"))
internalState.update {
it.copy(currentPosition = positionMs)
}
}
}
private val MediaPlayer.State.isMyTrack: Boolean
get() = if (eventId == null) false else this.mediaId == eventId.value
private fun inControl(): Boolean = mediaPlayer.state.value.let {
it.isMyTrack && (it.isReady || it.isEnded)
}
}

View file

@ -72,12 +72,19 @@ class VoiceMessagePresenter @AssistedInject constructor(
)
private val play = mutableStateOf<Async<Unit>>(Async.Uninitialized)
private var progressCache: Float = 0f
@Composable
override fun present(): VoiceMessageState {
val playerState by player.state.collectAsState(VoiceMessagePlayer.State(isPlaying = false, isMyMedia = false, currentPosition = 0L))
val playerState by player.state.collectAsState(
VoiceMessagePlayer.State(
isReady = false,
isPlaying = false,
isEnded = false,
currentPosition = 0L,
duration = null
)
)
val button by remember {
derivedStateOf {
@ -90,18 +97,26 @@ class VoiceMessagePresenter @AssistedInject constructor(
}
}
}
val duration by remember {
derivedStateOf { playerState.duration ?: content.duration.toMillis() }
}
val progress by remember {
derivedStateOf {
if (playerState.isMyMedia) {
progressCache = playerState.currentPosition / content.duration.toMillis().toFloat()
}
progressCache
playerState.currentPosition / duration.toFloat()
}
}
val time by remember {
derivedStateOf {
val time = if (playerState.isMyMedia) playerState.currentPosition else content.duration.toMillis()
time.milliseconds.formatShort()
when {
playerState.isReady && !playerState.isEnded -> playerState.currentPosition
playerState.currentPosition > 0 -> playerState.currentPosition
else -> duration
}.milliseconds.formatShort()
}
}
val showCursor by remember {
derivedStateOf {
!play.value.isUninitialized() && !playerState.isEnded
}
}
@ -110,6 +125,8 @@ class VoiceMessagePresenter @AssistedInject constructor(
is VoiceMessageEvents.PlayPause -> {
if (playerState.isPlaying) {
player.pause()
} else if (playerState.isReady) {
player.play()
} else {
scope.launch {
play.runUpdatingState(
@ -120,24 +137,15 @@ class VoiceMessagePresenter @AssistedInject constructor(
it
},
) {
player.play()
player.prepare().apply {
player.play()
}
}
}
}
}
is VoiceMessageEvents.Seek -> {
scope.launch {
play.runUpdatingState(
errorTransform = {
analyticsService.trackError(
VoiceMessageException.PlayMessageError("Error while trying to seek voice message", it)
)
it
},
) {
player.seekTo((event.percentage * content.duration.toMillis()).toLong())
}
}
player.seekTo((event.percentage * duration).toLong())
}
}
}
@ -146,6 +154,7 @@ class VoiceMessagePresenter @AssistedInject constructor(
button = button,
progress = progress,
time = time,
showCursor = showCursor,
eventSink = { eventSink(it) },
)
}

View file

@ -20,6 +20,7 @@ data class VoiceMessageState(
val button: Button,
val progress: Float,
val time: String,
val showCursor: Boolean,
val eventSink: (event: VoiceMessageEvents) -> Unit,
) {
enum class Button {

View file

@ -35,11 +35,13 @@ open class VoiceMessageStateProvider : PreviewParameterProvider<VoiceMessageStat
VoiceMessageState.Button.Play,
progress = 1f,
time = "1:00",
showCursor = true,
),
aVoiceMessageState(
VoiceMessageState.Button.Pause,
progress = 0.2f,
time = "10:00",
showCursor = true,
),
aVoiceMessageState(
VoiceMessageState.Button.Disabled,
@ -53,9 +55,11 @@ fun aVoiceMessageState(
button: VoiceMessageState.Button = VoiceMessageState.Button.Play,
progress: Float = 0f,
time: String = "1:00",
showCursor: Boolean = false,
) = VoiceMessageState(
button = button,
progress = progress,
time = time,
showCursor = showCursor,
eventSink = {},
)