Skip to content

Commit

Permalink
Voice message scrubbing improvements
Browse files Browse the repository at this point in the history
- 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: element-hq/element-meta#2113
  • Loading branch information
Marco Romano committed Nov 21, 2023
1 parent de7b175 commit af35146
Show file tree
Hide file tree
Showing 11 changed files with 377 additions and 204 deletions.
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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

/**
Expand Down Expand Up @@ -60,43 +63,52 @@ 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.
*/
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?,
)
}

Expand Down Expand Up @@ -140,50 +152,84 @@ class DefaultVoiceMessagePlayer(
body = body
)

override val state: Flow<VoiceMessagePlayer.State> = mediaPlayer.state.map { state ->
private var internalState = MutableStateFlow(
VoiceMessagePlayer.State(
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(
isPlaying = state.mediaId.isMyTrack() && state.isPlaying,
isMyMedia = state.mediaId.isMyTrack(),
currentPosition = if (state.mediaId.isMyTrack()) state.currentPosition else 0L
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 pause() {
ifInControl {
mediaPlayer.pause()
override fun play() {
if (inControl()) {
mediaPlayer.play()
}
}

override suspend fun seekTo(positionMs: Long): Result<Unit> = acquireControl {
mediaPlayer.seekTo(positionMs)
override fun pause() {
if (inControl()) {
mediaPlayer.pause()
}
}

private fun String?.isMyTrack(): Boolean = if (eventId == null) false else this == eventId.value

private inline fun ifInControl(block: () -> Unit) {
if (inControl()) block()
override fun seekTo(positionMs: Long) {
if (inControl()) {
mediaPlayer.seekTo(positionMs)
} else {
internalState.update {
it.copy(currentPosition = positionMs)
}
}
}

private fun inControl(): Boolean = mediaPlayer.state.value.mediaId.isMyTrack()
private val MediaPlayer.State.isMyTrack: Boolean
get() = if (eventId == null) false else this.mediaId == eventId.value

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)
}
} else {
Result.failure(IllegalStateException("Cannot acquireControl on a voice message with no eventId"))
}
private fun inControl(): Boolean = mediaPlayer.state.value.let {
it.isMyTrack && (it.isReady || it.isEnded)
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand All @@ -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
}
}

Expand All @@ -110,6 +125,8 @@ class VoiceMessagePresenter @AssistedInject constructor(
is VoiceMessageEvents.PlayPause -> {
if (playerState.isPlaying) {
player.pause()
} else if (playerState.isReady) {
player.play()

Check warning on line 129 in features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/voicemessages/timeline/VoiceMessagePresenter.kt

View check run for this annotation

Codecov / codecov/patch

features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/voicemessages/timeline/VoiceMessagePresenter.kt#L129

Added line #L129 was not covered by tests
} else {
scope.launch {
play.runUpdatingState(
Expand All @@ -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())
}
}
}
Expand All @@ -146,6 +154,7 @@ class VoiceMessagePresenter @AssistedInject constructor(
button = button,
progress = progress,
time = time,
showCursor = showCursor,
eventSink = { eventSink(it) },
)
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -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 = {},
)
Loading

0 comments on commit af35146

Please sign in to comment.