Skip to content

Commit 14be887

Browse files
authored
Enable seeking a recorded voice message (#1758)
1 parent 5d2770a commit 14be887

File tree

8 files changed

+260
-94
lines changed

8 files changed

+260
-94
lines changed

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

Lines changed: 180 additions & 43 deletions
Original file line numberDiff line numberDiff line change
@@ -17,96 +17,233 @@
1717
package io.element.android.features.messages.impl.voicemessages.composer
1818

1919
import io.element.android.libraries.mediaplayer.api.MediaPlayer
20+
import kotlinx.coroutines.CoroutineScope
21+
import kotlinx.coroutines.Job
22+
import kotlinx.coroutines.cancelAndJoin
2023
import kotlinx.coroutines.flow.Flow
24+
import kotlinx.coroutines.flow.MutableStateFlow
25+
import kotlinx.coroutines.flow.combine
2126
import kotlinx.coroutines.flow.distinctUntilChanged
2227
import kotlinx.coroutines.flow.map
28+
import kotlinx.coroutines.flow.scan
29+
import kotlinx.coroutines.launch
30+
import timber.log.Timber
2331
import javax.inject.Inject
2432

2533
/**
2634
* A media player for the voice message composer.
2735
*
2836
* @param mediaPlayer The [MediaPlayer] to use.
37+
* @param coroutineScope
2938
*/
3039
class VoiceMessageComposerPlayer @Inject constructor(
3140
private val mediaPlayer: MediaPlayer,
41+
private val coroutineScope: CoroutineScope,
3242
) {
33-
private var lastPlayedMediaPath: String? = null
34-
private val curPlayingMediaId
35-
get() = mediaPlayer.state.value.mediaId
43+
companion object {
44+
const val MIME_TYPE = "audio/ogg"
45+
}
46+
47+
private var mediaPath: String? = null
48+
49+
private var seekJob: Job? = null
50+
private val seekingTo = MutableStateFlow<Float?>(null)
3651

37-
val state: Flow<State> = mediaPlayer.state.map { state ->
38-
if (lastPlayedMediaPath == null || lastPlayedMediaPath != state.mediaId) {
39-
return@map State.NotLoaded
52+
val state: Flow<State> = combine(mediaPlayer.state, seekingTo) { state, seekingTo ->
53+
state to seekingTo
54+
}.scan(InternalState.NotLoaded) { prevState, (state, seekingTo) ->
55+
if (mediaPath == null || mediaPath != state.mediaId) {
56+
return@scan InternalState.NotLoaded
4057
}
4158

42-
State(
43-
isPlaying = state.isPlaying,
59+
InternalState(
60+
playState = calcPlayState(prevState.playState, seekingTo, state),
4461
currentPosition = state.currentPosition,
45-
duration = state.duration ?: 0L,
62+
duration = state.duration,
63+
seekingTo = seekingTo,
64+
)
65+
}.map {
66+
State(
67+
playState = it.playState,
68+
currentPosition = it.currentPosition,
69+
progress = calcProgress(it),
4670
)
4771
}.distinctUntilChanged()
4872

73+
/**
74+
* Set the voice message to be played.
75+
*/
76+
suspend fun setMedia(mediaPath: String) {
77+
this.mediaPath = mediaPath
78+
mediaPlayer.setMedia(
79+
uri = mediaPath,
80+
mediaId = mediaPath,
81+
mimeType = MIME_TYPE,
82+
)
83+
}
84+
4985
/**
5086
* Start playing from the current position.
5187
*
52-
* @param mediaPath The path to the media to be played.
53-
* @param mimeType The mime type of the media file.
88+
* Call [setMedia] before calling this method.
5489
*/
55-
suspend fun play(mediaPath: String, mimeType: String) {
56-
if (mediaPath == curPlayingMediaId) {
57-
mediaPlayer.play()
58-
} else {
59-
lastPlayedMediaPath = mediaPath
60-
mediaPlayer.setMedia(
61-
uri = mediaPath,
62-
mediaId = mediaPath,
63-
mimeType = mimeType,
64-
)
65-
mediaPlayer.play()
90+
suspend fun play() {
91+
val mediaPath = this.mediaPath
92+
if (mediaPath == null) {
93+
Timber.e("Set media before playing")
94+
return
6695
}
96+
97+
mediaPlayer.ensureMediaReady(mediaPath)
98+
99+
mediaPlayer.play()
67100
}
68101

69102
/**
70103
* Pause playback.
71104
*/
72105
fun pause() {
73-
if (lastPlayedMediaPath == curPlayingMediaId) {
106+
if (mediaPath == mediaPlayer.state.value.mediaId) {
74107
mediaPlayer.pause()
75108
}
76109
}
77110

111+
/**
112+
* Seek to a given position in the current media.
113+
*
114+
* Call [setMedia] before calling this method.
115+
*
116+
* @param position The position to seek to between 0 and 1.
117+
*/
118+
suspend fun seek(position: Float) {
119+
val mediaPath = this.mediaPath
120+
if (mediaPath == null) {
121+
Timber.e("Set media before seeking")
122+
return
123+
}
124+
125+
seekJob?.cancelAndJoin()
126+
seekingTo.value = position
127+
seekJob = coroutineScope.launch {
128+
val mediaState = mediaPlayer.ensureMediaReady(mediaPath)
129+
val duration = mediaState.duration ?: return@launch
130+
val positionMs = (duration * position).toLong()
131+
mediaPlayer.seekTo(positionMs)
132+
}.apply {
133+
invokeOnCompletion {
134+
seekingTo.value = null
135+
}
136+
}
137+
}
138+
139+
private suspend fun MediaPlayer.ensureMediaReady(mediaPath: String): MediaPlayer.State {
140+
val state = state.value
141+
if (state.mediaId == mediaPath && state.isReady) {
142+
return state
143+
}
144+
145+
return setMedia(
146+
uri = mediaPath,
147+
mediaId = mediaPath,
148+
mimeType = MIME_TYPE,
149+
)
150+
}
151+
152+
private fun calcPlayState(prevPlayState: PlayState, seekingTo: Float?, state: MediaPlayer.State): PlayState {
153+
if (state.mediaId == null || state.mediaId != mediaPath) {
154+
return PlayState.Stopped
155+
}
156+
157+
// If we were stopped and the player didn't start playing or seeking, we are still stopped.
158+
if (prevPlayState == PlayState.Stopped && !state.isPlaying && seekingTo == null) {
159+
return PlayState.Stopped
160+
}
161+
162+
return if (state.isPlaying) {
163+
PlayState.Playing
164+
} else {
165+
PlayState.Paused
166+
}
167+
}
168+
169+
private fun calcProgress(state: InternalState): Float {
170+
if (state.seekingTo != null) {
171+
return state.seekingTo
172+
}
173+
174+
if (state.playState == PlayState.Stopped) {
175+
return 0f
176+
}
177+
178+
if (state.duration == null) {
179+
return 0f
180+
}
181+
182+
return (state.currentPosition.toFloat() / state.duration.toFloat())
183+
.coerceAtMost(1f) // Current position may exceed reported duration
184+
}
185+
186+
/**
187+
* @property playState Whether this player is currently playing. See [PlayState].
188+
* @property currentPosition The elapsed time of this player in milliseconds.
189+
* @property progress The progress of this player between 0 and 1.
190+
*/
78191
data class State(
192+
val playState: PlayState,
193+
val currentPosition: Long,
194+
val progress: Float,
195+
) {
196+
197+
companion object {
198+
val Initial = State(
199+
playState = PlayState.Stopped,
200+
currentPosition = 0L,
201+
progress = 0f,
202+
)
203+
}
79204
/**
80205
* Whether this player is currently playing.
81206
*/
82-
val isPlaying: Boolean,
207+
val isPlaying get() = this.playState == PlayState.Playing
208+
209+
/**
210+
* Whether this player is currently stopped.
211+
*/
212+
val isStopped get() = this.playState == PlayState.Stopped
213+
}
214+
215+
216+
enum class PlayState {
83217
/**
84-
* The elapsed time of this player in milliseconds.
218+
* The player is stopped, i.e. it has just been initialised.
85219
*/
86-
val currentPosition: Long,
220+
Stopped,
221+
222+
/**
223+
* The player is playing.
224+
*/
225+
Playing,
226+
87227
/**
88-
* The duration of this player in milliseconds.
228+
* The player has been paused. The player can also enter the paused state after seeking to a position.
89229
*/
90-
val duration: Long,
230+
Paused,
231+
}
232+
233+
private data class InternalState(
234+
val playState: PlayState,
235+
val currentPosition: Long,
236+
val duration: Long?,
237+
val seekingTo: Float?,
91238
) {
92239
companion object {
93-
val NotLoaded = State(
94-
isPlaying = false,
240+
val NotLoaded = InternalState(
241+
playState = PlayState.Stopped,
95242
currentPosition = 0L,
96-
duration = 0L,
243+
duration = null,
244+
seekingTo = null,
97245
)
98246
}
99-
100-
val isLoaded get() = this != NotLoaded
101-
102-
/**
103-
* The progress of this player between 0 and 1.
104-
*/
105-
val progress: Float =
106-
if (duration == 0L)
107-
0f
108-
else
109-
(currentPosition.toFloat() / duration.toFloat())
110-
.coerceAtMost(1f) // Current position may exceed reported duration
111247
}
112248
}
249+

0 commit comments

Comments
 (0)