|
17 | 17 | package io.element.android.features.messages.impl.voicemessages.composer |
18 | 18 |
|
19 | 19 | import io.element.android.libraries.mediaplayer.api.MediaPlayer |
| 20 | +import kotlinx.coroutines.CoroutineScope |
| 21 | +import kotlinx.coroutines.Job |
| 22 | +import kotlinx.coroutines.cancelAndJoin |
20 | 23 | import kotlinx.coroutines.flow.Flow |
| 24 | +import kotlinx.coroutines.flow.MutableStateFlow |
| 25 | +import kotlinx.coroutines.flow.combine |
21 | 26 | import kotlinx.coroutines.flow.distinctUntilChanged |
22 | 27 | import kotlinx.coroutines.flow.map |
| 28 | +import kotlinx.coroutines.flow.scan |
| 29 | +import kotlinx.coroutines.launch |
| 30 | +import timber.log.Timber |
23 | 31 | import javax.inject.Inject |
24 | 32 |
|
25 | 33 | /** |
26 | 34 | * A media player for the voice message composer. |
27 | 35 | * |
28 | 36 | * @param mediaPlayer The [MediaPlayer] to use. |
| 37 | + * @param coroutineScope |
29 | 38 | */ |
30 | 39 | class VoiceMessageComposerPlayer @Inject constructor( |
31 | 40 | private val mediaPlayer: MediaPlayer, |
| 41 | + private val coroutineScope: CoroutineScope, |
32 | 42 | ) { |
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) |
36 | 51 |
|
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 |
40 | 57 | } |
41 | 58 |
|
42 | | - State( |
43 | | - isPlaying = state.isPlaying, |
| 59 | + InternalState( |
| 60 | + playState = calcPlayState(prevState.playState, seekingTo, state), |
44 | 61 | 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), |
46 | 70 | ) |
47 | 71 | }.distinctUntilChanged() |
48 | 72 |
|
| 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 | + |
49 | 85 | /** |
50 | 86 | * Start playing from the current position. |
51 | 87 | * |
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. |
54 | 89 | */ |
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 |
66 | 95 | } |
| 96 | + |
| 97 | + mediaPlayer.ensureMediaReady(mediaPath) |
| 98 | + |
| 99 | + mediaPlayer.play() |
67 | 100 | } |
68 | 101 |
|
69 | 102 | /** |
70 | 103 | * Pause playback. |
71 | 104 | */ |
72 | 105 | fun pause() { |
73 | | - if (lastPlayedMediaPath == curPlayingMediaId) { |
| 106 | + if (mediaPath == mediaPlayer.state.value.mediaId) { |
74 | 107 | mediaPlayer.pause() |
75 | 108 | } |
76 | 109 | } |
77 | 110 |
|
| 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 | + */ |
78 | 191 | 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 | + } |
79 | 204 | /** |
80 | 205 | * Whether this player is currently playing. |
81 | 206 | */ |
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 { |
83 | 217 | /** |
84 | | - * The elapsed time of this player in milliseconds. |
| 218 | + * The player is stopped, i.e. it has just been initialised. |
85 | 219 | */ |
86 | | - val currentPosition: Long, |
| 220 | + Stopped, |
| 221 | + |
| 222 | + /** |
| 223 | + * The player is playing. |
| 224 | + */ |
| 225 | + Playing, |
| 226 | + |
87 | 227 | /** |
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. |
89 | 229 | */ |
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?, |
91 | 238 | ) { |
92 | 239 | companion object { |
93 | | - val NotLoaded = State( |
94 | | - isPlaying = false, |
| 240 | + val NotLoaded = InternalState( |
| 241 | + playState = PlayState.Stopped, |
95 | 242 | currentPosition = 0L, |
96 | | - duration = 0L, |
| 243 | + duration = null, |
| 244 | + seekingTo = null, |
97 | 245 | ) |
98 | 246 | } |
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 |
111 | 247 | } |
112 | 248 | } |
| 249 | + |
0 commit comments