From 440247d6602c856c7fbd2bb2b76d2e3a4255bd99 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ga=C3=ABtan=20Muller?= Date: Wed, 18 Sep 2024 16:06:58 +0200 Subject: [PATCH 01/23] Introduce `PillarboxPreloadManager` --- .../demo/ui/player/SimplePlayerViewModel.kt | 17 +- .../ui/showcases/layouts/OptimizedStory.kt | 169 +++++++++----- .../ui/showcases/layouts/StoryViewModel.kt | 119 ++++------ .../player/PillarboxBandwidthMeter.kt | 19 ++ .../pillarbox/player/PillarboxExoPlayer.kt | 27 +-- .../player/PillarboxPreloadManager.kt | 216 ++++++++++++++++++ .../player/PillarboxRenderersFactory.kt | 21 ++ .../player/PillarboxTrackSelector.kt | 26 +++ .../ch/srgssr/pillarbox/player/PlayerPool.kt | 56 +++++ .../player/PillarboxPreloadManagerTest.kt | 115 ++++++++++ .../srgssr/pillarbox/player/PlayerPoolTest.kt | 80 +++++++ 11 files changed, 696 insertions(+), 169 deletions(-) create mode 100644 pillarbox-player/src/main/java/ch/srgssr/pillarbox/player/PillarboxBandwidthMeter.kt create mode 100644 pillarbox-player/src/main/java/ch/srgssr/pillarbox/player/PillarboxPreloadManager.kt create mode 100644 pillarbox-player/src/main/java/ch/srgssr/pillarbox/player/PillarboxRenderersFactory.kt create mode 100644 pillarbox-player/src/main/java/ch/srgssr/pillarbox/player/PillarboxTrackSelector.kt create mode 100644 pillarbox-player/src/main/java/ch/srgssr/pillarbox/player/PlayerPool.kt create mode 100644 pillarbox-player/src/test/java/ch/srgssr/pillarbox/player/PillarboxPreloadManagerTest.kt create mode 100644 pillarbox-player/src/test/java/ch/srgssr/pillarbox/player/PlayerPoolTest.kt diff --git a/pillarbox-demo/src/main/java/ch/srgssr/pillarbox/demo/ui/player/SimplePlayerViewModel.kt b/pillarbox-demo/src/main/java/ch/srgssr/pillarbox/demo/ui/player/SimplePlayerViewModel.kt index 89d8040f8..b7fb24676 100644 --- a/pillarbox-demo/src/main/java/ch/srgssr/pillarbox/demo/ui/player/SimplePlayerViewModel.kt +++ b/pillarbox-demo/src/main/java/ch/srgssr/pillarbox/demo/ui/player/SimplePlayerViewModel.kt @@ -22,6 +22,7 @@ import ch.srgssr.pillarbox.player.asset.timeRange.Chapter import ch.srgssr.pillarbox.player.asset.timeRange.Credit import ch.srgssr.pillarbox.player.extension.setHandleAudioFocus import ch.srgssr.pillarbox.player.extension.toRational +import ch.srgssr.pillarbox.player.utils.StringUtil import kotlinx.coroutines.flow.MutableStateFlow /** @@ -95,11 +96,7 @@ class SimplePlayerViewModel(application: Application) : AndroidViewModel(applica } override fun onTimelineChanged(timeline: Timeline, reason: Int) { - val reasonString = when (reason) { - Player.TIMELINE_CHANGE_REASON_PLAYLIST_CHANGED -> "TIMELINE_CHANGE_REASON_PLAYLIST_CHANGED" - Player.TIMELINE_CHANGE_REASON_SOURCE_UPDATE -> "TIMELINE_CHANGE_REASON_SOURCE_UPDATE" - else -> "?" - } + val reasonString = StringUtil.timelineChangeReasonString(reason) Log.d( TAG, "onTimelineChanged $reasonString ${player.currentMediaItem?.mediaId}" + @@ -122,13 +119,7 @@ class SimplePlayerViewModel(application: Application) : AndroidViewModel(applica } override fun onPlaybackStateChanged(@Player.State playbackState: Int) { - val stateString = when (playbackState) { - Player.STATE_IDLE -> "STATE_IDLE" - Player.STATE_READY -> "STATE_READY" - Player.STATE_BUFFERING -> "STATE_BUFFERING" - Player.STATE_ENDED -> "STATE_ENDED" - else -> "?" - } + val stateString = StringUtil.playerStateString(playbackState) Log.d(TAG, "onPlaybackStateChanged $stateString ${player.currentMediaItem?.mediaMetadata?.title}") } @@ -137,7 +128,7 @@ class SimplePlayerViewModel(application: Application) : AndroidViewModel(applica } override fun onPlayerErrorChanged(error: PlaybackException?) { - Log.d(TAG, "onPlayerErrorChanged $error") + Log.d(TAG, "onPlayerErrorChanged", error) } override fun onPlaybackParametersChanged(playbackParameters: PlaybackParameters) { diff --git a/pillarbox-demo/src/main/java/ch/srgssr/pillarbox/demo/ui/showcases/layouts/OptimizedStory.kt b/pillarbox-demo/src/main/java/ch/srgssr/pillarbox/demo/ui/showcases/layouts/OptimizedStory.kt index 113e1a175..1c722e75b 100644 --- a/pillarbox-demo/src/main/java/ch/srgssr/pillarbox/demo/ui/showcases/layouts/OptimizedStory.kt +++ b/pillarbox-demo/src/main/java/ch/srgssr/pillarbox/demo/ui/showcases/layouts/OptimizedStory.kt @@ -4,112 +4,159 @@ */ package ch.srgssr.pillarbox.demo.ui.showcases.layouts -import androidx.compose.animation.core.Spring -import androidx.compose.animation.core.spring -import androidx.compose.foundation.layout.Arrangement +import androidx.compose.animation.animateColorAsState +import androidx.compose.foundation.background import androidx.compose.foundation.layout.Box -import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.fillMaxHeight import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size -import androidx.compose.foundation.pager.HorizontalPager import androidx.compose.foundation.pager.PagerDefaults import androidx.compose.foundation.pager.PagerSnapDistance +import androidx.compose.foundation.pager.VerticalPager import androidx.compose.foundation.pager.rememberPagerState +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.material3.LinearProgressIndicator import androidx.compose.material3.MaterialTheme -import androidx.compose.material3.Text import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableIntStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.drawBehind import androidx.compose.ui.graphics.Color +import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp -import androidx.lifecycle.compose.LifecycleStartEffect import androidx.lifecycle.viewmodel.compose.viewModel +import ch.srgssr.pillarbox.demo.ui.theme.PillarboxTheme import ch.srgssr.pillarbox.demo.ui.theme.paddings +import ch.srgssr.pillarbox.player.currentPositionAsFlow import ch.srgssr.pillarbox.ui.ScaleMode import ch.srgssr.pillarbox.ui.widget.player.PlayerSurface -import ch.srgssr.pillarbox.ui.widget.player.SurfaceType +import kotlinx.coroutines.delay +import kotlinx.coroutines.flow.map +import kotlin.time.Duration.Companion.milliseconds +import kotlin.time.Duration.Companion.seconds /** - * Optimized story trying to reproduce story-like TikTok or Instagram. - * - * Surface view may sometimes keep on screen. Maybe if we use TextView with PlayerView this strange behavior will disappear. + * Optimized story-like layout. */ @Composable fun OptimizedStory(storyViewModel: StoryViewModel = viewModel()) { - val pagerState = rememberPagerState { - storyViewModel.playlist.items.size - } - LifecycleStartEffect(pagerState) { - storyViewModel.getPlayerForPageNumber(pagerState.currentPage).play() + val mediaItems = storyViewModel.mediaItems + val pagerState = rememberPagerState { mediaItems.size } - onStopOrDispose { - storyViewModel.pauseAllPlayer() - } + LaunchedEffect(pagerState.currentPage) { + storyViewModel.setActivePage(pagerState.currentPage) } - val playlist = storyViewModel.playlist.items Box(modifier = Modifier.fillMaxSize()) { - HorizontalPager( - beyondViewportPageCount = 0, - key = { page -> playlist[page].uri }, + VerticalPager( + key = { page -> mediaItems[page].mediaId }, flingBehavior = PagerDefaults.flingBehavior( state = pagerState, pagerSnapDistance = PagerSnapDistance.atMost(0), - snapAnimationSpec = spring(stiffness = Spring.StiffnessHigh) ), - pageSpacing = 1.dp, state = pagerState ) { page -> - // When flinging -> may "load" more that 3 pages - val currentPage = pagerState.currentPage - val player = if (page == currentPage - 1 || page == currentPage + 1 || page == currentPage) { - val playerConfig = storyViewModel.getPlayerAndMediaItemIndexForPage(page) - val playerPage = storyViewModel.getPlayerFromIndex(playerConfig.first) - playerPage.playWhenReady = currentPage == page - playerPage.seekToDefaultPosition(playerConfig.second) - playerPage - } else { - null - } - player?.let { + val player = storyViewModel.getConfiguredPlayerForPageNumber(page) + val progress by player.currentPositionAsFlow(100.milliseconds) + .map { it / player.duration.coerceAtLeast(1L).toFloat() } + .collectAsState(0f) + + Box { PlayerSurface( modifier = Modifier.fillMaxHeight(), scaleMode = ScaleMode.Crop, - // Using Texture instead of Surface because on Android API 34 animations are not working well due to the hack - // See PlayerSurfaceView in AndroidPlayerSurfaceView - surfaceType = SurfaceType.Texture, player = player, ) - } - Text(text = "Page $page") - } - Row( - Modifier - .fillMaxWidth() - .align(Alignment.BottomCenter) - .padding(bottom = MaterialTheme.paddings.baseline), - horizontalArrangement = Arrangement.Center - ) { - repeat(playlist.size) { iteration -> - val color = if (pagerState.currentPage == iteration) ColorIndicatorCurrent else ColorIndicator - Box( - modifier = Modifier - .padding(MaterialTheme.paddings.micro) - .size(IndicatorSize) - .drawBehind { - drawCircle(color) - } + LinearProgressIndicator( + progress = { progress }, + modifier = Modifier + .fillMaxWidth() + .align(Alignment.BottomCenter), + color = PrimaryComponentColor, + trackColor = SecondaryComponentColor, + gapSize = 0.dp, + drawStopIndicator = {}, ) } } + + PagerIndicator( + currentPage = pagerState.currentPage, + pageCount = mediaItems.size, + modifier = Modifier + .align(Alignment.CenterEnd) + .padding(end = MaterialTheme.paddings.small), + ) + } +} + +@Composable +private fun PagerIndicator( + currentPage: Int, + pageCount: Int, + modifier: Modifier = Modifier, +) { + Column( + modifier = modifier + .background( + color = SurfaceComponentColor, + shape = CircleShape, + ) + .padding(MaterialTheme.paddings.micro), + ) { + repeat(pageCount) { index -> + val dotColor by animateColorAsState(if (currentPage == index) PrimaryComponentColor else SecondaryComponentColor) + + Box( + modifier = Modifier + .padding(MaterialTheme.paddings.micro) + .size(IndicatorSize) + .drawBehind { + drawCircle(dotColor) + }, + ) + } + } +} + +@Preview +@Composable +private fun PageIndicatorPreview() { + val pageCount = 5 + var step by remember { mutableIntStateOf(1) } + var currentPage by remember { mutableIntStateOf(0) } + + LaunchedEffect(currentPage) { + delay(1.seconds) + currentPage += step + + if (currentPage == pageCount - 1) { + step = -1 + } else if (currentPage == 0) { + step = 1 + } + } + + PillarboxTheme { + PagerIndicator( + currentPage = currentPage, + pageCount = pageCount, + ) } } -private val ColorIndicatorCurrent = Color.LightGray.copy(alpha = 0.75f) -private val ColorIndicator = Color.LightGray.copy(alpha = 0.25f) +private val ComponentColor = Color.LightGray +private val PrimaryComponentColor = ComponentColor.copy(alpha = 0.88f) +private val SecondaryComponentColor = ComponentColor.copy(alpha = 0.33f) +private val SurfaceComponentColor = ComponentColor.copy(alpha = 0.25f) private val IndicatorSize = 12.dp diff --git a/pillarbox-demo/src/main/java/ch/srgssr/pillarbox/demo/ui/showcases/layouts/StoryViewModel.kt b/pillarbox-demo/src/main/java/ch/srgssr/pillarbox/demo/ui/showcases/layouts/StoryViewModel.kt index d4937b31d..b5a1bb2af 100644 --- a/pillarbox-demo/src/main/java/ch/srgssr/pillarbox/demo/ui/showcases/layouts/StoryViewModel.kt +++ b/pillarbox-demo/src/main/java/ch/srgssr/pillarbox/demo/ui/showcases/layouts/StoryViewModel.kt @@ -6,107 +6,78 @@ package ch.srgssr.pillarbox.demo.ui.showcases.layouts import android.app.Application import androidx.lifecycle.AndroidViewModel +import androidx.lifecycle.ViewModel import androidx.media3.common.C +import androidx.media3.common.MediaItem import androidx.media3.common.Player +import ch.srgssr.pillarbox.core.business.source.SRGAssetLoader import ch.srgssr.pillarbox.demo.shared.data.Playlist import ch.srgssr.pillarbox.demo.shared.di.PlayerModule import ch.srgssr.pillarbox.player.PillarboxExoPlayer -import kotlin.math.ceil +import ch.srgssr.pillarbox.player.PillarboxPreloadManager +import ch.srgssr.pillarbox.player.PlayerPool +import ch.srgssr.pillarbox.player.source.PillarboxMediaSourceFactory /** - * Story view model - * - * 3 Players that interleaved DemoItems + * [ViewModel] that manages multiple [Player]s that can be used in a story-like layout. */ class StoryViewModel(application: Application) : AndroidViewModel(application) { - - /** - * Players - */ - private val players = arrayOf( - PlayerModule.provideDefaultPlayer( - context = application, - ), - PlayerModule.provideDefaultPlayer( - context = application, - ), - PlayerModule.provideDefaultPlayer( - context = application, + private val preloadManager = PillarboxPreloadManager( + context = application, + mediaSourceFactory = PillarboxMediaSourceFactory(application).apply { + addAssetLoader(SRGAssetLoader(application)) + }, + playerPool = PlayerPool( + playersCount = 3, + playerFactory = { + PlayerModule.provideDefaultPlayer(application).apply { + repeatMode = Player.REPEAT_MODE_ONE + videoScalingMode = C.VIDEO_SCALING_MODE_SCALE_TO_FIT_WITH_CROPPING + prepare() + } + }, ), ) /** - * Playlist to use with viewpager + * The list of items to play. */ - val playlist: Playlist = Playlist.VideoUrns + val mediaItems: List = Playlist.VideoUrns.items.map { it.toMediaItem() } init { - preparePlayers() - } - - /** - * Get player for page number - * - * @param pageNumber - * @return [PillarboxExoPlayer] that should be used for this [pageNumber] - */ - fun getPlayerForPageNumber(pageNumber: Int): PillarboxExoPlayer { - return players[playerIndex(pageNumber)] - } - - private fun preparePlayers() { - players.forEachIndexed { index, player -> - for (i in index until playlist.items.size step players.size) { - player.addMediaItem(playlist.items[i].toMediaItem()) - } - player.repeatMode = Player.REPEAT_MODE_ONE // Repeat endlessly the current item. - player.prepare() - player.videoScalingMode = C.VIDEO_SCALING_MODE_SCALE_TO_FIT_WITH_CROPPING - } - } - - override fun onCleared() { - super.onCleared() - for (player in players) { - player.stop() - player.release() + mediaItems.forEachIndexed { index, mediaItem -> + preloadManager.add(mediaItem, index) } + preloadManager.invalidate() } /** - * Pause all player - */ - fun pauseAllPlayer() { - for (player in players) { - player.pause() - } - } - - /** - * Get player and media item index for page - * I0 I1 I2 I3 - * P0 0 3 6 9 - * P1 1 4 7 10 - * P2 2 5 8 11 + * Set the [pageNumber] as the currently active page. * - * @param page - * @return Pair + * @param pageNumber The currently active page. */ - fun getPlayerAndMediaItemIndexForPage(page: Int): Pair { - val playerMaxItemCount = playerMaxItemCount() - val i = playerIndex(page) - val j = (page - i) / playerMaxItemCount - return Pair(i, j) + fun setActivePage(pageNumber: Int) { + preloadManager.getCurrentlyPlayingPlayer()?.pause() + preloadManager.currentPlayingIndex = pageNumber + preloadManager.invalidate() + preloadManager.getCurrentlyPlayingPlayer()?.play() } /** - * Get player from index + * Get the [PillarboxExoPlayer] instance for page [pageNumber], with its media source set. * - * @param playerIndex the index received from [getPlayerAndMediaItemIndexForPage] + * @param pageNumber The page number. */ - fun getPlayerFromIndex(playerIndex: Int) = players[playerIndex] + fun getConfiguredPlayerForPageNumber(pageNumber: Int): PillarboxExoPlayer { + val mediaSource = checkNotNull(preloadManager.getMediaSource(mediaItems[pageNumber])) + val player = checkNotNull(preloadManager.getPlayer(pageNumber)) + player.setMediaSource(mediaSource) - private fun playerIndex(pageNumber: Int) = pageNumber % players.size + return player + } - private fun playerMaxItemCount() = ceil(playlist.items.size / players.size.toFloat()).toInt() + override fun onCleared() { + super.onCleared() + preloadManager.release() + } } diff --git a/pillarbox-player/src/main/java/ch/srgssr/pillarbox/player/PillarboxBandwidthMeter.kt b/pillarbox-player/src/main/java/ch/srgssr/pillarbox/player/PillarboxBandwidthMeter.kt new file mode 100644 index 000000000..c4231fd82 --- /dev/null +++ b/pillarbox-player/src/main/java/ch/srgssr/pillarbox/player/PillarboxBandwidthMeter.kt @@ -0,0 +1,19 @@ +/* + * Copyright (c) SRG SSR. All rights reserved. + * License information is available from the LICENSE file. + */ +package ch.srgssr.pillarbox.player + +import android.content.Context +import androidx.media3.exoplayer.upstream.BandwidthMeter +import androidx.media3.exoplayer.upstream.DefaultBandwidthMeter + +/** + * Preconfigured [BandwidthMeter] for Pillarbox. + * + * @param context The [Context] needed to create the [BandwidthMeter]. + */ +@Suppress("FunctionName") +fun PillarboxBandwidthMeter(context: Context): BandwidthMeter { + return DefaultBandwidthMeter.getSingletonInstance(context) +} diff --git a/pillarbox-player/src/main/java/ch/srgssr/pillarbox/player/PillarboxExoPlayer.kt b/pillarbox-player/src/main/java/ch/srgssr/pillarbox/player/PillarboxExoPlayer.kt index f2ddc10b0..5127a8a5b 100644 --- a/pillarbox-player/src/main/java/ch/srgssr/pillarbox/player/PillarboxExoPlayer.kt +++ b/pillarbox-player/src/main/java/ch/srgssr/pillarbox/player/PillarboxExoPlayer.kt @@ -13,14 +13,11 @@ import androidx.media3.common.PlaybackException import androidx.media3.common.PlaybackParameters import androidx.media3.common.Player import androidx.media3.common.Timeline.Window -import androidx.media3.common.TrackSelectionParameters import androidx.media3.common.util.Clock import androidx.media3.common.util.ListenerSet -import androidx.media3.exoplayer.DefaultRenderersFactory import androidx.media3.exoplayer.ExoPlayer import androidx.media3.exoplayer.LoadControl -import androidx.media3.exoplayer.trackselection.DefaultTrackSelector -import androidx.media3.exoplayer.upstream.DefaultBandwidthMeter +import androidx.media3.exoplayer.source.MediaSource import ch.srgssr.pillarbox.player.analytics.PillarboxAnalyticsCollector import ch.srgssr.pillarbox.player.analytics.PlaybackSessionManager import ch.srgssr.pillarbox.player.analytics.metrics.MetricsCollector @@ -32,7 +29,6 @@ import ch.srgssr.pillarbox.player.asset.timeRange.TimeRange import ch.srgssr.pillarbox.player.extension.getBlockedTimeRangeOrNull import ch.srgssr.pillarbox.player.extension.getMediaItemTrackerDataOrNull import ch.srgssr.pillarbox.player.extension.getPlaybackSpeed -import ch.srgssr.pillarbox.player.extension.setPreferredAudioRoleFlagsToAccessibilityManagerSettings import ch.srgssr.pillarbox.player.extension.setSeekIncrements import ch.srgssr.pillarbox.player.monitoring.Monitoring import ch.srgssr.pillarbox.player.monitoring.MonitoringMessageHandler @@ -132,7 +128,7 @@ class PillarboxExoPlayer internal constructor( constructor( context: Context, - mediaSourceFactory: PillarboxMediaSourceFactory = PillarboxMediaSourceFactory(context), + mediaSourceFactory: MediaSource.Factory = PillarboxMediaSourceFactory(context), loadControl: LoadControl = PillarboxLoadControl(), seekIncrement: SeekIncrement = SeekIncrement(), maxSeekToPreviousPosition: Duration = DEFAULT_MAX_SEEK_TO_PREVIOUS_POSITION, @@ -152,7 +148,7 @@ class PillarboxExoPlayer internal constructor( @VisibleForTesting constructor( context: Context, - mediaSourceFactory: PillarboxMediaSourceFactory = PillarboxMediaSourceFactory(context), + mediaSourceFactory: MediaSource.Factory = PillarboxMediaSourceFactory(context), loadControl: LoadControl = PillarboxLoadControl(), seekIncrement: SeekIncrement = SeekIncrement(), maxSeekToPreviousPosition: Duration = DEFAULT_MAX_SEEK_TO_PREVIOUS_POSITION, @@ -169,22 +165,11 @@ class PillarboxExoPlayer internal constructor( .setUsePlatformDiagnostics(false) .setSeekIncrements(seekIncrement) .setMaxSeekToPreviousPositionMs(maxSeekToPreviousPosition.inWholeMilliseconds) - .setRenderersFactory( - DefaultRenderersFactory(context) - .setExtensionRendererMode(DefaultRenderersFactory.EXTENSION_RENDERER_MODE_OFF) - .setEnableDecoderFallback(true) - ) - .setBandwidthMeter(DefaultBandwidthMeter.getSingletonInstance(context)) + .setRenderersFactory(PillarboxRenderersFactory(context)) + .setBandwidthMeter(PillarboxBandwidthMeter(context)) .setLoadControl(loadControl) .setMediaSourceFactory(mediaSourceFactory) - .setTrackSelector( - DefaultTrackSelector( - context, - TrackSelectionParameters.Builder(context) - .setPreferredAudioRoleFlagsToAccessibilityManagerSettings(context) - .build() - ) - ) + .setTrackSelector(PillarboxTrackSelector(context)) .setAnalyticsCollector(analyticsCollector) .setDeviceVolumeControlEnabled(true) // allow player to control device volume .build(), diff --git a/pillarbox-player/src/main/java/ch/srgssr/pillarbox/player/PillarboxPreloadManager.kt b/pillarbox-player/src/main/java/ch/srgssr/pillarbox/player/PillarboxPreloadManager.kt new file mode 100644 index 000000000..74c42378c --- /dev/null +++ b/pillarbox-player/src/main/java/ch/srgssr/pillarbox/player/PillarboxPreloadManager.kt @@ -0,0 +1,216 @@ +/* + * Copyright (c) SRG SSR. All rights reserved. + * License information is available from the LICENSE file. + */ +package ch.srgssr.pillarbox.player + +import android.content.Context +import android.os.Looper +import androidx.media3.common.C +import androidx.media3.common.MediaItem +import androidx.media3.exoplayer.DefaultRendererCapabilitiesList +import androidx.media3.exoplayer.LoadControl +import androidx.media3.exoplayer.RendererCapabilitiesList +import androidx.media3.exoplayer.source.MediaSource +import androidx.media3.exoplayer.source.preload.DefaultPreloadManager +import androidx.media3.exoplayer.source.preload.DefaultPreloadManager.Status +import androidx.media3.exoplayer.source.preload.DefaultPreloadManager.Status.STAGE_LOADED_TO_POSITION_MS +import androidx.media3.exoplayer.source.preload.TargetPreloadStatusControl +import androidx.media3.exoplayer.trackselection.TrackSelector +import androidx.media3.exoplayer.upstream.BandwidthMeter +import ch.srgssr.pillarbox.player.source.PillarboxMediaSourceFactory +import kotlin.math.abs +import kotlin.time.Duration.Companion.milliseconds +import kotlin.time.Duration.Companion.seconds + +/** + * Helper class for the Media3's [DefaultPreloadManager]. The main difference between this class and [DefaultPreloadManager] is the addition of the + * [PlayerPool] argument. It allows the dynamic creation of a fixed number of [PillarboxExoPlayer] instances. + * + * This class provides the same methods as [DefaultPreloadManager] plus [getPlayer] and [getCurrentlyPlayingPlayer] to get an instance of a + * [PillarboxExoPlayer]. + * + * @param context + * @param targetPreloadStatusControl + * @param mediaSourceFactory + * @param trackSelector + * @param bandwidthMeter + * @param rendererCapabilitiesListFactory + * @param loadControl + * @param preloadLooper + * @param playerPool + * + * @see DefaultPreloadManager + */ +class PillarboxPreloadManager( + context: Context, + targetPreloadStatusControl: TargetPreloadStatusControl? = null, + private val mediaSourceFactory: MediaSource.Factory = PillarboxMediaSourceFactory(context), + trackSelector: TrackSelector = PillarboxTrackSelector(context).apply { + init({}, PillarboxBandwidthMeter(context)) + }, + bandwidthMeter: BandwidthMeter = PillarboxBandwidthMeter(context), + rendererCapabilitiesListFactory: RendererCapabilitiesList.Factory = DefaultRendererCapabilitiesList.Factory( + PillarboxRenderersFactory(context) + ), + private val loadControl: LoadControl = PillarboxLoadControl(), + preloadLooper: Looper = context.mainLooper, + private val playerPool: PlayerPool = PlayerPool( + playersCount = 3, + playerFactory = { + PillarboxExoPlayer( + context = context, + mediaSourceFactory = mediaSourceFactory, + loadControl = loadControl, + ) + }, + ), +) { + private val preloadManager = DefaultPreloadManager( + targetPreloadStatusControl ?: DefaultTargetPreloadStatusControl(), + mediaSourceFactory, + trackSelector, + bandwidthMeter, + rendererCapabilitiesListFactory, + loadControl.allocator, + preloadLooper, + ) + + /** + * The index of the currently playing media item. + * + * @see DefaultPreloadManager.setCurrentPlayingIndex + */ + var currentPlayingIndex: Int = C.INDEX_UNSET + set(value) { + preloadManager.setCurrentPlayingIndex(value) + field = value + } + + /** + * Get the count of [MediaSource] currently managed by this preload manager. + * + * @see DefaultPreloadManager.getSourceCount + */ + val sourceCount: Int + get() = preloadManager.sourceCount + + /** + * Add a [MediaItem] with its [rankingData] to the preload manager. + * + * @param mediaItem The [MediaItem] to add. + * @param rankingData The ranking data that is associated with the [mediaItem]. + * @see DefaultPreloadManager.add + */ + fun add(mediaItem: MediaItem, rankingData: Int) { + preloadManager.add(mediaItem, rankingData) + } + + /** + * Add a [MediaSource] with its [rankingData] to the preload manager. + * + * @param mediaSource The [MediaSource] to add. + * @param rankingData The ranking data that is associated with the [mediaSource]. + * @see DefaultPreloadManager.add + */ + fun add(mediaSource: MediaSource, rankingData: Int) { + preloadManager.add(mediaSource, rankingData) + } + + /** + * Returns the [MediaSource] for the given [MediaItem]. + * + * @param mediaItem The [MediaItem]. + * @return The source for the give [mediaItem] if it is managed by the preload manager, `null` otherwise. + * @see DefaultPreloadManager.getMediaSource + */ + fun getMediaSource(mediaItem: MediaItem): MediaSource? { + return preloadManager.getMediaSource(mediaItem) + } + + /** + * Invalidate the current preload manager. + * + * @see DefaultPreloadManager.invalidate + */ + fun invalidate() { + preloadManager.invalidate() + } + + /** + * Release the preload manager and the underlying [PlayerPool]. + * The preload manager must not be used after calling this method. + * + * @see DefaultPreloadManager.release + */ + fun release() { + playerPool.release() + preloadManager.release() + } + + /** + * Remove a [MediaItem] from the preload manager. + * + * @param mediaItem The [MediaItem] to remove. + * @return `true` if the preload manager is holding a [MediaSource] of the given [MediaItem] and it has been removed, `false` otherwise. + * @see DefaultPreloadManager.remove + */ + fun remove(mediaItem: MediaItem): Boolean { + return preloadManager.remove(mediaItem) + } + + /** + * Remove a [MediaSource] from the preload manager. + * + * @param mediaSource The [MediaSource] to remove. + * @return `true` if the preload manager is holding the given [MediaSource] and it has been removed, `false` otherwise. + * @see DefaultPreloadManager.remove + */ + fun remove(mediaSource: MediaSource): Boolean { + return preloadManager.remove(mediaSource) + } + + /** + * Reset the preload manager. All sources that the preload manager is holding will be released. + * + * @see DefaultPreloadManager.reset + */ + fun reset() { + preloadManager.reset() + } + + /** + * Get a [PillarboxExoPlayer] for the given [index]. If the desired player has not been created yet, [PlayerPool.playerFactory] will be called. + * + * @param index The index of the [PillarboxExoPlayer] to retrieve. + * @return The desired [PillarboxExoPlayer], or `null` if [index] is negative. + */ + fun getPlayer(index: Int): PillarboxExoPlayer? { + return playerPool.getPlayerAtPosition(index) + } + + /** + * Get the currently playing [PillarboxExoPlayer]. + * + * @return The currently playing [PillarboxExoPlayer], or `null` if there is no active player. + */ + fun getCurrentlyPlayingPlayer(): PillarboxExoPlayer? { + return getPlayer(currentPlayingIndex) + } + + /** + * Default implementation of [TargetPreloadStatusControl] that will preload the first second of the `n ± 1` item, and the first half-second of + * the `n ± 2` item, where `n` is the index of the current item. + */ + inner class DefaultTargetPreloadStatusControl : TargetPreloadStatusControl { + override fun getTargetPreloadStatus(rankingData: Int): TargetPreloadStatusControl.PreloadStatus? { + val offset = abs(rankingData - currentPlayingIndex) + + return when (offset) { + 1 -> Status(STAGE_LOADED_TO_POSITION_MS, 1.seconds.inWholeMicroseconds) + 2 -> Status(STAGE_LOADED_TO_POSITION_MS, 500.milliseconds.inWholeMicroseconds) + else -> null + } + } + } +} diff --git a/pillarbox-player/src/main/java/ch/srgssr/pillarbox/player/PillarboxRenderersFactory.kt b/pillarbox-player/src/main/java/ch/srgssr/pillarbox/player/PillarboxRenderersFactory.kt new file mode 100644 index 000000000..ce58af8e2 --- /dev/null +++ b/pillarbox-player/src/main/java/ch/srgssr/pillarbox/player/PillarboxRenderersFactory.kt @@ -0,0 +1,21 @@ +/* + * Copyright (c) SRG SSR. All rights reserved. + * License information is available from the LICENSE file. + */ +package ch.srgssr.pillarbox.player + +import android.content.Context +import androidx.media3.exoplayer.DefaultRenderersFactory +import androidx.media3.exoplayer.RenderersFactory + +/** + * Preconfigured [RenderersFactory] for Pillarbox. + * + * @param context The [Context] needed to create the [RenderersFactory]. + */ +@Suppress("FunctionName") +fun PillarboxRenderersFactory(context: Context): RenderersFactory { + return DefaultRenderersFactory(context) + .setExtensionRendererMode(DefaultRenderersFactory.EXTENSION_RENDERER_MODE_OFF) + .setEnableDecoderFallback(true) +} diff --git a/pillarbox-player/src/main/java/ch/srgssr/pillarbox/player/PillarboxTrackSelector.kt b/pillarbox-player/src/main/java/ch/srgssr/pillarbox/player/PillarboxTrackSelector.kt new file mode 100644 index 000000000..c5a910278 --- /dev/null +++ b/pillarbox-player/src/main/java/ch/srgssr/pillarbox/player/PillarboxTrackSelector.kt @@ -0,0 +1,26 @@ +/* + * Copyright (c) SRG SSR. All rights reserved. + * License information is available from the LICENSE file. + */ +package ch.srgssr.pillarbox.player + +import android.content.Context +import androidx.media3.common.TrackSelectionParameters +import androidx.media3.exoplayer.trackselection.DefaultTrackSelector +import androidx.media3.exoplayer.trackselection.TrackSelector +import ch.srgssr.pillarbox.player.extension.setPreferredAudioRoleFlagsToAccessibilityManagerSettings + +/** + * Preconfigured [TrackSelector] for Pillarbox. + * + * @param context The [Context] needed to create the [TrackSelector]. + */ +@Suppress("FunctionName") +fun PillarboxTrackSelector(context: Context): TrackSelector { + return DefaultTrackSelector( + context, + TrackSelectionParameters.Builder(context) + .setPreferredAudioRoleFlagsToAccessibilityManagerSettings(context) + .build(), + ) +} diff --git a/pillarbox-player/src/main/java/ch/srgssr/pillarbox/player/PlayerPool.kt b/pillarbox-player/src/main/java/ch/srgssr/pillarbox/player/PlayerPool.kt new file mode 100644 index 000000000..d7638e20e --- /dev/null +++ b/pillarbox-player/src/main/java/ch/srgssr/pillarbox/player/PlayerPool.kt @@ -0,0 +1,56 @@ +/* + * Copyright (c) SRG SSR. All rights reserved. + * License information is available from the LICENSE file. + */ +package ch.srgssr.pillarbox.player + +import android.util.SparseArray + +/** + * Pool of [playersCount] [PillarboxExoPlayer]. + * + * @param playersCount The maximum number of [PillarboxExoPlayer] managed by this pool. + * @param playerFactory The factory method to create a new [PillarboxExoPlayer]. + */ +class PlayerPool( + private val playersCount: Int, + private val playerFactory: () -> PillarboxExoPlayer, +) { + private val players: SparseArray + + init { + require(playersCount > 0) { + "playersCount must be greater than 0, but was $playersCount" + } + + players = SparseArray(playersCount) + } + + /** + * Get a [PillarboxExoPlayer] for the given [position]. If the desired player has not been created yet, [playerFactory] will be called. + * + * @param position The position of the [PillarboxExoPlayer] to retrieve. + * @return The desired [PillarboxExoPlayer], or `null` if [position] is negative. + */ + fun getPlayerAtPosition(position: Int): PillarboxExoPlayer? { + if (position < 0) { + return null + } + + val index = position % playersCount + + return players[index] ?: playerFactory().also { + players[index] = it + } + } + + /** + * Release this pool. This will also release all the managed [PillarboxExoPlayer]. + */ + fun release() { + repeat(playersCount) { index -> + players[index]?.release() + } + players.clear() + } +} diff --git a/pillarbox-player/src/test/java/ch/srgssr/pillarbox/player/PillarboxPreloadManagerTest.kt b/pillarbox-player/src/test/java/ch/srgssr/pillarbox/player/PillarboxPreloadManagerTest.kt new file mode 100644 index 000000000..776316582 --- /dev/null +++ b/pillarbox-player/src/test/java/ch/srgssr/pillarbox/player/PillarboxPreloadManagerTest.kt @@ -0,0 +1,115 @@ +/* + * Copyright (c) SRG SSR. All rights reserved. + * License information is available from the LICENSE file. + */ +package ch.srgssr.pillarbox.player + +import android.content.Context +import androidx.media3.common.C +import androidx.media3.common.MediaItem +import androidx.test.core.app.ApplicationProvider +import androidx.test.ext.junit.runners.AndroidJUnit4 +import org.junit.runner.RunWith +import kotlin.test.AfterTest +import kotlin.test.BeforeTest +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertNotNull +import kotlin.test.assertNull +import kotlin.test.assertTrue + +@RunWith(AndroidJUnit4::class) +class PillarboxPreloadManagerTest { + private var createdPlayersCount = 0 + private lateinit var preloadManager: PillarboxPreloadManager + + @BeforeTest + fun setUp() { + val context = ApplicationProvider.getApplicationContext() + + createdPlayersCount = 0 + preloadManager = PillarboxPreloadManager( + context = context, + playerPool = PlayerPool( + playersCount = PLAYERS_COUNT, + playerFactory = { + createdPlayersCount++ + + PillarboxExoPlayer(context) + } + ) + ) + } + + @AfterTest + fun tearDown() { + preloadManager.release() + } + + @Test + fun `initial state`() { + assertEquals(C.INDEX_UNSET, preloadManager.currentPlayingIndex) + assertEquals(0, preloadManager.sourceCount) + assertNull(preloadManager.getMediaSource(VOD1)) + assertNotNull(preloadManager.getPlayer(0)) + assertNull(preloadManager.getCurrentlyPlayingPlayer()) + assertEquals(1, createdPlayersCount) + } + + @Test + fun `add-remove media`() { + preloadManager.currentPlayingIndex = 2 + preloadManager.add(VOD1, 1) + preloadManager.add(VOD2, 2) + preloadManager.add(VOD3, 3) + preloadManager.add(VOD4, 4) + preloadManager.invalidate() + + assertEquals(2, preloadManager.currentPlayingIndex) + assertEquals(4, preloadManager.sourceCount) + assertNotNull(preloadManager.getMediaSource(VOD1)) + assertNotNull(preloadManager.getMediaSource(VOD2)) + assertNotNull(preloadManager.getMediaSource(VOD3)) + assertNotNull(preloadManager.getMediaSource(VOD4)) + assertNull(preloadManager.getMediaSource(VOD5)) + assertEquals(preloadManager.getPlayer(2), preloadManager.getCurrentlyPlayingPlayer()) + assertEquals(1, createdPlayersCount) + + assertTrue(preloadManager.remove(VOD2)) + assertTrue(preloadManager.remove(VOD3)) + preloadManager.invalidate() + + assertEquals(2, preloadManager.currentPlayingIndex) + assertEquals(2, preloadManager.sourceCount) + assertNotNull(preloadManager.getMediaSource(VOD1)) + assertNull(preloadManager.getMediaSource(VOD2)) + assertNull(preloadManager.getMediaSource(VOD3)) + assertNotNull(preloadManager.getMediaSource(VOD4)) + assertNull(preloadManager.getMediaSource(VOD5)) + assertEquals(preloadManager.getPlayer(2), preloadManager.getCurrentlyPlayingPlayer()) + assertEquals(1, createdPlayersCount) + + preloadManager.reset() + preloadManager.invalidate() + + assertEquals(2, preloadManager.currentPlayingIndex) + assertEquals(0, preloadManager.sourceCount) + assertNull(preloadManager.getMediaSource(VOD1)) + assertNull(preloadManager.getMediaSource(VOD2)) + assertNull(preloadManager.getMediaSource(VOD3)) + assertNull(preloadManager.getMediaSource(VOD4)) + assertNull(preloadManager.getMediaSource(VOD5)) + assertEquals(preloadManager.getPlayer(2), preloadManager.getCurrentlyPlayingPlayer()) + assertEquals(1, createdPlayersCount) + } + + private companion object { + private const val PLAYERS_COUNT = 3 + + private val VOD1 = MediaItem.fromUri("urn:rts:video:13444390") + private val VOD2 = MediaItem.fromUri("urn:rts:video:13444333") + private val VOD3 = MediaItem.fromUri("urn:rts:video:13444466") + private val VOD4 = MediaItem.fromUri("urn:rts:video:13444447") + private val VOD5 = MediaItem.fromUri("urn:rts:video:13444352") + } +} diff --git a/pillarbox-player/src/test/java/ch/srgssr/pillarbox/player/PlayerPoolTest.kt b/pillarbox-player/src/test/java/ch/srgssr/pillarbox/player/PlayerPoolTest.kt new file mode 100644 index 000000000..4fa73a948 --- /dev/null +++ b/pillarbox-player/src/test/java/ch/srgssr/pillarbox/player/PlayerPoolTest.kt @@ -0,0 +1,80 @@ +/* + * Copyright (c) SRG SSR. All rights reserved. + * License information is available from the LICENSE file. + */ +package ch.srgssr.pillarbox.player + +import androidx.test.core.app.ApplicationProvider +import androidx.test.ext.junit.runners.AndroidJUnit4 +import org.junit.runner.RunWith +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFalse +import kotlin.test.assertNotNull +import kotlin.test.assertNull +import kotlin.test.assertTrue + +@RunWith(AndroidJUnit4::class) +class PlayerPoolTest { + @Test(expected = IllegalArgumentException::class) + fun `playerCount can not be negative`() { + PlayerPool( + playersCount = -1, + playerFactory = { PillarboxExoPlayer(ApplicationProvider.getApplicationContext()) }, + ) + } + + @Test(expected = IllegalArgumentException::class) + fun `playerCount can not be 0`() { + PlayerPool( + playersCount = 0, + playerFactory = { PillarboxExoPlayer(ApplicationProvider.getApplicationContext()) }, + ) + } + + @Test + fun `get player at position`() { + val playersCount = 5 + var createdPlayers = 0 + val pool = PlayerPool( + playersCount = playersCount, + playerFactory = { + createdPlayers++ + + PillarboxExoPlayer(ApplicationProvider.getApplicationContext()) + }, + ) + + // Invalid position + assertNull(pool.getPlayerAtPosition(-1)) + assertEquals(0, createdPlayers) + + // Position < playersCount + assertNotNull(pool.getPlayerAtPosition(2)) + assertEquals(1, createdPlayers) + + // Position > playersCount + val requestedPlayerOffset = 3 + assertNotNull(pool.getPlayerAtPosition(playersCount + requestedPlayerOffset)) + assertEquals(2, createdPlayers) + + // Reuse player instance + assertEquals(pool.getPlayerAtPosition(requestedPlayerOffset), pool.getPlayerAtPosition(playersCount + requestedPlayerOffset)) + assertEquals(2, createdPlayers) + } + + @Test + fun release() { + val pool = PlayerPool( + playersCount = 3, + playerFactory = { PillarboxExoPlayer(ApplicationProvider.getApplicationContext()) }, + ) + val player = pool.getPlayerAtPosition(1) + + assertNotNull(player) + assertFalse(player.isReleased) + + pool.release() + assertTrue(player.isReleased) + } +} From 7184576c28621bfb34338dbd62a0464fd04adf09 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ga=C3=ABtan=20Muller?= Date: Thu, 26 Sep 2024 16:31:25 +0200 Subject: [PATCH 02/23] Fix Lint warning --- .../pillarbox/demo/ui/showcases/layouts/OptimizedStory.kt | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/pillarbox-demo/src/main/java/ch/srgssr/pillarbox/demo/ui/showcases/layouts/OptimizedStory.kt b/pillarbox-demo/src/main/java/ch/srgssr/pillarbox/demo/ui/showcases/layouts/OptimizedStory.kt index 1c722e75b..06af1a73f 100644 --- a/pillarbox-demo/src/main/java/ch/srgssr/pillarbox/demo/ui/showcases/layouts/OptimizedStory.kt +++ b/pillarbox-demo/src/main/java/ch/srgssr/pillarbox/demo/ui/showcases/layouts/OptimizedStory.kt @@ -66,9 +66,10 @@ fun OptimizedStory(storyViewModel: StoryViewModel = viewModel()) { state = pagerState ) { page -> val player = storyViewModel.getConfiguredPlayerForPageNumber(page) - val progress by player.currentPositionAsFlow(100.milliseconds) - .map { it / player.duration.coerceAtLeast(1L).toFloat() } - .collectAsState(0f) + val progress by remember { + player.currentPositionAsFlow(100.milliseconds) + .map { it / player.duration.coerceAtLeast(1L).toFloat() } + }.collectAsState(0f) Box { PlayerSurface( From 06acb328d0854aac28080d9addb2d21cd7f2ec41 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Joaquim=20Sta=CC=88hli?= Date: Fri, 27 Sep 2024 13:30:20 +0200 Subject: [PATCH 03/23] Add loading indicator Fix recomposition by remembering the player. --- .../ui/showcases/layouts/OptimizedStory.kt | 29 ++++++++++++++++--- 1 file changed, 25 insertions(+), 4 deletions(-) diff --git a/pillarbox-demo/src/main/java/ch/srgssr/pillarbox/demo/ui/showcases/layouts/OptimizedStory.kt b/pillarbox-demo/src/main/java/ch/srgssr/pillarbox/demo/ui/showcases/layouts/OptimizedStory.kt index 06af1a73f..d8364fdd0 100644 --- a/pillarbox-demo/src/main/java/ch/srgssr/pillarbox/demo/ui/showcases/layouts/OptimizedStory.kt +++ b/pillarbox-demo/src/main/java/ch/srgssr/pillarbox/demo/ui/showcases/layouts/OptimizedStory.kt @@ -18,6 +18,7 @@ import androidx.compose.foundation.pager.PagerSnapDistance import androidx.compose.foundation.pager.VerticalPager import androidx.compose.foundation.pager.rememberPagerState import androidx.compose.foundation.shape.CircleShape +import androidx.compose.material3.CircularProgressIndicator import androidx.compose.material3.LinearProgressIndicator import androidx.compose.material3.MaterialTheme import androidx.compose.runtime.Composable @@ -34,9 +35,11 @@ import androidx.compose.ui.graphics.Color import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp import androidx.lifecycle.viewmodel.compose.viewModel +import androidx.media3.common.Player import ch.srgssr.pillarbox.demo.ui.theme.PillarboxTheme import ch.srgssr.pillarbox.demo.ui.theme.paddings import ch.srgssr.pillarbox.player.currentPositionAsFlow +import ch.srgssr.pillarbox.player.playbackStateAsFlow import ch.srgssr.pillarbox.ui.ScaleMode import ch.srgssr.pillarbox.ui.widget.player.PlayerSurface import kotlinx.coroutines.delay @@ -63,21 +66,36 @@ fun OptimizedStory(storyViewModel: StoryViewModel = viewModel()) { state = pagerState, pagerSnapDistance = PagerSnapDistance.atMost(0), ), - state = pagerState + beyondViewportPageCount = 0, + state = pagerState, ) { page -> - val player = storyViewModel.getConfiguredPlayerForPageNumber(page) + val player = remember { storyViewModel.getConfiguredPlayerForPageNumber(page) } + val progress by remember { player.currentPositionAsFlow(100.milliseconds) .map { it / player.duration.coerceAtLeast(1L).toFloat() } }.collectAsState(0f) - Box { + val isBuffering by remember { + player.playbackStateAsFlow().map { it == Player.STATE_BUFFERING } + }.collectAsState(false) + + Box( + Modifier + .fillMaxSize() + .background(color = Color.Red) + ) { PlayerSurface( modifier = Modifier.fillMaxHeight(), scaleMode = ScaleMode.Crop, player = player, + defaultAspectRatio = 9 / 16f ) + if (isBuffering) { + CircularProgressIndicator(color = Color.White, modifier = Modifier.align(Alignment.Center)) + } + LinearProgressIndicator( progress = { progress }, modifier = Modifier @@ -116,7 +134,10 @@ private fun PagerIndicator( .padding(MaterialTheme.paddings.micro), ) { repeat(pageCount) { index -> - val dotColor by animateColorAsState(if (currentPage == index) PrimaryComponentColor else SecondaryComponentColor) + val dotColor by animateColorAsState( + targetValue = if (currentPage == index) PrimaryComponentColor else SecondaryComponentColor, + label = "indicator-animation" + ) Box( modifier = Modifier From 0f916554ef376ab6e0e0bc19f9c3c9287497d01e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Joaquim=20Sta=CC=88hli?= Date: Fri, 27 Sep 2024 13:51:21 +0200 Subject: [PATCH 04/23] Use Looper and same LoadControl like documented --- .../ui/showcases/layouts/StoryViewModel.kt | 32 +++++++++++++++++-- .../pillarbox/player/PillarboxExoPlayer.kt | 9 ++++++ 2 files changed, 39 insertions(+), 2 deletions(-) diff --git a/pillarbox-demo/src/main/java/ch/srgssr/pillarbox/demo/ui/showcases/layouts/StoryViewModel.kt b/pillarbox-demo/src/main/java/ch/srgssr/pillarbox/demo/ui/showcases/layouts/StoryViewModel.kt index b5a1bb2af..3d450f94a 100644 --- a/pillarbox-demo/src/main/java/ch/srgssr/pillarbox/demo/ui/showcases/layouts/StoryViewModel.kt +++ b/pillarbox-demo/src/main/java/ch/srgssr/pillarbox/demo/ui/showcases/layouts/StoryViewModel.kt @@ -5,23 +5,40 @@ package ch.srgssr.pillarbox.demo.ui.showcases.layouts import android.app.Application +import android.os.HandlerThread +import android.os.Process import androidx.lifecycle.AndroidViewModel import androidx.lifecycle.ViewModel import androidx.media3.common.C import androidx.media3.common.MediaItem import androidx.media3.common.Player +import androidx.media3.exoplayer.upstream.DefaultAllocator import ch.srgssr.pillarbox.core.business.source.SRGAssetLoader +import ch.srgssr.pillarbox.core.business.tracker.DefaultMediaItemTrackerRepository import ch.srgssr.pillarbox.demo.shared.data.Playlist -import ch.srgssr.pillarbox.demo.shared.di.PlayerModule +import ch.srgssr.pillarbox.demo.shared.source.BlockedTimeRangeAssetLoader import ch.srgssr.pillarbox.player.PillarboxExoPlayer +import ch.srgssr.pillarbox.player.PillarboxLoadControl import ch.srgssr.pillarbox.player.PillarboxPreloadManager import ch.srgssr.pillarbox.player.PlayerPool import ch.srgssr.pillarbox.player.source.PillarboxMediaSourceFactory +import kotlin.time.Duration.Companion.milliseconds +import kotlin.time.Duration.Companion.seconds /** * [ViewModel] that manages multiple [Player]s that can be used in a story-like layout. */ class StoryViewModel(application: Application) : AndroidViewModel(application) { + private val playbackThread = HandlerThread("MediaSourceEdge:Playback", Process.THREAD_PRIORITY_AUDIO).apply { start() } + private val preloadLooper = playbackThread.looper + private val loadControl = PillarboxLoadControl( + bufferDurations = PillarboxLoadControl.BufferDurations( + minBufferDuration = 5.seconds, + maxBufferDuration = 20.seconds, + bufferForPlayback = 500.milliseconds + ), + allocator = DefaultAllocator(false, C.DEFAULT_BUFFER_SEGMENT_SIZE) + ) private val preloadManager = PillarboxPreloadManager( context = application, mediaSourceFactory = PillarboxMediaSourceFactory(application).apply { @@ -30,7 +47,16 @@ class StoryViewModel(application: Application) : AndroidViewModel(application) { playerPool = PlayerPool( playersCount = 3, playerFactory = { - PlayerModule.provideDefaultPlayer(application).apply { + PillarboxExoPlayer( + context = application, + mediaSourceFactory = PillarboxMediaSourceFactory(application).apply { + addAssetLoader(SRGAssetLoader(application)) + addAssetLoader(BlockedTimeRangeAssetLoader(application)) + }, + mediaItemTrackerProvider = DefaultMediaItemTrackerRepository(), + loadControl = loadControl, + playbackLooper = preloadLooper, + ).apply { repeatMode = Player.REPEAT_MODE_ONE videoScalingMode = C.VIDEO_SCALING_MODE_SCALE_TO_FIT_WITH_CROPPING prepare() @@ -57,6 +83,7 @@ class StoryViewModel(application: Application) : AndroidViewModel(application) { * @param pageNumber The currently active page. */ fun setActivePage(pageNumber: Int) { + if (preloadManager.currentPlayingIndex == pageNumber) return preloadManager.getCurrentlyPlayingPlayer()?.pause() preloadManager.currentPlayingIndex = pageNumber preloadManager.invalidate() @@ -79,5 +106,6 @@ class StoryViewModel(application: Application) : AndroidViewModel(application) { override fun onCleared() { super.onCleared() preloadManager.release() + playbackThread.quit() } } diff --git a/pillarbox-player/src/main/java/ch/srgssr/pillarbox/player/PillarboxExoPlayer.kt b/pillarbox-player/src/main/java/ch/srgssr/pillarbox/player/PillarboxExoPlayer.kt index 5127a8a5b..9f57c9248 100644 --- a/pillarbox-player/src/main/java/ch/srgssr/pillarbox/player/PillarboxExoPlayer.kt +++ b/pillarbox-player/src/main/java/ch/srgssr/pillarbox/player/PillarboxExoPlayer.kt @@ -5,6 +5,7 @@ package ch.srgssr.pillarbox.player import android.content.Context +import android.os.Looper import android.os.Handler import androidx.annotation.VisibleForTesting import androidx.media3.common.C @@ -134,6 +135,7 @@ class PillarboxExoPlayer internal constructor( maxSeekToPreviousPosition: Duration = DEFAULT_MAX_SEEK_TO_PREVIOUS_POSITION, coroutineContext: CoroutineContext = Dispatchers.Default, monitoringMessageHandler: MonitoringMessageHandler = NoOpMonitoringMessageHandler, + playbackLooper: Looper? = null, ) : this( context = context, mediaSourceFactory = mediaSourceFactory, @@ -143,6 +145,7 @@ class PillarboxExoPlayer internal constructor( clock = Clock.DEFAULT, coroutineContext = coroutineContext, monitoringMessageHandler = monitoringMessageHandler, + playbackLooper = playbackLooper, ) @VisibleForTesting @@ -157,6 +160,7 @@ class PillarboxExoPlayer internal constructor( analyticsCollector: PillarboxAnalyticsCollector = PillarboxAnalyticsCollector(clock), metricsCollector: MetricsCollector = MetricsCollector(), monitoringMessageHandler: MonitoringMessageHandler = NoOpMonitoringMessageHandler, + playbackLooper: Looper? = null, ) : this( context, coroutineContext, @@ -172,6 +176,11 @@ class PillarboxExoPlayer internal constructor( .setTrackSelector(PillarboxTrackSelector(context)) .setAnalyticsCollector(analyticsCollector) .setDeviceVolumeControlEnabled(true) // allow player to control device volume + .apply { + playbackLooper?.let { + setPlaybackLooper(it) + } + } .build(), analyticsCollector = analyticsCollector, metricsCollector = metricsCollector, From c79eaad382675336645edb95c542cad08921d63a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Joaquim=20Sta=CC=88hli?= Date: Fri, 27 Sep 2024 13:52:46 +0200 Subject: [PATCH 05/23] Preload more items --- .../java/ch/srgssr/pillarbox/player/PillarboxPreloadManager.kt | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/pillarbox-player/src/main/java/ch/srgssr/pillarbox/player/PillarboxPreloadManager.kt b/pillarbox-player/src/main/java/ch/srgssr/pillarbox/player/PillarboxPreloadManager.kt index 74c42378c..6a946ceb9 100644 --- a/pillarbox-player/src/main/java/ch/srgssr/pillarbox/player/PillarboxPreloadManager.kt +++ b/pillarbox-player/src/main/java/ch/srgssr/pillarbox/player/PillarboxPreloadManager.kt @@ -202,13 +202,14 @@ class PillarboxPreloadManager( * Default implementation of [TargetPreloadStatusControl] that will preload the first second of the `n ± 1` item, and the first half-second of * the `n ± 2` item, where `n` is the index of the current item. */ + @Suppress("MagicNumber") inner class DefaultTargetPreloadStatusControl : TargetPreloadStatusControl { override fun getTargetPreloadStatus(rankingData: Int): TargetPreloadStatusControl.PreloadStatus? { val offset = abs(rankingData - currentPlayingIndex) return when (offset) { 1 -> Status(STAGE_LOADED_TO_POSITION_MS, 1.seconds.inWholeMicroseconds) - 2 -> Status(STAGE_LOADED_TO_POSITION_MS, 500.milliseconds.inWholeMicroseconds) + 2, 3 -> Status(STAGE_LOADED_TO_POSITION_MS, 500.milliseconds.inWholeMicroseconds) else -> null } } From c3e04a5c59e4157165eb31285961e4e8a8664679 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Joaquim=20Sta=CC=88hli?= Date: Fri, 27 Sep 2024 13:58:37 +0200 Subject: [PATCH 06/23] Use settledPage instead --- .../pillarbox/demo/ui/showcases/layouts/OptimizedStory.kt | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/pillarbox-demo/src/main/java/ch/srgssr/pillarbox/demo/ui/showcases/layouts/OptimizedStory.kt b/pillarbox-demo/src/main/java/ch/srgssr/pillarbox/demo/ui/showcases/layouts/OptimizedStory.kt index d8364fdd0..0aaf08e7e 100644 --- a/pillarbox-demo/src/main/java/ch/srgssr/pillarbox/demo/ui/showcases/layouts/OptimizedStory.kt +++ b/pillarbox-demo/src/main/java/ch/srgssr/pillarbox/demo/ui/showcases/layouts/OptimizedStory.kt @@ -54,9 +54,8 @@ import kotlin.time.Duration.Companion.seconds fun OptimizedStory(storyViewModel: StoryViewModel = viewModel()) { val mediaItems = storyViewModel.mediaItems val pagerState = rememberPagerState { mediaItems.size } - - LaunchedEffect(pagerState.currentPage) { - storyViewModel.setActivePage(pagerState.currentPage) + LaunchedEffect(pagerState.settledPage) { + storyViewModel.setActivePage(pagerState.settledPage) } Box(modifier = Modifier.fillMaxSize()) { From d5afacb2e6c8d27c255e8ab7401b9ee96722d2d2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ga=C3=ABtan=20Muller?= Date: Fri, 27 Sep 2024 23:38:44 +0200 Subject: [PATCH 07/23] Fix rebase --- .../pillarbox/demo/ui/showcases/layouts/StoryViewModel.kt | 2 -- 1 file changed, 2 deletions(-) diff --git a/pillarbox-demo/src/main/java/ch/srgssr/pillarbox/demo/ui/showcases/layouts/StoryViewModel.kt b/pillarbox-demo/src/main/java/ch/srgssr/pillarbox/demo/ui/showcases/layouts/StoryViewModel.kt index 3d450f94a..3405774ab 100644 --- a/pillarbox-demo/src/main/java/ch/srgssr/pillarbox/demo/ui/showcases/layouts/StoryViewModel.kt +++ b/pillarbox-demo/src/main/java/ch/srgssr/pillarbox/demo/ui/showcases/layouts/StoryViewModel.kt @@ -14,7 +14,6 @@ import androidx.media3.common.MediaItem import androidx.media3.common.Player import androidx.media3.exoplayer.upstream.DefaultAllocator import ch.srgssr.pillarbox.core.business.source.SRGAssetLoader -import ch.srgssr.pillarbox.core.business.tracker.DefaultMediaItemTrackerRepository import ch.srgssr.pillarbox.demo.shared.data.Playlist import ch.srgssr.pillarbox.demo.shared.source.BlockedTimeRangeAssetLoader import ch.srgssr.pillarbox.player.PillarboxExoPlayer @@ -53,7 +52,6 @@ class StoryViewModel(application: Application) : AndroidViewModel(application) { addAssetLoader(SRGAssetLoader(application)) addAssetLoader(BlockedTimeRangeAssetLoader(application)) }, - mediaItemTrackerProvider = DefaultMediaItemTrackerRepository(), loadControl = loadControl, playbackLooper = preloadLooper, ).apply { From e93f5e814cd68568aa5284e49420dc58d3b39559 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ga=C3=ABtan=20Muller?= Date: Mon, 30 Sep 2024 08:35:03 +0200 Subject: [PATCH 08/23] Remove red background --- .../demo/ui/showcases/layouts/OptimizedStory.kt | 13 +++++++------ .../demo/ui/showcases/layouts/StoryViewModel.kt | 4 ++-- 2 files changed, 9 insertions(+), 8 deletions(-) diff --git a/pillarbox-demo/src/main/java/ch/srgssr/pillarbox/demo/ui/showcases/layouts/OptimizedStory.kt b/pillarbox-demo/src/main/java/ch/srgssr/pillarbox/demo/ui/showcases/layouts/OptimizedStory.kt index 0aaf08e7e..da3df44ed 100644 --- a/pillarbox-demo/src/main/java/ch/srgssr/pillarbox/demo/ui/showcases/layouts/OptimizedStory.kt +++ b/pillarbox-demo/src/main/java/ch/srgssr/pillarbox/demo/ui/showcases/layouts/OptimizedStory.kt @@ -80,19 +80,20 @@ fun OptimizedStory(storyViewModel: StoryViewModel = viewModel()) { }.collectAsState(false) Box( - Modifier - .fillMaxSize() - .background(color = Color.Red) + modifier = Modifier.fillMaxSize(), ) { PlayerSurface( modifier = Modifier.fillMaxHeight(), scaleMode = ScaleMode.Crop, player = player, - defaultAspectRatio = 9 / 16f + defaultAspectRatio = 9 / 16f, ) if (isBuffering) { - CircularProgressIndicator(color = Color.White, modifier = Modifier.align(Alignment.Center)) + CircularProgressIndicator( + color = Color.White, + modifier = Modifier.align(Alignment.Center), + ) } LinearProgressIndicator( @@ -135,7 +136,7 @@ private fun PagerIndicator( repeat(pageCount) { index -> val dotColor by animateColorAsState( targetValue = if (currentPage == index) PrimaryComponentColor else SecondaryComponentColor, - label = "indicator-animation" + label = "indicator-animation", ) Box( diff --git a/pillarbox-demo/src/main/java/ch/srgssr/pillarbox/demo/ui/showcases/layouts/StoryViewModel.kt b/pillarbox-demo/src/main/java/ch/srgssr/pillarbox/demo/ui/showcases/layouts/StoryViewModel.kt index 3405774ab..ffb595edf 100644 --- a/pillarbox-demo/src/main/java/ch/srgssr/pillarbox/demo/ui/showcases/layouts/StoryViewModel.kt +++ b/pillarbox-demo/src/main/java/ch/srgssr/pillarbox/demo/ui/showcases/layouts/StoryViewModel.kt @@ -34,9 +34,9 @@ class StoryViewModel(application: Application) : AndroidViewModel(application) { bufferDurations = PillarboxLoadControl.BufferDurations( minBufferDuration = 5.seconds, maxBufferDuration = 20.seconds, - bufferForPlayback = 500.milliseconds + bufferForPlayback = 500.milliseconds, ), - allocator = DefaultAllocator(false, C.DEFAULT_BUFFER_SEGMENT_SIZE) + allocator = DefaultAllocator(false, C.DEFAULT_BUFFER_SEGMENT_SIZE), ) private val preloadManager = PillarboxPreloadManager( context = application, From 6048d1bcea3c06f28fb053733a19db6559f587f0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ga=C3=ABtan=20Muller?= Date: Mon, 30 Sep 2024 14:01:15 +0200 Subject: [PATCH 09/23] Improve `PillarboxPreloadManager` --- .../ui/showcases/layouts/StoryViewModel.kt | 47 +++---------- .../player/PillarboxPreloadManager.kt | 68 ++++++++++++------- .../player/PillarboxPreloadManagerTest.kt | 16 ++--- 3 files changed, 62 insertions(+), 69 deletions(-) diff --git a/pillarbox-demo/src/main/java/ch/srgssr/pillarbox/demo/ui/showcases/layouts/StoryViewModel.kt b/pillarbox-demo/src/main/java/ch/srgssr/pillarbox/demo/ui/showcases/layouts/StoryViewModel.kt index ffb595edf..27744d4a7 100644 --- a/pillarbox-demo/src/main/java/ch/srgssr/pillarbox/demo/ui/showcases/layouts/StoryViewModel.kt +++ b/pillarbox-demo/src/main/java/ch/srgssr/pillarbox/demo/ui/showcases/layouts/StoryViewModel.kt @@ -5,62 +5,36 @@ package ch.srgssr.pillarbox.demo.ui.showcases.layouts import android.app.Application -import android.os.HandlerThread -import android.os.Process import androidx.lifecycle.AndroidViewModel import androidx.lifecycle.ViewModel import androidx.media3.common.C import androidx.media3.common.MediaItem import androidx.media3.common.Player -import androidx.media3.exoplayer.upstream.DefaultAllocator import ch.srgssr.pillarbox.core.business.source.SRGAssetLoader import ch.srgssr.pillarbox.demo.shared.data.Playlist -import ch.srgssr.pillarbox.demo.shared.source.BlockedTimeRangeAssetLoader import ch.srgssr.pillarbox.player.PillarboxExoPlayer -import ch.srgssr.pillarbox.player.PillarboxLoadControl import ch.srgssr.pillarbox.player.PillarboxPreloadManager -import ch.srgssr.pillarbox.player.PlayerPool import ch.srgssr.pillarbox.player.source.PillarboxMediaSourceFactory -import kotlin.time.Duration.Companion.milliseconds -import kotlin.time.Duration.Companion.seconds /** * [ViewModel] that manages multiple [Player]s that can be used in a story-like layout. */ class StoryViewModel(application: Application) : AndroidViewModel(application) { - private val playbackThread = HandlerThread("MediaSourceEdge:Playback", Process.THREAD_PRIORITY_AUDIO).apply { start() } - private val preloadLooper = playbackThread.looper - private val loadControl = PillarboxLoadControl( - bufferDurations = PillarboxLoadControl.BufferDurations( - minBufferDuration = 5.seconds, - maxBufferDuration = 20.seconds, - bufferForPlayback = 500.milliseconds, - ), - allocator = DefaultAllocator(false, C.DEFAULT_BUFFER_SEGMENT_SIZE), - ) private val preloadManager = PillarboxPreloadManager( context = application, mediaSourceFactory = PillarboxMediaSourceFactory(application).apply { addAssetLoader(SRGAssetLoader(application)) }, - playerPool = PlayerPool( - playersCount = 3, - playerFactory = { - PillarboxExoPlayer( - context = application, - mediaSourceFactory = PillarboxMediaSourceFactory(application).apply { - addAssetLoader(SRGAssetLoader(application)) - addAssetLoader(BlockedTimeRangeAssetLoader(application)) - }, - loadControl = loadControl, - playbackLooper = preloadLooper, - ).apply { - repeatMode = Player.REPEAT_MODE_ONE - videoScalingMode = C.VIDEO_SCALING_MODE_SCALE_TO_FIT_WITH_CROPPING - prepare() - } - }, - ), + playerFactory = { playbackLooper -> + PillarboxExoPlayer( + context = application, + playbackLooper = playbackLooper, + ).apply { + repeatMode = Player.REPEAT_MODE_ONE + videoScalingMode = C.VIDEO_SCALING_MODE_SCALE_TO_FIT_WITH_CROPPING + prepare() + } + }, ) /** @@ -104,6 +78,5 @@ class StoryViewModel(application: Application) : AndroidViewModel(application) { override fun onCleared() { super.onCleared() preloadManager.release() - playbackThread.quit() } } diff --git a/pillarbox-player/src/main/java/ch/srgssr/pillarbox/player/PillarboxPreloadManager.kt b/pillarbox-player/src/main/java/ch/srgssr/pillarbox/player/PillarboxPreloadManager.kt index 6a946ceb9..9b8a7fd48 100644 --- a/pillarbox-player/src/main/java/ch/srgssr/pillarbox/player/PillarboxPreloadManager.kt +++ b/pillarbox-player/src/main/java/ch/srgssr/pillarbox/player/PillarboxPreloadManager.kt @@ -5,7 +5,9 @@ package ch.srgssr.pillarbox.player import android.content.Context +import android.os.HandlerThread import android.os.Looper +import android.os.Process import androidx.media3.common.C import androidx.media3.common.MediaItem import androidx.media3.exoplayer.DefaultRendererCapabilitiesList @@ -18,6 +20,7 @@ import androidx.media3.exoplayer.source.preload.DefaultPreloadManager.Status.STA import androidx.media3.exoplayer.source.preload.TargetPreloadStatusControl import androidx.media3.exoplayer.trackselection.TrackSelector import androidx.media3.exoplayer.upstream.BandwidthMeter +import androidx.media3.exoplayer.upstream.DefaultAllocator import ch.srgssr.pillarbox.player.source.PillarboxMediaSourceFactory import kotlin.math.abs import kotlin.time.Duration.Companion.milliseconds @@ -37,15 +40,16 @@ import kotlin.time.Duration.Companion.seconds * @param bandwidthMeter * @param rendererCapabilitiesListFactory * @param loadControl - * @param preloadLooper - * @param playerPool + * @param playbackThread + * @param playersCount + * @param playerFactory * * @see DefaultPreloadManager */ class PillarboxPreloadManager( context: Context, targetPreloadStatusControl: TargetPreloadStatusControl? = null, - private val mediaSourceFactory: MediaSource.Factory = PillarboxMediaSourceFactory(context), + mediaSourceFactory: MediaSource.Factory = PillarboxMediaSourceFactory(context), trackSelector: TrackSelector = PillarboxTrackSelector(context).apply { init({}, PillarboxBandwidthMeter(context)) }, @@ -53,29 +57,42 @@ class PillarboxPreloadManager( rendererCapabilitiesListFactory: RendererCapabilitiesList.Factory = DefaultRendererCapabilitiesList.Factory( PillarboxRenderersFactory(context) ), - private val loadControl: LoadControl = PillarboxLoadControl(), - preloadLooper: Looper = context.mainLooper, - private val playerPool: PlayerPool = PlayerPool( - playersCount = 3, - playerFactory = { - PillarboxExoPlayer( - context = context, - mediaSourceFactory = mediaSourceFactory, - loadControl = loadControl, - ) - }, + private val loadControl: LoadControl = PillarboxLoadControl( + bufferDurations = PillarboxLoadControl.BufferDurations( + minBufferDuration = 5.seconds, + maxBufferDuration = 20.seconds, + bufferForPlayback = 500.milliseconds, + ), + allocator = DefaultAllocator(false, C.DEFAULT_BUFFER_SEGMENT_SIZE), ), + private val playbackThread: HandlerThread = HandlerThread("PillarboxPreloadManager:Playback", Process.THREAD_PRIORITY_AUDIO), + playersCount: Int = 3, + playerFactory: (playbackLooper: Looper) -> PillarboxExoPlayer = { playbackLooper -> + PillarboxExoPlayer( + context = context, + loadControl = loadControl, + playbackLooper = playbackLooper, + ) + }, ) { - private val preloadManager = DefaultPreloadManager( - targetPreloadStatusControl ?: DefaultTargetPreloadStatusControl(), - mediaSourceFactory, - trackSelector, - bandwidthMeter, - rendererCapabilitiesListFactory, - loadControl.allocator, - preloadLooper, + private val playerPool = PlayerPool( + playersCount = playersCount, + playerFactory = { playerFactory(playbackThread.looper) }, ) + // We use a lazy creation so the playbackThread can be started first + private val preloadManager by lazy { + DefaultPreloadManager( + targetPreloadStatusControl ?: DefaultTargetPreloadStatusControl(), + mediaSourceFactory, + trackSelector, + bandwidthMeter, + rendererCapabilitiesListFactory, + loadControl.allocator, + playbackThread.looper, + ) + } + /** * The index of the currently playing media item. * @@ -95,6 +112,10 @@ class PillarboxPreloadManager( val sourceCount: Int get() = preloadManager.sourceCount + init { + playbackThread.start() + } + /** * Add a [MediaItem] with its [rankingData] to the preload manager. * @@ -146,6 +167,7 @@ class PillarboxPreloadManager( fun release() { playerPool.release() preloadManager.release() + playbackThread.quit() } /** @@ -200,7 +222,7 @@ class PillarboxPreloadManager( /** * Default implementation of [TargetPreloadStatusControl] that will preload the first second of the `n ± 1` item, and the first half-second of - * the `n ± 2` item, where `n` is the index of the current item. + * the `n ± 2,3` item, where `n` is the index of the current item. */ @Suppress("MagicNumber") inner class DefaultTargetPreloadStatusControl : TargetPreloadStatusControl { diff --git a/pillarbox-player/src/test/java/ch/srgssr/pillarbox/player/PillarboxPreloadManagerTest.kt b/pillarbox-player/src/test/java/ch/srgssr/pillarbox/player/PillarboxPreloadManagerTest.kt index 776316582..6d6acd412 100644 --- a/pillarbox-player/src/test/java/ch/srgssr/pillarbox/player/PillarboxPreloadManagerTest.kt +++ b/pillarbox-player/src/test/java/ch/srgssr/pillarbox/player/PillarboxPreloadManagerTest.kt @@ -30,14 +30,14 @@ class PillarboxPreloadManagerTest { createdPlayersCount = 0 preloadManager = PillarboxPreloadManager( context = context, - playerPool = PlayerPool( - playersCount = PLAYERS_COUNT, - playerFactory = { - createdPlayersCount++ + playerFactory = { playbackLooper -> + createdPlayersCount++ - PillarboxExoPlayer(context) - } - ) + PillarboxExoPlayer( + context = context, + playbackLooper = playbackLooper, + ) + }, ) } @@ -104,8 +104,6 @@ class PillarboxPreloadManagerTest { } private companion object { - private const val PLAYERS_COUNT = 3 - private val VOD1 = MediaItem.fromUri("urn:rts:video:13444390") private val VOD2 = MediaItem.fromUri("urn:rts:video:13444333") private val VOD3 = MediaItem.fromUri("urn:rts:video:13444466") From 727620b4729ac0777b3b9f1d4c99b923f88ef663 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ga=C3=ABtan=20Muller?= Date: Tue, 1 Oct 2024 11:06:41 +0200 Subject: [PATCH 10/23] Add documentation for each argument of `PillarboxPreloadManager` --- .../player/PillarboxPreloadManager.kt | 21 ++++++++++--------- 1 file changed, 11 insertions(+), 10 deletions(-) diff --git a/pillarbox-player/src/main/java/ch/srgssr/pillarbox/player/PillarboxPreloadManager.kt b/pillarbox-player/src/main/java/ch/srgssr/pillarbox/player/PillarboxPreloadManager.kt index 9b8a7fd48..5caddccb4 100644 --- a/pillarbox-player/src/main/java/ch/srgssr/pillarbox/player/PillarboxPreloadManager.kt +++ b/pillarbox-player/src/main/java/ch/srgssr/pillarbox/player/PillarboxPreloadManager.kt @@ -33,16 +33,17 @@ import kotlin.time.Duration.Companion.seconds * This class provides the same methods as [DefaultPreloadManager] plus [getPlayer] and [getCurrentlyPlayingPlayer] to get an instance of a * [PillarboxExoPlayer]. * - * @param context - * @param targetPreloadStatusControl - * @param mediaSourceFactory - * @param trackSelector - * @param bandwidthMeter - * @param rendererCapabilitiesListFactory - * @param loadControl - * @param playbackThread - * @param playersCount - * @param playerFactory + * @param context The current [Context]. + * @param targetPreloadStatusControl The [TargetPreloadStatusControl] to decide when to preload an item and for how long. + * @param mediaSourceFactory The [MediaSource.Factory] to create each [MediaSource]. + * @param trackSelector The [TrackSelector] for this preload manager. + * @param bandwidthMeter The [BandwidthMeter] for this preload manager. + * @param rendererCapabilitiesListFactory The [RendererCapabilitiesList.Factory] for this preload manager. + * @param loadControl The [LoadControl] for this preload manager. + * @param playbackThread The [Thread] on which the players run. + * @param playersCount The maximum number of [PillarboxExoPlayer] to create. + * @param playerFactory Called when a new [PillarboxExoPlayer] instance is necessary (up to `playersCount` times). The provided `Looper` **must** + * be passed to [PillarboxExoPlayer]'s constructor. * * @see DefaultPreloadManager */ From 915df6c5da78ce7e248c7147b14007c3191310b1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ga=C3=ABtan=20Muller?= Date: Fri, 4 Oct 2024 07:35:28 +0200 Subject: [PATCH 11/23] Small tweaks to `PlayerSurface` workaround --- .../ui/showcases/layouts/OptimizedStory.kt | 9 ++- .../widget/player/AndroidPlayerSurfaceView.kt | 80 +++++++++++-------- 2 files changed, 52 insertions(+), 37 deletions(-) diff --git a/pillarbox-demo/src/main/java/ch/srgssr/pillarbox/demo/ui/showcases/layouts/OptimizedStory.kt b/pillarbox-demo/src/main/java/ch/srgssr/pillarbox/demo/ui/showcases/layouts/OptimizedStory.kt index da3df44ed..c9b9af454 100644 --- a/pillarbox-demo/src/main/java/ch/srgssr/pillarbox/demo/ui/showcases/layouts/OptimizedStory.kt +++ b/pillarbox-demo/src/main/java/ch/srgssr/pillarbox/demo/ui/showcases/layouts/OptimizedStory.kt @@ -24,6 +24,7 @@ import androidx.compose.material3.MaterialTheme import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.derivedStateOf import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableIntStateOf import androidx.compose.runtime.remember @@ -54,13 +55,13 @@ import kotlin.time.Duration.Companion.seconds fun OptimizedStory(storyViewModel: StoryViewModel = viewModel()) { val mediaItems = storyViewModel.mediaItems val pagerState = rememberPagerState { mediaItems.size } - LaunchedEffect(pagerState.settledPage) { - storyViewModel.setActivePage(pagerState.settledPage) + val settledPage by remember { derivedStateOf { pagerState.settledPage } } + LaunchedEffect(settledPage) { + storyViewModel.setActivePage(settledPage) } Box(modifier = Modifier.fillMaxSize()) { VerticalPager( - key = { page -> mediaItems[page].mediaId }, flingBehavior = PagerDefaults.flingBehavior( state = pagerState, pagerSnapDistance = PagerSnapDistance.atMost(0), @@ -110,7 +111,7 @@ fun OptimizedStory(storyViewModel: StoryViewModel = viewModel()) { } PagerIndicator( - currentPage = pagerState.currentPage, + currentPage = settledPage, pageCount = mediaItems.size, modifier = Modifier .align(Alignment.CenterEnd) diff --git a/pillarbox-ui/src/main/java/ch/srgssr/pillarbox/ui/widget/player/AndroidPlayerSurfaceView.kt b/pillarbox-ui/src/main/java/ch/srgssr/pillarbox/ui/widget/player/AndroidPlayerSurfaceView.kt index bd12adc1a..ce25204f4 100644 --- a/pillarbox-ui/src/main/java/ch/srgssr/pillarbox/ui/widget/player/AndroidPlayerSurfaceView.kt +++ b/pillarbox-ui/src/main/java/ch/srgssr/pillarbox/ui/widget/player/AndroidPlayerSurfaceView.kt @@ -7,6 +7,7 @@ package ch.srgssr.pillarbox.ui.widget.player import android.content.Context import android.graphics.Canvas import android.os.Build +import android.os.Build.VERSION_CODES.UPSIDE_DOWN_CAKE import android.os.Handler import android.os.Looper import android.view.SurfaceControl @@ -52,12 +53,18 @@ internal fun AndroidPlayerSurfaceView(player: Player, modifier: Modifier = Modif /** * Player surface view */ -private class PlayerSurfaceView(context: Context) : SurfaceView(context) { - private val playerListener = PlayerListener() - private val surfaceSyncGroupV34 = when { - isInEditMode -> null - needSurfaceSyncWorkaround() -> SurfaceSyncGroupCompatV34() - else -> null +private class PlayerSurfaceView(context: Context) : SurfaceView(context), Player.Listener { + private val surfaceSyncGroup = when { + isInEditMode -> NoOpSurfaceSyncGroupCompat + + // Workaround for a surface sync issue on API 34: https://github.com/androidx/media/issues/1237 + // Imported from https://github.com/androidx/media/commit/30cb76269a67e09f6e1662ea9ead6aac70667028 + Build.VERSION.SDK_INT == UPSIDE_DOWN_CAKE -> SurfaceSyncGroupCompatV34( + surfaceView = this, + handler = Handler(Looper.getMainLooper()), + ) + + else -> NoOpSurfaceSyncGroupCompat } /** @@ -67,59 +74,66 @@ private class PlayerSurfaceView(context: Context) : SurfaceView(context) { set(value) { if (field != value) { field?.clearVideoSurfaceView(this) - field?.removeListener(playerListener) + field?.removeListener(this) value?.setVideoSurfaceView(this) - value?.addListener(playerListener) + value?.addListener(this) + field = value } - field = value } override fun dispatchDraw(canvas: Canvas) { super.dispatchDraw(canvas) - if (needSurfaceSyncWorkaround()) { - surfaceSyncGroupV34?.maybeMarkSyncReadyAndClear() + surfaceSyncGroup.maybeMarkSyncReadyAndClear() + } + + override fun onSurfaceSizeChanged(width: Int, height: Int) { + if (width > 0 && height > 0) { + surfaceSyncGroup.postRegister() } } - // Workaround for a surface sync issue on API 34: https://github.com/androidx/media/issues/1237 - // Imported from https://github.com/androidx/media/commit/30cb76269a67e09f6e1662ea9ead6aac70667028 - private fun needSurfaceSyncWorkaround(): Boolean { - return Build.VERSION.SDK_INT == Build.VERSION_CODES.UPSIDE_DOWN_CAKE + private sealed interface SurfaceSyncGroupCompat { + fun postRegister() + + fun maybeMarkSyncReadyAndClear() } - private inner class PlayerListener : Player.Listener { - private val mainLooperHandler = Handler(Looper.getMainLooper()) + private data object NoOpSurfaceSyncGroupCompat : SurfaceSyncGroupCompat { + override fun postRegister() = Unit - override fun onSurfaceSizeChanged(width: Int, height: Int) { - if (needSurfaceSyncWorkaround()) { - surfaceSyncGroupV34?.postRegister(mainLooperHandler, this@PlayerSurfaceView) - } - } + override fun maybeMarkSyncReadyAndClear() = Unit } - @RequiresApi(Build.VERSION_CODES.UPSIDE_DOWN_CAKE) - private class SurfaceSyncGroupCompatV34 { + @RequiresApi(UPSIDE_DOWN_CAKE) + private class SurfaceSyncGroupCompatV34( + private val surfaceView: SurfaceView, + private val handler: Handler, + ) : SurfaceSyncGroupCompat { private var surfaceSyncGroup: SurfaceSyncGroup? = null - fun postRegister( - mainLooperHandler: Handler, - surfaceView: SurfaceView, - ) { - mainLooperHandler.post { + override fun postRegister() { + handler.post { // The SurfaceView isn't attached to a window, so don't apply the workaround. - val rootSurfaceControl = surfaceView.getRootSurfaceControl() ?: return@post + val rootSurfaceControl = surfaceView.getRootSurfaceControl() + if (rootSurfaceControl == null || surfaceSyncGroup != null) { + return@post + } - surfaceSyncGroup = SurfaceSyncGroup("exo-sync-b-334901521") - surfaceSyncGroup?.add(rootSurfaceControl) {} + surfaceSyncGroup = SurfaceSyncGroup(SYNC_GROUP_NAME) + surfaceSyncGroup?.add(rootSurfaceControl, null) surfaceView.invalidate() rootSurfaceControl.applyTransactionOnDraw(SurfaceControl.Transaction()) } } - fun maybeMarkSyncReadyAndClear() { + override fun maybeMarkSyncReadyAndClear() { surfaceSyncGroup?.markSyncReady() surfaceSyncGroup = null } + + private companion object { + private const val SYNC_GROUP_NAME = "exo-sync-b-334901521" + } } } From 4a03028e1b085520d1c17f4beefd7e2b641798c0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Joaquim=20Sta=CC=88hli?= Date: Wed, 9 Oct 2024 15:31:38 +0200 Subject: [PATCH 12/23] Use texture instead of Surface --- .../pillarbox/demo/ui/showcases/layouts/OptimizedStory.kt | 2 ++ 1 file changed, 2 insertions(+) diff --git a/pillarbox-demo/src/main/java/ch/srgssr/pillarbox/demo/ui/showcases/layouts/OptimizedStory.kt b/pillarbox-demo/src/main/java/ch/srgssr/pillarbox/demo/ui/showcases/layouts/OptimizedStory.kt index c9b9af454..c5d2e0b4a 100644 --- a/pillarbox-demo/src/main/java/ch/srgssr/pillarbox/demo/ui/showcases/layouts/OptimizedStory.kt +++ b/pillarbox-demo/src/main/java/ch/srgssr/pillarbox/demo/ui/showcases/layouts/OptimizedStory.kt @@ -43,6 +43,7 @@ import ch.srgssr.pillarbox.player.currentPositionAsFlow import ch.srgssr.pillarbox.player.playbackStateAsFlow import ch.srgssr.pillarbox.ui.ScaleMode import ch.srgssr.pillarbox.ui.widget.player.PlayerSurface +import ch.srgssr.pillarbox.ui.widget.player.SurfaceType import kotlinx.coroutines.delay import kotlinx.coroutines.flow.map import kotlin.time.Duration.Companion.milliseconds @@ -86,6 +87,7 @@ fun OptimizedStory(storyViewModel: StoryViewModel = viewModel()) { PlayerSurface( modifier = Modifier.fillMaxHeight(), scaleMode = ScaleMode.Crop, + surfaceType = SurfaceType.Texture, player = player, defaultAspectRatio = 9 / 16f, ) From 2c76b98a04bd42c2af39fecdd2a1c423581af82f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Joaquim=20Sta=CC=88hli?= Date: Wed, 9 Oct 2024 16:51:00 +0200 Subject: [PATCH 13/23] Add some tataki story --- .../pillarbox/demo/shared/data/Playlist.kt | 35 +++++++++++++++++++ 1 file changed, 35 insertions(+) diff --git a/pillarbox-demo-shared/src/main/java/ch/srgssr/pillarbox/demo/shared/data/Playlist.kt b/pillarbox-demo-shared/src/main/java/ch/srgssr/pillarbox/demo/shared/data/Playlist.kt index 5044a29c9..7a6b4e645 100644 --- a/pillarbox-demo-shared/src/main/java/ch/srgssr/pillarbox/demo/shared/data/Playlist.kt +++ b/pillarbox-demo-shared/src/main/java/ch/srgssr/pillarbox/demo/shared/data/Playlist.kt @@ -139,6 +139,41 @@ data class Playlist(val title: String, val items: List, val descriptio DemoItem.OverlapinglockedSegments ) ) + val StoryUrns = Playlist( + title = "Story urns", + items = listOf( + DemoItem( + title = "Mario vs Sonic", + description = "Tataki 1", + uri = "urn:rts:video:13950405" + ), + DemoItem( + title = "Pourquoi Beyoncé fait de la country", + description = "Tataki 2", + uri = "urn:rts:video:14815579" + ), + DemoItem( + title = "L'île North Sentinel", + description = "Tataki 3", + uri = "urn:rts:video:13795051" + ), + DemoItem( + title = "Mourir pour ressembler à une idole", + description = "Tataki 4", + uri = "urn:rts:video:14020134" + ), + DemoItem( + title = "Pourquoi les gens mangent des insectes ?", + description = "Tataki 5", + uri = "urn:rts:video:12631996" + ), + DemoItem( + title = "Le concert de Beyoncé à Dubai", + description = "Tataki 6", + uri = "urn:rts:video:13752646" + ) + ) + ) private val googleStreams = Playlist( title = "Google streams", items = listOf( From 3a6d37889197f0b44de0e80a321f31e47908b694 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Joaquim=20Sta=CC=88hli?= Date: Wed, 9 Oct 2024 17:11:53 +0200 Subject: [PATCH 14/23] Redo story from scratch --- .../ui/showcases/layouts/OptimizedStory.kt | 103 +++++++---- .../ui/showcases/layouts/StoryViewModel.kt | 172 ++++++++++++++---- 2 files changed, 202 insertions(+), 73 deletions(-) diff --git a/pillarbox-demo/src/main/java/ch/srgssr/pillarbox/demo/ui/showcases/layouts/OptimizedStory.kt b/pillarbox-demo/src/main/java/ch/srgssr/pillarbox/demo/ui/showcases/layouts/OptimizedStory.kt index c5d2e0b4a..c8cf48c39 100644 --- a/pillarbox-demo/src/main/java/ch/srgssr/pillarbox/demo/ui/showcases/layouts/OptimizedStory.kt +++ b/pillarbox-demo/src/main/java/ch/srgssr/pillarbox/demo/ui/showcases/layouts/OptimizedStory.kt @@ -26,6 +26,7 @@ import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.collectAsState import androidx.compose.runtime.derivedStateOf import androidx.compose.runtime.getValue +import androidx.compose.runtime.movableContentOf import androidx.compose.runtime.mutableIntStateOf import androidx.compose.runtime.remember import androidx.compose.runtime.setValue @@ -57,8 +58,23 @@ fun OptimizedStory(storyViewModel: StoryViewModel = viewModel()) { val mediaItems = storyViewModel.mediaItems val pagerState = rememberPagerState { mediaItems.size } val settledPage by remember { derivedStateOf { pagerState.settledPage } } + val currentPage by remember { derivedStateOf { pagerState.currentPage } } LaunchedEffect(settledPage) { - storyViewModel.setActivePage(settledPage) + storyViewModel.play(storyViewModel.getPlayer(settledPage)) + } + LaunchedEffect(currentPage) { + storyViewModel.setCurrentPage(currentPage) + } + + val movablePlayerView = remember { + (0 until storyViewModel.playerCount).map { index -> + movableContentOf { + Box { + val player = remember { storyViewModel.getPlayer(index) } + PlayerView(player, modifier = Modifier.fillMaxSize()) + } + } + } } Box(modifier = Modifier.fillMaxSize()) { @@ -70,46 +86,10 @@ fun OptimizedStory(storyViewModel: StoryViewModel = viewModel()) { beyondViewportPageCount = 0, state = pagerState, ) { page -> - val player = remember { storyViewModel.getConfiguredPlayerForPageNumber(page) } - - val progress by remember { - player.currentPositionAsFlow(100.milliseconds) - .map { it / player.duration.coerceAtLeast(1L).toFloat() } - }.collectAsState(0f) - - val isBuffering by remember { - player.playbackStateAsFlow().map { it == Player.STATE_BUFFERING } - }.collectAsState(false) - - Box( - modifier = Modifier.fillMaxSize(), - ) { - PlayerSurface( - modifier = Modifier.fillMaxHeight(), - scaleMode = ScaleMode.Crop, - surfaceType = SurfaceType.Texture, - player = player, - defaultAspectRatio = 9 / 16f, - ) - - if (isBuffering) { - CircularProgressIndicator( - color = Color.White, - modifier = Modifier.align(Alignment.Center), - ) - } - - LinearProgressIndicator( - progress = { progress }, - modifier = Modifier - .fillMaxWidth() - .align(Alignment.BottomCenter), - color = PrimaryComponentColor, - trackColor = SecondaryComponentColor, - gapSize = 0.dp, - drawStopIndicator = {}, - ) + LaunchedEffect(page) { + storyViewModel.setupPlayerForPage(page) } + movablePlayerView[page % movablePlayerView.size]() } PagerIndicator( @@ -122,6 +102,49 @@ fun OptimizedStory(storyViewModel: StoryViewModel = viewModel()) { } } +@Composable +private fun PlayerView(player: Player, modifier: Modifier = Modifier) { + val progress by remember { + player.currentPositionAsFlow(100.milliseconds) + .map { it / player.duration.coerceAtLeast(1L).toFloat() } + }.collectAsState(0f) + + val isBuffering by remember { + player.playbackStateAsFlow().map { it == Player.STATE_BUFFERING } + }.collectAsState(false) + + Box( + modifier = modifier, + ) { + PlayerSurface( + modifier = Modifier + .fillMaxHeight(), + scaleMode = ScaleMode.Crop, + surfaceType = SurfaceType.Texture, + player = player, + defaultAspectRatio = 9 / 16f, + ) + + if (isBuffering) { + CircularProgressIndicator( + color = Color.White, + modifier = Modifier.align(Alignment.Center), + ) + } + + LinearProgressIndicator( + progress = { progress }, + modifier = Modifier + .fillMaxWidth() + .align(Alignment.BottomCenter), + color = PrimaryComponentColor, + trackColor = SecondaryComponentColor, + gapSize = 0.dp, + drawStopIndicator = {}, + ) + } +} + @Composable private fun PagerIndicator( currentPage: Int, diff --git a/pillarbox-demo/src/main/java/ch/srgssr/pillarbox/demo/ui/showcases/layouts/StoryViewModel.kt b/pillarbox-demo/src/main/java/ch/srgssr/pillarbox/demo/ui/showcases/layouts/StoryViewModel.kt index 27744d4a7..91012177d 100644 --- a/pillarbox-demo/src/main/java/ch/srgssr/pillarbox/demo/ui/showcases/layouts/StoryViewModel.kt +++ b/pillarbox-demo/src/main/java/ch/srgssr/pillarbox/demo/ui/showcases/layouts/StoryViewModel.kt @@ -5,78 +5,184 @@ package ch.srgssr.pillarbox.demo.ui.showcases.layouts import android.app.Application +import android.os.HandlerThread +import android.os.Process +import android.util.SparseArray +import androidx.core.util.forEach import androidx.lifecycle.AndroidViewModel import androidx.lifecycle.ViewModel import androidx.media3.common.C import androidx.media3.common.MediaItem import androidx.media3.common.Player +import androidx.media3.exoplayer.DefaultRendererCapabilitiesList +import androidx.media3.exoplayer.source.MediaSource +import androidx.media3.exoplayer.source.preload.DefaultPreloadManager +import androidx.media3.exoplayer.source.preload.DefaultPreloadManager.Status +import androidx.media3.exoplayer.source.preload.DefaultPreloadManager.Status.STAGE_LOADED_TO_POSITION_MS +import androidx.media3.exoplayer.source.preload.TargetPreloadStatusControl +import androidx.media3.exoplayer.upstream.DefaultAllocator import ch.srgssr.pillarbox.core.business.source.SRGAssetLoader import ch.srgssr.pillarbox.demo.shared.data.Playlist +import ch.srgssr.pillarbox.player.PillarboxBandwidthMeter import ch.srgssr.pillarbox.player.PillarboxExoPlayer -import ch.srgssr.pillarbox.player.PillarboxPreloadManager +import ch.srgssr.pillarbox.player.PillarboxLoadControl +import ch.srgssr.pillarbox.player.PillarboxRenderersFactory +import ch.srgssr.pillarbox.player.PillarboxTrackSelector import ch.srgssr.pillarbox.player.source.PillarboxMediaSourceFactory +import kotlin.math.abs +import kotlin.time.Duration.Companion.milliseconds +import kotlin.time.Duration.Companion.seconds /** * [ViewModel] that manages multiple [Player]s that can be used in a story-like layout. */ class StoryViewModel(application: Application) : AndroidViewModel(application) { - private val preloadManager = PillarboxPreloadManager( - context = application, - mediaSourceFactory = PillarboxMediaSourceFactory(application).apply { - addAssetLoader(SRGAssetLoader(application)) - }, - playerFactory = { playbackLooper -> - PillarboxExoPlayer( - context = application, - playbackLooper = playbackLooper, - ).apply { - repeatMode = Player.REPEAT_MODE_ONE - videoScalingMode = C.VIDEO_SCALING_MODE_SCALE_TO_FIT_WITH_CROPPING - prepare() - } - }, + + private val playbackThread: HandlerThread = HandlerThread("StoryMode-playback", Process.THREAD_PRIORITY_AUDIO).apply { + start() + } + private val playbackLooper = playbackThread.looper + private val allocator = DefaultAllocator(false, C.DEFAULT_BUFFER_SEGMENT_SIZE) + private val mediaSourceFactory = PillarboxMediaSourceFactory(application).apply { + addAssetLoader(SRGAssetLoader(application)) + } + private val loadControl = PillarboxLoadControl( + bufferDurations = PillarboxLoadControl.BufferDurations( + minBufferDuration = 5.seconds, + maxBufferDuration = 20.seconds, + bufferForPlayback = 500.milliseconds, + bufferForPlaybackAfterRebuffer = 1_000.milliseconds, + ), + allocator ) + private val preloadManager = + DefaultPreloadManager( + StoryPreloadStatusControl(), + mediaSourceFactory, + PillarboxTrackSelector(application).apply { + init({}, PillarboxBandwidthMeter(application)) + }, + PillarboxBandwidthMeter(application), + DefaultRendererCapabilitiesList.Factory(PillarboxRenderersFactory(application)), + allocator, + playbackLooper, + ) + + private var currentPage = C.INDEX_UNSET + + private val players = SparseArray(3).apply { + for (i in 0 until 3) { + put( + i, + PillarboxExoPlayer( + context = application, + playbackLooper = playbackLooper, + loadControl = loadControl + ).apply { + repeatMode = Player.REPEAT_MODE_ONE + videoScalingMode = C.VIDEO_SCALING_MODE_SCALE_TO_FIT_WITH_CROPPING + prepare() + } + ) + } + } + + /** + * Player count + */ + val playerCount: Int = players.size() + /** * The list of items to play. */ - val mediaItems: List = Playlist.VideoUrns.items.map { it.toMediaItem() } + val mediaItems: List = (Playlist.StoryUrns.items + Playlist.VideoUrns.items).map { it.toMediaItem() } init { mediaItems.forEachIndexed { index, mediaItem -> preloadManager.add(mediaItem, index) } + preloadManager.setCurrentPlayingIndex(0) preloadManager.invalidate() + + players.forEach { key, _ -> setupPlayerForPage(key) } } /** - * Set the [pageNumber] as the currently active page. + * Set up player for the [page]. * - * @param pageNumber The currently active page. + * @param page The page. */ - fun setActivePage(pageNumber: Int) { - if (preloadManager.currentPlayingIndex == pageNumber) return - preloadManager.getCurrentlyPlayingPlayer()?.pause() - preloadManager.currentPlayingIndex = pageNumber - preloadManager.invalidate() - preloadManager.getCurrentlyPlayingPlayer()?.play() + fun setupPlayerForPage(page: Int) { + val player = getPlayer(page) + val mediaSource = getMediaSourceForPage(page) + if (mediaSource.mediaItem == player.currentMediaItem) return + player.setMediaSource(mediaSource) + if (player.playbackState == Player.STATE_IDLE) { + player.prepare() + } } + private fun getMediaSourceForPage(page: Int): MediaSource = checkNotNull(preloadManager.getMediaSource(mediaItems[page])) + /** - * Get the [PillarboxExoPlayer] instance for page [pageNumber], with its media source set. + * @param page The page. + * @return the player for the [page]. + */ + fun getPlayer(page: Int): PillarboxExoPlayer { + return players[page % players.size()] + } + + /** + * Set the current page, do nothing if it is already the current. * - * @param pageNumber The page number. + * @param page The current page */ - fun getConfiguredPlayerForPageNumber(pageNumber: Int): PillarboxExoPlayer { - val mediaSource = checkNotNull(preloadManager.getMediaSource(mediaItems[pageNumber])) - val player = checkNotNull(preloadManager.getPlayer(pageNumber)) - player.setMediaSource(mediaSource) + fun setCurrentPage(page: Int) { + if (currentPage == page) return + currentPage = page + preloadManager.setCurrentPlayingIndex(currentPage) + preloadManager.invalidate() + } - return player + /** + * Play + * + * @param player to play all others are paused. + */ + fun play(player: PillarboxExoPlayer) { + if (player.playWhenReady) return + players.forEach { _, value -> + value.pause() + value.seekToDefaultPosition() + if (value == player) { + player.play() + } + } } override fun onCleared() { - super.onCleared() preloadManager.release() + players.forEach { _, value -> + value.release() + } + playbackThread.quitSafely() + } + + /** + * Default implementation of [TargetPreloadStatusControl] that will preload the first second of the `n ± 1` item, and the first half-second of + * the `n ± 2,3` item, where `n` is the index of the current item. + */ + @Suppress("MagicNumber") + inner class StoryPreloadStatusControl : TargetPreloadStatusControl { + override fun getTargetPreloadStatus(rankingData: Int): TargetPreloadStatusControl.PreloadStatus? { + val offset = abs(rankingData - currentPage) + + return when (offset) { + 1 -> Status(STAGE_LOADED_TO_POSITION_MS, 1.seconds.inWholeMicroseconds) + 2, 3, 4 -> Status(STAGE_LOADED_TO_POSITION_MS, 500.milliseconds.inWholeMicroseconds) + else -> null + } + } } } From 2624a921fa87b4638aba5d2ef80b80b4a1e2569b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Joaquim=20Sta=CC=88hli?= Date: Thu, 10 Oct 2024 08:58:47 +0200 Subject: [PATCH 15/23] Use texture only with API 34 --- .../pillarbox/demo/ui/showcases/layouts/OptimizedStory.kt | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/pillarbox-demo/src/main/java/ch/srgssr/pillarbox/demo/ui/showcases/layouts/OptimizedStory.kt b/pillarbox-demo/src/main/java/ch/srgssr/pillarbox/demo/ui/showcases/layouts/OptimizedStory.kt index c8cf48c39..871deeec0 100644 --- a/pillarbox-demo/src/main/java/ch/srgssr/pillarbox/demo/ui/showcases/layouts/OptimizedStory.kt +++ b/pillarbox-demo/src/main/java/ch/srgssr/pillarbox/demo/ui/showcases/layouts/OptimizedStory.kt @@ -4,6 +4,7 @@ */ package ch.srgssr.pillarbox.demo.ui.showcases.layouts +import android.os.Build import androidx.compose.animation.animateColorAsState import androidx.compose.foundation.background import androidx.compose.foundation.layout.Box @@ -120,7 +121,7 @@ private fun PlayerView(player: Player, modifier: Modifier = Modifier) { modifier = Modifier .fillMaxHeight(), scaleMode = ScaleMode.Crop, - surfaceType = SurfaceType.Texture, + surfaceType = if (Build.VERSION.SDK_INT == Build.VERSION_CODES.UPSIDE_DOWN_CAKE) SurfaceType.Texture else SurfaceType.Surface, player = player, defaultAspectRatio = 9 / 16f, ) From a61463ba94b82408fb444bb82b3f3b94d0e815c1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Joaquim=20Sta=CC=88hli?= Date: Thu, 10 Oct 2024 13:46:58 +0200 Subject: [PATCH 16/23] Remove Player management from PIllarboxPreloadManager --- .../ui/showcases/layouts/StoryViewModel.kt | 53 ++++------- .../player/PillarboxPreloadManager.kt | 94 +++++-------------- .../ch/srgssr/pillarbox/player/PlayerPool.kt | 56 ----------- 3 files changed, 45 insertions(+), 158 deletions(-) delete mode 100644 pillarbox-player/src/main/java/ch/srgssr/pillarbox/player/PlayerPool.kt diff --git a/pillarbox-demo/src/main/java/ch/srgssr/pillarbox/demo/ui/showcases/layouts/StoryViewModel.kt b/pillarbox-demo/src/main/java/ch/srgssr/pillarbox/demo/ui/showcases/layouts/StoryViewModel.kt index 91012177d..79c5c42f0 100644 --- a/pillarbox-demo/src/main/java/ch/srgssr/pillarbox/demo/ui/showcases/layouts/StoryViewModel.kt +++ b/pillarbox-demo/src/main/java/ch/srgssr/pillarbox/demo/ui/showcases/layouts/StoryViewModel.kt @@ -5,8 +5,6 @@ package ch.srgssr.pillarbox.demo.ui.showcases.layouts import android.app.Application -import android.os.HandlerThread -import android.os.Process import android.util.SparseArray import androidx.core.util.forEach import androidx.lifecycle.AndroidViewModel @@ -14,19 +12,15 @@ import androidx.lifecycle.ViewModel import androidx.media3.common.C import androidx.media3.common.MediaItem import androidx.media3.common.Player -import androidx.media3.exoplayer.DefaultRendererCapabilitiesList import androidx.media3.exoplayer.source.MediaSource -import androidx.media3.exoplayer.source.preload.DefaultPreloadManager import androidx.media3.exoplayer.source.preload.DefaultPreloadManager.Status import androidx.media3.exoplayer.source.preload.DefaultPreloadManager.Status.STAGE_LOADED_TO_POSITION_MS import androidx.media3.exoplayer.source.preload.TargetPreloadStatusControl -import androidx.media3.exoplayer.upstream.DefaultAllocator import ch.srgssr.pillarbox.core.business.source.SRGAssetLoader import ch.srgssr.pillarbox.demo.shared.data.Playlist -import ch.srgssr.pillarbox.player.PillarboxBandwidthMeter import ch.srgssr.pillarbox.player.PillarboxExoPlayer import ch.srgssr.pillarbox.player.PillarboxLoadControl -import ch.srgssr.pillarbox.player.PillarboxRenderersFactory +import ch.srgssr.pillarbox.player.PillarboxPreloadManager import ch.srgssr.pillarbox.player.PillarboxTrackSelector import ch.srgssr.pillarbox.player.source.PillarboxMediaSourceFactory import kotlin.math.abs @@ -38,14 +32,21 @@ import kotlin.time.Duration.Companion.seconds */ class StoryViewModel(application: Application) : AndroidViewModel(application) { - private val playbackThread: HandlerThread = HandlerThread("StoryMode-playback", Process.THREAD_PRIORITY_AUDIO).apply { - start() - } - private val playbackLooper = playbackThread.looper - private val allocator = DefaultAllocator(false, C.DEFAULT_BUFFER_SEGMENT_SIZE) private val mediaSourceFactory = PillarboxMediaSourceFactory(application).apply { addAssetLoader(SRGAssetLoader(application)) } + private val preloadManager = + PillarboxPreloadManager( + context = application, + targetPreloadStatusControl = StoryPreloadStatusControl(), + mediaSourceFactory = mediaSourceFactory, + trackSelector = PillarboxTrackSelector(application).apply { + parameters = parameters.buildUpon() + .setForceLowestBitrate(true) + .build() + } + ) + private val loadControl = PillarboxLoadControl( bufferDurations = PillarboxLoadControl.BufferDurations( minBufferDuration = 5.seconds, @@ -53,22 +54,9 @@ class StoryViewModel(application: Application) : AndroidViewModel(application) { bufferForPlayback = 500.milliseconds, bufferForPlaybackAfterRebuffer = 1_000.milliseconds, ), - allocator + preloadManager.allocator ) - private val preloadManager = - DefaultPreloadManager( - StoryPreloadStatusControl(), - mediaSourceFactory, - PillarboxTrackSelector(application).apply { - init({}, PillarboxBandwidthMeter(application)) - }, - PillarboxBandwidthMeter(application), - DefaultRendererCapabilitiesList.Factory(PillarboxRenderersFactory(application)), - allocator, - playbackLooper, - ) - private var currentPage = C.INDEX_UNSET private val players = SparseArray(3).apply { @@ -77,7 +65,7 @@ class StoryViewModel(application: Application) : AndroidViewModel(application) { i, PillarboxExoPlayer( context = application, - playbackLooper = playbackLooper, + playbackLooper = preloadManager.playbackLooper, loadControl = loadControl ).apply { repeatMode = Player.REPEAT_MODE_ONE @@ -102,7 +90,7 @@ class StoryViewModel(application: Application) : AndroidViewModel(application) { mediaItems.forEachIndexed { index, mediaItem -> preloadManager.add(mediaItem, index) } - preloadManager.setCurrentPlayingIndex(0) + preloadManager.currentPlayingIndex = 0 preloadManager.invalidate() players.forEach { key, _ -> setupPlayerForPage(key) } @@ -141,7 +129,7 @@ class StoryViewModel(application: Application) : AndroidViewModel(application) { fun setCurrentPage(page: Int) { if (currentPage == page) return currentPage = page - preloadManager.setCurrentPlayingIndex(currentPage) + preloadManager.currentPlayingIndex = currentPage preloadManager.invalidate() } @@ -162,16 +150,15 @@ class StoryViewModel(application: Application) : AndroidViewModel(application) { } override fun onCleared() { - preloadManager.release() players.forEach { _, value -> value.release() } - playbackThread.quitSafely() + preloadManager.release() } /** * Default implementation of [TargetPreloadStatusControl] that will preload the first second of the `n ± 1` item, and the first half-second of - * the `n ± 2,3` item, where `n` is the index of the current item. + * the `n ± 2,3,4` item, where `n` is the index of the current item. */ @Suppress("MagicNumber") inner class StoryPreloadStatusControl : TargetPreloadStatusControl { @@ -180,7 +167,7 @@ class StoryViewModel(application: Application) : AndroidViewModel(application) { return when (offset) { 1 -> Status(STAGE_LOADED_TO_POSITION_MS, 1.seconds.inWholeMicroseconds) - 2, 3, 4 -> Status(STAGE_LOADED_TO_POSITION_MS, 500.milliseconds.inWholeMicroseconds) + 2, 3, 4 -> Status(STAGE_LOADED_TO_POSITION_MS, 1.milliseconds.inWholeMicroseconds) else -> null } } diff --git a/pillarbox-player/src/main/java/ch/srgssr/pillarbox/player/PillarboxPreloadManager.kt b/pillarbox-player/src/main/java/ch/srgssr/pillarbox/player/PillarboxPreloadManager.kt index 5caddccb4..6c9839e92 100644 --- a/pillarbox-player/src/main/java/ch/srgssr/pillarbox/player/PillarboxPreloadManager.kt +++ b/pillarbox-player/src/main/java/ch/srgssr/pillarbox/player/PillarboxPreloadManager.kt @@ -11,7 +11,6 @@ import android.os.Process import androidx.media3.common.C import androidx.media3.common.MediaItem import androidx.media3.exoplayer.DefaultRendererCapabilitiesList -import androidx.media3.exoplayer.LoadControl import androidx.media3.exoplayer.RendererCapabilitiesList import androidx.media3.exoplayer.source.MediaSource import androidx.media3.exoplayer.source.preload.DefaultPreloadManager @@ -19,6 +18,7 @@ import androidx.media3.exoplayer.source.preload.DefaultPreloadManager.Status import androidx.media3.exoplayer.source.preload.DefaultPreloadManager.Status.STAGE_LOADED_TO_POSITION_MS import androidx.media3.exoplayer.source.preload.TargetPreloadStatusControl import androidx.media3.exoplayer.trackselection.TrackSelector +import androidx.media3.exoplayer.upstream.Allocator import androidx.media3.exoplayer.upstream.BandwidthMeter import androidx.media3.exoplayer.upstream.DefaultAllocator import ch.srgssr.pillarbox.player.source.PillarboxMediaSourceFactory @@ -27,72 +27,32 @@ import kotlin.time.Duration.Companion.milliseconds import kotlin.time.Duration.Companion.seconds /** - * Helper class for the Media3's [DefaultPreloadManager]. The main difference between this class and [DefaultPreloadManager] is the addition of the - * [PlayerPool] argument. It allows the dynamic creation of a fixed number of [PillarboxExoPlayer] instances. - * - * This class provides the same methods as [DefaultPreloadManager] plus [getPlayer] and [getCurrentlyPlayingPlayer] to get an instance of a - * [PillarboxExoPlayer]. + * Helper class for the Media3's [DefaultPreloadManager]. * * @param context The current [Context]. * @param targetPreloadStatusControl The [TargetPreloadStatusControl] to decide when to preload an item and for how long. - * @param mediaSourceFactory The [MediaSource.Factory] to create each [MediaSource]. + * @param mediaSourceFactory The [PillarboxMediaSourceFactory] to create each [MediaSource]. * @param trackSelector The [TrackSelector] for this preload manager. * @param bandwidthMeter The [BandwidthMeter] for this preload manager. * @param rendererCapabilitiesListFactory The [RendererCapabilitiesList.Factory] for this preload manager. - * @param loadControl The [LoadControl] for this preload manager. + * @property allocator The [Allocator] for this preload manager. Have to be the same as the one used by the Player. * @param playbackThread The [Thread] on which the players run. - * @param playersCount The maximum number of [PillarboxExoPlayer] to create. - * @param playerFactory Called when a new [PillarboxExoPlayer] instance is necessary (up to `playersCount` times). The provided `Looper` **must** - * be passed to [PillarboxExoPlayer]'s constructor. * * @see DefaultPreloadManager */ class PillarboxPreloadManager( context: Context, targetPreloadStatusControl: TargetPreloadStatusControl? = null, - mediaSourceFactory: MediaSource.Factory = PillarboxMediaSourceFactory(context), - trackSelector: TrackSelector = PillarboxTrackSelector(context).apply { - init({}, PillarboxBandwidthMeter(context)) - }, + mediaSourceFactory: PillarboxMediaSourceFactory = PillarboxMediaSourceFactory(context), + trackSelector: TrackSelector = PillarboxTrackSelector(context), bandwidthMeter: BandwidthMeter = PillarboxBandwidthMeter(context), rendererCapabilitiesListFactory: RendererCapabilitiesList.Factory = DefaultRendererCapabilitiesList.Factory( PillarboxRenderersFactory(context) ), - private val loadControl: LoadControl = PillarboxLoadControl( - bufferDurations = PillarboxLoadControl.BufferDurations( - minBufferDuration = 5.seconds, - maxBufferDuration = 20.seconds, - bufferForPlayback = 500.milliseconds, - ), - allocator = DefaultAllocator(false, C.DEFAULT_BUFFER_SEGMENT_SIZE), - ), + val allocator: DefaultAllocator = DefaultAllocator(false, C.DEFAULT_BUFFER_SEGMENT_SIZE), private val playbackThread: HandlerThread = HandlerThread("PillarboxPreloadManager:Playback", Process.THREAD_PRIORITY_AUDIO), - playersCount: Int = 3, - playerFactory: (playbackLooper: Looper) -> PillarboxExoPlayer = { playbackLooper -> - PillarboxExoPlayer( - context = context, - loadControl = loadControl, - playbackLooper = playbackLooper, - ) - }, ) { - private val playerPool = PlayerPool( - playersCount = playersCount, - playerFactory = { playerFactory(playbackThread.looper) }, - ) - - // We use a lazy creation so the playbackThread can be started first - private val preloadManager by lazy { - DefaultPreloadManager( - targetPreloadStatusControl ?: DefaultTargetPreloadStatusControl(), - mediaSourceFactory, - trackSelector, - bandwidthMeter, - rendererCapabilitiesListFactory, - loadControl.allocator, - playbackThread.looper, - ) - } + private val preloadManager: DefaultPreloadManager /** * The index of the currently playing media item. @@ -113,8 +73,24 @@ class PillarboxPreloadManager( val sourceCount: Int get() = preloadManager.sourceCount + /** + * Playback looper to use with PillarboxExoPlayer. + */ + val playbackLooper: Looper + init { playbackThread.start() + playbackLooper = playbackThread.looper + trackSelector.init({}, bandwidthMeter) + preloadManager = DefaultPreloadManager( + targetPreloadStatusControl ?: DefaultTargetPreloadStatusControl(), + mediaSourceFactory, + trackSelector, + bandwidthMeter, + rendererCapabilitiesListFactory, + allocator, + playbackLooper, + ) } /** @@ -166,9 +142,8 @@ class PillarboxPreloadManager( * @see DefaultPreloadManager.release */ fun release() { - playerPool.release() preloadManager.release() - playbackThread.quit() + playbackThread.quitSafely() } /** @@ -202,25 +177,6 @@ class PillarboxPreloadManager( preloadManager.reset() } - /** - * Get a [PillarboxExoPlayer] for the given [index]. If the desired player has not been created yet, [PlayerPool.playerFactory] will be called. - * - * @param index The index of the [PillarboxExoPlayer] to retrieve. - * @return The desired [PillarboxExoPlayer], or `null` if [index] is negative. - */ - fun getPlayer(index: Int): PillarboxExoPlayer? { - return playerPool.getPlayerAtPosition(index) - } - - /** - * Get the currently playing [PillarboxExoPlayer]. - * - * @return The currently playing [PillarboxExoPlayer], or `null` if there is no active player. - */ - fun getCurrentlyPlayingPlayer(): PillarboxExoPlayer? { - return getPlayer(currentPlayingIndex) - } - /** * Default implementation of [TargetPreloadStatusControl] that will preload the first second of the `n ± 1` item, and the first half-second of * the `n ± 2,3` item, where `n` is the index of the current item. diff --git a/pillarbox-player/src/main/java/ch/srgssr/pillarbox/player/PlayerPool.kt b/pillarbox-player/src/main/java/ch/srgssr/pillarbox/player/PlayerPool.kt deleted file mode 100644 index d7638e20e..000000000 --- a/pillarbox-player/src/main/java/ch/srgssr/pillarbox/player/PlayerPool.kt +++ /dev/null @@ -1,56 +0,0 @@ -/* - * Copyright (c) SRG SSR. All rights reserved. - * License information is available from the LICENSE file. - */ -package ch.srgssr.pillarbox.player - -import android.util.SparseArray - -/** - * Pool of [playersCount] [PillarboxExoPlayer]. - * - * @param playersCount The maximum number of [PillarboxExoPlayer] managed by this pool. - * @param playerFactory The factory method to create a new [PillarboxExoPlayer]. - */ -class PlayerPool( - private val playersCount: Int, - private val playerFactory: () -> PillarboxExoPlayer, -) { - private val players: SparseArray - - init { - require(playersCount > 0) { - "playersCount must be greater than 0, but was $playersCount" - } - - players = SparseArray(playersCount) - } - - /** - * Get a [PillarboxExoPlayer] for the given [position]. If the desired player has not been created yet, [playerFactory] will be called. - * - * @param position The position of the [PillarboxExoPlayer] to retrieve. - * @return The desired [PillarboxExoPlayer], or `null` if [position] is negative. - */ - fun getPlayerAtPosition(position: Int): PillarboxExoPlayer? { - if (position < 0) { - return null - } - - val index = position % playersCount - - return players[index] ?: playerFactory().also { - players[index] = it - } - } - - /** - * Release this pool. This will also release all the managed [PillarboxExoPlayer]. - */ - fun release() { - repeat(playersCount) { index -> - players[index]?.release() - } - players.clear() - } -} From 2fcefcf801f72690e529809c6f93c6a574413542 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Joaquim=20Sta=CC=88hli?= Date: Thu, 10 Oct 2024 15:21:54 +0200 Subject: [PATCH 17/23] lint --- .../main/java/ch/srgssr/pillarbox/player/PillarboxExoPlayer.kt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pillarbox-player/src/main/java/ch/srgssr/pillarbox/player/PillarboxExoPlayer.kt b/pillarbox-player/src/main/java/ch/srgssr/pillarbox/player/PillarboxExoPlayer.kt index 9f57c9248..551bee4e1 100644 --- a/pillarbox-player/src/main/java/ch/srgssr/pillarbox/player/PillarboxExoPlayer.kt +++ b/pillarbox-player/src/main/java/ch/srgssr/pillarbox/player/PillarboxExoPlayer.kt @@ -5,8 +5,8 @@ package ch.srgssr.pillarbox.player import android.content.Context -import android.os.Looper import android.os.Handler +import android.os.Looper import androidx.annotation.VisibleForTesting import androidx.media3.common.C import androidx.media3.common.MediaItem From f3c0c095b11f8ae1609eb924be761bc064d16f44 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Joaquim=20Sta=CC=88hli?= Date: Thu, 10 Oct 2024 15:24:41 +0200 Subject: [PATCH 18/23] Fix tests --- .../player/PillarboxPreloadManagerTest.kt | 24 +----- .../srgssr/pillarbox/player/PlayerPoolTest.kt | 80 ------------------- 2 files changed, 1 insertion(+), 103 deletions(-) delete mode 100644 pillarbox-player/src/test/java/ch/srgssr/pillarbox/player/PlayerPoolTest.kt diff --git a/pillarbox-player/src/test/java/ch/srgssr/pillarbox/player/PillarboxPreloadManagerTest.kt b/pillarbox-player/src/test/java/ch/srgssr/pillarbox/player/PillarboxPreloadManagerTest.kt index 6d6acd412..5e4bc9f67 100644 --- a/pillarbox-player/src/test/java/ch/srgssr/pillarbox/player/PillarboxPreloadManagerTest.kt +++ b/pillarbox-player/src/test/java/ch/srgssr/pillarbox/player/PillarboxPreloadManagerTest.kt @@ -20,25 +20,12 @@ import kotlin.test.assertTrue @RunWith(AndroidJUnit4::class) class PillarboxPreloadManagerTest { - private var createdPlayersCount = 0 private lateinit var preloadManager: PillarboxPreloadManager @BeforeTest fun setUp() { val context = ApplicationProvider.getApplicationContext() - - createdPlayersCount = 0 - preloadManager = PillarboxPreloadManager( - context = context, - playerFactory = { playbackLooper -> - createdPlayersCount++ - - PillarboxExoPlayer( - context = context, - playbackLooper = playbackLooper, - ) - }, - ) + preloadManager = PillarboxPreloadManager(context = context) } @AfterTest @@ -51,9 +38,6 @@ class PillarboxPreloadManagerTest { assertEquals(C.INDEX_UNSET, preloadManager.currentPlayingIndex) assertEquals(0, preloadManager.sourceCount) assertNull(preloadManager.getMediaSource(VOD1)) - assertNotNull(preloadManager.getPlayer(0)) - assertNull(preloadManager.getCurrentlyPlayingPlayer()) - assertEquals(1, createdPlayersCount) } @Test @@ -72,8 +56,6 @@ class PillarboxPreloadManagerTest { assertNotNull(preloadManager.getMediaSource(VOD3)) assertNotNull(preloadManager.getMediaSource(VOD4)) assertNull(preloadManager.getMediaSource(VOD5)) - assertEquals(preloadManager.getPlayer(2), preloadManager.getCurrentlyPlayingPlayer()) - assertEquals(1, createdPlayersCount) assertTrue(preloadManager.remove(VOD2)) assertTrue(preloadManager.remove(VOD3)) @@ -86,8 +68,6 @@ class PillarboxPreloadManagerTest { assertNull(preloadManager.getMediaSource(VOD3)) assertNotNull(preloadManager.getMediaSource(VOD4)) assertNull(preloadManager.getMediaSource(VOD5)) - assertEquals(preloadManager.getPlayer(2), preloadManager.getCurrentlyPlayingPlayer()) - assertEquals(1, createdPlayersCount) preloadManager.reset() preloadManager.invalidate() @@ -99,8 +79,6 @@ class PillarboxPreloadManagerTest { assertNull(preloadManager.getMediaSource(VOD3)) assertNull(preloadManager.getMediaSource(VOD4)) assertNull(preloadManager.getMediaSource(VOD5)) - assertEquals(preloadManager.getPlayer(2), preloadManager.getCurrentlyPlayingPlayer()) - assertEquals(1, createdPlayersCount) } private companion object { diff --git a/pillarbox-player/src/test/java/ch/srgssr/pillarbox/player/PlayerPoolTest.kt b/pillarbox-player/src/test/java/ch/srgssr/pillarbox/player/PlayerPoolTest.kt deleted file mode 100644 index 4fa73a948..000000000 --- a/pillarbox-player/src/test/java/ch/srgssr/pillarbox/player/PlayerPoolTest.kt +++ /dev/null @@ -1,80 +0,0 @@ -/* - * Copyright (c) SRG SSR. All rights reserved. - * License information is available from the LICENSE file. - */ -package ch.srgssr.pillarbox.player - -import androidx.test.core.app.ApplicationProvider -import androidx.test.ext.junit.runners.AndroidJUnit4 -import org.junit.runner.RunWith -import kotlin.test.Test -import kotlin.test.assertEquals -import kotlin.test.assertFalse -import kotlin.test.assertNotNull -import kotlin.test.assertNull -import kotlin.test.assertTrue - -@RunWith(AndroidJUnit4::class) -class PlayerPoolTest { - @Test(expected = IllegalArgumentException::class) - fun `playerCount can not be negative`() { - PlayerPool( - playersCount = -1, - playerFactory = { PillarboxExoPlayer(ApplicationProvider.getApplicationContext()) }, - ) - } - - @Test(expected = IllegalArgumentException::class) - fun `playerCount can not be 0`() { - PlayerPool( - playersCount = 0, - playerFactory = { PillarboxExoPlayer(ApplicationProvider.getApplicationContext()) }, - ) - } - - @Test - fun `get player at position`() { - val playersCount = 5 - var createdPlayers = 0 - val pool = PlayerPool( - playersCount = playersCount, - playerFactory = { - createdPlayers++ - - PillarboxExoPlayer(ApplicationProvider.getApplicationContext()) - }, - ) - - // Invalid position - assertNull(pool.getPlayerAtPosition(-1)) - assertEquals(0, createdPlayers) - - // Position < playersCount - assertNotNull(pool.getPlayerAtPosition(2)) - assertEquals(1, createdPlayers) - - // Position > playersCount - val requestedPlayerOffset = 3 - assertNotNull(pool.getPlayerAtPosition(playersCount + requestedPlayerOffset)) - assertEquals(2, createdPlayers) - - // Reuse player instance - assertEquals(pool.getPlayerAtPosition(requestedPlayerOffset), pool.getPlayerAtPosition(playersCount + requestedPlayerOffset)) - assertEquals(2, createdPlayers) - } - - @Test - fun release() { - val pool = PlayerPool( - playersCount = 3, - playerFactory = { PillarboxExoPlayer(ApplicationProvider.getApplicationContext()) }, - ) - val player = pool.getPlayerAtPosition(1) - - assertNotNull(player) - assertFalse(player.isReleased) - - pool.release() - assertTrue(player.isReleased) - } -} From 47af02dee0ffb321e0f44f7bafe5d2efc25ee4b4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ga=C3=ABtan=20Muller?= Date: Fri, 11 Oct 2024 14:44:33 +0200 Subject: [PATCH 19/23] Small formating, doc, and code cleanup --- .../ui/showcases/layouts/OptimizedStory.kt | 9 +-- .../ui/showcases/layouts/StoryViewModel.kt | 68 +++++++++---------- .../player/PillarboxPreloadManager.kt | 6 +- 3 files changed, 39 insertions(+), 44 deletions(-) diff --git a/pillarbox-demo/src/main/java/ch/srgssr/pillarbox/demo/ui/showcases/layouts/OptimizedStory.kt b/pillarbox-demo/src/main/java/ch/srgssr/pillarbox/demo/ui/showcases/layouts/OptimizedStory.kt index 871deeec0..f031845f4 100644 --- a/pillarbox-demo/src/main/java/ch/srgssr/pillarbox/demo/ui/showcases/layouts/OptimizedStory.kt +++ b/pillarbox-demo/src/main/java/ch/srgssr/pillarbox/demo/ui/showcases/layouts/OptimizedStory.kt @@ -70,10 +70,8 @@ fun OptimizedStory(storyViewModel: StoryViewModel = viewModel()) { val movablePlayerView = remember { (0 until storyViewModel.playerCount).map { index -> movableContentOf { - Box { - val player = remember { storyViewModel.getPlayer(index) } - PlayerView(player, modifier = Modifier.fillMaxSize()) - } + val player = remember { storyViewModel.getPlayer(index) } + PlayerView(player, modifier = Modifier.fillMaxSize()) } } } @@ -118,8 +116,7 @@ private fun PlayerView(player: Player, modifier: Modifier = Modifier) { modifier = modifier, ) { PlayerSurface( - modifier = Modifier - .fillMaxHeight(), + modifier = Modifier.fillMaxHeight(), scaleMode = ScaleMode.Crop, surfaceType = if (Build.VERSION.SDK_INT == Build.VERSION_CODES.UPSIDE_DOWN_CAKE) SurfaceType.Texture else SurfaceType.Surface, player = player, diff --git a/pillarbox-demo/src/main/java/ch/srgssr/pillarbox/demo/ui/showcases/layouts/StoryViewModel.kt b/pillarbox-demo/src/main/java/ch/srgssr/pillarbox/demo/ui/showcases/layouts/StoryViewModel.kt index 79c5c42f0..f2a3f1b60 100644 --- a/pillarbox-demo/src/main/java/ch/srgssr/pillarbox/demo/ui/showcases/layouts/StoryViewModel.kt +++ b/pillarbox-demo/src/main/java/ch/srgssr/pillarbox/demo/ui/showcases/layouts/StoryViewModel.kt @@ -35,17 +35,16 @@ class StoryViewModel(application: Application) : AndroidViewModel(application) { private val mediaSourceFactory = PillarboxMediaSourceFactory(application).apply { addAssetLoader(SRGAssetLoader(application)) } - private val preloadManager = - PillarboxPreloadManager( - context = application, - targetPreloadStatusControl = StoryPreloadStatusControl(), - mediaSourceFactory = mediaSourceFactory, - trackSelector = PillarboxTrackSelector(application).apply { - parameters = parameters.buildUpon() - .setForceLowestBitrate(true) - .build() - } - ) + private val preloadManager = PillarboxPreloadManager( + context = application, + targetPreloadStatusControl = StoryPreloadStatusControl(), + mediaSourceFactory = mediaSourceFactory, + trackSelector = PillarboxTrackSelector(application).apply { + parameters = parameters.buildUpon() + .setForceLowestBitrate(true) + .build() + } + ) private val loadControl = PillarboxLoadControl( bufferDurations = PillarboxLoadControl.BufferDurations( @@ -54,25 +53,24 @@ class StoryViewModel(application: Application) : AndroidViewModel(application) { bufferForPlayback = 500.milliseconds, bufferForPlaybackAfterRebuffer = 1_000.milliseconds, ), - preloadManager.allocator + allocator = preloadManager.allocator, ) private var currentPage = C.INDEX_UNSET - private val players = SparseArray(3).apply { - for (i in 0 until 3) { - put( - i, - PillarboxExoPlayer( - context = application, - playbackLooper = preloadManager.playbackLooper, - loadControl = loadControl - ).apply { - repeatMode = Player.REPEAT_MODE_ONE - videoScalingMode = C.VIDEO_SCALING_MODE_SCALE_TO_FIT_WITH_CROPPING - prepare() - } - ) + private val players = SparseArray(PLAYERS_COUNT).apply { + for (i in 0 until PLAYERS_COUNT) { + val player = PillarboxExoPlayer( + context = application, + playbackLooper = preloadManager.playbackLooper, + loadControl = loadControl + ).apply { + repeatMode = Player.REPEAT_MODE_ONE + videoScalingMode = C.VIDEO_SCALING_MODE_SCALE_TO_FIT_WITH_CROPPING + prepare() + } + + put(i, player) } } @@ -90,8 +88,7 @@ class StoryViewModel(application: Application) : AndroidViewModel(application) { mediaItems.forEachIndexed { index, mediaItem -> preloadManager.add(mediaItem, index) } - preloadManager.currentPlayingIndex = 0 - preloadManager.invalidate() + setCurrentPage(0) players.forEach { key, _ -> setupPlayerForPage(key) } } @@ -136,16 +133,13 @@ class StoryViewModel(application: Application) : AndroidViewModel(application) { /** * Play * - * @param player to play all others are paused. + * @param player The player to play, all others are paused. */ fun play(player: PillarboxExoPlayer) { if (player.playWhenReady) return players.forEach { _, value -> - value.pause() value.seekToDefaultPosition() - if (value == player) { - player.play() - } + value.playWhenReady = value == player } } @@ -157,11 +151,11 @@ class StoryViewModel(application: Application) : AndroidViewModel(application) { } /** - * Default implementation of [TargetPreloadStatusControl] that will preload the first second of the `n ± 1` item, and the first half-second of + * Custom implementation of [TargetPreloadStatusControl] that will preload the first second of the `n ± 1` item, and the first millisecond of * the `n ± 2,3,4` item, where `n` is the index of the current item. */ @Suppress("MagicNumber") - inner class StoryPreloadStatusControl : TargetPreloadStatusControl { + private inner class StoryPreloadStatusControl : TargetPreloadStatusControl { override fun getTargetPreloadStatus(rankingData: Int): TargetPreloadStatusControl.PreloadStatus? { val offset = abs(rankingData - currentPage) @@ -172,4 +166,8 @@ class StoryViewModel(application: Application) : AndroidViewModel(application) { } } } + + private companion object { + private const val PLAYERS_COUNT = 3 + } } diff --git a/pillarbox-player/src/main/java/ch/srgssr/pillarbox/player/PillarboxPreloadManager.kt b/pillarbox-player/src/main/java/ch/srgssr/pillarbox/player/PillarboxPreloadManager.kt index 6c9839e92..7af9a547d 100644 --- a/pillarbox-player/src/main/java/ch/srgssr/pillarbox/player/PillarboxPreloadManager.kt +++ b/pillarbox-player/src/main/java/ch/srgssr/pillarbox/player/PillarboxPreloadManager.kt @@ -36,14 +36,14 @@ import kotlin.time.Duration.Companion.seconds * @param bandwidthMeter The [BandwidthMeter] for this preload manager. * @param rendererCapabilitiesListFactory The [RendererCapabilitiesList.Factory] for this preload manager. * @property allocator The [Allocator] for this preload manager. Have to be the same as the one used by the Player. - * @param playbackThread The [Thread] on which the players run. + * @param playbackThread The [Thread] on which the players run. Its lifecycle is handled internally by [PillarboxPreloadManager]. * * @see DefaultPreloadManager */ class PillarboxPreloadManager( context: Context, targetPreloadStatusControl: TargetPreloadStatusControl? = null, - mediaSourceFactory: PillarboxMediaSourceFactory = PillarboxMediaSourceFactory(context), + mediaSourceFactory: MediaSource.Factory = PillarboxMediaSourceFactory(context), trackSelector: TrackSelector = PillarboxTrackSelector(context), bandwidthMeter: BandwidthMeter = PillarboxBandwidthMeter(context), rendererCapabilitiesListFactory: RendererCapabilitiesList.Factory = DefaultRendererCapabilitiesList.Factory( @@ -136,7 +136,7 @@ class PillarboxPreloadManager( } /** - * Release the preload manager and the underlying [PlayerPool]. + * Release the preload manager. * The preload manager must not be used after calling this method. * * @see DefaultPreloadManager.release From 3df9a92e082cece2830eb183a95afb0d4bc6d91e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ga=C3=ABtan=20Muller?= Date: Fri, 11 Oct 2024 15:28:00 +0200 Subject: [PATCH 20/23] Try to use `ubuntu-22.04` --- .github/workflows/build.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 0151e17ce..236b93255 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -155,7 +155,7 @@ jobs: android-tests: name: Android Tests - runs-on: ubuntu-latest + runs-on: ubuntu-22.04 needs: build env: USERNAME: ${{ github.actor }} From 8ec2009080d4b604e5499cbaef30353af89ffc0b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ga=C3=ABtan=20Muller?= Date: Mon, 14 Oct 2024 08:52:10 +0200 Subject: [PATCH 21/23] Try to use `ubuntu-latest` again --- .github/workflows/build.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 236b93255..0151e17ce 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -155,7 +155,7 @@ jobs: android-tests: name: Android Tests - runs-on: ubuntu-22.04 + runs-on: ubuntu-latest needs: build env: USERNAME: ${{ github.actor }} From 80058924922d1aa6dfc35a7073c5fb48a0b082d1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Joaquim=20Sta=CC=88hli?= Date: Mon, 14 Oct 2024 10:54:14 +0200 Subject: [PATCH 22/23] Don't use generic MediaSource.Factory --- .../java/ch/srgssr/pillarbox/player/PillarboxExoPlayer.kt | 5 ++--- .../ch/srgssr/pillarbox/player/PillarboxPreloadManager.kt | 2 +- 2 files changed, 3 insertions(+), 4 deletions(-) diff --git a/pillarbox-player/src/main/java/ch/srgssr/pillarbox/player/PillarboxExoPlayer.kt b/pillarbox-player/src/main/java/ch/srgssr/pillarbox/player/PillarboxExoPlayer.kt index 551bee4e1..d10322762 100644 --- a/pillarbox-player/src/main/java/ch/srgssr/pillarbox/player/PillarboxExoPlayer.kt +++ b/pillarbox-player/src/main/java/ch/srgssr/pillarbox/player/PillarboxExoPlayer.kt @@ -18,7 +18,6 @@ import androidx.media3.common.util.Clock import androidx.media3.common.util.ListenerSet import androidx.media3.exoplayer.ExoPlayer import androidx.media3.exoplayer.LoadControl -import androidx.media3.exoplayer.source.MediaSource import ch.srgssr.pillarbox.player.analytics.PillarboxAnalyticsCollector import ch.srgssr.pillarbox.player.analytics.PlaybackSessionManager import ch.srgssr.pillarbox.player.analytics.metrics.MetricsCollector @@ -129,7 +128,7 @@ class PillarboxExoPlayer internal constructor( constructor( context: Context, - mediaSourceFactory: MediaSource.Factory = PillarboxMediaSourceFactory(context), + mediaSourceFactory: PillarboxMediaSourceFactory = PillarboxMediaSourceFactory(context), loadControl: LoadControl = PillarboxLoadControl(), seekIncrement: SeekIncrement = SeekIncrement(), maxSeekToPreviousPosition: Duration = DEFAULT_MAX_SEEK_TO_PREVIOUS_POSITION, @@ -151,7 +150,7 @@ class PillarboxExoPlayer internal constructor( @VisibleForTesting constructor( context: Context, - mediaSourceFactory: MediaSource.Factory = PillarboxMediaSourceFactory(context), + mediaSourceFactory: PillarboxMediaSourceFactory = PillarboxMediaSourceFactory(context), loadControl: LoadControl = PillarboxLoadControl(), seekIncrement: SeekIncrement = SeekIncrement(), maxSeekToPreviousPosition: Duration = DEFAULT_MAX_SEEK_TO_PREVIOUS_POSITION, diff --git a/pillarbox-player/src/main/java/ch/srgssr/pillarbox/player/PillarboxPreloadManager.kt b/pillarbox-player/src/main/java/ch/srgssr/pillarbox/player/PillarboxPreloadManager.kt index 7af9a547d..3a9ebca57 100644 --- a/pillarbox-player/src/main/java/ch/srgssr/pillarbox/player/PillarboxPreloadManager.kt +++ b/pillarbox-player/src/main/java/ch/srgssr/pillarbox/player/PillarboxPreloadManager.kt @@ -43,7 +43,7 @@ import kotlin.time.Duration.Companion.seconds class PillarboxPreloadManager( context: Context, targetPreloadStatusControl: TargetPreloadStatusControl? = null, - mediaSourceFactory: MediaSource.Factory = PillarboxMediaSourceFactory(context), + mediaSourceFactory: PillarboxMediaSourceFactory = PillarboxMediaSourceFactory(context), trackSelector: TrackSelector = PillarboxTrackSelector(context), bandwidthMeter: BandwidthMeter = PillarboxBandwidthMeter(context), rendererCapabilitiesListFactory: RendererCapabilitiesList.Factory = DefaultRendererCapabilitiesList.Factory( From e9d5c9a1a6a08ae400c00a975718077ec87dd112 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Joaquim=20Sta=CC=88hli?= Date: Mon, 14 Oct 2024 10:56:25 +0200 Subject: [PATCH 23/23] Update with new data model --- .../pillarbox/demo/shared/data/Playlist.kt | 24 +++++++++---------- 1 file changed, 12 insertions(+), 12 deletions(-) diff --git a/pillarbox-demo-shared/src/main/java/ch/srgssr/pillarbox/demo/shared/data/Playlist.kt b/pillarbox-demo-shared/src/main/java/ch/srgssr/pillarbox/demo/shared/data/Playlist.kt index 7a6b4e645..a48d6d844 100644 --- a/pillarbox-demo-shared/src/main/java/ch/srgssr/pillarbox/demo/shared/data/Playlist.kt +++ b/pillarbox-demo-shared/src/main/java/ch/srgssr/pillarbox/demo/shared/data/Playlist.kt @@ -142,35 +142,35 @@ data class Playlist(val title: String, val items: List, val descriptio val StoryUrns = Playlist( title = "Story urns", items = listOf( - DemoItem( + DemoItem.URN( title = "Mario vs Sonic", description = "Tataki 1", - uri = "urn:rts:video:13950405" + urn = "urn:rts:video:13950405" ), - DemoItem( + DemoItem.URN( title = "Pourquoi Beyoncé fait de la country", description = "Tataki 2", - uri = "urn:rts:video:14815579" + urn = "urn:rts:video:14815579" ), - DemoItem( + DemoItem.URN( title = "L'île North Sentinel", description = "Tataki 3", - uri = "urn:rts:video:13795051" + urn = "urn:rts:video:13795051" ), - DemoItem( + DemoItem.URN( title = "Mourir pour ressembler à une idole", description = "Tataki 4", - uri = "urn:rts:video:14020134" + urn = "urn:rts:video:14020134" ), - DemoItem( + DemoItem.URN( title = "Pourquoi les gens mangent des insectes ?", description = "Tataki 5", - uri = "urn:rts:video:12631996" + urn = "urn:rts:video:12631996" ), - DemoItem( + DemoItem.URN( title = "Le concert de Beyoncé à Dubai", description = "Tataki 6", - uri = "urn:rts:video:13752646" + urn = "urn:rts:video:13752646" ) ) )