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/player/controls/PlayerControls.kt b/pillarbox-demo/src/main/java/ch/srgssr/pillarbox/demo/ui/player/controls/PlayerControls.kt index 8dd22956a..3dc150c37 100644 --- a/pillarbox-demo/src/main/java/ch/srgssr/pillarbox/demo/ui/player/controls/PlayerControls.kt +++ b/pillarbox-demo/src/main/java/ch/srgssr/pillarbox/demo/ui/player/controls/PlayerControls.kt @@ -103,6 +103,7 @@ fun PlayerControls( ) { if (availableCommand.canSeek()) { PlayerTimeSlider( + modifier = Modifier.weight(1f), player = player, progressTracker = progressTracker, interactionSource = interactionSource 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-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/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 7c355c341..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 @@ -32,18 +31,21 @@ 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.player.PillarboxPlayer import ch.srgssr.pillarbox.ui.ScaleMode -import ch.srgssr.pillarbox.ui.widget.player.PlayerSurface +import ch.srgssr.pillarbox.ui.widget.player.PlayerFrame /** * Resizable player demo @@ -75,7 +77,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) } @@ -84,18 +86,26 @@ 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") - Box( - modifier = Modifier.size(width = playerWidth, height = playerHeight), - contentAlignment = Alignment.Center, + val presentationState = rememberPresentationState(player) + val contentScale = when (resizeMode) { + ScaleMode.Fit -> ContentScale.Fit + ScaleMode.Crop -> ContentScale.Crop + ScaleMode.Fill -> ContentScale.FillBounds + } + PlayerFrame( + player = player, + presentationState = presentationState, + modifier = Modifier + .size(width = playerWidth, height = playerHeight) + .background(color = Color.Black), + contentScale = contentScale, ) { - PlayerSurface( - modifier = Modifier - .matchParentSize() - .background(Color.Black), + val interactionSource = remember { MutableInteractionSource() } + PlayerControls( player = player, - displayDebugView = true, - contentAlignment = Alignment.Center, - scaleMode = resizeMode, + modifier = Modifier.fillMaxSize(), + interactionSource = interactionSource, + content = { } ) } 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-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 } diff --git a/pillarbox-ui/build.gradle.kts b/pillarbox-ui/build.gradle.kts index d06a57057..9a81a5318 100644 --- a/pillarbox-ui/build.gradle.kts +++ b/pillarbox-ui/build.gradle.kts @@ -29,6 +29,7 @@ dependencies { api(libs.androidx.media3.common) api(libs.androidx.media3.exoplayer) api(libs.androidx.media3.ui) + api(libs.androidx.media3.ui.compose) implementation(libs.guava) implementation(libs.kotlinx.coroutines.core) 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..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, @@ -72,19 +73,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 +152,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/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/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..9fd03c315 --- /dev/null +++ b/pillarbox-ui/src/main/java/ch/srgssr/pillarbox/ui/widget/player/PillarboxPlayerSurface.kt @@ -0,0 +1,113 @@ +/* + * 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 + +/** + * 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) + * + * @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( + 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 = {}, + onRelease = { + it.onPause() + }, + update = { + view = it + it.onResume() + }, + ) + + 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..04a2fe949 --- /dev/null +++ b/pillarbox-ui/src/main/java/ch/srgssr/pillarbox/ui/widget/player/PlayerFrame.kt @@ -0,0 +1,92 @@ +/* + * 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.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 +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 when [PresentationState.coverSurface] is true. + * @param overlay A composable function that draws on top of everything. + */ +@Composable +fun PlayerFrame( + player: Player?, + modifier: Modifier = Modifier, + contentScale: ContentScale = ContentScale.Fit, + surfaceType: SurfaceType = SurfaceType.Surface, + displayDebugView: Boolean = false, + presentationState: PresentationState = rememberPresentationState(player = player, keepContentOnReset = false), + surface: (@Composable BoxScope.() -> Unit)? = null, + subtitle: @Composable SubtitleBoxScope.() -> Unit = { + PlayerSubtitle( + modifier = Modifier, + player = player, + videoSizeDp = this.videoSizeDp, + videoContentScale = this.contentScale + ) + }, + shutter: @Composable BoxScope.() -> Unit = { + Box( + Modifier + .fillMaxSize() + .background(Color.Black) + ) + }, + overlay: @Composable BoxScope.() -> Unit = {}, +) { + 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()) + } + } + val subtitleScope = remember(presentationState.videoSizeDp, contentScale) { + SubtitleBoxScope(videoSizeDp = presentationState.videoSizeDp, contentScale = contentScale, boxScope = this) + } + subtitleScope.subtitle() + + if (presentationState.coverSurface) { + shutter() + } + 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 new file mode 100644 index 000000000..553f5ecea --- /dev/null +++ b/pillarbox-ui/src/main/java/ch/srgssr/pillarbox/ui/widget/player/PlayerSubtitle.kt @@ -0,0 +1,93 @@ +/* + * 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.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]. + * + * @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 ada1e1d31..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 @@ -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 @@ -51,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( @@ -136,57 +131,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 -private 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, +} 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 + } + } + } + } +}