diff --git a/pillarbox-demo-tv/src/main/java/ch/srgssr/pillarbox/demo/tv/ui/components/TVSlider.kt b/pillarbox-demo-tv/src/main/java/ch/srgssr/pillarbox/demo/tv/ui/components/TVSlider.kt new file mode 100644 index 000000000..da0347b19 --- /dev/null +++ b/pillarbox-demo-tv/src/main/java/ch/srgssr/pillarbox/demo/tv/ui/components/TVSlider.kt @@ -0,0 +1,196 @@ +/* + * Copyright (c) SRG SSR. All rights reserved. + * License information is available from the LICENSE file. + */ +package ch.srgssr.pillarbox.demo.tv.ui.components + +import androidx.compose.animation.animateColorAsState +import androidx.compose.animation.core.animateDpAsState +import androidx.compose.animation.core.animateFloatAsState +import androidx.compose.foundation.background +import androidx.compose.foundation.focusable +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.RowScope +import androidx.compose.foundation.layout.fillMaxHeight +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableLongStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.tooling.preview.PreviewLightDark +import androidx.compose.ui.tooling.preview.PreviewParameter +import androidx.compose.ui.tooling.preview.PreviewParameterProvider +import androidx.compose.ui.unit.dp +import androidx.tv.material3.MaterialTheme +import ch.srgssr.pillarbox.demo.tv.extension.onDpadEvent +import ch.srgssr.pillarbox.demo.tv.ui.theme.PillarboxTheme + +/** + * Slider component suited for use on TV. + * + * @param value The current value of this slider. + * @param range The range of values supported by this slider. + * @param compactMode If `true`, the slider will be thinner. + * @param modifier The [Modifier] to apply to the layout. + * @param enabled Whether or not this slider is enabled. + * @param onSeekBack The action to perform when seeking back. + * @param onSeekForward The action to perform when seeking forward. + */ +@Composable +fun TVSlider( + value: Long, + range: LongRange, + compactMode: Boolean, + modifier: Modifier = Modifier, + enabled: Boolean = true, + onSeekBack: () -> Unit, + onSeekForward: () -> Unit, +) { + val seekBarHeight by animateDpAsState(targetValue = if (compactMode) 8.dp else 16.dp, label = "seek_bar_height") + val thumbColor by animateColorAsState( + targetValue = if (enabled) MaterialTheme.colorScheme.primary else MaterialTheme.colorScheme.onSurface.copy(alpha = 0.38f), + label = "thumb_color", + ) + + val activeTrackWeight by animateFloatAsState(targetValue = value / range.last.toFloat(), label = "active_track_weight") + val activeTrackColor by animateColorAsState( + targetValue = if (enabled) MaterialTheme.colorScheme.primary else MaterialTheme.colorScheme.onSurface.copy(alpha = 0.38f), + label = "active_track_color", + ) + + val inactiveTrackWeight by animateFloatAsState(targetValue = 1f - activeTrackWeight, label = "inactive_track_weight") + val inactiveTrackColor by animateColorAsState( + targetValue = if (enabled) MaterialTheme.colorScheme.surfaceVariant else MaterialTheme.colorScheme.onSurface.copy(alpha = 0.12f), + label = "inactive_track_color", + ) + + Row( + modifier = modifier.height(seekBarHeight), + horizontalArrangement = Arrangement.spacedBy(6.dp), + ) { + Track( + weight = activeTrackWeight, + color = activeTrackColor, + ) + + Thumb( + color = thumbColor, + enabled = enabled, + onSeekBack = onSeekBack, + onSeekForward = onSeekForward, + ) + + Track( + weight = inactiveTrackWeight, + color = inactiveTrackColor, + ) + } +} + +@Composable +private fun RowScope.Track( + weight: Float, + color: Color, + modifier: Modifier = Modifier, +) { + if (weight > 0f) { + Box( + modifier = modifier + .fillMaxHeight() + .weight(weight) + .background( + color = color, + shape = CircleShape, + ), + ) + } +} + +@Composable +private fun Thumb( + color: Color, + enabled: Boolean, + modifier: Modifier = Modifier, + onSeekBack: () -> Unit, + onSeekForward: () -> Unit, +) { + Box( + modifier = modifier + .fillMaxHeight() + .width(8.dp) + .background( + color = color, + shape = CircleShape, + ) + .then( + if (enabled) { + Modifier + .focusable() + .onDpadEvent( + onLeft = { + onSeekBack() + true + }, + onRight = { + onSeekForward() + true + }, + ) + } else { + Modifier + } + ), + ) +} + +@Composable +@PreviewLightDark +private fun TVSliderPreview( + @PreviewParameter(TVSliderPreviewParameters::class) previewParameters: PreviewParameters, +) { + var progress by remember { + mutableLongStateOf(previewParameters.initialValue) + } + + PillarboxTheme { + TVSlider( + value = progress, + range = 0L..100L, + compactMode = previewParameters.compactMode, + enabled = previewParameters.enabled, + onSeekBack = { progress-- }, + onSeekForward = { progress++ }, + ) + } +} + +private class PreviewParameters( + val compactMode: Boolean, + val enabled: Boolean, + val initialValue: Long, +) + +private class TVSliderPreviewParameters : PreviewParameterProvider { + override val values = sequence { + listOf(false, true).forEach { compactMode -> + listOf(false, true).forEach { enabled -> + listOf(0L, 50L, 100L).forEach { initialValue -> + yield( + PreviewParameters( + compactMode = compactMode, + enabled = enabled, + initialValue = initialValue, + ) + ) + } + } + } + } +} diff --git a/pillarbox-demo-tv/src/main/java/ch/srgssr/pillarbox/demo/tv/ui/player/compose/MediaMetadataView.kt b/pillarbox-demo-tv/src/main/java/ch/srgssr/pillarbox/demo/tv/ui/player/compose/MediaMetadataView.kt index 46b6aaa5d..e523e99e7 100644 --- a/pillarbox-demo-tv/src/main/java/ch/srgssr/pillarbox/demo/tv/ui/player/compose/MediaMetadataView.kt +++ b/pillarbox-demo-tv/src/main/java/ch/srgssr/pillarbox/demo/tv/ui/player/compose/MediaMetadataView.kt @@ -15,7 +15,6 @@ import androidx.compose.foundation.layout.width import androidx.compose.foundation.layout.wrapContentHeight import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.runtime.Composable -import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip import androidx.compose.ui.graphics.Brush @@ -47,10 +46,9 @@ fun MediaMetadataView( modifier = modifier .background( brush = Brush.verticalGradient( - colors = listOf(Color.Transparent, Color.Black), + colors = listOf(Color.Black, Color.Transparent), ), ), - verticalAlignment = Alignment.Bottom, ) { AsyncImage( modifier = Modifier @@ -67,7 +65,7 @@ fun MediaMetadataView( modifier = Modifier.padding( start = MaterialTheme.paddings.mini, top = MaterialTheme.paddings.small, - end = 72.dp, // baseline + 56dp to not overlap with the settings button + end = MaterialTheme.paddings.baseline, bottom = MaterialTheme.paddings.small, ) ) { 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 0d7f536e3..9299ef722 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 @@ -12,8 +12,12 @@ import androidx.compose.animation.shrinkVertically import androidx.compose.animation.slideInHorizontally import androidx.compose.animation.slideOutHorizontally import androidx.compose.animation.togetherWith +import androidx.compose.foundation.background import androidx.compose.foundation.focusable +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.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.padding @@ -28,8 +32,12 @@ import androidx.compose.runtime.remember import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier +import androidx.compose.ui.focus.onFocusChanged +import androidx.compose.ui.graphics.Brush +import androidx.compose.ui.graphics.Color import androidx.compose.ui.input.key.KeyEventType import androidx.compose.ui.res.stringResource +import androidx.media3.common.C import androidx.media3.common.Player import androidx.tv.material3.Button import androidx.tv.material3.DrawerValue @@ -39,19 +47,28 @@ import androidx.tv.material3.MaterialTheme import androidx.tv.material3.Text import androidx.tv.material3.rememberDrawerState import ch.srgssr.pillarbox.demo.shared.R +import ch.srgssr.pillarbox.demo.shared.ui.getFormatter import ch.srgssr.pillarbox.demo.tv.extension.onDpadEvent +import ch.srgssr.pillarbox.demo.tv.ui.components.TVSlider import ch.srgssr.pillarbox.demo.tv.ui.player.compose.controls.PlayerError import ch.srgssr.pillarbox.demo.tv.ui.player.compose.controls.PlayerPlaybackRow import ch.srgssr.pillarbox.demo.tv.ui.player.compose.settings.PlaybackSettingsDrawer import ch.srgssr.pillarbox.demo.tv.ui.theme.paddings +import ch.srgssr.pillarbox.player.extension.canSeek +import ch.srgssr.pillarbox.ui.extension.availableCommandsAsState import ch.srgssr.pillarbox.ui.extension.currentMediaMetadataAsState +import ch.srgssr.pillarbox.ui.extension.currentPositionAsState +import ch.srgssr.pillarbox.ui.extension.durationAsState import ch.srgssr.pillarbox.ui.extension.getCurrentChapterAsState import ch.srgssr.pillarbox.ui.extension.getCurrentCreditAsState import ch.srgssr.pillarbox.ui.extension.playerErrorAsState +import ch.srgssr.pillarbox.ui.widget.DelayedVisibilityState import ch.srgssr.pillarbox.ui.widget.maintainVisibleOnFocus import ch.srgssr.pillarbox.ui.widget.player.PlayerSurface import ch.srgssr.pillarbox.ui.widget.rememberDelayedVisibilityState import kotlinx.coroutines.delay +import kotlin.time.Duration.Companion.ZERO +import kotlin.time.Duration.Companion.milliseconds import kotlin.time.Duration.Companion.seconds /** @@ -67,7 +84,6 @@ fun PlayerView( ) { val drawerState = rememberDrawerState(initialValue = DrawerValue.Closed) val visibilityState = rememberDelayedVisibilityState(player = player, visible = true) - val currentCredit by player.getCurrentCreditAsState() LaunchedEffect(drawerState.currentValue) { when (drawerState.currentValue) { @@ -76,16 +92,23 @@ fun PlayerView( } } + BackHandler(enabled = visibilityState.isVisible) { + visibilityState.hide() + } + PlaybackSettingsDrawer( player = player, drawerState = drawerState, - modifier = modifier + modifier = modifier, ) { val error by player.playerErrorAsState() if (error != null) { - PlayerError(modifier = Modifier.fillMaxSize(), playerError = error!!, onRetry = player::prepare) + PlayerError( + modifier = Modifier.fillMaxSize(), + playerError = error!!, + onRetry = player::prepare, + ) } else { - val currentChapter by player.getCurrentChapterAsState() PlayerSurface( player = player, modifier = Modifier @@ -95,93 +118,184 @@ fun PlayerView( onEnter = { visibilityState.show() true - } + }, ) - .focusable(true) + .focusable(true), ) - var chapterInfoVisibility by remember { - mutableStateOf(currentChapter != null) - } - LaunchedEffect(currentChapter) { - chapterInfoVisibility = currentChapter != null - if (chapterInfoVisibility) { - delay(5.seconds) - chapterInfoVisibility = false - } - } - AnimatedVisibility( - visible = !visibilityState.isVisible && chapterInfoVisibility, - enter = expandVertically { it }, - exit = shrinkVertically { it } - ) { - Box(modifier = Modifier.fillMaxSize()) { - currentChapter?.let { - MediaMetadataView( - modifier = Modifier - .fillMaxWidth() - .wrapContentHeight() - .align(Alignment.BottomStart), - mediaMetadata = it.mediaMetadata - ) - } - } - } - AnimatedVisibility(currentCredit != null) { - Button( - onClick = { player.seekTo(currentCredit?.end ?: 0L) }, - modifier = Modifier.padding(MaterialTheme.paddings.baseline), - ) { - Text(text = stringResource(R.string.skip)) + + Box(modifier = Modifier.fillMaxSize()) { + val currentCredit by player.getCurrentCreditAsState() + + ChapterInfo( + player = player, + visibilityState = visibilityState, + ) + + if (!visibilityState.isVisible && currentCredit != null) { + SkipButton( + modifier = Modifier + .align(Alignment.BottomEnd) + .padding(MaterialTheme.paddings.baseline), + onClick = { player.seekTo(currentCredit?.end ?: 0L) }, + ) } - } - AnimatedVisibility( - visible = visibilityState.isVisible, - enter = expandVertically { it }, - exit = shrinkVertically { it } - ) { - Box( + + AnimatedVisibility( + visible = visibilityState.isVisible, modifier = Modifier .fillMaxSize() .maintainVisibleOnFocus(delayedVisibilityState = visibilityState), - contentAlignment = Alignment.Center ) { - PlayerPlaybackRow( - player = player, - state = visibilityState - ) + Box { + PlayerPlaybackRow( + player = player, + state = visibilityState, + modifier = Modifier.align(Alignment.Center), + ) - val currentMediaMetadata by player.currentMediaMetadataAsState() - AnimatedContent( - targetState = currentChapter?.mediaMetadata ?: currentMediaMetadata, - modifier = Modifier - .fillMaxWidth() - .wrapContentHeight() - .align(Alignment.BottomStart), - transitionSpec = { - slideInHorizontally { it } - .togetherWith(slideOutHorizontally { -it }) - }, - label = "media_metadata_transition", - ) { mediaMetadata -> - MediaMetadataView(mediaMetadata) - } + Column( + modifier = Modifier + .align(Alignment.BottomStart) + .background( + brush = Brush.verticalGradient( + colors = listOf(Color.Transparent, Color.Black), + ), + ) + .padding(horizontal = MaterialTheme.paddings.baseline), + ) { + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.SpaceBetween, + ) { + IconButton( + onClick = { drawerState.setValue(DrawerValue.Open) }, + ) { + Icon( + imageVector = Icons.Default.Settings, + contentDescription = stringResource(R.string.settings), + ) + } - IconButton( - onClick = { drawerState.setValue(DrawerValue.Open) }, - modifier = Modifier - .padding(MaterialTheme.paddings.baseline) - .align(Alignment.BottomEnd) - ) { - Icon( - imageVector = Icons.Default.Settings, - contentDescription = stringResource(R.string.settings) - ) + if (currentCredit != null) { + SkipButton( + onClick = { player.seekTo(currentCredit?.end ?: 0L) }, + ) + } + } + + PlayerTimeRow( + player = player, + onSeek = { value -> + visibilityState.resetAutoHide() + player.seekTo(value) + }, + ) + } } } } - BackHandler(enabled = visibilityState.isVisible) { - visibilityState.hide() + } + } +} + +@Composable +private fun ChapterInfo( + player: Player, + visibilityState: DelayedVisibilityState, + modifier: Modifier = Modifier, +) { + val currentMediaMetadata by player.currentMediaMetadataAsState() + val currentChapter by player.getCurrentChapterAsState() + + var showChapterInfo by remember { + mutableStateOf(currentChapter?.mediaMetadata != null) + } + + LaunchedEffect(currentChapter) { + showChapterInfo = currentChapter?.mediaMetadata != null + if (showChapterInfo) { + delay(5.seconds) + showChapterInfo = false + } + } + + AnimatedVisibility( + visible = visibilityState.isVisible || showChapterInfo, + modifier = modifier, + enter = expandVertically(), + exit = shrinkVertically(), + ) { + AnimatedContent( + targetState = currentChapter?.mediaMetadata ?: currentMediaMetadata, + modifier = Modifier + .fillMaxWidth() + .wrapContentHeight(), + transitionSpec = { + slideInHorizontally { it } + .togetherWith(slideOutHorizontally { -it }) + }, + label = "media_metadata_transition", + ) { mediaMetadata -> + MediaMetadataView(mediaMetadata) + } + } +} + +@Composable +private fun SkipButton( + modifier: Modifier = Modifier, + onClick: () -> Unit, +) { + Button( + onClick = onClick, + modifier = modifier, + ) { + Text(text = stringResource(R.string.skip)) + } +} + +@Composable +private fun PlayerTimeRow( + player: Player, + onSeek: (value: Long) -> Unit, +) { + val durationMs by player.durationAsState() + val positionMs by player.currentPositionAsState() + val availableCommands by player.availableCommandsAsState() + val duration = durationMs.takeIf { it != C.TIME_UNSET }?.milliseconds ?: ZERO + val formatter = duration.getFormatter() + + @Suppress("Indentation", "Wrapping") + val onSeekProxy = remember(durationMs, positionMs) { + { newPosition: Long -> + if (newPosition in 0..durationMs && newPosition != positionMs) { + onSeek(newPosition) } } } + + var compactMode by remember { + mutableStateOf(true) + } + + Text( + text = "${formatter(positionMs.milliseconds)} / ${formatter(duration)}", + modifier = Modifier.padding( + top = MaterialTheme.paddings.baseline, + bottom = MaterialTheme.paddings.small, + ), + color = Color.White, + ) + + TVSlider( + value = positionMs, + range = 0..durationMs, + compactMode = compactMode, + modifier = Modifier + .onFocusChanged { compactMode = !it.hasFocus } + .padding(bottom = MaterialTheme.paddings.baseline), + enabled = availableCommands.canSeek(), + onSeekBack = { onSeekProxy(positionMs - player.seekBackIncrement) }, + onSeekForward = { onSeekProxy(positionMs + player.seekBackIncrement) }, + ) } diff --git a/pillarbox-demo-tv/src/main/java/ch/srgssr/pillarbox/demo/tv/ui/player/compose/controls/PlayerPlaybackRow.kt b/pillarbox-demo-tv/src/main/java/ch/srgssr/pillarbox/demo/tv/ui/player/compose/controls/PlayerPlaybackRow.kt index bd94508cb..79b69856d 100644 --- a/pillarbox-demo-tv/src/main/java/ch/srgssr/pillarbox/demo/tv/ui/player/compose/controls/PlayerPlaybackRow.kt +++ b/pillarbox-demo-tv/src/main/java/ch/srgssr/pillarbox/demo/tv/ui/player/compose/controls/PlayerPlaybackRow.kt @@ -66,6 +66,7 @@ fun PlayerPlaybackRow( modifier = modifier.onDpadEvent( onLeft = resetAutoHideCallback, onRight = resetAutoHideCallback, + onDown = resetAutoHideCallback, onEnter = resetAutoHideCallback, ), horizontalArrangement = Arrangement.spacedBy(MaterialTheme.paddings.baseline),