From aa6bd088bb732c95b6a5ee9491facc82a8408568 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Joaquim=20St=C3=A4hli?= Date: Mon, 15 Sep 2025 16:44:54 +0200 Subject: [PATCH 01/24] wip --- .../ch/srgssr/pillarbox/demo/MainActivity.kt | 78 ++++++++++++++++++- .../showcases/misc/ResizablePlayerShowcase.kt | 21 ++--- .../srgssr/pillarbox/ui/PillarboxSurface.kt | 78 +++++++++++++++++++ .../widget/player/AndroidPlayerSurfaceView.kt | 2 +- .../ui/widget/player/PlayerSurface.kt | 2 +- .../player/SurfaceViewWithApi34WorkAround.kt | 48 ++++++++++++ 6 files changed, 216 insertions(+), 13 deletions(-) create mode 100644 pillarbox-ui/src/main/java/ch/srgssr/pillarbox/ui/PillarboxSurface.kt create mode 100644 pillarbox-ui/src/main/java/ch/srgssr/pillarbox/ui/widget/player/SurfaceViewWithApi34WorkAround.kt diff --git a/pillarbox-demo/src/main/java/ch/srgssr/pillarbox/demo/MainActivity.kt b/pillarbox-demo/src/main/java/ch/srgssr/pillarbox/demo/MainActivity.kt index 1483c0265..793e9df53 100644 --- a/pillarbox-demo/src/main/java/ch/srgssr/pillarbox/demo/MainActivity.kt +++ b/pillarbox-demo/src/main/java/ch/srgssr/pillarbox/demo/MainActivity.kt @@ -8,7 +8,34 @@ import android.os.Bundle import androidx.activity.ComponentActivity import androidx.activity.compose.setContent import androidx.activity.enableEdgeToEdge +import androidx.annotation.Dimension +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Box +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.material3.Slider +import androidx.compose.runtime.Composable +import androidx.compose.runtime.DisposableEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableFloatStateOf +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.clipToBounds +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.layout.ContentScale +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.unit.dp +import ch.srgssr.pillarbox.demo.shared.data.samples.SamplesSRG +import ch.srgssr.pillarbox.demo.shared.di.PlayerModule import ch.srgssr.pillarbox.demo.ui.theme.PillarboxTheme +import ch.srgssr.pillarbox.ui.PillarboxSurface +import ch.srgssr.pillarbox.ui.exoplayer.ExoPlayerSubtitleView +import ch.srgssr.pillarbox.ui.exoplayer.SubtitleTextSize /** * Main activity @@ -23,8 +50,57 @@ class MainActivity : ComponentActivity() { setContent { PillarboxTheme { - MainNavigation() + // MainNavigation() + Simple() + // ExoPlayerDemo() } } } } + +@Composable +private fun Simple() { + val context = LocalContext.current + val player = remember { + PlayerModule.provideDefaultPlayer(context).apply { + setMediaItem(SamplesSRG.OnDemandHorizontalVideo.toMediaItem()) + // setMediaItem(MediaItem.fromUri("https://rts-vod-amd.akamaized.net/ww/14827306/98923d94-071c-3d48-ac0c-dbababe70a68/master.m3u8")) + volume = 0f + } + } + + var percentWidth by remember { mutableFloatStateOf(1f) } + var percentHeight by remember { mutableFloatStateOf(1f) } + + Box(modifier = Modifier.fillMaxSize(), contentAlignment = Alignment.Center) { + Box( + modifier = Modifier + .fillMaxWidth(percentWidth) + .fillMaxHeight(percentHeight) + .clipToBounds() + .background(color = Color.Black), + contentAlignment = Alignment.Center + ) { + PillarboxSurface(pillarboxPlayer = player, contentScale = ContentScale.Fit, surfaceContent = { + }) + ExoPlayerSubtitleView(player = player, modifier = Modifier, subtitleTextSize = SubtitleTextSize.Fixed(Dimension.SP, 48f)) + } + Column( + modifier = Modifier + .fillMaxWidth() + .padding(48.dp) + .align(Alignment.BottomCenter) + ) { + Slider(percentWidth, onValueChange = { percentWidth = it }) + Slider(percentHeight, onValueChange = { percentHeight = it }) + } + } + + DisposableEffect(Unit) { + player.prepare() + player.play() + onDispose { + player.release() + } + } +} diff --git a/pillarbox-demo/src/main/java/ch/srgssr/pillarbox/demo/ui/showcases/misc/ResizablePlayerShowcase.kt b/pillarbox-demo/src/main/java/ch/srgssr/pillarbox/demo/ui/showcases/misc/ResizablePlayerShowcase.kt index 7c355c341..5be18ca31 100644 --- a/pillarbox-demo/src/main/java/ch/srgssr/pillarbox/demo/ui/showcases/misc/ResizablePlayerShowcase.kt +++ b/pillarbox-demo/src/main/java/ch/srgssr/pillarbox/demo/ui/showcases/misc/ResizablePlayerShowcase.kt @@ -31,7 +31,6 @@ import androidx.compose.runtime.remember import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier -import androidx.compose.ui.graphics.Color import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.semantics.contentDescription import androidx.compose.ui.semantics.semantics @@ -42,8 +41,8 @@ import ch.srgssr.pillarbox.demo.shared.data.samples.SamplesSRG import ch.srgssr.pillarbox.demo.shared.di.PlayerModule import ch.srgssr.pillarbox.demo.shared.ui.components.PillarboxSlider import ch.srgssr.pillarbox.demo.ui.theme.paddings +import ch.srgssr.pillarbox.ui.PillarboxSurface import ch.srgssr.pillarbox.ui.ScaleMode -import ch.srgssr.pillarbox.ui.widget.player.PlayerSurface /** * Resizable player demo @@ -88,14 +87,16 @@ private fun AdaptivePlayer(player: Player, modifier: Modifier = Modifier) { modifier = Modifier.size(width = playerWidth, height = playerHeight), contentAlignment = Alignment.Center, ) { - PlayerSurface( - modifier = Modifier - .matchParentSize() - .background(Color.Black), - player = player, - displayDebugView = true, - contentAlignment = Alignment.Center, - scaleMode = resizeMode, + PillarboxSurface( + pillarboxPlayer = player, + Modifier + .matchParentSize(), + // .background(Color.Black), + contentScale = when (resizeMode) { + ScaleMode.Fit -> androidx.compose.ui.layout.ContentScale.Fit + ScaleMode.Crop -> androidx.compose.ui.layout.ContentScale.Crop + ScaleMode.Fill -> androidx.compose.ui.layout.ContentScale.FillBounds + } ) } diff --git a/pillarbox-ui/src/main/java/ch/srgssr/pillarbox/ui/PillarboxSurface.kt b/pillarbox-ui/src/main/java/ch/srgssr/pillarbox/ui/PillarboxSurface.kt new file mode 100644 index 000000000..38c20a837 --- /dev/null +++ b/pillarbox-ui/src/main/java/ch/srgssr/pillarbox/ui/PillarboxSurface.kt @@ -0,0 +1,78 @@ +/* + * Copyright (c) SRG SSR. All rights reserved. + * License information is available from the LICENSE file. + */ +package ch.srgssr.pillarbox.ui + +import android.os.Build +import android.os.Build.VERSION_CODES.UPSIDE_DOWN_CAKE +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.BoxScope +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.layout.ContentScale +import androidx.media3.common.Player +import androidx.media3.ui.compose.PlayerSurface +import androidx.media3.ui.compose.SURFACE_TYPE_SURFACE_VIEW +import androidx.media3.ui.compose.modifiers.resizeWithContentScale +import androidx.media3.ui.compose.state.PresentationState +import androidx.media3.ui.compose.state.rememberPresentationState +import ch.srgssr.pillarbox.ui.widget.player.AndroidSurfaceViewWithApi34WorkAround +import ch.srgssr.pillarbox.ui.widget.player.DebugPlayerView + +/** + * Remove the choice + * Since minSDK = 24, we force to always use SURFACE_TYPE_SURFACE_VIEW. + * https://developer.android.com/media/media3/ui/surface + */ +@Composable +fun PillarboxSurface( + pillarboxPlayer: Player?, + modifier: Modifier = Modifier, + contentScale: ContentScale = ContentScale.Fit, + displayDebugView: Boolean = true, + keepContentOnReset: Boolean = false, + presentationState: PresentationState = rememberPresentationState(player = pillarboxPlayer, keepContentOnReset = keepContentOnReset), + surfaceContent: (@Composable BoxScope.() -> Unit)? = null, + shutter: @Composable () -> Unit = { + Box( + Modifier + .fillMaxSize() + .background(Color.Black) + ) + }, +) { + val scaledModifier = modifier.resizeWithContentScale(contentScale = contentScale, presentationState.videoSizeDp) + + // Always leave PlayerSurface to be part of the Compose tree because it will be initialized in + // the process. If this composable is guarded by some condition, it might never become visible + // because the Player will not emit the relevant event, e.g. the first frame being ready. + // PlayerSurface(player = pillarboxPlayer, surfaceType = SURFACE_TYPE_SURFACE_VIEW, modifier = scaledModifier) + Box(modifier = scaledModifier, contentAlignment = Alignment.Center) { + PlayerSurfaceInternal(player = pillarboxPlayer) + if (displayDebugView) { + DebugPlayerView(Modifier.fillMaxSize()) + } + surfaceContent?.invoke(this) + } + + if (presentationState.coverSurface) { + shutter() + } +} + +@Composable +private fun PlayerSurfaceInternal(player: Player?) { + if (Build.VERSION.SDK_INT == UPSIDE_DOWN_CAKE) { + // player?.let { AndroidPlayerSurfaceView(it) } + AndroidSurfaceViewWithApi34WorkAround(player) + } else { + PlayerSurface(player = player, surfaceType = SURFACE_TYPE_SURFACE_VIEW) + } +} 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 ce25204f4..1494ce4f7 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 @@ -53,7 +53,7 @@ internal fun AndroidPlayerSurfaceView(player: Player, modifier: Modifier = Modif /** * Player surface view */ -private class PlayerSurfaceView(context: Context) : SurfaceView(context), Player.Listener { +internal class PlayerSurfaceView(context: Context) : SurfaceView(context), Player.Listener { private val surfaceSyncGroup = when { isInEditMode -> NoOpSurfaceSyncGroupCompat diff --git a/pillarbox-ui/src/main/java/ch/srgssr/pillarbox/ui/widget/player/PlayerSurface.kt b/pillarbox-ui/src/main/java/ch/srgssr/pillarbox/ui/widget/player/PlayerSurface.kt index ada1e1d31..b0da38a5f 100644 --- a/pillarbox-ui/src/main/java/ch/srgssr/pillarbox/ui/widget/player/PlayerSurface.kt +++ b/pillarbox-ui/src/main/java/ch/srgssr/pillarbox/ui/widget/player/PlayerSurface.kt @@ -170,7 +170,7 @@ enum class SurfaceType { * @param modifier The modifier to use to layout. */ @Composable -private fun DebugPlayerView(modifier: Modifier) { +fun DebugPlayerView(modifier: Modifier) { Canvas(modifier = modifier) { drawLine( color = Color.Green, diff --git a/pillarbox-ui/src/main/java/ch/srgssr/pillarbox/ui/widget/player/SurfaceViewWithApi34WorkAround.kt b/pillarbox-ui/src/main/java/ch/srgssr/pillarbox/ui/widget/player/SurfaceViewWithApi34WorkAround.kt new file mode 100644 index 000000000..b012c032b --- /dev/null +++ b/pillarbox-ui/src/main/java/ch/srgssr/pillarbox/ui/widget/player/SurfaceViewWithApi34WorkAround.kt @@ -0,0 +1,48 @@ +/* + * Copyright (c) SRG SSR. All rights reserved. + * License information is available from the LICENSE file. + */ +package ch.srgssr.pillarbox.ui.widget.player + +import android.os.Build.VERSION_CODES.UPSIDE_DOWN_CAKE +import androidx.annotation.RequiresApi +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Modifier +import androidx.compose.ui.viewinterop.AndroidView +import androidx.media3.common.Player +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.withContext + +@RequiresApi(UPSIDE_DOWN_CAKE) +@Composable +internal fun AndroidSurfaceViewWithApi34WorkAround(player: Player?, modifier: Modifier = Modifier) { + var view by remember { mutableStateOf(null) } + + AndroidView( + modifier = modifier, + factory = { PlayerSurfaceView(it) }, + onReset = {}, + update = { view = it }, + ) + + view?.let { view -> + LaunchedEffect(view, player) { + if (player != null) { + view.player = player + } else { + // Now that our player got null'd, we are not in a rush to get the old view from the + // previous player. Instead, we schedule clearing of the view for later on the main thread, + // since that player might have a new view attached to it in the meantime. This will avoid + // unnecessarily creating a Surface placeholder. + withContext(Dispatchers.Main) { + view.player = null + } + } + } + } +} From fa61d314e05735a772cf76223a78d1aa2f924fac Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Joaquim=20St=C3=A4hli?= Date: Wed, 17 Sep 2025 13:57:17 +0200 Subject: [PATCH 02/24] Improve PlayerSurface with a PlayerFrame --- .../ch/srgssr/pillarbox/demo/MainActivity.kt | 78 +------------- .../showcases/misc/ResizablePlayerShowcase.kt | 41 ++++--- .../srgssr/pillarbox/ui/PillarboxSurface.kt | 78 -------------- .../ui/exoplayer/ExoplayerSubtitleView.kt | 30 +++--- .../ui/widget/player/DebugPlayerView.kt | 39 +++++++ .../widget/player/PillarboxPlayerSurface.kt | 100 ++++++++++++++++++ .../pillarbox/ui/widget/player/PlayerFrame.kt | 75 +++++++++++++ .../ui/widget/player/PlayerSubtitle.kt | 97 +++++++++++++++++ .../ui/widget/player/PlayerSurface.kt | 60 ----------- .../pillarbox/ui/widget/player/SurfaceType.kt | 36 +++++++ 10 files changed, 391 insertions(+), 243 deletions(-) delete mode 100644 pillarbox-ui/src/main/java/ch/srgssr/pillarbox/ui/PillarboxSurface.kt create mode 100644 pillarbox-ui/src/main/java/ch/srgssr/pillarbox/ui/widget/player/DebugPlayerView.kt create mode 100644 pillarbox-ui/src/main/java/ch/srgssr/pillarbox/ui/widget/player/PillarboxPlayerSurface.kt create mode 100644 pillarbox-ui/src/main/java/ch/srgssr/pillarbox/ui/widget/player/PlayerFrame.kt create mode 100644 pillarbox-ui/src/main/java/ch/srgssr/pillarbox/ui/widget/player/PlayerSubtitle.kt create mode 100644 pillarbox-ui/src/main/java/ch/srgssr/pillarbox/ui/widget/player/SurfaceType.kt diff --git a/pillarbox-demo/src/main/java/ch/srgssr/pillarbox/demo/MainActivity.kt b/pillarbox-demo/src/main/java/ch/srgssr/pillarbox/demo/MainActivity.kt index 793e9df53..1483c0265 100644 --- a/pillarbox-demo/src/main/java/ch/srgssr/pillarbox/demo/MainActivity.kt +++ b/pillarbox-demo/src/main/java/ch/srgssr/pillarbox/demo/MainActivity.kt @@ -8,34 +8,7 @@ import android.os.Bundle import androidx.activity.ComponentActivity import androidx.activity.compose.setContent import androidx.activity.enableEdgeToEdge -import androidx.annotation.Dimension -import androidx.compose.foundation.background -import androidx.compose.foundation.layout.Box -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.material3.Slider -import androidx.compose.runtime.Composable -import androidx.compose.runtime.DisposableEffect -import androidx.compose.runtime.getValue -import androidx.compose.runtime.mutableFloatStateOf -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.clipToBounds -import androidx.compose.ui.graphics.Color -import androidx.compose.ui.layout.ContentScale -import androidx.compose.ui.platform.LocalContext -import androidx.compose.ui.unit.dp -import ch.srgssr.pillarbox.demo.shared.data.samples.SamplesSRG -import ch.srgssr.pillarbox.demo.shared.di.PlayerModule import ch.srgssr.pillarbox.demo.ui.theme.PillarboxTheme -import ch.srgssr.pillarbox.ui.PillarboxSurface -import ch.srgssr.pillarbox.ui.exoplayer.ExoPlayerSubtitleView -import ch.srgssr.pillarbox.ui.exoplayer.SubtitleTextSize /** * Main activity @@ -50,57 +23,8 @@ class MainActivity : ComponentActivity() { setContent { PillarboxTheme { - // MainNavigation() - Simple() - // ExoPlayerDemo() + MainNavigation() } } } } - -@Composable -private fun Simple() { - val context = LocalContext.current - val player = remember { - PlayerModule.provideDefaultPlayer(context).apply { - setMediaItem(SamplesSRG.OnDemandHorizontalVideo.toMediaItem()) - // setMediaItem(MediaItem.fromUri("https://rts-vod-amd.akamaized.net/ww/14827306/98923d94-071c-3d48-ac0c-dbababe70a68/master.m3u8")) - volume = 0f - } - } - - var percentWidth by remember { mutableFloatStateOf(1f) } - var percentHeight by remember { mutableFloatStateOf(1f) } - - Box(modifier = Modifier.fillMaxSize(), contentAlignment = Alignment.Center) { - Box( - modifier = Modifier - .fillMaxWidth(percentWidth) - .fillMaxHeight(percentHeight) - .clipToBounds() - .background(color = Color.Black), - contentAlignment = Alignment.Center - ) { - PillarboxSurface(pillarboxPlayer = player, contentScale = ContentScale.Fit, surfaceContent = { - }) - ExoPlayerSubtitleView(player = player, modifier = Modifier, subtitleTextSize = SubtitleTextSize.Fixed(Dimension.SP, 48f)) - } - Column( - modifier = Modifier - .fillMaxWidth() - .padding(48.dp) - .align(Alignment.BottomCenter) - ) { - Slider(percentWidth, onValueChange = { percentWidth = it }) - Slider(percentHeight, onValueChange = { percentHeight = it }) - } - } - - DisposableEffect(Unit) { - player.prepare() - player.play() - onDispose { - player.release() - } - } -} diff --git a/pillarbox-demo/src/main/java/ch/srgssr/pillarbox/demo/ui/showcases/misc/ResizablePlayerShowcase.kt b/pillarbox-demo/src/main/java/ch/srgssr/pillarbox/demo/ui/showcases/misc/ResizablePlayerShowcase.kt index 5be18ca31..406cd1411 100644 --- a/pillarbox-demo/src/main/java/ch/srgssr/pillarbox/demo/ui/showcases/misc/ResizablePlayerShowcase.kt +++ b/pillarbox-demo/src/main/java/ch/srgssr/pillarbox/demo/ui/showcases/misc/ResizablePlayerShowcase.kt @@ -31,18 +31,22 @@ import androidx.compose.runtime.remember import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.layout.ContentScale import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.semantics.contentDescription import androidx.compose.ui.semantics.semantics import androidx.compose.ui.text.font.FontFamily import androidx.lifecycle.compose.LifecycleStartEffect -import androidx.media3.common.Player +import androidx.media3.ui.compose.state.rememberPresentationState import ch.srgssr.pillarbox.demo.shared.data.samples.SamplesSRG import ch.srgssr.pillarbox.demo.shared.di.PlayerModule import ch.srgssr.pillarbox.demo.shared.ui.components.PillarboxSlider +import ch.srgssr.pillarbox.demo.ui.player.controls.PlayerControls import ch.srgssr.pillarbox.demo.ui.theme.paddings -import ch.srgssr.pillarbox.ui.PillarboxSurface +import ch.srgssr.pillarbox.player.PillarboxPlayer import ch.srgssr.pillarbox.ui.ScaleMode +import ch.srgssr.pillarbox.ui.widget.player.PlayerFrame /** * Resizable player demo @@ -74,7 +78,7 @@ fun ResizablePlayerShowcase() { } @Composable -private fun AdaptivePlayer(player: Player, modifier: Modifier = Modifier) { +private fun AdaptivePlayer(player: PillarboxPlayer, modifier: Modifier = Modifier) { var resizeMode by remember { mutableStateOf(ScaleMode.Fit) } val (widthPercent, setWidthPercent) = remember { mutableFloatStateOf(1f) } val (heightPercent, setHeightPercent) = remember { mutableFloatStateOf(1f) } @@ -83,20 +87,27 @@ private fun AdaptivePlayer(player: Player, modifier: Modifier = Modifier) { val playerWidth by animateDpAsState(targetValue = this.maxWidth * widthPercent, label = "player_width") val playerHeight by animateDpAsState(targetValue = this.maxHeight * heightPercent, label = "player_height") + val presentationState = rememberPresentationState(player) + val contentScale = when (resizeMode) { + ScaleMode.Fit -> ContentScale.Fit + ScaleMode.Crop -> ContentScale.Crop + ScaleMode.Fill -> ContentScale.FillBounds + } Box( - modifier = Modifier.size(width = playerWidth, height = playerHeight), - contentAlignment = Alignment.Center, + modifier = Modifier.size(width = playerWidth, height = playerHeight).background(color = Color.Black) ) { - PillarboxSurface( - pillarboxPlayer = player, - Modifier - .matchParentSize(), - // .background(Color.Black), - contentScale = when (resizeMode) { - ScaleMode.Fit -> androidx.compose.ui.layout.ContentScale.Fit - ScaleMode.Crop -> androidx.compose.ui.layout.ContentScale.Crop - ScaleMode.Fill -> androidx.compose.ui.layout.ContentScale.FillBounds - } + PlayerFrame( + player = player, + presentationState = presentationState, + modifier = Modifier, + contentScale = contentScale, + ) + val interactionSource = remember { MutableInteractionSource() } + PlayerControls( + player = player, + modifier = Modifier.fillMaxSize(), + interactionSource = interactionSource, + content = { } ) } diff --git a/pillarbox-ui/src/main/java/ch/srgssr/pillarbox/ui/PillarboxSurface.kt b/pillarbox-ui/src/main/java/ch/srgssr/pillarbox/ui/PillarboxSurface.kt deleted file mode 100644 index 38c20a837..000000000 --- a/pillarbox-ui/src/main/java/ch/srgssr/pillarbox/ui/PillarboxSurface.kt +++ /dev/null @@ -1,78 +0,0 @@ -/* - * Copyright (c) SRG SSR. All rights reserved. - * License information is available from the LICENSE file. - */ -package ch.srgssr.pillarbox.ui - -import android.os.Build -import android.os.Build.VERSION_CODES.UPSIDE_DOWN_CAKE -import androidx.compose.foundation.background -import androidx.compose.foundation.layout.Box -import androidx.compose.foundation.layout.BoxScope -import androidx.compose.foundation.layout.fillMaxSize -import androidx.compose.runtime.Composable -import androidx.compose.runtime.getValue -import androidx.compose.runtime.setValue -import androidx.compose.ui.Alignment -import androidx.compose.ui.Modifier -import androidx.compose.ui.graphics.Color -import androidx.compose.ui.layout.ContentScale -import androidx.media3.common.Player -import androidx.media3.ui.compose.PlayerSurface -import androidx.media3.ui.compose.SURFACE_TYPE_SURFACE_VIEW -import androidx.media3.ui.compose.modifiers.resizeWithContentScale -import androidx.media3.ui.compose.state.PresentationState -import androidx.media3.ui.compose.state.rememberPresentationState -import ch.srgssr.pillarbox.ui.widget.player.AndroidSurfaceViewWithApi34WorkAround -import ch.srgssr.pillarbox.ui.widget.player.DebugPlayerView - -/** - * Remove the choice - * Since minSDK = 24, we force to always use SURFACE_TYPE_SURFACE_VIEW. - * https://developer.android.com/media/media3/ui/surface - */ -@Composable -fun PillarboxSurface( - pillarboxPlayer: Player?, - modifier: Modifier = Modifier, - contentScale: ContentScale = ContentScale.Fit, - displayDebugView: Boolean = true, - keepContentOnReset: Boolean = false, - presentationState: PresentationState = rememberPresentationState(player = pillarboxPlayer, keepContentOnReset = keepContentOnReset), - surfaceContent: (@Composable BoxScope.() -> Unit)? = null, - shutter: @Composable () -> Unit = { - Box( - Modifier - .fillMaxSize() - .background(Color.Black) - ) - }, -) { - val scaledModifier = modifier.resizeWithContentScale(contentScale = contentScale, presentationState.videoSizeDp) - - // Always leave PlayerSurface to be part of the Compose tree because it will be initialized in - // the process. If this composable is guarded by some condition, it might never become visible - // because the Player will not emit the relevant event, e.g. the first frame being ready. - // PlayerSurface(player = pillarboxPlayer, surfaceType = SURFACE_TYPE_SURFACE_VIEW, modifier = scaledModifier) - Box(modifier = scaledModifier, contentAlignment = Alignment.Center) { - PlayerSurfaceInternal(player = pillarboxPlayer) - if (displayDebugView) { - DebugPlayerView(Modifier.fillMaxSize()) - } - surfaceContent?.invoke(this) - } - - if (presentationState.coverSurface) { - shutter() - } -} - -@Composable -private fun PlayerSurfaceInternal(player: Player?) { - if (Build.VERSION.SDK_INT == UPSIDE_DOWN_CAKE) { - // player?.let { AndroidPlayerSurfaceView(it) } - AndroidSurfaceViewWithApi34WorkAround(player) - } else { - PlayerSurface(player = player, surfaceType = SURFACE_TYPE_SURFACE_VIEW) - } -} diff --git a/pillarbox-ui/src/main/java/ch/srgssr/pillarbox/ui/exoplayer/ExoplayerSubtitleView.kt b/pillarbox-ui/src/main/java/ch/srgssr/pillarbox/ui/exoplayer/ExoplayerSubtitleView.kt index 1b7fb1637..6b120a6e2 100644 --- a/pillarbox-ui/src/main/java/ch/srgssr/pillarbox/ui/exoplayer/ExoplayerSubtitleView.kt +++ b/pillarbox-ui/src/main/java/ch/srgssr/pillarbox/ui/exoplayer/ExoplayerSubtitleView.kt @@ -72,19 +72,7 @@ fun ExoPlayerSubtitleView( update = { view -> view.setCues(cues) captionStyle?.let { view.setStyle(it) } ?: view.setUserDefaultStyle() - when (subtitleTextSize) { - is SubtitleTextSize.Fixed -> { - view.setFixedTextSize(subtitleTextSize.unit, subtitleTextSize.size) - } - - is SubtitleTextSize.Fractional -> { - view.setFractionalTextSize(subtitleTextSize.fractionOfHeight, subtitleTextSize.ignorePadding) - } - - else -> { - view.setUserDefaultTextSize() - } - } + view.setTextSize(subtitleTextSize) }, onRelease = { view -> view.setCues(null) @@ -163,3 +151,19 @@ private fun PreviewSubtitleViewStyled() { captionStyle = style ) } + +internal fun SubtitleView.setTextSize(subtitleTextSize: SubtitleTextSize?) { + when (subtitleTextSize) { + is SubtitleTextSize.Fixed -> { + setFixedTextSize(subtitleTextSize.unit, subtitleTextSize.size) + } + + is SubtitleTextSize.Fractional -> { + setFractionalTextSize(subtitleTextSize.fractionOfHeight, subtitleTextSize.ignorePadding) + } + + else -> { + setUserDefaultTextSize() + } + } +} diff --git a/pillarbox-ui/src/main/java/ch/srgssr/pillarbox/ui/widget/player/DebugPlayerView.kt b/pillarbox-ui/src/main/java/ch/srgssr/pillarbox/ui/widget/player/DebugPlayerView.kt new file mode 100644 index 000000000..24b262a89 --- /dev/null +++ b/pillarbox-ui/src/main/java/ch/srgssr/pillarbox/ui/widget/player/DebugPlayerView.kt @@ -0,0 +1,39 @@ +/* + * Copyright (c) SRG SSR. All rights reserved. + * License information is available from the LICENSE file. + */ +package ch.srgssr.pillarbox.ui.widget.player + +import androidx.compose.foundation.Canvas +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.geometry.Offset +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.drawscope.Stroke + +/** + * Debug player view + * + * @param modifier The modifier to use to layout. + */ +@Composable +fun DebugPlayerView(modifier: Modifier) { + Canvas(modifier = modifier) { + drawLine( + color = Color.Green, + start = Offset.Zero, + end = Offset(size.width, size.height), + strokeWidth = 2f, + ) + drawLine( + color = Color.Green, + start = Offset(size.width, 0f), + end = Offset(0f, size.height), + strokeWidth = 2f, + ) + drawRect( + color = Color.Magenta, + style = Stroke(width = 4f), + ) + } +} diff --git a/pillarbox-ui/src/main/java/ch/srgssr/pillarbox/ui/widget/player/PillarboxPlayerSurface.kt b/pillarbox-ui/src/main/java/ch/srgssr/pillarbox/ui/widget/player/PillarboxPlayerSurface.kt new file mode 100644 index 000000000..0120df726 --- /dev/null +++ b/pillarbox-ui/src/main/java/ch/srgssr/pillarbox/ui/widget/player/PillarboxPlayerSurface.kt @@ -0,0 +1,100 @@ +/* + * Copyright (c) SRG SSR. All rights reserved. + * License information is available from the LICENSE file. + */ +package ch.srgssr.pillarbox.ui.widget.player + +import android.os.Build +import android.os.Build.VERSION_CODES.UPSIDE_DOWN_CAKE +import android.view.View +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Modifier +import androidx.compose.ui.viewinterop.AndroidView +import androidx.media3.common.Player +import androidx.media3.exoplayer.video.spherical.SphericalGLSurfaceView +import androidx.media3.ui.compose.PlayerSurface +import androidx.media3.ui.compose.SURFACE_TYPE_SURFACE_VIEW +import androidx.media3.ui.compose.SURFACE_TYPE_TEXTURE_VIEW +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.withContext + +/** + * Remove the choice + * Since minSDK = 24, we force to always use SURFACE_TYPE_SURFACE_VIEW. + * https://developer.android.com/media/media3/ui/surface + */ +@Composable +fun PillarboxPlayerSurface( + player: Player?, + modifier: Modifier = Modifier, + surfaceType: SurfaceType = SurfaceType.Surface, +) { + // Always leave PlayerSurface to be part of the Compose tree because it will be initialized in + // the process. If this composable is guarded by some condition, it might never become visible + // because the Player will not emit the relevant event, e.g. the first frame being ready. + when (surfaceType) { + SurfaceType.Surface -> PlayerSurfaceInternal(player = player, modifier = modifier) + SurfaceType.Texture -> PlayerSurface(modifier = modifier, player = player, surfaceType = SURFACE_TYPE_TEXTURE_VIEW) + SurfaceType.Spherical -> PlayerSurfaceSphericalInternal(player = player, modifier = modifier) + } +} + +@Composable +private fun PlayerSurfaceSphericalInternal(player: Player?, modifier: Modifier) { + var view by remember { mutableStateOf(null) } + AndroidView( + modifier = modifier, + factory = { SphericalGLSurfaceView(it) }, + onReset = {}, + update = { view = it }, + ) + + view?.let { view -> + LaunchedEffect(view, player) { + if (player != null) { + view.attachedPlayer?.let { previousPlayer -> + if (previousPlayer != player && previousPlayer.isCommandAvailable(Player.COMMAND_SET_VIDEO_SURFACE)) { + previousPlayer.clearVideoSurfaceView(view) + } + } + if (player.isCommandAvailable(Player.COMMAND_SET_VIDEO_SURFACE)) { + player.setVideoSurfaceView(view) + view.attachedPlayer = player + } + } else { + // Now that our player got null'd, we are not in a rush to get the old view from the + // previous player. Instead, we schedule clearing of the view for later on the main thread, + // since that player might have a new view attached to it in the meantime. This will avoid + // unnecessarily creating a Surface placeholder. + withContext(Dispatchers.Main) { + view.attachedPlayer?.let { previousPlayer -> + if (previousPlayer.isCommandAvailable(Player.COMMAND_SET_VIDEO_SURFACE)) { + previousPlayer.clearVideoSurfaceView(view) + } + view.attachedPlayer = null + } + } + } + } + } +} + +@Composable +private fun PlayerSurfaceInternal(player: Player?, modifier: Modifier) { + if (Build.VERSION.SDK_INT == UPSIDE_DOWN_CAKE) { + AndroidSurfaceViewWithApi34WorkAround(player = player, modifier = modifier) + } else { + PlayerSurface(modifier = modifier, player = player, surfaceType = SURFACE_TYPE_SURFACE_VIEW) + } +} + +private var View.attachedPlayer: Player? + get() = tag as? Player + set(player) { + tag = player + } diff --git a/pillarbox-ui/src/main/java/ch/srgssr/pillarbox/ui/widget/player/PlayerFrame.kt b/pillarbox-ui/src/main/java/ch/srgssr/pillarbox/ui/widget/player/PlayerFrame.kt new file mode 100644 index 000000000..9e7fa3e91 --- /dev/null +++ b/pillarbox-ui/src/main/java/ch/srgssr/pillarbox/ui/widget/player/PlayerFrame.kt @@ -0,0 +1,75 @@ +/* + * Copyright (c) SRG SSR. All rights reserved. + * License information is available from the LICENSE file. + */ +package ch.srgssr.pillarbox.ui.widget.player + +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.BoxScope +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clipToBounds +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.layout.ContentScale +import androidx.media3.common.Player +import androidx.media3.ui.compose.modifiers.resizeWithContentScale +import androidx.media3.ui.compose.state.PresentationState +import androidx.media3.ui.compose.state.rememberPresentationState + +/** + * Provides a surface for a [Player]. + * + * @param player The [Player] to be displayed. + * @param modifier The [Modifier] to be applied to the surface. + * @param contentScale The [ContentScale] to be applied to the surface. + * @param surfaceType The type of surface to be used. + * @param displayDebugView Whether to display a debug view. + * @param presentationState The [PresentationState] to be used. + * @param surface A composable function that draws on top of the surface. It may be displayed outside the bounds. + * @param subtitle A composable function that draws the subtitle. + * @param shutter A composable function that draws the shutter when the player hasn't active video tracks. + */ +@Composable +fun PlayerFrame( + player: Player?, + modifier: Modifier = Modifier, + contentScale: ContentScale = ContentScale.Fit, + surfaceType: SurfaceType = SurfaceType.Surface, + displayDebugView: Boolean = true, + presentationState: PresentationState = rememberPresentationState(player = player, keepContentOnReset = false), + surface: (@Composable BoxScope.() -> Unit)? = null, + subtitle: @Composable () -> Unit = { + PlayerSubtitle( + modifier = Modifier, + player = player, + presentationState = presentationState, + videoContentScale = contentScale + ) + }, + shutter: @Composable () -> Unit = { + Box( + Modifier + .fillMaxSize() + .background(Color.Black) + ) + }, +) { + Box( + modifier = modifier + .clipToBounds() + .resizeWithContentScale(contentScale = contentScale, sourceSizeDp = presentationState.videoSizeDp) + ) { + PillarboxPlayerSurface(player = player, surfaceType = surfaceType, modifier = Modifier.fillMaxSize()) + surface?.invoke(this) + if (displayDebugView) { + DebugPlayerView(Modifier.fillMaxSize()) + } + } + subtitle() + + if (presentationState.coverSurface) { + shutter() + } +} diff --git a/pillarbox-ui/src/main/java/ch/srgssr/pillarbox/ui/widget/player/PlayerSubtitle.kt b/pillarbox-ui/src/main/java/ch/srgssr/pillarbox/ui/widget/player/PlayerSubtitle.kt new file mode 100644 index 000000000..935cc8c04 --- /dev/null +++ b/pillarbox-ui/src/main/java/ch/srgssr/pillarbox/ui/widget/player/PlayerSubtitle.kt @@ -0,0 +1,97 @@ +/* + * Copyright (c) SRG SSR. All rights reserved. + * License information is available from the LICENSE file. + */ +package ch.srgssr.pillarbox.ui.widget.player + +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Modifier +import androidx.compose.ui.layout.ContentScale +import androidx.compose.ui.viewinterop.AndroidView +import androidx.media3.common.Player +import androidx.media3.common.listen +import androidx.media3.ui.CaptionStyleCompat +import androidx.media3.ui.SubtitleView +import androidx.media3.ui.compose.modifiers.resizeWithContentScale +import androidx.media3.ui.compose.state.PresentationState +import androidx.media3.ui.compose.state.rememberPresentationState +import ch.srgssr.pillarbox.ui.exoplayer.SubtitleTextSize +import ch.srgssr.pillarbox.ui.exoplayer.setTextSize + +/** + * A smart Composable function to display subtitles that are always visible even when the player surface is bigger than the view bounds. + * + * @param player The [Player] instance to retrieve subtitle cues from. + * @param videoContentScale The [ContentScale] applied to the video content. + * @param modifier The [Modifier] to apply to this layout. + * @param presentationState The [PresentationState] to be used. + * @param captionStyle Optional [CaptionStyleCompat] to override the user's preferred caption style. + * @param subtitleTextSize Optional [SubtitleTextSize] to override the user's preferred subtitle text size. + * + * @see rememberPresentationState + */ +@Composable +fun PlayerSubtitle( + player: Player?, + videoContentScale: ContentScale, + modifier: Modifier = Modifier, + presentationState: PresentationState = rememberPresentationState(player), + captionStyle: CaptionStyleCompat? = null, + subtitleTextSize: SubtitleTextSize? = null +) { + val textContentScale = when (videoContentScale) { + ContentScale.Crop, ContentScale.FillHeight, ContentScale.FillWidth -> ContentScale.FillBounds + else -> videoContentScale + } + val textModifier = modifier.resizeWithContentScale(contentScale = textContentScale, presentationState.videoSizeDp) + player?.let { + PlayerSubtitle(player = it, modifier = textModifier, captionStyle = captionStyle, subtitleTextSize = subtitleTextSize) + } +} + +/** + * A Composable function that displays an ExoPlayer [SubtitleView]. + * It observes the active cues from the provided [player] and displays them in a [SubtitleView]. + * + * @param player The [Player] instance to retrieve subtitle cues from. + * @param modifier The [Modifier] to apply to this layout. + * @param captionStyle Optional [CaptionStyleCompat] to override the user's preferred caption style. + * @param subtitleTextSize Optional [SubtitleTextSize] to override the user's preferred subtitle text size. + */ +@Composable +fun PlayerSubtitle( + player: Player?, + modifier: Modifier = Modifier, + captionStyle: CaptionStyleCompat? = null, + subtitleTextSize: SubtitleTextSize? = null +) { + var view by remember { mutableStateOf(null) } + AndroidView( + modifier = modifier, + factory = { + SubtitleView(it) + }, + onReset = {}, + update = { subtitleView -> + captionStyle?.let { subtitleView.setStyle(it) } ?: subtitleView.setUserDefaultStyle() + subtitleView.setTextSize(subtitleTextSize) + view = subtitleView + } + ) + + view?.let { view -> + LaunchedEffect(view, player) { + view.setCues(player?.currentCues?.cues) + player?.listen { + if (it.contains(Player.EVENT_CUES)) { + view.setCues(currentCues.cues) + } + } + } + } +} diff --git a/pillarbox-ui/src/main/java/ch/srgssr/pillarbox/ui/widget/player/PlayerSurface.kt b/pillarbox-ui/src/main/java/ch/srgssr/pillarbox/ui/widget/player/PlayerSurface.kt index b0da38a5f..4cb125dee 100644 --- a/pillarbox-ui/src/main/java/ch/srgssr/pillarbox/ui/widget/player/PlayerSurface.kt +++ b/pillarbox-ui/src/main/java/ch/srgssr/pillarbox/ui/widget/player/PlayerSurface.kt @@ -4,9 +4,6 @@ */ package ch.srgssr.pillarbox.ui.widget.player -import android.view.SurfaceView -import android.view.TextureView -import androidx.compose.foundation.Canvas import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.BoxScope import androidx.compose.foundation.layout.BoxWithConstraints @@ -24,12 +21,9 @@ import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clipToBounds -import androidx.compose.ui.geometry.Offset import androidx.compose.ui.graphics.Color -import androidx.compose.ui.graphics.drawscope.Stroke import androidx.media3.common.Player import androidx.media3.common.Tracks -import androidx.media3.exoplayer.video.spherical.SphericalGLSurfaceView import ch.srgssr.pillarbox.player.extension.containsImageTrack import ch.srgssr.pillarbox.player.getCurrentTracksAsFlow import ch.srgssr.pillarbox.player.tracks.videoTracks @@ -136,57 +130,3 @@ fun PlayerSurface( } } } - -/** - * Represents the type of surface used for video rendering. - */ -enum class SurfaceType { - /** - * Renders the video into a [SurfaceView]. - * - * This is the most optimized option, and it supports DRM content. - */ - Surface, - - /** - * Renders the video into a [TextureView]. - * - * This option may be interesting when dealing with animation, and the [SurfaceType.Surface] option doesn't work as expected. However, it does - * not support DRM content. - */ - Texture, - - /** - * Renders the video into a [SphericalGLSurfaceView]. - * - * This is suited for 360° video content. However, it does not support DRM content. - */ - Spherical, -} - -/** - * Debug player view - * - * @param modifier The modifier to use to layout. - */ -@Composable -fun DebugPlayerView(modifier: Modifier) { - Canvas(modifier = modifier) { - drawLine( - color = Color.Green, - start = Offset.Zero, - end = Offset(size.width, size.height), - strokeWidth = 2f, - ) - drawLine( - color = Color.Green, - start = Offset(size.width, 0f), - end = Offset(0f, size.height), - strokeWidth = 2f, - ) - drawRect( - color = Color.Magenta, - style = Stroke(width = 4f), - ) - } -} diff --git a/pillarbox-ui/src/main/java/ch/srgssr/pillarbox/ui/widget/player/SurfaceType.kt b/pillarbox-ui/src/main/java/ch/srgssr/pillarbox/ui/widget/player/SurfaceType.kt new file mode 100644 index 000000000..02d879cf2 --- /dev/null +++ b/pillarbox-ui/src/main/java/ch/srgssr/pillarbox/ui/widget/player/SurfaceType.kt @@ -0,0 +1,36 @@ +/* + * Copyright (c) SRG SSR. All rights reserved. + * License information is available from the LICENSE file. + */ +package ch.srgssr.pillarbox.ui.widget.player + +import android.view.SurfaceView +import android.view.TextureView +import androidx.media3.exoplayer.video.spherical.SphericalGLSurfaceView + +/** + * Represents the type of surface used for video rendering. + */ +enum class SurfaceType { + /** + * Renders the video into a [SurfaceView]. + * + * This is the most optimized option, and it supports DRM content. + */ + Surface, + + /** + * Renders the video into a [TextureView]. + * + * This option may be interesting when dealing with animation, and the [SurfaceType.Surface] option doesn't work as expected. However, it does + * not support DRM content. + */ + Texture, + + /** + * Renders the video into a [SphericalGLSurfaceView]. + * + * This is suited for 360° video content. However, it does not support DRM content. + */ + Spherical, +} From eff579d42b33ea2b124733a88c20a2b03317b7e6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Joaquim=20St=C3=A4hli?= Date: Wed, 17 Sep 2025 14:46:43 +0200 Subject: [PATCH 03/24] Make easier integration --- .../pillarbox/ui/widget/player/PlayerFrame.kt | 35 ++++++++++--------- 1 file changed, 19 insertions(+), 16 deletions(-) diff --git a/pillarbox-ui/src/main/java/ch/srgssr/pillarbox/ui/widget/player/PlayerFrame.kt b/pillarbox-ui/src/main/java/ch/srgssr/pillarbox/ui/widget/player/PlayerFrame.kt index 9e7fa3e91..fd173e38f 100644 --- a/pillarbox-ui/src/main/java/ch/srgssr/pillarbox/ui/widget/player/PlayerFrame.kt +++ b/pillarbox-ui/src/main/java/ch/srgssr/pillarbox/ui/widget/player/PlayerFrame.kt @@ -30,6 +30,7 @@ import androidx.media3.ui.compose.state.rememberPresentationState * @param surface A composable function that draws on top of the surface. It may be displayed outside the bounds. * @param subtitle A composable function that draws the subtitle. * @param shutter A composable function that draws the shutter when the player hasn't active video tracks. + * @param overlay A composable function that draws on top of everything. */ @Composable fun PlayerFrame( @@ -37,10 +38,10 @@ fun PlayerFrame( modifier: Modifier = Modifier, contentScale: ContentScale = ContentScale.Fit, surfaceType: SurfaceType = SurfaceType.Surface, - displayDebugView: Boolean = true, + displayDebugView: Boolean = false, presentationState: PresentationState = rememberPresentationState(player = player, keepContentOnReset = false), surface: (@Composable BoxScope.() -> Unit)? = null, - subtitle: @Composable () -> Unit = { + subtitle: @Composable BoxScope.() -> Unit = { PlayerSubtitle( modifier = Modifier, player = player, @@ -48,28 +49,30 @@ fun PlayerFrame( videoContentScale = contentScale ) }, - shutter: @Composable () -> Unit = { + shutter: @Composable BoxScope.() -> Unit = { Box( Modifier .fillMaxSize() .background(Color.Black) ) }, + overlay: @Composable BoxScope.() -> Unit = {}, ) { - Box( - modifier = modifier - .clipToBounds() - .resizeWithContentScale(contentScale = contentScale, sourceSizeDp = presentationState.videoSizeDp) - ) { - PillarboxPlayerSurface(player = player, surfaceType = surfaceType, modifier = Modifier.fillMaxSize()) - surface?.invoke(this) - if (displayDebugView) { - DebugPlayerView(Modifier.fillMaxSize()) + Box(modifier = modifier.clipToBounds()) { + Box( + modifier = Modifier.resizeWithContentScale(contentScale = contentScale, sourceSizeDp = presentationState.videoSizeDp) + ) { + PillarboxPlayerSurface(player = player, surfaceType = surfaceType, modifier = Modifier.fillMaxSize()) + surface?.invoke(this) + if (displayDebugView) { + DebugPlayerView(Modifier.fillMaxSize()) + } } - } - subtitle() + subtitle() - if (presentationState.coverSurface) { - shutter() + if (presentationState.coverSurface) { + shutter() + } + overlay() } } From 7c106c97e2f4ed16bd93c1601497e124ed5095de Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Joaquim=20St=C3=A4hli?= Date: Wed, 17 Sep 2025 14:47:30 +0200 Subject: [PATCH 04/24] Replace showcase player with the PlayerFrame --- .../ui/showcases/misc/MultiPlayerShowcase.kt | 8 +++++--- .../ui/showcases/misc/ResizablePlayerShowcase.kt | 16 +++++++--------- 2 files changed, 12 insertions(+), 12 deletions(-) diff --git a/pillarbox-demo/src/main/java/ch/srgssr/pillarbox/demo/ui/showcases/misc/MultiPlayerShowcase.kt b/pillarbox-demo/src/main/java/ch/srgssr/pillarbox/demo/ui/showcases/misc/MultiPlayerShowcase.kt index a2e364de0..ced09ee1a 100644 --- a/pillarbox-demo/src/main/java/ch/srgssr/pillarbox/demo/ui/showcases/misc/MultiPlayerShowcase.kt +++ b/pillarbox-demo/src/main/java/ch/srgssr/pillarbox/demo/ui/showcases/misc/MultiPlayerShowcase.kt @@ -6,6 +6,7 @@ package ch.srgssr.pillarbox.demo.ui.showcases.misc import android.content.res.Configuration import androidx.compose.foundation.clickable +import androidx.compose.foundation.interaction.MutableInteractionSource import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.fillMaxSize @@ -28,7 +29,7 @@ import androidx.lifecycle.viewmodel.compose.viewModel import ch.srgssr.pillarbox.demo.ui.player.controls.PlayerControls import ch.srgssr.pillarbox.demo.ui.theme.paddings import ch.srgssr.pillarbox.player.PillarboxPlayer -import ch.srgssr.pillarbox.ui.widget.player.PlayerSurface +import ch.srgssr.pillarbox.ui.widget.player.PlayerFrame /** * Demo displaying two players, that can be swapped. @@ -83,7 +84,7 @@ private fun ActivablePlayer( modifier: Modifier = Modifier, onClick: () -> Unit, ) { - PlayerSurface( + PlayerFrame( modifier = modifier .padding(MaterialTheme.paddings.mini) .clickable( @@ -94,11 +95,11 @@ private fun ActivablePlayer( ), player = player, ) { + // overlay on top of the view val inactivePlayerOverlay = Modifier.drawWithContent { drawContent() drawRect(Color.LightGray.copy(alpha = 0.7f)) } - PlayerControls( player = player, modifier = Modifier @@ -106,6 +107,7 @@ private fun ActivablePlayer( .then(if (isActive) Modifier else inactivePlayerOverlay), backgroundColor = Color.Unspecified, content = {}, + interactionSource = remember { MutableInteractionSource() } ) } } diff --git a/pillarbox-demo/src/main/java/ch/srgssr/pillarbox/demo/ui/showcases/misc/ResizablePlayerShowcase.kt b/pillarbox-demo/src/main/java/ch/srgssr/pillarbox/demo/ui/showcases/misc/ResizablePlayerShowcase.kt index 406cd1411..f818cfa0b 100644 --- a/pillarbox-demo/src/main/java/ch/srgssr/pillarbox/demo/ui/showcases/misc/ResizablePlayerShowcase.kt +++ b/pillarbox-demo/src/main/java/ch/srgssr/pillarbox/demo/ui/showcases/misc/ResizablePlayerShowcase.kt @@ -8,7 +8,6 @@ import androidx.compose.animation.core.animateDpAsState import androidx.compose.foundation.background import androidx.compose.foundation.interaction.MutableInteractionSource import androidx.compose.foundation.layout.Arrangement -import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.BoxWithConstraints import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Row @@ -93,15 +92,14 @@ private fun AdaptivePlayer(player: PillarboxPlayer, modifier: Modifier = Modifie ScaleMode.Crop -> ContentScale.Crop ScaleMode.Fill -> ContentScale.FillBounds } - Box( - modifier = Modifier.size(width = playerWidth, height = playerHeight).background(color = Color.Black) + PlayerFrame( + player = player, + presentationState = presentationState, + modifier = Modifier + .size(width = playerWidth, height = playerHeight) + .background(color = Color.Black), + contentScale = contentScale, ) { - PlayerFrame( - player = player, - presentationState = presentationState, - modifier = Modifier, - contentScale = contentScale, - ) val interactionSource = remember { MutableInteractionSource() } PlayerControls( player = player, From 426652387d91bfb9ce74998f876052ea27534282 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Joaquim=20St=C3=A4hli?= Date: Wed, 17 Sep 2025 15:06:41 +0200 Subject: [PATCH 05/24] Fix spherical surface --- .../pillarbox/ui/widget/player/PillarboxPlayerSurface.kt | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/pillarbox-ui/src/main/java/ch/srgssr/pillarbox/ui/widget/player/PillarboxPlayerSurface.kt b/pillarbox-ui/src/main/java/ch/srgssr/pillarbox/ui/widget/player/PillarboxPlayerSurface.kt index 0120df726..36cd88597 100644 --- a/pillarbox-ui/src/main/java/ch/srgssr/pillarbox/ui/widget/player/PillarboxPlayerSurface.kt +++ b/pillarbox-ui/src/main/java/ch/srgssr/pillarbox/ui/widget/player/PillarboxPlayerSurface.kt @@ -51,7 +51,13 @@ private fun PlayerSurfaceSphericalInternal(player: Player?, modifier: Modifier) modifier = modifier, factory = { SphericalGLSurfaceView(it) }, onReset = {}, - update = { view = it }, + onRelease = { + it.onPause() + }, + update = { + view = it + it.onResume() + }, ) view?.let { view -> From 65fc7d721f98e330bd2f8ac0917d8910feba4276 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Joaquim=20St=C3=A4hli?= Date: Wed, 17 Sep 2025 16:28:30 +0200 Subject: [PATCH 06/24] Migrate all player view to the new PlayerFrame --- .../demo/tv/ui/player/compose/PlayerView.kt | 4 +- .../demo/ui/player/DemoPlayerView.kt | 12 +- .../pillarbox/demo/ui/player/PlayerView.kt | 192 +++++++++--------- .../ui/showcases/layouts/OptimizedStory.kt | 20 +- .../showcases/layouts/SimpleLayoutShowcase.kt | 7 +- .../demo/ui/showcases/layouts/SimpleStory.kt | 8 +- .../showcases/misc/ContentNotYetAvailable.kt | 4 +- .../showcases/misc/SmoothSeekingShowcase.kt | 22 +- .../misc/SphericalSurfaceShowcase.kt | 8 +- .../misc/UpdatableMediaItemShowcase.kt | 4 +- .../widget/player/PillarboxPlayerSurface.kt | 12 +- 11 files changed, 151 insertions(+), 142 deletions(-) diff --git a/pillarbox-demo-tv/src/main/java/ch/srgssr/pillarbox/demo/tv/ui/player/compose/PlayerView.kt b/pillarbox-demo-tv/src/main/java/ch/srgssr/pillarbox/demo/tv/ui/player/compose/PlayerView.kt index 410b66e0a..23d3af406 100644 --- a/pillarbox-demo-tv/src/main/java/ch/srgssr/pillarbox/demo/tv/ui/player/compose/PlayerView.kt +++ b/pillarbox-demo-tv/src/main/java/ch/srgssr/pillarbox/demo/tv/ui/player/compose/PlayerView.kt @@ -83,7 +83,7 @@ import ch.srgssr.pillarbox.ui.extension.isCurrentMediaItemLiveAsState import ch.srgssr.pillarbox.ui.extension.isPlayingAsState import ch.srgssr.pillarbox.ui.extension.playerErrorAsState import ch.srgssr.pillarbox.ui.state.rememberCreditState -import ch.srgssr.pillarbox.ui.widget.player.PlayerSurface +import ch.srgssr.pillarbox.ui.widget.player.PlayerFrame import coil3.compose.AsyncImage import kotlinx.coroutines.delay import kotlinx.coroutines.flow.map @@ -183,7 +183,7 @@ fun PlayerView( onRetry = player::prepare, ) } else { - PlayerSurface( + PlayerFrame( player = player, modifier = Modifier .fillMaxSize() diff --git a/pillarbox-demo/src/main/java/ch/srgssr/pillarbox/demo/ui/player/DemoPlayerView.kt b/pillarbox-demo/src/main/java/ch/srgssr/pillarbox/demo/ui/player/DemoPlayerView.kt index 576dfb893..bd138fb08 100644 --- a/pillarbox-demo/src/main/java/ch/srgssr/pillarbox/demo/ui/player/DemoPlayerView.kt +++ b/pillarbox-demo/src/main/java/ch/srgssr/pillarbox/demo/ui/player/DemoPlayerView.kt @@ -33,6 +33,7 @@ import androidx.compose.runtime.remember import androidx.compose.runtime.setValue import androidx.compose.ui.Modifier import androidx.compose.ui.input.pointer.pointerInput +import androidx.compose.ui.layout.ContentScale import androidx.lifecycle.compose.collectAsStateWithLifecycle import androidx.lifecycle.viewmodel.compose.viewModel import androidx.media3.common.Player @@ -47,7 +48,6 @@ import ch.srgssr.pillarbox.demo.ui.player.playlist.PlaylistView import ch.srgssr.pillarbox.demo.ui.player.settings.PlaybackSettingsContent import ch.srgssr.pillarbox.demo.ui.player.state.rememberFullscreenButtonState import ch.srgssr.pillarbox.player.PillarboxPlayer -import ch.srgssr.pillarbox.ui.ScaleMode /** * Demo player @@ -137,15 +137,15 @@ private fun PlayerContent( val appSettings by appSettingsViewModel.currentAppSettings.collectAsStateWithLifecycle() Column(modifier = modifier) { - var pinchScaleMode by remember(fullscreenButtonState.isInFullscreen) { - mutableStateOf(ScaleMode.Fit) + var pinchContentScale by remember(fullscreenButtonState.isInFullscreen) { + mutableStateOf(ContentScale.Fit) } val scalableModifier = if (fullscreenButtonState.isInFullscreen) { - Modifier.pointerInput(pinchScaleMode) { + Modifier.pointerInput(pinchContentScale) { var lastZoomValue = 1f detectTransformGestures(true) { _, _, zoom, _ -> lastZoomValue *= zoom - pinchScaleMode = if (lastZoomValue < 1f) ScaleMode.Fit else ScaleMode.Crop + pinchContentScale = if (lastZoomValue < 1f) ContentScale.Fit else ContentScale.Crop } } } else { @@ -159,7 +159,7 @@ private fun PlayerContent( player = player, controlsToggleable = !isInPictureInPicture, controlsVisible = !isInPictureInPicture, - scaleMode = pinchScaleMode, + contentScale = pinchContentScale, overlayEnabled = appSettings.metricsOverlayEnabled, overlayOptions = MetricsOverlayOptions( textColor = appSettings.metricsOverlayTextColor.color, diff --git a/pillarbox-demo/src/main/java/ch/srgssr/pillarbox/demo/ui/player/PlayerView.kt b/pillarbox-demo/src/main/java/ch/srgssr/pillarbox/demo/ui/player/PlayerView.kt index 0c1ae36b2..74f107ab5 100644 --- a/pillarbox-demo/src/main/java/ch/srgssr/pillarbox/demo/ui/player/PlayerView.kt +++ b/pillarbox-demo/src/main/java/ch/srgssr/pillarbox/demo/ui/player/PlayerView.kt @@ -4,7 +4,6 @@ */ package ch.srgssr.pillarbox.demo.ui.player -import android.net.Uri import androidx.compose.animation.AnimatedVisibility import androidx.compose.animation.fadeIn import androidx.compose.animation.fadeOut @@ -12,7 +11,6 @@ import androidx.compose.foundation.background import androidx.compose.foundation.interaction.MutableInteractionSource import androidx.compose.foundation.interaction.collectIsDraggedAsState import androidx.compose.foundation.layout.Box -import androidx.compose.foundation.layout.BoxScope import androidx.compose.foundation.layout.ColumnScope import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.padding @@ -26,7 +24,6 @@ import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.focus.onFocusChanged import androidx.compose.ui.graphics.Color -import androidx.compose.ui.graphics.painter.Painter import androidx.compose.ui.layout.ContentScale import androidx.compose.ui.res.painterResource import androidx.compose.ui.res.stringResource @@ -34,13 +31,13 @@ import androidx.compose.ui.semantics.semantics import androidx.compose.ui.semantics.stateDescription import androidx.media3.common.DeviceInfo import androidx.media3.common.Player +import androidx.media3.ui.compose.state.rememberPresentationState import ch.srgssr.pillarbox.demo.shared.R import ch.srgssr.pillarbox.demo.shared.extension.onDpadEvent import ch.srgssr.pillarbox.demo.shared.ui.player.DefaultVisibilityDelay import ch.srgssr.pillarbox.demo.shared.ui.player.metrics.MetricsOverlay import ch.srgssr.pillarbox.demo.shared.ui.player.rememberDelayedControlsVisibility import ch.srgssr.pillarbox.demo.shared.ui.player.rememberProgressTrackerState -import ch.srgssr.pillarbox.demo.shared.ui.player.shouldDisplayArtworkAsState import ch.srgssr.pillarbox.demo.shared.ui.rememberIsTalkBackEnabled import ch.srgssr.pillarbox.demo.shared.ui.settings.MetricsOverlayOptions import ch.srgssr.pillarbox.demo.ui.player.controls.PlayerControls @@ -50,8 +47,6 @@ import ch.srgssr.pillarbox.demo.ui.player.controls.SkipButton import ch.srgssr.pillarbox.demo.ui.theme.paddings import ch.srgssr.pillarbox.player.PillarboxPlayer import ch.srgssr.pillarbox.ui.ProgressTrackerState -import ch.srgssr.pillarbox.ui.ScaleMode -import ch.srgssr.pillarbox.ui.exoplayer.ExoPlayerSubtitleView import ch.srgssr.pillarbox.ui.extension.currentMediaMetadataAsState import ch.srgssr.pillarbox.ui.extension.getDeviceInfoAsState import ch.srgssr.pillarbox.ui.extension.getPeriodicallyCurrentMetricsAsState @@ -62,7 +57,7 @@ import ch.srgssr.pillarbox.ui.extension.playerErrorAsState import ch.srgssr.pillarbox.ui.state.CreditState import ch.srgssr.pillarbox.ui.state.rememberCreditState import ch.srgssr.pillarbox.ui.widget.keepScreenOn -import ch.srgssr.pillarbox.ui.widget.player.PlayerSurface +import ch.srgssr.pillarbox.ui.widget.player.PlayerFrame import coil3.compose.AsyncImage import kotlin.time.Duration.Companion.ZERO import kotlin.time.Duration.Companion.milliseconds @@ -72,7 +67,7 @@ import kotlin.time.Duration.Companion.milliseconds * * @param player The [Player] to observe. * @param modifier The modifier to be applied to the layout. - * @param scaleMode The surface scale mode. + * @param contentScale The surface [ContentScale]. * @param controlsVisible The control visibility. * @param controlsToggleable The controls are toggleable. * @param progressTracker The progress tracker. @@ -84,7 +79,7 @@ import kotlin.time.Duration.Companion.milliseconds fun PlayerView( player: PillarboxPlayer, modifier: Modifier = Modifier, - scaleMode: ScaleMode = ScaleMode.Fit, + contentScale: ContentScale = ContentScale.Fit, controlsVisible: Boolean = true, controlsToggleable: Boolean = true, progressTracker: ProgressTrackerState = rememberProgressTrackerState(player = player), @@ -92,26 +87,99 @@ fun PlayerView( overlayEnabled: Boolean = false, content: @Composable ColumnScope.() -> Unit = {}, ) { - val playerError by player.playerErrorAsState() - playerError?.let { - val sessionId = remember { - player.getCurrentPlaybackSessionId() + val presentationState = rememberPresentationState(player, keepContentOnReset = false) + PlayerFrame( + modifier = modifier, + player = player, + contentScale = contentScale, + presentationState = presentationState, + shutter = { + val deviceInfo by player.getDeviceInfoAsState() + val mediaMetadata by player.currentMediaMetadataAsState() + val placeholder = if (deviceInfo.playbackType == DeviceInfo.PLAYBACK_TYPE_REMOTE) { + androidx.media3.cast.R.drawable.ic_mr_button_disconnected_dark + } else { + R.drawable.placeholder + } + val placeHolderPainter = painterResource(placeholder) + Box( + modifier = Modifier + .fillMaxSize() + .background(color = Color.Black) + ) { + AsyncImage( + modifier = Modifier + .matchParentSize() + .background(color = Color.Black) + .align(Alignment.Center), + model = mediaMetadata.artworkUri, + contentDescription = null, + contentScale = ContentScale.Fit, + placeholder = placeHolderPainter, + error = placeHolderPainter, + ) + } + } + ) { + val playerError by player.playerErrorAsState() + playerError?.let { + val sessionId = remember { + player.getCurrentPlaybackSessionId() + } + PlayerError( + modifier = Modifier + .fillMaxSize() + .align(Alignment.Center), + playerError = it, + sessionId = sessionId, + onRetry = player::prepare + ) + return@PlayerFrame } - PlayerError( - modifier = modifier, - playerError = it, - sessionId = sessionId, - onRetry = player::prepare + + val hasMediaItem by player.hasMediaItemsAsState() + if (!hasMediaItem) { + PlayerNoContent( + modifier = Modifier + .fillMaxSize() + .align(Alignment.Center) + ) + return@PlayerFrame + } + + if (overlayEnabled) { + val currentMetrics by player.getPeriodicallyCurrentMetricsAsState(500.milliseconds) + currentMetrics?.let { + MetricsOverlay( + modifier = Modifier + .fillMaxSize() + .align(Alignment.TopStart), + playbackMetrics = it, + overlayOptions = overlayOptions, + ) + } + } + + PlayerOverlay( + player = player, + controlsVisible = controlsVisible, + controlsToggleable = controlsToggleable, + progressTracker = progressTracker, + controlsContent = content, ) - return } +} - val hasMediaItem by player.hasMediaItemsAsState() - if (!hasMediaItem) { - PlayerNoContent(modifier = modifier) - return - } +@Composable +private fun PlayerOverlay( + player: PillarboxPlayer, + controlsVisible: Boolean, + controlsToggleable: Boolean, + progressTracker: ProgressTrackerState, + controlsContent: @Composable ColumnScope.() -> Unit, +) { player.keepScreenOn() + val interactionSource = remember { MutableInteractionSource() } @@ -120,15 +188,14 @@ fun PlayerView( val isPlaying by player.isPlayingAsState() val keepControlDelay = if (!talkBackEnabled && !isSliderDragged && isPlaying) DefaultVisibilityDelay else ZERO val controlsVisibility = rememberDelayedControlsVisibility(initialVisible = controlsVisible, initialDelay = keepControlDelay) - val playbackState by player.playbackStateAsState() - val isBuffering = playbackState == Player.STATE_BUFFERING val controlsStateDescription = if (controlsVisibility.visible) { stringResource(R.string.controls_visible) } else { stringResource(R.string.controls_hidden) } Box( - modifier = modifier + modifier = Modifier + .fillMaxSize() .toggleable( value = controlsVisibility.visible, enabled = controlsToggleable, @@ -141,33 +208,6 @@ fun PlayerView( } ) { val creditState = rememberCreditState(player) - val shouldDisplayArtwork by player.shouldDisplayArtworkAsState() - val deviceInfo by player.getDeviceInfoAsState() - val mediaMetadata by player.currentMediaMetadataAsState() - val placeholder = if (deviceInfo.playbackType == DeviceInfo.PLAYBACK_TYPE_REMOTE) { - androidx.media3.cast.R.drawable.ic_mr_button_disconnected_dark - } else { - R.drawable.placeholder - } - - PlayerSurface( - modifier = Modifier - .fillMaxSize() - .background(color = Color.Black), - player = player, - scaleMode = scaleMode - ) { - SurfaceOverlay( - player = player, - displayBuffering = isBuffering && !isSliderDragged, - overlayEnabled = overlayEnabled, - overlayOptions = overlayOptions, - shouldDisplayArtwork = shouldDisplayArtwork, - artworkUri = mediaMetadata.artworkUri, - placeholder = painterResource(placeholder) - ) - } - AnimatedVisibility( visible = creditState.isInCredit && !controlsVisibility.visible, modifier = Modifier @@ -179,6 +219,8 @@ fun PlayerView( SkipButton(onClick = creditState::onClick) } + ProgressIndicator(player, isSliderDragged) + DemoControls( modifier = Modifier .matchParentSize() @@ -192,36 +234,17 @@ fun PlayerView( progressTracker = progressTracker, interactionSource = interactionSource, creditState = creditState, - content = content, + content = controlsContent, ) } } @Composable -private fun BoxScope.SurfaceOverlay( - player: Player, - displayBuffering: Boolean, - overlayEnabled: Boolean, - overlayOptions: MetricsOverlayOptions, - shouldDisplayArtwork: Boolean, - artworkUri: Uri?, - placeholder: Painter? -) { - if (shouldDisplayArtwork) { - AsyncImage( - modifier = Modifier - .matchParentSize() - .background(color = Color.Black) - .align(Alignment.Center), - model = artworkUri, - contentDescription = null, - contentScale = ContentScale.Fit, - placeholder = placeholder, - error = placeholder, - ) - } +private fun ProgressIndicator(player: PillarboxPlayer, isSliderDragging: Boolean) { + val playbackState by player.playbackStateAsState() + val isBuffering = playbackState == Player.STATE_BUFFERING AnimatedVisibility( - displayBuffering, + isBuffering && !isSliderDragging, enter = fadeIn(), exit = fadeOut(), ) { @@ -229,19 +252,6 @@ private fun BoxScope.SurfaceOverlay( CircularProgressIndicator(modifier = Modifier.align(Alignment.Center), color = Color.White) } } - ExoPlayerSubtitleView(player = player) - if (overlayEnabled && player is PillarboxPlayer) { - val currentMetrics by player.getPeriodicallyCurrentMetricsAsState(500.milliseconds) - currentMetrics?.let { - MetricsOverlay( - modifier = Modifier - .fillMaxSize() - .align(Alignment.TopStart), - playbackMetrics = it, - overlayOptions = overlayOptions, - ) - } - } } @Composable 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 f031845f4..c20590669 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,12 +4,10 @@ */ 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 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 @@ -35,6 +33,7 @@ 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.layout.ContentScale import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp import androidx.lifecycle.viewmodel.compose.viewModel @@ -43,8 +42,7 @@ 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.PlayerFrame import ch.srgssr.pillarbox.ui.widget.player.SurfaceType import kotlinx.coroutines.delay import kotlinx.coroutines.flow.map @@ -112,24 +110,18 @@ private fun PlayerView(player: Player, modifier: Modifier = Modifier) { player.playbackStateAsFlow().map { it == Player.STATE_BUFFERING } }.collectAsState(false) - Box( + PlayerFrame( modifier = modifier, + contentScale = ContentScale.FillHeight, + surfaceType = SurfaceType.Surface, + player = player, ) { - 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 diff --git a/pillarbox-demo/src/main/java/ch/srgssr/pillarbox/demo/ui/showcases/layouts/SimpleLayoutShowcase.kt b/pillarbox-demo/src/main/java/ch/srgssr/pillarbox/demo/ui/showcases/layouts/SimpleLayoutShowcase.kt index 754e99253..68e0461d4 100644 --- a/pillarbox-demo/src/main/java/ch/srgssr/pillarbox/demo/ui/showcases/layouts/SimpleLayoutShowcase.kt +++ b/pillarbox-demo/src/main/java/ch/srgssr/pillarbox/demo/ui/showcases/layouts/SimpleLayoutShowcase.kt @@ -10,10 +10,11 @@ import androidx.compose.runtime.remember import androidx.compose.ui.platform.LocalContext import ch.srgssr.pillarbox.demo.shared.data.samples.SamplesApple import ch.srgssr.pillarbox.demo.shared.di.PlayerModule -import ch.srgssr.pillarbox.ui.widget.player.PlayerSurface +import ch.srgssr.pillarbox.ui.widget.player.PillarboxPlayerSurface +import ch.srgssr.pillarbox.ui.widget.player.SurfaceType /** - * Simple player integration with only using [PlayerSurface] without any controls or UI. + * Simple player integration with only using [PillarboxPlayerSurface] without any controls or UI. */ @Composable fun SimpleLayoutShowcase() { @@ -30,5 +31,5 @@ fun SimpleLayoutShowcase() { player.release() } } - PlayerSurface(player = player) + PillarboxPlayerSurface(player = player, surfaceType = SurfaceType.Surface) } diff --git a/pillarbox-demo/src/main/java/ch/srgssr/pillarbox/demo/ui/showcases/layouts/SimpleStory.kt b/pillarbox-demo/src/main/java/ch/srgssr/pillarbox/demo/ui/showcases/layouts/SimpleStory.kt index a8eadd096..1563dee65 100644 --- a/pillarbox-demo/src/main/java/ch/srgssr/pillarbox/demo/ui/showcases/layouts/SimpleStory.kt +++ b/pillarbox-demo/src/main/java/ch/srgssr/pillarbox/demo/ui/showcases/layouts/SimpleStory.kt @@ -16,6 +16,7 @@ import androidx.compose.runtime.Composable import androidx.compose.runtime.DisposableEffect import androidx.compose.runtime.remember import androidx.compose.ui.Modifier +import androidx.compose.ui.layout.ContentScale import androidx.compose.ui.platform.LocalContext import androidx.lifecycle.compose.LifecycleStartEffect import androidx.media3.common.C @@ -24,8 +25,7 @@ import ch.srgssr.pillarbox.demo.shared.data.DemoItem import ch.srgssr.pillarbox.demo.shared.data.samples.SamplesSRG import ch.srgssr.pillarbox.demo.shared.di.PlayerModule import ch.srgssr.pillarbox.player.PillarboxExoPlayer -import ch.srgssr.pillarbox.ui.ScaleMode -import ch.srgssr.pillarbox.ui.widget.player.PlayerSurface +import ch.srgssr.pillarbox.ui.widget.player.PlayerFrame /** * A sample trying to reproduce story-like TikTok. @@ -76,10 +76,10 @@ private fun SimpleStoryPlayer(demoItem: DemoItem, isPlaying: Boolean = false) { player.release() } } - PlayerSurface( + PlayerFrame( modifier = Modifier.fillMaxSize(), player = player, - scaleMode = ScaleMode.Crop + contentScale = ContentScale.FillHeight ) LifecycleStartEffect(isPlaying) { diff --git a/pillarbox-demo/src/main/java/ch/srgssr/pillarbox/demo/ui/showcases/misc/ContentNotYetAvailable.kt b/pillarbox-demo/src/main/java/ch/srgssr/pillarbox/demo/ui/showcases/misc/ContentNotYetAvailable.kt index c3bb9baa1..60caf808d 100644 --- a/pillarbox-demo/src/main/java/ch/srgssr/pillarbox/demo/ui/showcases/misc/ContentNotYetAvailable.kt +++ b/pillarbox-demo/src/main/java/ch/srgssr/pillarbox/demo/ui/showcases/misc/ContentNotYetAvailable.kt @@ -17,7 +17,7 @@ import ch.srgssr.pillarbox.core.business.exception.BlockReasonException import ch.srgssr.pillarbox.demo.ui.player.Countdown import ch.srgssr.pillarbox.demo.ui.player.controls.PlayerError import ch.srgssr.pillarbox.ui.extension.playerErrorAsState -import ch.srgssr.pillarbox.ui.widget.player.PlayerSurface +import ch.srgssr.pillarbox.ui.widget.player.PlayerFrame import kotlinx.coroutines.delay import kotlin.time.Clock import kotlin.time.Duration @@ -29,7 +29,7 @@ import kotlin.time.Duration fun ContentNotYetAvailable() { val viewModel: ContentNotYetAvailableViewModel = viewModel() val player = viewModel.player - PlayerSurface(player = player) { + PlayerFrame(player = player) { val error by player.playerErrorAsState() error?.let { ErrorViewWithCountdown( diff --git a/pillarbox-demo/src/main/java/ch/srgssr/pillarbox/demo/ui/showcases/misc/SmoothSeekingShowcase.kt b/pillarbox-demo/src/main/java/ch/srgssr/pillarbox/demo/ui/showcases/misc/SmoothSeekingShowcase.kt index 46ecf768f..aff3106dd 100644 --- a/pillarbox-demo/src/main/java/ch/srgssr/pillarbox/demo/ui/showcases/misc/SmoothSeekingShowcase.kt +++ b/pillarbox-demo/src/main/java/ch/srgssr/pillarbox/demo/ui/showcases/misc/SmoothSeekingShowcase.kt @@ -9,6 +9,7 @@ import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.aspectRatio import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.padding @@ -41,9 +42,8 @@ import ch.srgssr.pillarbox.demo.shared.ui.settings.AppSettingsViewModel import ch.srgssr.pillarbox.demo.ui.player.controls.PlayerPlaybackRow import ch.srgssr.pillarbox.demo.ui.player.controls.PlayerTimeSlider import ch.srgssr.pillarbox.demo.ui.theme.paddings -import ch.srgssr.pillarbox.ui.exoplayer.ExoPlayerSubtitleView import ch.srgssr.pillarbox.ui.extension.playbackStateAsState -import ch.srgssr.pillarbox.ui.widget.player.PlayerSurface +import ch.srgssr.pillarbox.ui.widget.player.PlayerFrame /** * Smooth seeking showcase @@ -72,17 +72,12 @@ fun SmoothSeekingShowcase() { } Column { - Box { + PlayerFrame( + modifier = Modifier.fillMaxWidth().aspectRatio(16 / 9f), + player = player + ) { val playbackState by player.playbackStateAsState() val isBuffering = playbackState == Player.STATE_BUFFERING - PlayerSurface(player = player, defaultAspectRatio = 16 / 9f) { - if (isBuffering) { - Box(modifier = Modifier.fillMaxSize(), contentAlignment = Alignment.Center) { - CircularProgressIndicator(modifier = Modifier.align(Alignment.Center), color = Color.White) - } - } - ExoPlayerSubtitleView(player = player) - } PlayerPlaybackRow( player = player, modifier = Modifier.align(Alignment.Center), @@ -96,6 +91,11 @@ fun SmoothSeekingShowcase() { progressTracker = rememberProgressTrackerState(player = player), interactionSource = remember { MutableInteractionSource() }, ) + if (isBuffering) { + Box(modifier = Modifier.fillMaxSize(), contentAlignment = Alignment.Center) { + CircularProgressIndicator(modifier = Modifier.align(Alignment.Center), color = Color.White) + } + } } Row( modifier = Modifier diff --git a/pillarbox-demo/src/main/java/ch/srgssr/pillarbox/demo/ui/showcases/misc/SphericalSurfaceShowcase.kt b/pillarbox-demo/src/main/java/ch/srgssr/pillarbox/demo/ui/showcases/misc/SphericalSurfaceShowcase.kt index dcffc4d86..dea72a7e5 100644 --- a/pillarbox-demo/src/main/java/ch/srgssr/pillarbox/demo/ui/showcases/misc/SphericalSurfaceShowcase.kt +++ b/pillarbox-demo/src/main/java/ch/srgssr/pillarbox/demo/ui/showcases/misc/SphericalSurfaceShowcase.kt @@ -9,13 +9,13 @@ import androidx.compose.runtime.Composable import androidx.compose.runtime.DisposableEffect import androidx.compose.runtime.remember import androidx.compose.ui.Modifier +import androidx.compose.ui.layout.ContentScale import androidx.compose.ui.platform.LocalContext import androidx.lifecycle.compose.LifecycleStartEffect import androidx.media3.common.Player import ch.srgssr.pillarbox.core.business.PillarboxExoPlayer import ch.srgssr.pillarbox.core.business.SRGMediaItem -import ch.srgssr.pillarbox.ui.ScaleMode -import ch.srgssr.pillarbox.ui.widget.player.PlayerSurface +import ch.srgssr.pillarbox.ui.widget.player.PlayerFrame import ch.srgssr.pillarbox.ui.widget.player.SurfaceType /** @@ -45,10 +45,10 @@ fun SphericalSurfaceShowcase() { } } - PlayerSurface( + PlayerFrame( player = player, modifier = Modifier.fillMaxSize(), surfaceType = SurfaceType.Spherical, - scaleMode = ScaleMode.Fill, + contentScale = ContentScale.FillBounds, ) } diff --git a/pillarbox-demo/src/main/java/ch/srgssr/pillarbox/demo/ui/showcases/misc/UpdatableMediaItemShowcase.kt b/pillarbox-demo/src/main/java/ch/srgssr/pillarbox/demo/ui/showcases/misc/UpdatableMediaItemShowcase.kt index 52f54e3f0..22a2216a5 100644 --- a/pillarbox-demo/src/main/java/ch/srgssr/pillarbox/demo/ui/showcases/misc/UpdatableMediaItemShowcase.kt +++ b/pillarbox-demo/src/main/java/ch/srgssr/pillarbox/demo/ui/showcases/misc/UpdatableMediaItemShowcase.kt @@ -15,7 +15,7 @@ import androidx.compose.ui.graphics.Color import androidx.lifecycle.compose.LifecycleResumeEffect import androidx.lifecycle.viewmodel.compose.viewModel import ch.srgssr.pillarbox.ui.extension.currentMediaMetadataAsState -import ch.srgssr.pillarbox.ui.widget.player.PlayerSurface +import ch.srgssr.pillarbox.ui.widget.player.PlayerFrame /** * Updatable media item view @@ -25,7 +25,7 @@ fun UpdatableMediaItemShowcase() { val updatableMediaItemViewModel: UpdatableMediaItemViewModel = viewModel() val player = updatableMediaItemViewModel.player val currentItem by player.currentMediaMetadataAsState() - PlayerSurface(player = player) { + PlayerFrame(player = player) { Box(modifier = Modifier.fillMaxSize(), contentAlignment = Alignment.TopStart) { Text( color = Color.Green, diff --git a/pillarbox-ui/src/main/java/ch/srgssr/pillarbox/ui/widget/player/PillarboxPlayerSurface.kt b/pillarbox-ui/src/main/java/ch/srgssr/pillarbox/ui/widget/player/PillarboxPlayerSurface.kt index 36cd88597..877a9d89d 100644 --- a/pillarbox-ui/src/main/java/ch/srgssr/pillarbox/ui/widget/player/PillarboxPlayerSurface.kt +++ b/pillarbox-ui/src/main/java/ch/srgssr/pillarbox/ui/widget/player/PillarboxPlayerSurface.kt @@ -24,9 +24,15 @@ import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.withContext /** - * Remove the choice - * Since minSDK = 24, we force to always use SURFACE_TYPE_SURFACE_VIEW. - * https://developer.android.com/media/media3/ui/surface + * A Composable function that displays a [Player]. + * + * Since minSDK = 24, [surfaceType] should be always [SurfaceType.Surface] or [SurfaceType.Spherical]. + * + * [Choosing surface type Media3 documentation](https://developer.android.com/media/media3/ui/surface) + * + * @param player The [Player] instance to use for playback. + * @param modifier The [Modifier] to apply to the layout. + * @param surfaceType The [SurfaceType] to use for rendering the video. */ @Composable fun PillarboxPlayerSurface( From 7ed228245cb69b5baceb10324f5f9e4138351749 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Joaquim=20St=C3=A4hli?= Date: Wed, 17 Sep 2025 16:51:39 +0200 Subject: [PATCH 07/24] Improve documentation --- .../srgssr/pillarbox/ui/widget/player/PillarboxPlayerSurface.kt | 1 + 1 file changed, 1 insertion(+) diff --git a/pillarbox-ui/src/main/java/ch/srgssr/pillarbox/ui/widget/player/PillarboxPlayerSurface.kt b/pillarbox-ui/src/main/java/ch/srgssr/pillarbox/ui/widget/player/PillarboxPlayerSurface.kt index 877a9d89d..9fd03c315 100644 --- a/pillarbox-ui/src/main/java/ch/srgssr/pillarbox/ui/widget/player/PillarboxPlayerSurface.kt +++ b/pillarbox-ui/src/main/java/ch/srgssr/pillarbox/ui/widget/player/PillarboxPlayerSurface.kt @@ -27,6 +27,7 @@ import kotlinx.coroutines.withContext * A Composable function that displays a [Player]. * * Since minSDK = 24, [surfaceType] should be always [SurfaceType.Surface] or [SurfaceType.Spherical]. + * It also includes a workaround on Android 34 with Android SurfaceView. * * [Choosing surface type Media3 documentation](https://developer.android.com/media/media3/ui/surface) * From 8befb01ec50db29f04f0f19136e438b383ad3b73 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Joaquim=20St=C3=A4hli?= Date: Thu, 18 Sep 2025 14:17:20 +0200 Subject: [PATCH 08/24] Add a Scope to make easier customization of Subtitles --- .../pillarbox/ui/widget/player/PlayerFrame.kt | 24 +++++++++++++++---- .../ui/widget/player/PlayerSubtitle.kt | 14 ++++------- 2 files changed, 24 insertions(+), 14 deletions(-) diff --git a/pillarbox-ui/src/main/java/ch/srgssr/pillarbox/ui/widget/player/PlayerFrame.kt b/pillarbox-ui/src/main/java/ch/srgssr/pillarbox/ui/widget/player/PlayerFrame.kt index fd173e38f..04a2fe949 100644 --- a/pillarbox-ui/src/main/java/ch/srgssr/pillarbox/ui/widget/player/PlayerFrame.kt +++ b/pillarbox-ui/src/main/java/ch/srgssr/pillarbox/ui/widget/player/PlayerFrame.kt @@ -9,8 +9,10 @@ import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.BoxScope import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.runtime.Composable +import androidx.compose.runtime.remember import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clipToBounds +import androidx.compose.ui.geometry.Size import androidx.compose.ui.graphics.Color import androidx.compose.ui.layout.ContentScale import androidx.media3.common.Player @@ -29,7 +31,7 @@ import androidx.media3.ui.compose.state.rememberPresentationState * @param presentationState The [PresentationState] to be used. * @param surface A composable function that draws on top of the surface. It may be displayed outside the bounds. * @param subtitle A composable function that draws the subtitle. - * @param shutter A composable function that draws the shutter when the player hasn't active video tracks. + * @param shutter A composable function that draws when [PresentationState.coverSurface] is true. * @param overlay A composable function that draws on top of everything. */ @Composable @@ -41,12 +43,12 @@ fun PlayerFrame( displayDebugView: Boolean = false, presentationState: PresentationState = rememberPresentationState(player = player, keepContentOnReset = false), surface: (@Composable BoxScope.() -> Unit)? = null, - subtitle: @Composable BoxScope.() -> Unit = { + subtitle: @Composable SubtitleBoxScope.() -> Unit = { PlayerSubtitle( modifier = Modifier, player = player, - presentationState = presentationState, - videoContentScale = contentScale + videoSizeDp = this.videoSizeDp, + videoContentScale = this.contentScale ) }, shutter: @Composable BoxScope.() -> Unit = { @@ -68,7 +70,10 @@ fun PlayerFrame( DebugPlayerView(Modifier.fillMaxSize()) } } - subtitle() + val subtitleScope = remember(presentationState.videoSizeDp, contentScale) { + SubtitleBoxScope(videoSizeDp = presentationState.videoSizeDp, contentScale = contentScale, boxScope = this) + } + subtitleScope.subtitle() if (presentationState.coverSurface) { shutter() @@ -76,3 +81,12 @@ fun PlayerFrame( overlay() } } + +/** + * A [BoxScope] with a [videoSizeDp] and a [contentScale]. + */ +class SubtitleBoxScope( + private val boxScope: BoxScope, + val videoSizeDp: Size?, + val contentScale: ContentScale +) : BoxScope by boxScope diff --git a/pillarbox-ui/src/main/java/ch/srgssr/pillarbox/ui/widget/player/PlayerSubtitle.kt b/pillarbox-ui/src/main/java/ch/srgssr/pillarbox/ui/widget/player/PlayerSubtitle.kt index 935cc8c04..764a242f3 100644 --- a/pillarbox-ui/src/main/java/ch/srgssr/pillarbox/ui/widget/player/PlayerSubtitle.kt +++ b/pillarbox-ui/src/main/java/ch/srgssr/pillarbox/ui/widget/player/PlayerSubtitle.kt @@ -11,6 +11,7 @@ import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.runtime.setValue import androidx.compose.ui.Modifier +import androidx.compose.ui.geometry.Size import androidx.compose.ui.layout.ContentScale import androidx.compose.ui.viewinterop.AndroidView import androidx.media3.common.Player @@ -19,7 +20,6 @@ import androidx.media3.ui.CaptionStyleCompat import androidx.media3.ui.SubtitleView import androidx.media3.ui.compose.modifiers.resizeWithContentScale import androidx.media3.ui.compose.state.PresentationState -import androidx.media3.ui.compose.state.rememberPresentationState import ch.srgssr.pillarbox.ui.exoplayer.SubtitleTextSize import ch.srgssr.pillarbox.ui.exoplayer.setTextSize @@ -29,18 +29,16 @@ import ch.srgssr.pillarbox.ui.exoplayer.setTextSize * @param player The [Player] instance to retrieve subtitle cues from. * @param videoContentScale The [ContentScale] applied to the video content. * @param modifier The [Modifier] to apply to this layout. - * @param presentationState The [PresentationState] to be used. + * @param videoSizeDp The [Size] of the video content. @see [PresentationState.videoSizeDp] * @param captionStyle Optional [CaptionStyleCompat] to override the user's preferred caption style. * @param subtitleTextSize Optional [SubtitleTextSize] to override the user's preferred subtitle text size. - * - * @see rememberPresentationState */ @Composable fun PlayerSubtitle( player: Player?, videoContentScale: ContentScale, modifier: Modifier = Modifier, - presentationState: PresentationState = rememberPresentationState(player), + videoSizeDp: Size?, captionStyle: CaptionStyleCompat? = null, subtitleTextSize: SubtitleTextSize? = null ) { @@ -48,10 +46,8 @@ fun PlayerSubtitle( ContentScale.Crop, ContentScale.FillHeight, ContentScale.FillWidth -> ContentScale.FillBounds else -> videoContentScale } - val textModifier = modifier.resizeWithContentScale(contentScale = textContentScale, presentationState.videoSizeDp) - player?.let { - PlayerSubtitle(player = it, modifier = textModifier, captionStyle = captionStyle, subtitleTextSize = subtitleTextSize) - } + val textModifier = modifier.resizeWithContentScale(contentScale = textContentScale, videoSizeDp) + PlayerSubtitle(player = player, modifier = textModifier, captionStyle = captionStyle, subtitleTextSize = subtitleTextSize) } /** From cc72235be6a31c5eb4d8e179ad63cfc2068f76da Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Joaquim=20St=C3=A4hli?= Date: Thu, 18 Sep 2025 15:32:44 +0200 Subject: [PATCH 09/24] Workaround when seeking during live stream --- .../ch/srgssr/pillarbox/player/PillarboxExoPlayer.kt | 9 +++++++++ 1 file changed, 9 insertions(+) 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 f66e627dd..a1d7e792a 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 @@ -153,6 +153,15 @@ class PillarboxExoPlayer internal constructor( } } + override fun getBufferedPercentage(): Int { + // Workaround, sometime it throws when playing live. Due to Util.percentInt, it should be fixed in Media3 main branch. + return try { + exoPlayer.bufferedPercentage + } catch (_: IllegalArgumentException) { + 0 + } + } + override fun getAnalyticsCollector(): PillarboxAnalyticsCollector { return exoPlayer.analyticsCollector as PillarboxAnalyticsCollector } From 65d58dffe185e5d71caaeb8ecb4d235081f7ff03 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Joaquim=20St=C3=A4hli?= Date: Fri, 19 Sep 2025 10:39:12 +0200 Subject: [PATCH 10/24] Mark depreciated old apis --- .../demo/ui/showcases/integrations/Media3ComposeSample.kt | 4 ++-- .../ch/srgssr/pillarbox/ui/exoplayer/ExoplayerSubtitleView.kt | 1 + .../ch/srgssr/pillarbox/ui/widget/player/PlayerSurface.kt | 1 + 3 files changed, 4 insertions(+), 2 deletions(-) diff --git a/pillarbox-demo/src/main/java/ch/srgssr/pillarbox/demo/ui/showcases/integrations/Media3ComposeSample.kt b/pillarbox-demo/src/main/java/ch/srgssr/pillarbox/demo/ui/showcases/integrations/Media3ComposeSample.kt index f58aa6c23..250bc1192 100644 --- a/pillarbox-demo/src/main/java/ch/srgssr/pillarbox/demo/ui/showcases/integrations/Media3ComposeSample.kt +++ b/pillarbox-demo/src/main/java/ch/srgssr/pillarbox/demo/ui/showcases/integrations/Media3ComposeSample.kt @@ -29,7 +29,7 @@ import androidx.media3.ui.compose.state.rememberPlayPauseButtonState import androidx.media3.ui.compose.state.rememberPresentationState import ch.srgssr.pillarbox.core.business.PillarboxExoPlayer import ch.srgssr.pillarbox.demo.shared.data.samples.SamplesSRG -import ch.srgssr.pillarbox.ui.exoplayer.ExoPlayerSubtitleView +import ch.srgssr.pillarbox.ui.widget.player.PlayerSubtitle /** * Sample that shows Media3 compose ui components. @@ -71,7 +71,7 @@ fun Media3ComposeSample() { surfaceType = SURFACE_TYPE_SURFACE_VIEW, player = player ) - ExoPlayerSubtitleView(player = player, modifier = Modifier.fillMaxSize()) + PlayerSubtitle(player = player, modifier = Modifier.fillMaxSize()) } val playPauseState = rememberPlayPauseButtonState(player) diff --git a/pillarbox-ui/src/main/java/ch/srgssr/pillarbox/ui/exoplayer/ExoplayerSubtitleView.kt b/pillarbox-ui/src/main/java/ch/srgssr/pillarbox/ui/exoplayer/ExoplayerSubtitleView.kt index 6b120a6e2..7c979bfbf 100644 --- a/pillarbox-ui/src/main/java/ch/srgssr/pillarbox/ui/exoplayer/ExoplayerSubtitleView.kt +++ b/pillarbox-ui/src/main/java/ch/srgssr/pillarbox/ui/exoplayer/ExoplayerSubtitleView.kt @@ -33,6 +33,7 @@ import com.google.common.collect.ImmutableList * @param captionStyle Optional [CaptionStyleCompat] to override the user's preferred caption style. * @param subtitleTextSize Optional [SubtitleTextSize] to override the user's preferred subtitle text size. */ +@Deprecated(message = "Use PlayerSubtitle instead.", replaceWith = ReplaceWith("PlayerSubtitle")) @Composable fun ExoPlayerSubtitleView( player: Player, diff --git a/pillarbox-ui/src/main/java/ch/srgssr/pillarbox/ui/widget/player/PlayerSurface.kt b/pillarbox-ui/src/main/java/ch/srgssr/pillarbox/ui/widget/player/PlayerSurface.kt index 4cb125dee..992a39967 100644 --- a/pillarbox-ui/src/main/java/ch/srgssr/pillarbox/ui/widget/player/PlayerSurface.kt +++ b/pillarbox-ui/src/main/java/ch/srgssr/pillarbox/ui/widget/player/PlayerSurface.kt @@ -45,6 +45,7 @@ import ch.srgssr.pillarbox.ui.extension.getAspectRatioAsState * @param surfaceType The type of surface to use for rendering the video. * @param surfaceContent The content to display on top of the [Player]. */ +@Deprecated("Use PlayerFrame instead.", replaceWith = ReplaceWith("PlayerFrame")) @Suppress("CyclomaticComplexMethod") @Composable fun PlayerSurface( From 4fd522f747291582f7ac7a77cf52a205400374cf Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Joaquim=20St=C3=A4hli?= Date: Fri, 19 Sep 2025 10:50:50 +0200 Subject: [PATCH 11/24] Update parameters order --- .../ch/srgssr/pillarbox/ui/widget/player/PlayerSubtitle.kt | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/pillarbox-ui/src/main/java/ch/srgssr/pillarbox/ui/widget/player/PlayerSubtitle.kt b/pillarbox-ui/src/main/java/ch/srgssr/pillarbox/ui/widget/player/PlayerSubtitle.kt index 764a242f3..553f5ecea 100644 --- a/pillarbox-ui/src/main/java/ch/srgssr/pillarbox/ui/widget/player/PlayerSubtitle.kt +++ b/pillarbox-ui/src/main/java/ch/srgssr/pillarbox/ui/widget/player/PlayerSubtitle.kt @@ -28,8 +28,8 @@ import ch.srgssr.pillarbox.ui.exoplayer.setTextSize * * @param player The [Player] instance to retrieve subtitle cues from. * @param videoContentScale The [ContentScale] applied to the video content. - * @param modifier The [Modifier] to apply to this layout. * @param videoSizeDp The [Size] of the video content. @see [PresentationState.videoSizeDp] + * @param modifier The [Modifier] to apply to this layout. * @param captionStyle Optional [CaptionStyleCompat] to override the user's preferred caption style. * @param subtitleTextSize Optional [SubtitleTextSize] to override the user's preferred subtitle text size. */ @@ -37,8 +37,8 @@ import ch.srgssr.pillarbox.ui.exoplayer.setTextSize fun PlayerSubtitle( player: Player?, videoContentScale: ContentScale, - modifier: Modifier = Modifier, videoSizeDp: Size?, + modifier: Modifier = Modifier, captionStyle: CaptionStyleCompat? = null, subtitleTextSize: SubtitleTextSize? = null ) { From e1610e54c26d7854d5dac058eee5cc878d3150be Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Joaquim=20St=C3=A4hli?= Date: Tue, 25 Nov 2025 15:10:49 +0100 Subject: [PATCH 12/24] Update to Media3 1.9.x --- gradle/libs.versions.toml | 2 +- .../demo/ui/showcases/layouts/StoryViewModel.kt | 5 ++--- .../ch/srgssr/pillarbox/player/PillarboxExoPlayer.kt | 4 ++++ .../srgssr/pillarbox/player/PillarboxLoadControl.kt | 11 ++++++----- .../pillarbox/player/PillarboxPreloadManager.kt | 4 ++-- .../player/session/PillarboxMediaController.kt | 8 ++++++++ 6 files changed, 23 insertions(+), 11 deletions(-) diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 68f2a8a67..09c57fa39 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -6,7 +6,7 @@ androidx-compose = "2025.11.01" androidx-core = "1.17.0" androidx-datastore = "1.2.0" androidx-lifecycle = "2.10.0" -androidx-media3 = "1.8.0" +androidx-media3 = "1.9.0-beta01" androidx-navigation = "2.9.6" androidx-paging = "3.3.6" androidx-test-core = "1.7.0" 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 6e4a53bd6..674fced0d 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 @@ -126,7 +126,6 @@ class StoryViewModel(application: Application) : AndroidViewModel(application) { if (currentPage == page) return currentPage = page preloadManager.currentPlayingIndex = currentPage - preloadManager.invalidate() } /** @@ -155,13 +154,13 @@ class StoryViewModel(application: Application) : AndroidViewModel(application) { */ @Suppress("MagicNumber") private inner class StoryPreloadStatusControl : TargetPreloadStatusControl { - override fun getTargetPreloadStatus(rankingData: Int): PreloadStatus? { + override fun getTargetPreloadStatus(rankingData: Int): PreloadStatus { val offset = abs(rankingData - currentPage) return when (offset) { 1 -> PreloadStatus.specifiedRangeLoaded(1.seconds.inWholeMilliseconds) 2, 3, 4 -> PreloadStatus.specifiedRangeLoaded(1.milliseconds.inWholeMilliseconds) - else -> null + else -> PreloadStatus.PRELOAD_STATUS_NOT_PRELOADED } } } 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 a1d7e792a..9b04afd61 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 @@ -162,6 +162,10 @@ class PillarboxExoPlayer internal constructor( } } + override fun getAudioSessionId(): Int { + return exoPlayer.audioSessionId + } + override fun getAnalyticsCollector(): PillarboxAnalyticsCollector { return exoPlayer.analyticsCollector as PillarboxAnalyticsCollector } diff --git a/pillarbox-player/src/main/java/ch/srgssr/pillarbox/player/PillarboxLoadControl.kt b/pillarbox-player/src/main/java/ch/srgssr/pillarbox/player/PillarboxLoadControl.kt index 43aee9add..9d9115836 100644 --- a/pillarbox-player/src/main/java/ch/srgssr/pillarbox/player/PillarboxLoadControl.kt +++ b/pillarbox-player/src/main/java/ch/srgssr/pillarbox/player/PillarboxLoadControl.kt @@ -27,7 +27,7 @@ import kotlin.time.Duration.Companion.seconds */ class PillarboxLoadControl( bufferDurations: BufferDurations = DEFAULT_BUFFER_DURATIONS, - private val allocator: DefaultAllocator = DefaultAllocator(true, C.DEFAULT_BUFFER_SEGMENT_SIZE), + allocator: DefaultAllocator = DefaultAllocator(true, C.DEFAULT_BUFFER_SEGMENT_SIZE), ) : LoadControl { private val defaultLoadControl: DefaultLoadControl = DefaultLoadControl.Builder() @@ -62,8 +62,8 @@ class PillarboxLoadControl( defaultLoadControl.onReleased(playerId) } - override fun getAllocator(): Allocator { - return allocator + override fun getAllocator(playerId: PlayerId): Allocator { + return defaultLoadControl.getAllocator(playerId) } override fun getBackBufferDurationUs(playerId: PlayerId): Long { @@ -79,11 +79,12 @@ class PillarboxLoadControl( } override fun shouldContinuePreloading( + playerId: PlayerId, timeline: Timeline, mediaPeriodId: MediaSource.MediaPeriodId, - bufferedDurationUs: Long, + bufferedDurationUs: Long ): Boolean { - return defaultLoadControl.shouldContinuePreloading(timeline, mediaPeriodId, bufferedDurationUs) + return defaultLoadControl.shouldContinuePreloading(playerId, timeline, mediaPeriodId, bufferedDurationUs) } override fun shouldStartPlayback(parameters: LoadControl.Parameters): Boolean { 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 b4be6fa22..d1e40b946 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 @@ -184,13 +184,13 @@ class PillarboxPreloadManager( */ @Suppress("MagicNumber") inner class DefaultTargetPreloadStatusControl : TargetPreloadStatusControl { - override fun getTargetPreloadStatus(rankingData: Int): PreloadStatus? { + override fun getTargetPreloadStatus(rankingData: Int): PreloadStatus { val offset = abs(rankingData - currentPlayingIndex) return when (offset) { 1 -> PreloadStatus.specifiedRangeLoaded(1.seconds.inWholeMilliseconds) 2, 3 -> PreloadStatus.specifiedRangeLoaded(500.milliseconds.inWholeMilliseconds) - else -> null + else -> PreloadStatus.PRELOAD_STATUS_NOT_PRELOADED } } } diff --git a/pillarbox-player/src/main/java/ch/srgssr/pillarbox/player/session/PillarboxMediaController.kt b/pillarbox-player/src/main/java/ch/srgssr/pillarbox/player/session/PillarboxMediaController.kt index 5a1bd1764..cb3985c35 100644 --- a/pillarbox-player/src/main/java/ch/srgssr/pillarbox/player/session/PillarboxMediaController.kt +++ b/pillarbox-player/src/main/java/ch/srgssr/pillarbox/player/session/PillarboxMediaController.kt @@ -850,6 +850,14 @@ open class PillarboxMediaController internal constructor() : PillarboxPlayer { return mediaController.getVolume() } + override fun mute() { + mediaController.mute() + } + + override fun unmute() { + mediaController.unmute() + } + override fun clearVideoSurface() { mediaController.clearVideoSurface() } From 4dae8a6e09e11eb25e614b799d0eb88d5c5d5edc Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Joaquim=20St=C3=A4hli?= Date: Tue, 25 Nov 2025 15:27:15 +0100 Subject: [PATCH 13/24] Remove the API 34 workaround --- .../ui/widget/player/PillarboxPlayerSurface.kt | 13 +------------ 1 file changed, 1 insertion(+), 12 deletions(-) diff --git a/pillarbox-ui/src/main/java/ch/srgssr/pillarbox/ui/widget/player/PillarboxPlayerSurface.kt b/pillarbox-ui/src/main/java/ch/srgssr/pillarbox/ui/widget/player/PillarboxPlayerSurface.kt index 9fd03c315..ea11e6f5a 100644 --- a/pillarbox-ui/src/main/java/ch/srgssr/pillarbox/ui/widget/player/PillarboxPlayerSurface.kt +++ b/pillarbox-ui/src/main/java/ch/srgssr/pillarbox/ui/widget/player/PillarboxPlayerSurface.kt @@ -4,8 +4,6 @@ */ package ch.srgssr.pillarbox.ui.widget.player -import android.os.Build -import android.os.Build.VERSION_CODES.UPSIDE_DOWN_CAKE import android.view.View import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect @@ -45,7 +43,7 @@ fun PillarboxPlayerSurface( // the process. If this composable is guarded by some condition, it might never become visible // because the Player will not emit the relevant event, e.g. the first frame being ready. when (surfaceType) { - SurfaceType.Surface -> PlayerSurfaceInternal(player = player, modifier = modifier) + SurfaceType.Surface -> PlayerSurface(player = player, modifier = modifier, surfaceType = SURFACE_TYPE_SURFACE_VIEW) SurfaceType.Texture -> PlayerSurface(modifier = modifier, player = player, surfaceType = SURFACE_TYPE_TEXTURE_VIEW) SurfaceType.Spherical -> PlayerSurfaceSphericalInternal(player = player, modifier = modifier) } @@ -97,15 +95,6 @@ private fun PlayerSurfaceSphericalInternal(player: Player?, modifier: Modifier) } } -@Composable -private fun PlayerSurfaceInternal(player: Player?, modifier: Modifier) { - if (Build.VERSION.SDK_INT == UPSIDE_DOWN_CAKE) { - AndroidSurfaceViewWithApi34WorkAround(player = player, modifier = modifier) - } else { - PlayerSurface(modifier = modifier, player = player, surfaceType = SURFACE_TYPE_SURFACE_VIEW) - } -} - private var View.attachedPlayer: Player? get() = tag as? Player set(player) { From 5942c4f46bf5ca62dde6c359cbe9cb639595a47b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Joaquim=20St=C3=A4hli?= Date: Mon, 1 Dec 2025 10:54:01 +0100 Subject: [PATCH 14/24] Simplify Subtitle handling --- .../pillarbox/ui/widget/player/PlayerFrame.kt | 52 ++++++++++++++----- 1 file changed, 38 insertions(+), 14 deletions(-) diff --git a/pillarbox-ui/src/main/java/ch/srgssr/pillarbox/ui/widget/player/PlayerFrame.kt b/pillarbox-ui/src/main/java/ch/srgssr/pillarbox/ui/widget/player/PlayerFrame.kt index 04a2fe949..5cc29c339 100644 --- a/pillarbox-ui/src/main/java/ch/srgssr/pillarbox/ui/widget/player/PlayerFrame.kt +++ b/pillarbox-ui/src/main/java/ch/srgssr/pillarbox/ui/widget/player/PlayerFrame.kt @@ -12,7 +12,6 @@ import androidx.compose.runtime.Composable import androidx.compose.runtime.remember import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clipToBounds -import androidx.compose.ui.geometry.Size import androidx.compose.ui.graphics.Color import androidx.compose.ui.layout.ContentScale import androidx.media3.common.Player @@ -30,9 +29,9 @@ import androidx.media3.ui.compose.state.rememberPresentationState * @param displayDebugView Whether to display a debug view. * @param presentationState The [PresentationState] to be used. * @param surface A composable function that draws on top of the surface. It may be displayed outside the bounds. - * @param subtitle A composable function that draws the subtitle. + * @param subtitle A composable function that draws the subtitle. Subtitle can only [SubtitleContentScale.Fill] or [SubtitleContentScale.Fill]. * @param shutter A composable function that draws when [PresentationState.coverSurface] is true. - * @param overlay A composable function that draws on top of everything. + * @param overlay A composable function that draws on top of everything including [shutter] and [subtitle]. */ @Composable fun PlayerFrame( @@ -47,8 +46,6 @@ fun PlayerFrame( PlayerSubtitle( modifier = Modifier, player = player, - videoSizeDp = this.videoSizeDp, - videoContentScale = this.contentScale ) }, shutter: @Composable BoxScope.() -> Unit = { @@ -70,23 +67,50 @@ fun PlayerFrame( DebugPlayerView(Modifier.fillMaxSize()) } } - val subtitleScope = remember(presentationState.videoSizeDp, contentScale) { - SubtitleBoxScope(videoSizeDp = presentationState.videoSizeDp, contentScale = contentScale, boxScope = this) - } - subtitleScope.subtitle() - if (presentationState.coverSurface) { shutter() } + + val subtitleContentScale = contentScale.toSubtitleContentScale() + val subtitleModifier = Modifier.resizeWithContentScale(contentScale = subtitleContentScale.contentScale, presentationState.videoSizeDp) + Box(modifier = subtitleModifier) { + val subtitleScope = remember(subtitleContentScale) { + SubtitleBoxScope(contentScale = subtitleContentScale, boxScope = this) + } + subtitleScope.subtitle() + } overlay() } } /** - * A [BoxScope] with a [videoSizeDp] and a [contentScale]. + * A [BoxScope] with the [SubtitleContentScale] applied to the scope. + * @property contentScale The [SubtitleContentScale]. */ -class SubtitleBoxScope( +class SubtitleBoxScope internal constructor( private val boxScope: BoxScope, - val videoSizeDp: Size?, - val contentScale: ContentScale + val contentScale: SubtitleContentScale, ) : BoxScope by boxScope + +/** + * Content scale for Subtitles + * @property contentScale The [ContentScale] associated. + */ +enum class SubtitleContentScale(val contentScale: ContentScale) { + /** + * A content scale that fit the video surface + */ + Fit(ContentScale.Fit), + + /** + * Content scale that fill the bounds. + */ + Fill(ContentScale.FillBounds) +} + +internal fun ContentScale.toSubtitleContentScale(): SubtitleContentScale { + return when (this) { + ContentScale.Fit -> SubtitleContentScale.Fit + else -> SubtitleContentScale.Fill + } +} From e3053a0988ec835f59da21ffb5096f94c5ecc99c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Joaquim=20St=C3=A4hli?= Date: Mon, 1 Dec 2025 10:59:20 +0100 Subject: [PATCH 15/24] Remove workaround --- .../player/SurfaceViewWithApi34WorkAround.kt | 48 ------------------- 1 file changed, 48 deletions(-) delete mode 100644 pillarbox-ui/src/main/java/ch/srgssr/pillarbox/ui/widget/player/SurfaceViewWithApi34WorkAround.kt diff --git a/pillarbox-ui/src/main/java/ch/srgssr/pillarbox/ui/widget/player/SurfaceViewWithApi34WorkAround.kt b/pillarbox-ui/src/main/java/ch/srgssr/pillarbox/ui/widget/player/SurfaceViewWithApi34WorkAround.kt deleted file mode 100644 index b012c032b..000000000 --- a/pillarbox-ui/src/main/java/ch/srgssr/pillarbox/ui/widget/player/SurfaceViewWithApi34WorkAround.kt +++ /dev/null @@ -1,48 +0,0 @@ -/* - * Copyright (c) SRG SSR. All rights reserved. - * License information is available from the LICENSE file. - */ -package ch.srgssr.pillarbox.ui.widget.player - -import android.os.Build.VERSION_CODES.UPSIDE_DOWN_CAKE -import androidx.annotation.RequiresApi -import androidx.compose.runtime.Composable -import androidx.compose.runtime.LaunchedEffect -import androidx.compose.runtime.getValue -import androidx.compose.runtime.mutableStateOf -import androidx.compose.runtime.remember -import androidx.compose.runtime.setValue -import androidx.compose.ui.Modifier -import androidx.compose.ui.viewinterop.AndroidView -import androidx.media3.common.Player -import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.withContext - -@RequiresApi(UPSIDE_DOWN_CAKE) -@Composable -internal fun AndroidSurfaceViewWithApi34WorkAround(player: Player?, modifier: Modifier = Modifier) { - var view by remember { mutableStateOf(null) } - - AndroidView( - modifier = modifier, - factory = { PlayerSurfaceView(it) }, - onReset = {}, - update = { view = it }, - ) - - view?.let { view -> - LaunchedEffect(view, player) { - if (player != null) { - view.player = player - } else { - // Now that our player got null'd, we are not in a rush to get the old view from the - // previous player. Instead, we schedule clearing of the view for later on the main thread, - // since that player might have a new view attached to it in the meantime. This will avoid - // unnecessarily creating a Surface placeholder. - withContext(Dispatchers.Main) { - view.player = null - } - } - } - } -} From eb86b28092d69681aac19e7cba7c83273869e0c2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Joaquim=20St=C3=A4hli?= Date: Mon, 1 Dec 2025 10:59:52 +0100 Subject: [PATCH 16/24] Remove no more needed method --- .../ui/widget/player/PlayerSubtitle.kt | 31 ------------------- 1 file changed, 31 deletions(-) diff --git a/pillarbox-ui/src/main/java/ch/srgssr/pillarbox/ui/widget/player/PlayerSubtitle.kt b/pillarbox-ui/src/main/java/ch/srgssr/pillarbox/ui/widget/player/PlayerSubtitle.kt index 553f5ecea..9a8e19c66 100644 --- a/pillarbox-ui/src/main/java/ch/srgssr/pillarbox/ui/widget/player/PlayerSubtitle.kt +++ b/pillarbox-ui/src/main/java/ch/srgssr/pillarbox/ui/widget/player/PlayerSubtitle.kt @@ -11,45 +11,14 @@ import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.runtime.setValue import androidx.compose.ui.Modifier -import androidx.compose.ui.geometry.Size -import androidx.compose.ui.layout.ContentScale import androidx.compose.ui.viewinterop.AndroidView import androidx.media3.common.Player import androidx.media3.common.listen import androidx.media3.ui.CaptionStyleCompat import androidx.media3.ui.SubtitleView -import androidx.media3.ui.compose.modifiers.resizeWithContentScale -import androidx.media3.ui.compose.state.PresentationState import ch.srgssr.pillarbox.ui.exoplayer.SubtitleTextSize import ch.srgssr.pillarbox.ui.exoplayer.setTextSize -/** - * A smart Composable function to display subtitles that are always visible even when the player surface is bigger than the view bounds. - * - * @param player The [Player] instance to retrieve subtitle cues from. - * @param videoContentScale The [ContentScale] applied to the video content. - * @param videoSizeDp The [Size] of the video content. @see [PresentationState.videoSizeDp] - * @param modifier The [Modifier] to apply to this layout. - * @param captionStyle Optional [CaptionStyleCompat] to override the user's preferred caption style. - * @param subtitleTextSize Optional [SubtitleTextSize] to override the user's preferred subtitle text size. - */ -@Composable -fun PlayerSubtitle( - player: Player?, - videoContentScale: ContentScale, - videoSizeDp: Size?, - modifier: Modifier = Modifier, - captionStyle: CaptionStyleCompat? = null, - subtitleTextSize: SubtitleTextSize? = null -) { - val textContentScale = when (videoContentScale) { - ContentScale.Crop, ContentScale.FillHeight, ContentScale.FillWidth -> ContentScale.FillBounds - else -> videoContentScale - } - val textModifier = modifier.resizeWithContentScale(contentScale = textContentScale, videoSizeDp) - PlayerSubtitle(player = player, modifier = textModifier, captionStyle = captionStyle, subtitleTextSize = subtitleTextSize) -} - /** * A Composable function that displays an ExoPlayer [SubtitleView]. * It observes the active cues from the provided [player] and displays them in a [SubtitleView]. From a69708f8406f7267714831f336a96b4d5f955881 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Joaquim=20St=C3=A4hli?= Date: Mon, 1 Dec 2025 14:15:13 +0100 Subject: [PATCH 17/24] Disable PlayerStuckDetection during test like they do in media3 library tests --- .../core/business/PillarboxTestPlayer.kt | 2 + .../pillarbox/player/PillarboxBuilder.kt | 40 +++++++++++++++++++ .../pillarbox/player/PillarboxTestPlayer.kt | 1 + 3 files changed, 43 insertions(+) diff --git a/pillarbox-core-business/src/test/java/ch/srgssr/pillarbox/core/business/PillarboxTestPlayer.kt b/pillarbox-core-business/src/test/java/ch/srgssr/pillarbox/core/business/PillarboxTestPlayer.kt index dcb7e652a..2686fb904 100644 --- a/pillarbox-core-business/src/test/java/ch/srgssr/pillarbox/core/business/PillarboxTestPlayer.kt +++ b/pillarbox-core-business/src/test/java/ch/srgssr/pillarbox/core/business/PillarboxTestPlayer.kt @@ -11,6 +11,7 @@ import androidx.media3.test.utils.FakeClock import androidx.test.core.app.ApplicationProvider import ch.srgssr.pillarbox.player.PillarboxDsl import ch.srgssr.pillarbox.player.PillarboxExoPlayer +import ch.srgssr.pillarbox.player.PlayerStuckDetectionTimeouts import kotlin.coroutines.EmptyCoroutineContext /** @@ -26,6 +27,7 @@ fun PillarboxExoPlayer(context: Context = ApplicationProvider.getApplicationCont loadControl(DefaultLoadControl()) clock(FakeClock(true)) coroutineContext(EmptyCoroutineContext) + playerStuckDetectionTimeouts(PlayerStuckDetectionTimeouts.DisabledForTest) disableMonitoring() block() }.apply { diff --git a/pillarbox-player/src/main/java/ch/srgssr/pillarbox/player/PillarboxBuilder.kt b/pillarbox-player/src/main/java/ch/srgssr/pillarbox/player/PillarboxBuilder.kt index 2d79cbdea..3f2e80a1f 100644 --- a/pillarbox-player/src/main/java/ch/srgssr/pillarbox/player/PillarboxBuilder.kt +++ b/pillarbox-player/src/main/java/ch/srgssr/pillarbox/player/PillarboxBuilder.kt @@ -68,6 +68,7 @@ abstract class PillarboxBuilder { private var seekForwardIncrement: Duration = C.DEFAULT_SEEK_FORWARD_INCREMENT_MS.milliseconds private var preloadConfiguration: ExoPlayer.PreloadConfiguration = ExoPlayer.PreloadConfiguration.DEFAULT private var seekableLiveConfig: SeekableLiveConfig = SeekableLiveConfig() + private var playerStuckDetectionTimeouts: PlayerStuckDetectionTimeouts = PlayerStuckDetectionTimeouts() /** * Registers a custom [AssetLoader] with the [PillarboxExoPlayer]. @@ -222,6 +223,14 @@ abstract class PillarboxBuilder { this.preloadConfiguration = preloadConfiguration } + /** + * Sets the [ExoPlayer.Builder.setStuck*] timeouts + * @param playerStuckDetectionTimeout The [PlayerStuckDetectionTimeouts] to be used by the player. + */ + fun playerStuckDetectionTimeouts(playerStuckDetectionTimeout: PlayerStuckDetectionTimeouts) { + this.playerStuckDetectionTimeouts = playerStuckDetectionTimeout + } + internal fun create(context: Context): PillarboxExoPlayer { return PillarboxExoPlayer( context = context, @@ -261,6 +270,10 @@ abstract class PillarboxBuilder { .setMaxSeekToPreviousPositionMs(maxSeekToPreviousPosition.inWholeMilliseconds) .setRenderersFactory(PillarboxRenderersFactory(context)) .setBandwidthMeter(PillarboxBandwidthMeter(context)) + .setStuckSuppressedDetectionTimeoutMs(playerStuckDetectionTimeouts.stuckSuppressedDetectionTimeoutMs) + .setStuckBufferingDetectionTimeoutMs(playerStuckDetectionTimeouts.stuckBufferingDetectionTimeoutMs) + .setStuckPlayingDetectionTimeoutMs(playerStuckDetectionTimeouts.stuckPlayingDetectionTimeoutMs) + .setStuckPlayingNotEndingTimeoutMs(playerStuckDetectionTimeouts.stuckPlayingDetectionTimeoutMs) .setLoadControl(loadControl ?: PillarboxLoadControl()) .setMediaSourceFactory(mediaSourceFactory) .setTrackSelector(PillarboxTrackSelector(context)) @@ -301,3 +314,30 @@ object Default : PlayerConfig { } } } + +/** + * @property stuckPlayingDetectionTimeoutMs to configure [ExoPlayer.Builder.setStuckPlayingDetectionTimeoutMs]. + * @property stuckSuppressedDetectionTimeoutMs to configure [ExoPlayer.Builder.setStuckSuppressedDetectionTimeoutMs]. + * @property stuckBufferingDetectionTimeoutMs to configure [ExoPlayer.Builder.setStuckBufferingDetectionTimeoutMs]. + * @property stuckPlayingNotEndingTimeoutMs to configure [ExoPlayer.Builder.setStuckPlayingNotEndingTimeoutMs]. + */ +data class PlayerStuckDetectionTimeouts( + val stuckPlayingDetectionTimeoutMs: Int = ExoPlayer.DEFAULT_STUCK_PLAYING_DETECTION_TIMEOUT_MS, + val stuckSuppressedDetectionTimeoutMs: Int = ExoPlayer.DEFAULT_STUCK_SUPPRESSED_DETECTION_TIMEOUT_MS, + val stuckBufferingDetectionTimeoutMs: Int = ExoPlayer.DEFAULT_STUCK_BUFFERING_DETECTION_TIMEOUT_MS, + val stuckPlayingNotEndingTimeoutMs: Int = ExoPlayer.DEFAULT_STUCK_PLAYING_NOT_ENDING_TIMEOUT_MS, +) { + companion object { + /** + * Timeouts to disable player stuck detection during tests. + */ + @VisibleForTesting + @RestrictTo(RestrictTo.Scope.LIBRARY_GROUP) + val DisabledForTest = PlayerStuckDetectionTimeouts( + stuckPlayingDetectionTimeoutMs = Int.MAX_VALUE, + stuckSuppressedDetectionTimeoutMs = Int.MAX_VALUE, + stuckBufferingDetectionTimeoutMs = Int.MAX_VALUE, + stuckPlayingNotEndingTimeoutMs = Int.MAX_VALUE, + ) + } +} diff --git a/pillarbox-player/src/test/java/ch/srgssr/pillarbox/player/PillarboxTestPlayer.kt b/pillarbox-player/src/test/java/ch/srgssr/pillarbox/player/PillarboxTestPlayer.kt index 5be6cabc2..e4c478663 100644 --- a/pillarbox-player/src/test/java/ch/srgssr/pillarbox/player/PillarboxTestPlayer.kt +++ b/pillarbox-player/src/test/java/ch/srgssr/pillarbox/player/PillarboxTestPlayer.kt @@ -22,6 +22,7 @@ fun PillarboxExoPlayer(context: Context = ApplicationProvider.getApplicationCont loadControl(PillarboxTestLoadControl()) clock(FakeClock(true)) coroutineContext(EmptyCoroutineContext) + playerStuckDetectionTimeouts(PlayerStuckDetectionTimeouts.DisabledForTest) block() } } From 2bc705386d34301cda736ab1fd09c28c88867571 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Joaquim=20St=C3=A4hli?= Date: Mon, 8 Dec 2025 13:15:05 +0100 Subject: [PATCH 18/24] Fix resource after updating to 1.9.0-rc01 --- gradle/libs.versions.toml | 2 +- .../srgssr/pillarbox/demo/ui/player/PlayerView.kt | 2 +- .../PillarboxMediaDescriptionAdapter.kt | 15 ++++++++++++--- 3 files changed, 14 insertions(+), 5 deletions(-) diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 09c57fa39..adf25ea8c 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -6,7 +6,7 @@ androidx-compose = "2025.11.01" androidx-core = "1.17.0" androidx-datastore = "1.2.0" androidx-lifecycle = "2.10.0" -androidx-media3 = "1.9.0-beta01" +androidx-media3 = "1.9.0-rc01" androidx-navigation = "2.9.6" androidx-paging = "3.3.6" androidx-test-core = "1.7.0" diff --git a/pillarbox-demo/src/main/java/ch/srgssr/pillarbox/demo/ui/player/PlayerView.kt b/pillarbox-demo/src/main/java/ch/srgssr/pillarbox/demo/ui/player/PlayerView.kt index 74f107ab5..7bcbc2c65 100644 --- a/pillarbox-demo/src/main/java/ch/srgssr/pillarbox/demo/ui/player/PlayerView.kt +++ b/pillarbox-demo/src/main/java/ch/srgssr/pillarbox/demo/ui/player/PlayerView.kt @@ -97,7 +97,7 @@ fun PlayerView( val deviceInfo by player.getDeviceInfoAsState() val mediaMetadata by player.currentMediaMetadataAsState() val placeholder = if (deviceInfo.playbackType == DeviceInfo.PLAYBACK_TYPE_REMOTE) { - androidx.media3.cast.R.drawable.ic_mr_button_disconnected_dark + androidx.media3.cast.R.drawable.media_route_button_disconnected } else { R.drawable.placeholder } diff --git a/pillarbox-player/src/main/java/ch/srgssr/pillarbox/player/notification/PillarboxMediaDescriptionAdapter.kt b/pillarbox-player/src/main/java/ch/srgssr/pillarbox/player/notification/PillarboxMediaDescriptionAdapter.kt index d453bf53a..116c6e8f1 100644 --- a/pillarbox-player/src/main/java/ch/srgssr/pillarbox/player/notification/PillarboxMediaDescriptionAdapter.kt +++ b/pillarbox-player/src/main/java/ch/srgssr/pillarbox/player/notification/PillarboxMediaDescriptionAdapter.kt @@ -10,10 +10,10 @@ import android.graphics.Bitmap import android.graphics.BitmapFactory import android.net.Uri import android.util.LruCache +import androidx.core.util.TypedValueCompat import androidx.media3.common.Player import androidx.media3.ui.PlayerNotificationManager import androidx.media3.ui.PlayerNotificationManager.MediaDescriptionAdapter -import androidx.media3.ui.R import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.MainScope @@ -32,8 +32,14 @@ class PillarboxMediaDescriptionAdapter( context: Context, private val coroutineScope: CoroutineScope = MainScope() ) : MediaDescriptionAdapter { - private val imageMaxWidth: Int = context.resources.getDimensionPixelSize(R.dimen.compat_notification_large_icon_max_width) - private val imageMaxHeight: Int = context.resources.getDimensionPixelSize(R.dimen.compat_notification_large_icon_max_height) + // Hard-code the value of compat_notification_large_icon_max_width and + // compat_notification_large_icon_max_width as 320dp because the resource IDs are not public + // in + // https://cs.android.com/android/platform/superproject/+/androidx-main:frameworks/support/core/core/src/main/res/values/dimens.xml + private val imageMaxWidth: Int = TypedValueCompat.dpToPx(NOTIFICATION_SIZE_DP.toFloat(), context.resources.displayMetrics).toInt() + + private val imageMaxHeight: Int = imageMaxWidth + private val bitmapCache = LruCache(3) override fun getCurrentContentTitle(player: Player): CharSequence { @@ -98,4 +104,7 @@ class PillarboxMediaDescriptionAdapter( callback.onBitmap(it) } } + companion object { + private const val NOTIFICATION_SIZE_DP = 320 + } } From e4b465b3982c5cd095a00b618566dc88ac285d35 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Joaquim=20St=C3=A4hli?= Date: Mon, 5 Jan 2026 16:54:38 +0100 Subject: [PATCH 19/24] Update media3 to 1.9.0 --- gradle/libs.versions.toml | 2 +- .../ch/srgssr/pillarbox/player/PillarboxExoPlayer.kt | 9 --------- 2 files changed, 1 insertion(+), 10 deletions(-) diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 4fc82763d..9d0c2681a 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -6,7 +6,7 @@ androidx-compose = "2025.12.00" androidx-core = "1.17.0" androidx-datastore = "1.2.0" androidx-lifecycle = "2.10.0" -androidx-media3 = "1.9.0-rc01" +androidx-media3 = "1.9.0" androidx-navigation = "2.9.6" androidx-paging = "3.3.6" androidx-test-core = "1.7.0" 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 579d984f6..232517861 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 @@ -135,15 +135,6 @@ class PillarboxExoPlayer internal constructor( } } - override fun getBufferedPercentage(): Int { - // Workaround, sometime it throws when playing live. Due to Util.percentInt, it should be fixed in Media3 main branch. - return try { - exoPlayer.bufferedPercentage - } catch (_: IllegalArgumentException) { - 0 - } - } - override fun getAudioSessionId(): Int { return exoPlayer.audioSessionId } From f807c8cf180642be91566556c1bb68415d0429b6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Joaquim=20St=C3=A4hli?= Date: Mon, 5 Jan 2026 17:09:36 +0100 Subject: [PATCH 20/24] Fix condition for using ImageProgressTrackerState --- .../demo/shared/ui/player/ProgressTrackerState.kt | 9 ++++----- 1 file changed, 4 insertions(+), 5 deletions(-) diff --git a/pillarbox-demo-shared/src/main/java/ch/srgssr/pillarbox/demo/shared/ui/player/ProgressTrackerState.kt b/pillarbox-demo-shared/src/main/java/ch/srgssr/pillarbox/demo/shared/ui/player/ProgressTrackerState.kt index 030602ed7..5279531f1 100644 --- a/pillarbox-demo-shared/src/main/java/ch/srgssr/pillarbox/demo/shared/ui/player/ProgressTrackerState.kt +++ b/pillarbox-demo-shared/src/main/java/ch/srgssr/pillarbox/demo/shared/ui/player/ProgressTrackerState.kt @@ -31,7 +31,7 @@ import kotlinx.coroutines.CoroutineScope fun rememberProgressTrackerState( player: PillarboxPlayer, coroutineScope: CoroutineScope = rememberCoroutineScope(), - imageOutput: ImageOutput = ImageOutput.NO_OP, + imageOutput: ImageOutput? = null, ): ProgressTrackerState { val context = LocalContext.current val appSettingsRepository = remember { AppSettingsRepository(context) } @@ -39,9 +39,8 @@ fun rememberProgressTrackerState( val smoothSeekingEnabled = appSettings.smoothSeekingEnabled return remember(player, smoothSeekingEnabled, imageOutput) { - when (imageOutput) { - ImageOutput.NO_OP -> ImageProgressTrackerState(player, coroutineScope, imageOutput) - else -> SimpleProgressTrackerState(player = player, coroutineScope = coroutineScope, useScrubbingMode = smoothSeekingEnabled) - } + imageOutput?.let { + ImageProgressTrackerState(player, coroutineScope, imageOutput) + } ?: SimpleProgressTrackerState(player = player, coroutineScope = coroutineScope, useScrubbingMode = smoothSeekingEnabled) } } From c1a153789b41c673f1c28e3aa6a2faa0a750a648 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Joaquim=20St=C3=A4hli?= Date: Mon, 5 Jan 2026 17:09:54 +0100 Subject: [PATCH 21/24] Improve image tracker state --- .../ch/srgssr/pillarbox/ui/ImageProgressTrackerState.kt | 6 +----- 1 file changed, 1 insertion(+), 5 deletions(-) diff --git a/pillarbox-ui/src/main/java/ch/srgssr/pillarbox/ui/ImageProgressTrackerState.kt b/pillarbox-ui/src/main/java/ch/srgssr/pillarbox/ui/ImageProgressTrackerState.kt index 49e3f9ab0..6d52d89b1 100644 --- a/pillarbox-ui/src/main/java/ch/srgssr/pillarbox/ui/ImageProgressTrackerState.kt +++ b/pillarbox-ui/src/main/java/ch/srgssr/pillarbox/ui/ImageProgressTrackerState.kt @@ -4,7 +4,6 @@ */ package ch.srgssr.pillarbox.ui -import androidx.media3.common.C import androidx.media3.common.DeviceInfo import androidx.media3.common.Player import androidx.media3.exoplayer.image.ImageOutput @@ -40,10 +39,7 @@ class ImageProgressTrackerState( val imageAvailable = player.isImageOutputAvailable && player.currentTracks.containsImageTrack() && imageOutput != ImageOutput.NO_OP if (imageAvailable) { player.trackSelectionParameters = storedTrackSelectionParameters.buildUpon() - .apply { - setDisabledTrackTypes(setOf(C.TRACK_TYPE_TEXT, C.TRACK_TYPE_AUDIO)) - setPrioritizeImageOverVideoEnabled(true) - } + .setPrioritizeImageOverVideoEnabled(true) .build() player.setImageOutput(imageOutput) } From c221b0508ae0442ad2f38bf80968c69a0aca88bf Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Joaquim=20St=C3=A4hli?= Date: Mon, 5 Jan 2026 17:18:58 +0100 Subject: [PATCH 22/24] same parameters order --- .../srgssr/pillarbox/ui/widget/player/PillarboxPlayerSurface.kt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pillarbox-ui/src/main/java/ch/srgssr/pillarbox/ui/widget/player/PillarboxPlayerSurface.kt b/pillarbox-ui/src/main/java/ch/srgssr/pillarbox/ui/widget/player/PillarboxPlayerSurface.kt index ea11e6f5a..61ffe2374 100644 --- a/pillarbox-ui/src/main/java/ch/srgssr/pillarbox/ui/widget/player/PillarboxPlayerSurface.kt +++ b/pillarbox-ui/src/main/java/ch/srgssr/pillarbox/ui/widget/player/PillarboxPlayerSurface.kt @@ -44,7 +44,7 @@ fun PillarboxPlayerSurface( // because the Player will not emit the relevant event, e.g. the first frame being ready. when (surfaceType) { SurfaceType.Surface -> PlayerSurface(player = player, modifier = modifier, surfaceType = SURFACE_TYPE_SURFACE_VIEW) - SurfaceType.Texture -> PlayerSurface(modifier = modifier, player = player, surfaceType = SURFACE_TYPE_TEXTURE_VIEW) + SurfaceType.Texture -> PlayerSurface(player = player, modifier = modifier, surfaceType = SURFACE_TYPE_TEXTURE_VIEW) SurfaceType.Spherical -> PlayerSurfaceSphericalInternal(player = player, modifier = modifier) } } From df49b89f99a464b05796e89370075785d59c6930 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Joaquim=20St=C3=A4hli?= Date: Mon, 5 Jan 2026 17:35:43 +0100 Subject: [PATCH 23/24] Update doc --- pillarbox-ui/docs/README.md | 69 +++++++------------ .../widget/player/PillarboxPlayerSurface.kt | 1 - 2 files changed, 24 insertions(+), 46 deletions(-) diff --git a/pillarbox-ui/docs/README.md b/pillarbox-ui/docs/README.md index a9f7be91a..f6627ee2a 100644 --- a/pillarbox-ui/docs/README.md +++ b/pillarbox-ui/docs/README.md @@ -4,8 +4,9 @@ Provides UI Compose components and helpers. This includes: -- [PlayerSurface][ch.srgssr.pillarbox.ui.widget.player.PlayerSurface], to display a player on a surface, texture, or spherical surface. +- [PillarboxPlayerSurface][ch.srgssr.pillarbox.ui.widget.player.PillarboxPlayerSurface], to display a player on a surface, texture, or spherical surface. - Compose wrapper for ExoPlayer `View`s. +- [PlayerFrame][ch.srgssr.pillarbox.ui.widget.player.PlayerFrame] to handle player component such as subtitles, overlays and surface content. - [ProgressTracker][ch.srgssr.pillarbox.ui.ProgressTrackerState] to connect the player to a progress bar or slider. ## Integration @@ -26,7 +27,7 @@ fun SimplePlayer( player: Player, modifier: Modifier = Modifier, ) { - PlayerSurface( + PillarboxPlayerSurface( player = player, modifier = modifier, ) @@ -36,8 +37,7 @@ fun SimplePlayer( ### Create a `Player` with controls and subtitles In this example, we are drawing controls and subtitles on top of the [Player][androidx.media3.common.Player]. To add controls, you can use -[ExoPlayerControlView][ch.srgssr.pillarbox.ui.exoplayer.ExoPlayerControlView]. And for subtitles, you can use -[ExoPlayerSubtitleView][ch.srgssr.pillarbox.ui.exoplayer.ExoPlayerSubtitleView]. +[ExoPlayerControlView][ch.srgssr.pillarbox.ui.exoplayer.ExoPlayerControlView]. ```kotlin @Composable @@ -52,52 +52,35 @@ fun MyPlayer( .background(color = Color.Black), contentAlignment = Alignment.Center, ) { - PlayerSurface( + val presentationState: PresentationState = rememberPresentationState(player = player, keepContentOnReset = false) + PlayerFrame( player = player, - defaultAspectRatio = 1f, - ) - - ExoPlayerControlView( - player = player, - modifier = Modifier.matchParentSize(), - ) - - ExoPlayerSubtitleView( - player = player, - modifier = Modifier.matchParentSize(), - ) + presentationState = presentationState, + contentScale = ContentScale.Fit, + subtitle = { + PlayerSubitle(player) + }, + shutter = { + // Draw when no video is playing or when the player is loading + DrawShutter(player) + } + ) { + ExoPlayerControlView( + player = player, + modifier = Modifier.matchParentSize(), + ) + } } } ``` -The `defaultAspectRatio` is used while the video is loading or if the [Player][androidx.media3.common.Player] doesn't play a video. - -### Scale mode - -You can customize how the [Player][androidx.media3.common.Player] scales in the [PlayerSurface][ch.srgssr.pillarbox.ui.widget.player.PlayerSurface], -by setting the `scaleMode` argument. - -```kotlin -PlayerSurface( - player = player, - scaleMode = ScaleMode.Fit, -) -``` - -- [ScaleMode.Fit][ch.srgssr.pillarbox.ui.ScaleMode.Fit] (default): resizes the [Player][androidx.media3.common.Player] to fit within its parent while - maintaining its aspect ratio. -- [ScaleMode.Fill][ch.srgssr.pillarbox.ui.ScaleMode.Fill]: stretches the [Player][androidx.media3.common.Player] to fill its parent, ignoring the - defined aspect ratio. -- [ScaleMode.Crop][ch.srgssr.pillarbox.ui.ScaleMode.Crop]: trims the [Player][androidx.media3.common.Player] to fill its parent while maintaining its - aspect ratio. - ### Surface type [PlayerSurface][ch.srgssr.pillarbox.ui.widget.player.PlayerSurface] lets you set the type of surface used to render its content, using its `surfaceType` argument. ```kotlin -PlayerSurface( +PillarboxPlayerSurface( player = player, surfaceType = SurfaceType.Surface, ) @@ -133,14 +116,10 @@ fun MyPlayer(player: Player) { [androidx.media3.common.Player]: https://developer.android.com/reference/androidx/media3/common/Player [androidx.media3.exoplayer.video.spherical.SphericalGLSurfaceView]: https://developer.android.com/reference/androidx/media3/exoplayer/video/spherical/SphericalGLSurfaceView [ch.srgssr.pillarbox.ui.exoplayer.ExoPlayerControlView]: https://android.pillarbox.ch/api/pillarbox-ui/ch.srgssr.pillarbox.ui.exoplayer/-exo-player-control-view.html -[ch.srgssr.pillarbox.ui.exoplayer.ExoPlayerSubtitleView]: https://android.pillarbox.ch/api/pillarbox-ui/ch.srgssr.pillarbox.ui.exoplayer/-exo-player-subtitle-view.html [ch.srgssr.pillarbox.ui.extension]: https://android.pillarbox.ch/api/pillarbox-ui/ch.srgssr.pillarbox.ui.extension/index.html -[ch.srgssr.pillarbox.ui.widget.player.PlayerSurface]: https://android.pillarbox.ch/api/pillarbox-ui/ch.srgssr.pillarbox.ui.widget.player/-player-surface.html +[ch.srgssr.pillarbox.ui.widget.player.PillarboxPlayerSurface]: https://android.pillarbox.ch/api/pillarbox-ui/ch.srgssr.pillarbox.ui.widget.player/-pillarbox-player-surface.html +[ch.srgssr.pillarbox.ui.widget.player.PlayerFrame]: https://android.pillarbox.ch/api/pillarbox-ui/ch.srgssr.pillarbox.ui.widget.player/-player-frame.html [ch.srgssr.pillarbox.ui.widget.player.SurfaceType.Spherical]: https://android.pillarbox.ch/api/pillarbox-ui/ch.srgssr.pillarbox.ui.widget.player/-surface-type/-spherical/index.html [ch.srgssr.pillarbox.ui.widget.player.SurfaceType.Surface]: https://android.pillarbox.ch/api/pillarbox-ui/ch.srgssr.pillarbox.ui.widget.player/-surface-type/-surface/index.html [ch.srgssr.pillarbox.ui.widget.player.SurfaceType.Texture]: https://android.pillarbox.ch/api/pillarbox-ui/ch.srgssr.pillarbox.ui.widget.player/-surface-type/-texture/index.html [ch.srgssr.pillarbox.ui.ProgressTrackerState]: https://android.pillarbox.ch/api/pillarbox-ui/ch.srgssr.pillarbox.ui/-progress-tracker-state/index.html -[ch.srgssr.pillarbox.ui.ScaleMode]: https://android.pillarbox.ch/api/pillarbox-ui/ch.srgssr.pillarbox.ui/-scale-mode/index.html -[ch.srgssr.pillarbox.ui.ScaleMode.Crop]: https://android.pillarbox.ch/api/pillarbox-ui/ch.srgssr.pillarbox.ui/-scale-mode/-crop/index.html -[ch.srgssr.pillarbox.ui.ScaleMode.Fill]: https://android.pillarbox.ch/api/pillarbox-ui/ch.srgssr.pillarbox.ui/-scale-mode/-fill/index.html -[ch.srgssr.pillarbox.ui.ScaleMode.Fit]: https://android.pillarbox.ch/api/pillarbox-ui/ch.srgssr.pillarbox.ui/-scale-mode/-fit/index.html diff --git a/pillarbox-ui/src/main/java/ch/srgssr/pillarbox/ui/widget/player/PillarboxPlayerSurface.kt b/pillarbox-ui/src/main/java/ch/srgssr/pillarbox/ui/widget/player/PillarboxPlayerSurface.kt index 61ffe2374..b7fc333dc 100644 --- a/pillarbox-ui/src/main/java/ch/srgssr/pillarbox/ui/widget/player/PillarboxPlayerSurface.kt +++ b/pillarbox-ui/src/main/java/ch/srgssr/pillarbox/ui/widget/player/PillarboxPlayerSurface.kt @@ -25,7 +25,6 @@ import kotlinx.coroutines.withContext * A Composable function that displays a [Player]. * * Since minSDK = 24, [surfaceType] should be always [SurfaceType.Surface] or [SurfaceType.Spherical]. - * It also includes a workaround on Android 34 with Android SurfaceView. * * [Choosing surface type Media3 documentation](https://developer.android.com/media/media3/ui/surface) * From 4e175bd8ecd8e969e2474e374c48ed8fdeba65a6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Joaquim=20St=C3=A4hli?= Date: Tue, 6 Jan 2026 15:55:48 +0100 Subject: [PATCH 24/24] Fix doc --- .../java/ch/srgssr/pillarbox/player/PlayerCallbackFlow.kt | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/pillarbox-player/src/main/java/ch/srgssr/pillarbox/player/PlayerCallbackFlow.kt b/pillarbox-player/src/main/java/ch/srgssr/pillarbox/player/PlayerCallbackFlow.kt index 42ed3048b..137844609 100644 --- a/pillarbox-player/src/main/java/ch/srgssr/pillarbox/player/PlayerCallbackFlow.kt +++ b/pillarbox-player/src/main/java/ch/srgssr/pillarbox/player/PlayerCallbackFlow.kt @@ -466,7 +466,7 @@ fun Player.getCurrentDefaultPositionAsFlow(): Flow = callbackFlow { }.distinctUntilChanged() /** - * Collects the [current chapter][Player.getChapterAtPosition] as a [Flow]. + * Collects the [current chapter][PillarboxPlayer.getChapterAtPosition] as a [Flow]. * * @return A [Flow] emitting the current chapter. */ @@ -481,7 +481,7 @@ fun PillarboxPlayer.getCurrentChapterAsFlow(): Flow = callbackFlow { } /** - * Collects the [current credit][Player.getCreditAtPosition] as a [Flow]. + * Collects the [current credit][PillarboxPlayer.getCreditAtPosition] as a [Flow]. * * @return A [Flow] emitting the current credit. */