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..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 @@ -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.URN( + title = "Mario vs Sonic", + description = "Tataki 1", + urn = "urn:rts:video:13950405" + ), + DemoItem.URN( + title = "Pourquoi Beyoncé fait de la country", + description = "Tataki 2", + urn = "urn:rts:video:14815579" + ), + DemoItem.URN( + title = "L'île North Sentinel", + description = "Tataki 3", + urn = "urn:rts:video:13795051" + ), + DemoItem.URN( + title = "Mourir pour ressembler à une idole", + description = "Tataki 4", + urn = "urn:rts:video:14020134" + ), + DemoItem.URN( + title = "Pourquoi les gens mangent des insectes ?", + description = "Tataki 5", + urn = "urn:rts:video:12631996" + ), + DemoItem.URN( + title = "Le concert de Beyoncé à Dubai", + description = "Tataki 6", + urn = "urn:rts:video:13752646" + ) + ) + ) private val googleStreams = Playlist( title = "Google streams", items = listOf( 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..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 @@ -4,112 +4,205 @@ */ 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 android.os.Build +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.CircularProgressIndicator +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.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 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 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 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 + 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.play(storyViewModel.getPlayer(settledPage)) + } + LaunchedEffect(currentPage) { + storyViewModel.setCurrentPage(currentPage) } - LifecycleStartEffect(pagerState) { - storyViewModel.getPlayerForPageNumber(pagerState.currentPage).play() - onStopOrDispose { - storyViewModel.pauseAllPlayer() + val movablePlayerView = remember { + (0 until storyViewModel.playerCount).map { index -> + movableContentOf { + val player = remember { storyViewModel.getPlayer(index) } + PlayerView(player, modifier = Modifier.fillMaxSize()) + } } } - val playlist = storyViewModel.playlist.items Box(modifier = Modifier.fillMaxSize()) { - HorizontalPager( - beyondViewportPageCount = 0, - key = { page -> playlist[page].uri }, + VerticalPager( flingBehavior = PagerDefaults.flingBehavior( state = pagerState, pagerSnapDistance = PagerSnapDistance.atMost(0), - snapAnimationSpec = spring(stiffness = Spring.StiffnessHigh) ), - pageSpacing = 1.dp, - state = pagerState + beyondViewportPageCount = 0, + 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 + LaunchedEffect(page) { + storyViewModel.setupPlayerForPage(page) } - player?.let { - 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") + movablePlayerView[page % movablePlayerView.size]() } - Row( - Modifier + + PagerIndicator( + currentPage = settledPage, + pageCount = mediaItems.size, + modifier = Modifier + .align(Alignment.CenterEnd) + .padding(end = MaterialTheme.paddings.small), + ) + } +} + +@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 = if (Build.VERSION.SDK_INT == Build.VERSION_CODES.UPSIDE_DOWN_CAKE) SurfaceType.Texture else SurfaceType.Surface, + 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) - .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) - } - - ) - } + .align(Alignment.BottomCenter), + color = PrimaryComponentColor, + trackColor = SecondaryComponentColor, + gapSize = 0.dp, + drawStopIndicator = {}, + ) + } +} + +@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( + targetValue = if (currentPage == index) PrimaryComponentColor else SecondaryComponentColor, + label = "indicator-animation", + ) + + Box( + modifier = Modifier + .padding(MaterialTheme.paddings.micro) + .size(IndicatorSize) + .drawBehind { + drawCircle(dotColor) + }, + ) } } } -private val ColorIndicatorCurrent = Color.LightGray.copy(alpha = 0.75f) -private val ColorIndicator = Color.LightGray.copy(alpha = 0.25f) +@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 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..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 @@ -5,108 +5,169 @@ package ch.srgssr.pillarbox.demo.ui.showcases.layouts import android.app.Application +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.source.MediaSource +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 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.PillarboxLoadControl +import ch.srgssr.pillarbox.player.PillarboxPreloadManager +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 /** - * 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 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, + maxBufferDuration = 20.seconds, + bufferForPlayback = 500.milliseconds, + bufferForPlaybackAfterRebuffer = 1_000.milliseconds, ), + allocator = preloadManager.allocator, ) + private var currentPage = C.INDEX_UNSET + + 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) + } + } + + /** + * Player count + */ + val playerCount: Int = players.size() + /** - * Playlist to use with viewpager + * The list of items to play. */ - val playlist: Playlist = Playlist.VideoUrns + val mediaItems: List = (Playlist.StoryUrns.items + Playlist.VideoUrns.items).map { it.toMediaItem() } init { - preparePlayers() + mediaItems.forEachIndexed { index, mediaItem -> + preloadManager.add(mediaItem, index) + } + setCurrentPage(0) + + players.forEach { key, _ -> setupPlayerForPage(key) } } /** - * Get player for page number + * Set up player for the [page]. * - * @param pageNumber - * @return [PillarboxExoPlayer] that should be used for this [pageNumber] + * @param page The page. */ - 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. + 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() - player.videoScalingMode = C.VIDEO_SCALING_MODE_SCALE_TO_FIT_WITH_CROPPING } } - override fun onCleared() { - super.onCleared() - for (player in players) { - player.stop() - player.release() - } - } + private fun getMediaSourceForPage(page: Int): MediaSource = checkNotNull(preloadManager.getMediaSource(mediaItems[page])) /** - * Pause all player + * @param page The page. + * @return the player for the [page]. */ - fun pauseAllPlayer() { - for (player in players) { - player.pause() - } + fun getPlayer(page: Int): PillarboxExoPlayer { + return players[page % players.size()] } /** - * 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 current page, do nothing if it is already the current. * - * @param page - * @return Pair + * @param page The current page */ - fun getPlayerAndMediaItemIndexForPage(page: Int): Pair { - val playerMaxItemCount = playerMaxItemCount() - val i = playerIndex(page) - val j = (page - i) / playerMaxItemCount - return Pair(i, j) + fun setCurrentPage(page: Int) { + if (currentPage == page) return + currentPage = page + preloadManager.currentPlayingIndex = currentPage + preloadManager.invalidate() } /** - * Get player from index + * Play * - * @param playerIndex the index received from [getPlayerAndMediaItemIndexForPage] + * @param player The player to play, all others are paused. */ - fun getPlayerFromIndex(playerIndex: Int) = players[playerIndex] + fun play(player: PillarboxExoPlayer) { + if (player.playWhenReady) return + players.forEach { _, value -> + value.seekToDefaultPosition() + value.playWhenReady = value == player + } + } - private fun playerIndex(pageNumber: Int) = pageNumber % players.size + override fun onCleared() { + players.forEach { _, value -> + value.release() + } + preloadManager.release() + } - private fun playerMaxItemCount() = ceil(playlist.items.size / players.size.toFloat()).toInt() + /** + * 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") + private 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, 1.milliseconds.inWholeMicroseconds) + else -> null + } + } + } + + private companion object { + private const val PLAYERS_COUNT = 3 + } } 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..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 @@ -6,6 +6,7 @@ package ch.srgssr.pillarbox.player import android.content.Context import android.os.Handler +import android.os.Looper import androidx.annotation.VisibleForTesting import androidx.media3.common.C import androidx.media3.common.MediaItem @@ -13,14 +14,10 @@ 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 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 @@ -138,6 +134,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, @@ -147,6 +144,7 @@ class PillarboxExoPlayer internal constructor( clock = Clock.DEFAULT, coroutineContext = coroutineContext, monitoringMessageHandler = monitoringMessageHandler, + playbackLooper = playbackLooper, ) @VisibleForTesting @@ -161,6 +159,7 @@ class PillarboxExoPlayer internal constructor( analyticsCollector: PillarboxAnalyticsCollector = PillarboxAnalyticsCollector(clock), metricsCollector: MetricsCollector = MetricsCollector(), monitoringMessageHandler: MonitoringMessageHandler = NoOpMonitoringMessageHandler, + playbackLooper: Looper? = null, ) : this( context, coroutineContext, @@ -169,24 +168,18 @@ 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 + .apply { + playbackLooper?.let { + setPlaybackLooper(it) + } + } .build(), analyticsCollector = analyticsCollector, metricsCollector = metricsCollector, 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..3a9ebca57 --- /dev/null +++ b/pillarbox-player/src/main/java/ch/srgssr/pillarbox/player/PillarboxPreloadManager.kt @@ -0,0 +1,196 @@ +/* + * 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.HandlerThread +import android.os.Looper +import android.os.Process +import androidx.media3.common.C +import androidx.media3.common.MediaItem +import androidx.media3.exoplayer.DefaultRendererCapabilitiesList +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.Allocator +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 +import kotlin.time.Duration.Companion.seconds + +/** + * 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 [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. + * @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. Its lifecycle is handled internally by [PillarboxPreloadManager]. + * + * @see DefaultPreloadManager + */ +class PillarboxPreloadManager( + context: Context, + targetPreloadStatusControl: TargetPreloadStatusControl? = null, + mediaSourceFactory: PillarboxMediaSourceFactory = PillarboxMediaSourceFactory(context), + trackSelector: TrackSelector = PillarboxTrackSelector(context), + bandwidthMeter: BandwidthMeter = PillarboxBandwidthMeter(context), + rendererCapabilitiesListFactory: RendererCapabilitiesList.Factory = DefaultRendererCapabilitiesList.Factory( + PillarboxRenderersFactory(context) + ), + val allocator: DefaultAllocator = DefaultAllocator(false, C.DEFAULT_BUFFER_SEGMENT_SIZE), + private val playbackThread: HandlerThread = HandlerThread("PillarboxPreloadManager:Playback", Process.THREAD_PRIORITY_AUDIO), +) { + private val preloadManager: DefaultPreloadManager + + /** + * 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 + + /** + * 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, + ) + } + + /** + * 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. + * The preload manager must not be used after calling this method. + * + * @see DefaultPreloadManager.release + */ + fun release() { + preloadManager.release() + playbackThread.quitSafely() + } + + /** + * 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() + } + + /** + * 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 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, 3 -> 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/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..5e4bc9f67 --- /dev/null +++ b/pillarbox-player/src/test/java/ch/srgssr/pillarbox/player/PillarboxPreloadManagerTest.kt @@ -0,0 +1,91 @@ +/* + * 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 lateinit var preloadManager: PillarboxPreloadManager + + @BeforeTest + fun setUp() { + val context = ApplicationProvider.getApplicationContext() + preloadManager = PillarboxPreloadManager(context = context) + } + + @AfterTest + fun tearDown() { + preloadManager.release() + } + + @Test + fun `initial state`() { + assertEquals(C.INDEX_UNSET, preloadManager.currentPlayingIndex) + assertEquals(0, preloadManager.sourceCount) + assertNull(preloadManager.getMediaSource(VOD1)) + } + + @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)) + + 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)) + + 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)) + } + + private companion object { + 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-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" + } } }