Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Enable seeking a recorded voice message #1758

Merged
merged 1 commit into from
Nov 10, 2023
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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) {

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Sorry about not checking for this properly in the MediaPlayer. I've opened this: #1783

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