Skip to content

Commit

Permalink
Enable seeking a recorded voice message
Browse files Browse the repository at this point in the history
  • Loading branch information
jonnyandrew committed Nov 9, 2023
1 parent 5d2770a commit 93cb444
Show file tree
Hide file tree
Showing 8 changed files with 260 additions and 94 deletions.
Original file line number Diff line number Diff line change
Expand Up @@ -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"
}

private var mediaPath: String? = null

private var seekJob: Job? = null
private val seekingTo = MutableStateFlow<Float?>(null)

val state: Flow<State> = mediaPlayer.state.map { state ->
if (lastPlayedMediaPath == null || lastPlayedMediaPath != state.mediaId) {
return@map State.NotLoaded
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),

Check warning on line 69 in features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/voicemessages/composer/VoiceMessageComposerPlayer.kt

View check run for this annotation

Codecov / codecov/patch

features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/voicemessages/composer/VoiceMessageComposerPlayer.kt#L66-L69

Added lines #L66 - L69 were not covered by tests
)
}.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")

Check warning on line 121 in features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/voicemessages/composer/VoiceMessageComposerPlayer.kt

View check run for this annotation

Codecov / codecov/patch

features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/voicemessages/composer/VoiceMessageComposerPlayer.kt#L121

Added line #L121 was not covered by tests
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

Check warning on line 154 in features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/voicemessages/composer/VoiceMessageComposerPlayer.kt

View check run for this annotation

Codecov / codecov/patch

features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/voicemessages/composer/VoiceMessageComposerPlayer.kt#L154

Added line #L154 was not covered by tests
}

// 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

Check warning on line 179 in features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/voicemessages/composer/VoiceMessageComposerPlayer.kt

View check run for this annotation

Codecov / codecov/patch

features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/voicemessages/composer/VoiceMessageComposerPlayer.kt#L179

Added line #L179 was not covered by tests
}

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

/**
* Whether this player is currently stopped.
*/
val isStopped get() = this.playState == PlayState.Stopped
}


enum class PlayState {
/**
* The elapsed time of this player in milliseconds.
* The player is stopped, i.e. it has just been initialised.
*/
val currentPosition: Long,
Stopped,

/**
* The player is playing.
*/
Playing,

/**
* The duration of this player in milliseconds.
* The player has been paused. The player can also enter the paused state after seeking to a position.
*/
val duration: Long,
Paused,
}

private data class InternalState(
val playState: PlayState,
val currentPosition: 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
}
}

Loading

0 comments on commit 93cb444

Please sign in to comment.