Skip to content

Commit fa8b894

Browse files
authored
Merge pull request #1147 from tunjid/tj/video-player-states
Introduce VideoPlayerStates to manage video player states consistentl…
2 parents 861d027 + a389d33 commit fa8b894

File tree

8 files changed

+198
-269
lines changed

8 files changed

+198
-269
lines changed

ui/media/src/androidMain/kotlin/com/tunjid/heron/media/video/ExoPlayerState.kt

Lines changed: 0 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -191,10 +191,6 @@ internal class ExoPlayerState internal constructor(
191191

192192
private fun VideoSize.toIntSize() = IntSize(width, height)
193193

194-
internal fun VideoPlayerState.seekPositionOnPlayMs(seekToMs: Long?): Long {
195-
return seekToMs ?: if (shouldReplay) 0L else lastPositionMs
196-
}
197-
198194
internal fun ExoPlayer.unbind(state: ExoPlayerState) {
199195
state.status = PlayerStatus.Pause.Requested
200196
removeListener(state.playerListener)

ui/media/src/androidMain/kotlin/com/tunjid/heron/media/video/ExoplayerController.kt

Lines changed: 46 additions & 83 deletions
Original file line numberDiff line numberDiff line change
@@ -21,7 +21,6 @@ import androidx.annotation.OptIn
2121
import androidx.compose.runtime.Stable
2222
import androidx.compose.runtime.derivedStateOf
2323
import androidx.compose.runtime.getValue
24-
import androidx.compose.runtime.mutableStateMapOf
2524
import androidx.compose.runtime.mutableStateOf
2625
import androidx.compose.runtime.setValue
2726
import androidx.compose.runtime.snapshotFlow
@@ -71,8 +70,6 @@ class ExoplayerController(
7170
) : VideoPlayerController,
7271
Player.Listener {
7372

74-
override var isMuted: Boolean by mutableStateOf(true)
75-
7673
/**
7774
* A [Job] for diffing the [ExoPlayer] playlist such that changing the active video does not discard buffered
7875
* videos.
@@ -85,12 +82,9 @@ class ExoplayerController(
8582
*/
8683
private val mediaItemMutationsChannel = Channel<(List<MediaItem>) -> List<MediaItem>>()
8784

88-
/**
89-
* A map of ids registered in this [VideoPlayerController] and its associated
90-
* [ExoPlayerState]s. Note that the presence of an id in this map is _not_ an indication
91-
* that the backing media is available to play. For that information, reference [currentPlaylistIds].
92-
*/
93-
private val idsToStates = mutableStateMapOf<String, ExoPlayerState>()
85+
private val states = VideoPlayerStates<ExoPlayerState>()
86+
87+
override var isMuted: Boolean by states::isMuted
9488

9589
/**
9690
* The ids of media available to play in the available [player] instance.
@@ -104,23 +98,17 @@ class ExoplayerController(
10498

10599
private var player: ExoPlayer? by mutableStateOf(null)
106100

107-
private var activeVideoId: String by mutableStateOf("")
108-
109101
// TODO: Revisit this. The Coroutine should be launched lazily instead of in an init block.
110102
init {
111103
player = exoPlayer(context = context).apply {
112104
// The first listener should always be the ExoplayerManager
113105
addListener(this@ExoplayerController)
114106
// Bind active video to restore its properties and attach its listener
115-
val hasActiveVideo = idsToStates[activeVideoId]?.let(::bind) != null
116-
// Restore the players playlist if it was previously saved
117-
val restoredMediaItems = idsToStates.values.map { it.toMediaItem() }
118-
addMediaItems(restoredMediaItems)
119-
currentPlaylistIds = restoredMediaItems.map(MediaItem::mediaId).toSet()
120-
playWhenReady = getVideoStateById(activeVideoId)?.autoplay == true
107+
val hasActiveVideo = states.activeState?.let(::bind) != null
108+
playWhenReady = states.activeState?.autoplay == true
121109
if (playWhenReady) {
122110
prepare()
123-
if (playWhenReady && hasActiveVideo) play(activeVideoId)
111+
if (playWhenReady && hasActiveVideo) play(states.activeVideoId)
124112
}
125113
}
126114
diffingJob?.cancel()
@@ -141,7 +129,7 @@ class ExoplayerController(
141129
// TODO this should also be governed by coroutine launch semantics
142130
// as the one used for list diffing
143131
// Pause playback when nothing is visible to play
144-
snapshotFlow { idsToStates[activeVideoId]?.status }
132+
snapshotFlow { states.activeState?.status }
145133
.map { it == null || it is PlayerStatus.Idle }
146134
.filter(true::equals)
147135
.onEach { player?.pause() }
@@ -156,10 +144,10 @@ class ExoplayerController(
156144
videoId: String,
157145
autoplay: Boolean,
158146
) {
159-
idsToStates[videoId]?.let {
147+
states[videoId]?.let {
160148
it.autoplay = autoplay
161149
}
162-
if (videoId != activeVideoId) return
150+
if (videoId != states.activeVideoId) return
163151

164152
if (!autoplay) {
165153
player?.pause()
@@ -172,38 +160,41 @@ class ExoplayerController(
172160
videoId: String?,
173161
seekToMs: Long?,
174162
) {
175-
val playerIdToPlay = videoId ?: activeVideoId
163+
val playerIdToPlay = videoId ?: states.activeVideoId
176164

177165
// Video has not been previously registered
178-
val stateToPlay = idsToStates[playerIdToPlay] ?: return
166+
val stateToPlay = states[playerIdToPlay] ?: return
179167

180168
setActiveVideo(playerIdToPlay)
181169

182170
// Already playing and not seeking, do nothing
183171
if (stateToPlay.status is PlayerStatus.Play.Confirmed && seekToMs == null) return
184172

185173
// Diffing is async. Suspend until the video to play is registered in the player
186-
playAsync(playerIdToPlay, seekToMs)
174+
playAsync(
175+
playerIdToPlay = playerIdToPlay,
176+
seekToMs = seekToMs,
177+
)
187178
}
188179

189180
override fun pauseActiveVideo() {
190-
activeVideoId.let(idsToStates::get)?.apply {
181+
states.activeState?.apply {
191182
status = PlayerStatus.Pause.Requested
192183
}
193184
player?.pause()
194185
}
195186

196187
private fun setActiveVideo(videoId: String) {
197188
// Video has not been previously registered
198-
val activeState = idsToStates[videoId] ?: return
189+
val activeState = states[videoId] ?: return
199190

200-
val previousId = activeVideoId
201-
activeVideoId = videoId
191+
val previousId = states.activeVideoId
192+
states.activeVideoId = videoId
202193

203-
if (previousId == activeVideoId) return
194+
if (previousId == states.activeVideoId) return
204195

205196
player?.apply {
206-
idsToStates[previousId]?.let(::unbind)
197+
states[previousId]?.let(::unbind)
207198
bind(activeState)
208199
}
209200
// NOTE: Play must be called on the manager and not on the exoplayer instance itself.
@@ -221,15 +212,18 @@ class ExoplayerController(
221212
}
222213
}
223214

224-
override fun getVideoStateById(videoId: String): VideoPlayerState? = idsToStates[videoId]
215+
override fun getVideoStateById(videoId: String): VideoPlayerState? = states[videoId]
225216

226217
override fun retry(videoId: String) {
227218
setActiveVideo(videoId)
228219
player?.prepare()
229220
}
230221

231222
override fun seekTo(position: Long) {
232-
play(videoId = activeVideoId, seekToMs = position)
223+
play(
224+
videoId = states.activeVideoId,
225+
seekToMs = position,
226+
)
233227
}
234228

235229
override fun onPlaybackStateChanged(playbackState: Int) {
@@ -255,24 +249,24 @@ class ExoplayerController(
255249
isLooping: Boolean,
256250
autoplay: Boolean,
257251
): VideoPlayerState {
258-
idsToStates[videoId]?.let { return it }
259-
260-
trim()
261-
val videoPlayerState = ExoPlayerState(
262-
videoUrl = videoUrl,
263-
videoId = videoId,
264-
thumbnail = thumbnail,
265-
autoplay = autoplay,
266-
isLooping = isLooping,
267-
isMuted = derivedStateOf {
268-
isMuted
269-
},
270-
exoPlayerState = derivedStateOf {
271-
player.takeIf { isCurrentMediaItem(videoId) }
272-
},
273-
)
252+
states[videoId]?.let { return it }
253+
254+
val videoPlayerState = states.registerOrGet(videoId = videoId) {
255+
ExoPlayerState(
256+
videoUrl = videoUrl,
257+
videoId = videoId,
258+
thumbnail = thumbnail,
259+
autoplay = autoplay,
260+
isLooping = isLooping,
261+
isMuted = derivedStateOf {
262+
isMuted
263+
},
264+
exoPlayerState = derivedStateOf {
265+
player.takeIf { isCurrentMediaItem(videoId) }
266+
},
267+
)
268+
}
274269

275-
idsToStates[videoId] = videoPlayerState
276270
val mediaItem = videoPlayerState.toMediaItem()
277271

278272
// Add the new media item to the ExoPlayer playlist.
@@ -291,30 +285,11 @@ class ExoplayerController(
291285
return videoPlayerState
292286
}
293287

294-
override fun unregisterAll(retainedVideoIds: Set<String>): Set<String> {
295-
idsToStates
296-
.filterNot { retainedVideoIds.contains(it.key) }
297-
.forEach { (id, videoState) ->
298-
if (activeVideoId == id) {
299-
player?.pause()
300-
}
301-
player?.unbind(videoState)
302-
idsToStates.remove(id)
303-
}
304-
// Remove all videos that have not been retained from the playlist.In
305-
scope.launch {
306-
mediaItemMutationsChannel.send { existingItems ->
307-
existingItems.filter { retainedVideoIds.contains(it.mediaId) }
308-
}
309-
}
310-
return retainedVideoIds - idsToStates.keys
311-
}
312-
313288
internal fun teardown() {
314289
diffingJob?.cancel()
315290
player?.apply {
316291
removeListener(this@ExoplayerController)
317-
idsToStates[activeVideoId]?.let {
292+
states.activeState?.let {
318293
removeListener(it.playerListener)
319294
it.updateFromPlayer()
320295
}
@@ -332,7 +307,7 @@ class ExoplayerController(
332307
) {
333308
scope.launch {
334309
// Do this only while playerId is the activeVideo
335-
snapshotFlow { activeVideoId == playerIdToPlay }
310+
snapshotFlow { states.activeVideoId == playerIdToPlay }
336311
.flatMapLatest { isActiveVideo ->
337312
if (isActiveVideo) {
338313
snapshotFlow { currentPlaylistIds.contains(playerIdToPlay) }
@@ -356,16 +331,6 @@ class ExoplayerController(
356331
}
357332
}
358333

359-
private fun trim() {
360-
val size = idsToStates.size
361-
if (size >= MaxVideoStates) idsToStates.keys.filter {
362-
val state = idsToStates[it]
363-
state?.status is PlayerStatus.Idle.Evicted
364-
}
365-
.take(size - MaxVideoStates)
366-
.forEach(idsToStates::remove)
367-
}
368-
369334
/**
370335
* Diffs media items and inserts them in the ExoPlayer playlist.
371336
* This ensures that a previously buffered video does not need to re-buffer when it is swapped
@@ -392,7 +357,7 @@ class ExoplayerController(
392357
}
393358

394359
private fun isCurrentMediaItem(videoId: String) =
395-
activeVideoId == videoId && currentMediaItem?.mediaId == videoId
360+
states.activeVideoId == videoId && currentMediaItem?.mediaId == videoId
396361

397362
private val Player.currentMediaItems: List<MediaItem>
398363
get() = List(mediaItemCount, ::getMediaItemAt)
@@ -445,5 +410,3 @@ private fun VideoPlayerState.toMediaItem() =
445410
.setUri(videoUrl)
446411
.setMediaId(videoId)
447412
.build()
448-
449-
private const val MaxVideoStates = 30

ui/media/src/commonMain/kotlin/com/tunjid/heron/media/video/Stubs.kt

Lines changed: 0 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -61,10 +61,6 @@ object StubVideoPlayerController : VideoPlayerController {
6161
override fun getVideoStateById(videoId: String): VideoPlayerState? = null
6262

6363
override fun retry(videoId: String) = Unit
64-
65-
override fun unregisterAll(
66-
retainedVideoIds: Set<String>,
67-
): Set<String> = retainedVideoIds
6864
}
6965

7066
private data class NoOpVideoPlayerState(

ui/media/src/commonMain/kotlin/com/tunjid/heron/media/video/VideoPlayerController.kt

Lines changed: 0 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -74,14 +74,6 @@ interface VideoPlayerController {
7474
* is different, sets the active video to the one with the given [videoId].
7575
*/
7676
fun retry(videoId: String)
77-
78-
/**
79-
* Unregisters all videos with IDs that are not present in [retainedVideoIds].
80-
*
81-
* @return A set of videoIds that are present in [retainedVideoIds] but not the video list
82-
* (aka. IDs of videos that have not been registered)
83-
*/
84-
fun unregisterAll(retainedVideoIds: Set<String>): Set<String>
8577
}
8678

8779
val LocalVideoPlayerController = staticCompositionLocalOf<VideoPlayerController> {
Lines changed: 77 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,77 @@
1+
/*
2+
* Copyright 2024 Adetunji Dahunsi
3+
*
4+
* Licensed under the Apache License, Version 2.0 (the "License");
5+
* you may not use this file except in compliance with the License.
6+
* You may obtain a copy of the License at
7+
*
8+
* http://www.apache.org/licenses/LICENSE-2.0
9+
*
10+
* Unless required by applicable law or agreed to in writing, software
11+
* distributed under the License is distributed on an "AS IS" BASIS,
12+
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13+
* See the License for the specific language governing permissions and
14+
* limitations under the License.
15+
*/
16+
17+
package com.tunjid.heron.media.video
18+
19+
import androidx.compose.runtime.Stable
20+
import androidx.compose.runtime.getValue
21+
import androidx.compose.runtime.mutableStateMapOf
22+
import androidx.compose.runtime.mutableStateOf
23+
import androidx.compose.runtime.setValue
24+
25+
/**
26+
* A Compose state holder for managing [VideoPlayerState] instances across
27+
* platform-specific [VideoPlayerController] implementations.
28+
*
29+
* Encapsulates the common state management pattern shared by all controllers:
30+
* the map of registered states, the active video ID, and eviction of old states
31+
* when the maximum capacity is reached.
32+
*/
33+
@Stable
34+
class VideoPlayerStates<S : VideoPlayerState>(
35+
private val onEvicted: (S) -> Unit = {},
36+
) {
37+
private val idsToStates = mutableStateMapOf<String, S>()
38+
39+
var isMuted: Boolean by mutableStateOf(true)
40+
41+
var activeVideoId: String by mutableStateOf("")
42+
43+
val activeState: S? get() = idsToStates[activeVideoId]
44+
45+
operator fun get(videoId: String): S? = idsToStates[videoId]
46+
47+
/**
48+
* Returns existing state for [videoId] if registered, otherwise calls [create]
49+
* after trimming evicted states. The [onEvicted] callback passed at construction
50+
* is invoked for each state removed during trim, allowing platforms to dispose
51+
* native resources.
52+
*/
53+
fun registerOrGet(
54+
videoId: String,
55+
create: () -> S,
56+
): S {
57+
idsToStates[videoId]?.let { return it }
58+
trim()
59+
return create().also { idsToStates[videoId] = it }
60+
}
61+
62+
private fun trim() {
63+
val size = idsToStates.size
64+
if (size <= MaxVideoStates) return
65+
idsToStates.keys
66+
.toList()
67+
.filter { idsToStates[it]?.status is PlayerStatus.Idle.Evicted }
68+
.take(size - MaxVideoStates)
69+
.mapNotNull { idsToStates.remove(it) }
70+
.forEach(onEvicted)
71+
}
72+
}
73+
74+
internal fun VideoPlayerState.seekPositionOnPlayMs(seekToMs: Long?): Long =
75+
seekToMs ?: if (shouldReplay) 0L else lastPositionMs
76+
77+
private const val MaxVideoStates = 30

0 commit comments

Comments
 (0)