From 6923e5bcceefb3ba6aeebe8789965285bcfe8139 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Mon, 16 Dec 2024 17:14:22 +0100 Subject: [PATCH 1/3] Bump com.autonomousapps.dependency-analysis from 2.6.0 to 2.6.1 (#838) Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- gradle/libs.versions.toml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index a0b5b2d8f..7e5bce5bf 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -17,7 +17,7 @@ androidx-test-runner = "1.6.2" androidx-tv-material = "1.0.0" coil = "3.0.4" comscore = "6.11.1" -dependency-analysis-gradle-plugin = "2.6.0" +dependency-analysis-gradle-plugin = "2.6.1" detekt = "1.23.7" dokka = "2.0.0-Beta" guava = "33.3.1-android" From a30acc80d034a002c0968bfab96fe1fa5f374570 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Mon, 16 Dec 2024 21:41:47 +0100 Subject: [PATCH 2/3] Bump kotlinx-kover from 0.8.3 to 0.9.0 (#836) Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- gradle/libs.versions.toml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 7e5bce5bf..07f801859 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -26,7 +26,7 @@ junit = "4.13.2" kotlin = "2.1.0" kotlinx-coroutines = "1.9.0" kotlinx-datetime = "0.6.1" -kotlinx-kover = "0.8.3" +kotlinx-kover = "0.9.0" kotlinx-serialization = "1.7.3" mockk = "1.13.13" okhttp = "4.12.0" From 2ee6651ed9f4208d38d9270b7091b9fbc8c91de3 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Joaquim=20St=C3=A4hli?= Date: Mon, 16 Dec 2024 22:07:54 +0100 Subject: [PATCH 3/3] 822 remove toggleablebox (#828) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-authored-by: Gaëtan Muller Co-authored-by: Gaëtan Muller --- .../pillarbox/demo/shared/ui/TalkBack.kt | 24 ++ .../ui/player/DelayedControlsVisibility.kt | 164 ++++++++++ .../demo/tv/ui/player/compose/PlayerView.kt | 47 +-- .../compose/controls/PlayerPlaybackRow.kt | 24 +- .../demo/ui/player/BlockedTimeRangeWarning.kt | 100 ------ .../pillarbox/demo/ui/player/PlayerView.kt | 164 ++++++---- pillarbox-ui/build.gradle.kts | 2 - .../ui/widget/DelayedVisibilityState.kt | 291 ------------------ .../pillarbox/ui/widget/ToggleableBox.kt | 160 ---------- 9 files changed, 327 insertions(+), 649 deletions(-) create mode 100644 pillarbox-demo-shared/src/main/java/ch/srgssr/pillarbox/demo/shared/ui/player/DelayedControlsVisibility.kt delete mode 100644 pillarbox-demo/src/main/java/ch/srgssr/pillarbox/demo/ui/player/BlockedTimeRangeWarning.kt delete mode 100644 pillarbox-ui/src/main/java/ch/srgssr/pillarbox/ui/widget/DelayedVisibilityState.kt delete mode 100644 pillarbox-ui/src/main/java/ch/srgssr/pillarbox/ui/widget/ToggleableBox.kt diff --git a/pillarbox-demo-shared/src/main/java/ch/srgssr/pillarbox/demo/shared/ui/TalkBack.kt b/pillarbox-demo-shared/src/main/java/ch/srgssr/pillarbox/demo/shared/ui/TalkBack.kt index 6e48ee580..6bcae4c21 100644 --- a/pillarbox-demo-shared/src/main/java/ch/srgssr/pillarbox/demo/shared/ui/TalkBack.kt +++ b/pillarbox-demo-shared/src/main/java/ch/srgssr/pillarbox/demo/shared/ui/TalkBack.kt @@ -36,3 +36,27 @@ fun rememberIsTouchExplorationEnabled(): Boolean { return isTouchExplorationEnabled } + +/** + * A composable function that returns a boolean indicating whether TalkBack is currently enabled. + * + * This function uses a [DisposableEffect] to register an [AccessibilityManager.AccessibilityStateChangeListener] + * that updates the state of the composable when the accessibility state changes. + * + * @return `true` if TalkBack is enabled, `false` otherwise. + */ +@Composable +fun rememberIsTalkBackEnabled(): Boolean { + val accessibilityManager = LocalContext.current.getSystemService() ?: return false + val (isTalkBackEnabled, setTalkBackEnabled) = remember { + mutableStateOf(accessibilityManager.isEnabled) + } + DisposableEffect(Unit) { + val l = AccessibilityManager.AccessibilityStateChangeListener(setTalkBackEnabled) + accessibilityManager.addAccessibilityStateChangeListener(l) + onDispose { + accessibilityManager.removeAccessibilityStateChangeListener(l) + } + } + return isTalkBackEnabled +} diff --git a/pillarbox-demo-shared/src/main/java/ch/srgssr/pillarbox/demo/shared/ui/player/DelayedControlsVisibility.kt b/pillarbox-demo-shared/src/main/java/ch/srgssr/pillarbox/demo/shared/ui/player/DelayedControlsVisibility.kt new file mode 100644 index 000000000..e002b841f --- /dev/null +++ b/pillarbox-demo-shared/src/main/java/ch/srgssr/pillarbox/demo/shared/ui/player/DelayedControlsVisibility.kt @@ -0,0 +1,164 @@ +/* + * Copyright (c) SRG SSR. All rights reserved. + * License information is available from the LICENSE file. + */ +package ch.srgssr.pillarbox.demo.shared.ui.player + +import androidx.compose.animation.fadeIn +import androidx.compose.animation.fadeOut +import androidx.compose.foundation.background +import androidx.compose.foundation.clickable +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.text.BasicText +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.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.tooling.preview.Preview +import kotlinx.coroutines.delay +import kotlin.time.Duration +import kotlin.time.Duration.Companion.ZERO +import kotlin.time.Duration.Companion.seconds + +/** + * A class that manages the visibility of controls with a delay. + * + * This class is used to control the visibility of UI elements that should be hidden + * after a certain period of inactivity. The visibility is initially set to [initialVisible] + * and the delay before hiding is set to [initialDelay]. + * + * To reset the delay and keep the controls visible, call the [reset] function. + * This will restart the delay timer. + * + * @param initialVisible The initial visibility of the controls. + * @param initialDelay The initial delay before hiding the controls. + */ +class DelayedControlsVisibility internal constructor(initialVisible: Boolean, initialDelay: Duration) { + /** + * Controls visibility. + */ + var visible by mutableStateOf(initialVisible) + + /** + * The [delay] after the controls become no more visible. + * Can be reset with [reset] method. + */ + var delay by mutableStateOf(initialDelay) + internal var reset by mutableStateOf(null) + + /** + * Resets the ongoing delay. + */ + fun reset() { + if (visible && delay > ZERO) { + reset = Any() + } + } +} + +/** + * Remembers and controls the visibility of UI elements with a delay. + * + * Initially sets visibility to [initialVisible]. If visible, hides after [initialDelay]. + * + * @param initialVisible Initial visibility. Defaults to false. + * @param initialDelay Delay before hiding, if initially visible. Defaults to 3 seconds. + * @return A [DelayedControlsVisibility] instance to control and observe visibility. + */ +@Composable +fun rememberDelayedControlsVisibility(initialVisible: Boolean = false, initialDelay: Duration = DefaultVisibilityDelay): DelayedControlsVisibility { + val visibility = remember(initialVisible, initialDelay) { DelayedControlsVisibility(initialVisible, initialDelay) } + LaunchedEffect(visibility.visible, visibility.delay, visibility.reset) { + if (visibility.visible && visibility.delay > ZERO) { + delay(visibility.delay) + visibility.visible = false + } + } + return visibility +} + +/** + * Default visibility delay + */ +val DefaultVisibilityDelay = 3.seconds + +@Preview +@Composable +private fun KeepVisibleDelayPreview() { + val visibility = rememberDelayedControlsVisibility(true, 2.seconds) + + Column { + Box( + modifier = Modifier + .fillMaxWidth() + .aspectRatio(16 / 9f) + .clickable { visibility.visible = !visibility.visible }, + ) { + Box( + modifier = Modifier + .fillMaxSize() + .background(color = Color.Green) + ) + androidx.compose.animation.AnimatedVisibility( + visible = visibility.visible, + modifier = Modifier.fillMaxSize(), + enter = fadeIn(), + exit = fadeOut(), + ) { + Box(modifier = Modifier.background(color = Color.Black.copy(alpha = 0.5f)), contentAlignment = Alignment.Center) { + BasicText(text = "Text to hide", color = { Color.Red }) + } + } + } + + Row( + modifier = Modifier + .fillMaxWidth() + .background(color = Color.White), + horizontalArrangement = Arrangement.SpaceAround + ) { + BasicText( + text = "Show", + modifier = Modifier.clickable { + visibility.visible = true + visibility.reset() + } + ) + BasicText( + text = "Toggle", + modifier = Modifier.clickable { + visibility.visible = !visibility.visible + } + ) + BasicText( + text = "Hide", + modifier = Modifier.clickable { + visibility.visible = false + } + ) + BasicText( + text = "Disable", + modifier = Modifier.clickable { + visibility.delay = ZERO + } + ) + BasicText( + text = "Enable", + modifier = Modifier.clickable { + visibility.delay = 2.seconds + } + ) + } + } +} 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 f958a86bc..7ff404739 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 @@ -53,7 +53,10 @@ import ch.srgssr.pillarbox.demo.shared.extension.onDpadEvent import ch.srgssr.pillarbox.demo.shared.ui.components.PillarboxSlider import ch.srgssr.pillarbox.demo.shared.ui.getFormatter import ch.srgssr.pillarbox.demo.shared.ui.localTimeFormatter +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.rememberIsTalkBackEnabled import ch.srgssr.pillarbox.demo.shared.ui.settings.MetricsOverlayOptions import ch.srgssr.pillarbox.demo.tv.ui.player.compose.controls.PlayerError import ch.srgssr.pillarbox.demo.tv.ui.player.compose.controls.PlayerPlaybackRow @@ -70,11 +73,9 @@ 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.isCurrentMediaItemLiveAsState +import ch.srgssr.pillarbox.ui.extension.isPlayingAsState 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 kotlinx.coroutines.flow.map import kotlinx.datetime.Instant @@ -92,6 +93,8 @@ import kotlin.time.Duration.Companion.seconds * @param metricsOverlayEnabled * @param metricsOverlayOptions */ + +@Suppress("CyclomaticComplexMethod") @Composable fun PlayerView( player: PillarboxExoPlayer, @@ -100,17 +103,21 @@ fun PlayerView( metricsOverlayOptions: MetricsOverlayOptions, ) { val drawerState = rememberDrawerState(initialValue = DrawerValue.Closed) - val visibilityState = rememberDelayedVisibilityState(player = player, visible = true) + + val talkBackEnabled = rememberIsTalkBackEnabled() + val isPlaying by player.isPlayingAsState() + val keepControlDelay = if (!talkBackEnabled && isPlaying) DefaultVisibilityDelay else ZERO + val controlsVisibilityState = rememberDelayedControlsVisibility(initialVisible = true, keepControlDelay) LaunchedEffect(drawerState.currentValue) { - when (drawerState.currentValue) { - DrawerValue.Closed -> visibilityState.show() - DrawerValue.Open -> visibilityState.hide() + controlsVisibilityState.visible = when (drawerState.currentValue) { + DrawerValue.Closed -> true + DrawerValue.Open -> false } } - BackHandler(enabled = visibilityState.isVisible) { - visibilityState.hide() + BackHandler(enabled = controlsVisibilityState.visible) { + controlsVisibilityState.visible = false } PlaybackSettingsDrawer( @@ -133,7 +140,7 @@ fun PlayerView( .onDpadEvent( eventType = KeyEventType.KeyUp, onEnter = { - visibilityState.show() + controlsVisibilityState.visible = true true }, ) @@ -146,7 +153,7 @@ fun PlayerView( Column { ChapterInfo( player = player, - visibilityState = visibilityState, + controlsVisible = controlsVisibilityState.visible, ) if (metricsOverlayEnabled) { @@ -166,7 +173,7 @@ fun PlayerView( } } - if (!visibilityState.isVisible && currentCredit != null) { + if (!controlsVisibilityState.visible && currentCredit != null) { SkipButton( modifier = Modifier .align(Alignment.BottomEnd) @@ -174,17 +181,19 @@ fun PlayerView( onClick = { player.seekTo(currentCredit?.end ?: 0L) }, ) } - AnimatedVisibility( - visible = visibilityState.isVisible, + visible = controlsVisibilityState.visible, modifier = Modifier .fillMaxSize() - .maintainVisibleOnFocus(delayedVisibilityState = visibilityState), + .onFocusChanged { + if (it.isFocused) { + controlsVisibilityState.reset() + } + }, ) { Box { PlayerPlaybackRow( player = player, - state = visibilityState, modifier = Modifier.align(Alignment.Center), ) @@ -221,7 +230,7 @@ fun PlayerView( PlayerTimeRow( player = player, onSeek = { value -> - visibilityState.resetAutoHide() + controlsVisibilityState.reset() player.seekTo(value) }, ) @@ -236,7 +245,7 @@ fun PlayerView( @Composable private fun ChapterInfo( player: Player, - visibilityState: DelayedVisibilityState, + controlsVisible: Boolean, modifier: Modifier = Modifier, ) { val currentMediaMetadata by player.currentMediaMetadataAsState() @@ -255,7 +264,7 @@ private fun ChapterInfo( } AnimatedVisibility( - visible = visibilityState.isVisible || showChapterInfo, + visible = controlsVisible || showChapterInfo, modifier = modifier, enter = expandVertically(), exit = shrinkVertically(), 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 814e40f61..ca4a6f77d 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 @@ -24,7 +24,6 @@ import androidx.media3.common.Player import androidx.tv.material3.Icon import androidx.tv.material3.IconButton import androidx.tv.material3.MaterialTheme -import ch.srgssr.pillarbox.demo.shared.extension.onDpadEvent import ch.srgssr.pillarbox.demo.tv.ui.theme.paddings import ch.srgssr.pillarbox.player.extension.canSeekBack import ch.srgssr.pillarbox.player.extension.canSeekForward @@ -32,43 +31,26 @@ import ch.srgssr.pillarbox.player.extension.canSeekToNext import ch.srgssr.pillarbox.player.extension.canSeekToPrevious import ch.srgssr.pillarbox.ui.extension.availableCommandsAsState import ch.srgssr.pillarbox.ui.extension.isPlayingAsState -import ch.srgssr.pillarbox.ui.widget.DelayedVisibilityState /** * Tv playback row * * @param player - * @param state * @param modifier */ @Composable fun PlayerPlaybackRow( player: Player, - state: DelayedVisibilityState, modifier: Modifier = Modifier, ) { val isPlaying by player.isPlayingAsState() val focusRequester = remember { FocusRequester() } - val resetAutoHideCallback = remember { - { - state.resetAutoHide() - false - } - } - - LaunchedEffect(state.isVisible) { - if (state.isVisible) { - focusRequester.requestFocus() - } + LaunchedEffect(Unit) { + focusRequester.requestFocus() } Row( - modifier = modifier.onDpadEvent( - onLeft = resetAutoHideCallback, - onRight = resetAutoHideCallback, - onDown = resetAutoHideCallback, - onEnter = resetAutoHideCallback, - ), + modifier = modifier, horizontalArrangement = Arrangement.spacedBy(MaterialTheme.paddings.baseline), ) { val availableCommands by player.availableCommandsAsState() diff --git a/pillarbox-demo/src/main/java/ch/srgssr/pillarbox/demo/ui/player/BlockedTimeRangeWarning.kt b/pillarbox-demo/src/main/java/ch/srgssr/pillarbox/demo/ui/player/BlockedTimeRangeWarning.kt deleted file mode 100644 index 3e82ee2ee..000000000 --- a/pillarbox-demo/src/main/java/ch/srgssr/pillarbox/demo/ui/player/BlockedTimeRangeWarning.kt +++ /dev/null @@ -1,100 +0,0 @@ -/* - * Copyright (c) SRG SSR. All rights reserved. - * License information is available from the LICENSE file. - */ -package ch.srgssr.pillarbox.demo.ui.player - -import androidx.compose.animation.AnimatedVisibility -import androidx.compose.foundation.background -import androidx.compose.foundation.layout.fillMaxWidth -import androidx.compose.foundation.layout.padding -import androidx.compose.material3.MaterialTheme -import androidx.compose.material3.Text -import androidx.compose.runtime.Composable -import androidx.compose.runtime.DisposableEffect -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.graphics.Color -import androidx.compose.ui.tooling.preview.Preview -import androidx.media3.common.Player -import ch.srgssr.pillarbox.demo.ui.theme.PillarboxTheme -import ch.srgssr.pillarbox.demo.ui.theme.paddings -import ch.srgssr.pillarbox.player.PillarboxPlayer -import ch.srgssr.pillarbox.player.asset.timeRange.BlockedTimeRange -import kotlinx.coroutines.delay -import kotlin.time.Duration -import kotlin.time.Duration.Companion.seconds - -/** - * Display a message when the player reaches a blocked time range. - * - * @param player - * @param modifier - * @param visibilityDelay - */ -@Composable -fun BlockedTimeRangeWarning( - player: Player, - modifier: Modifier = Modifier, - visibilityDelay: Duration = 5.seconds, -) { - var currentBlockedTimeRange: BlockedTimeRange? by remember(player) { - mutableStateOf(null) - } - DisposableEffect(player) { - val listener = object : PillarboxPlayer.Listener { - override fun onBlockedTimeRangeReached(blockedTimeRange: BlockedTimeRange) { - currentBlockedTimeRange = blockedTimeRange - } - } - player.addListener(listener) - onDispose { - player.removeListener(listener) - } - } - LaunchedEffect(currentBlockedTimeRange) { - if (currentBlockedTimeRange != null) { - delay(visibilityDelay) - currentBlockedTimeRange = null - } - } - AnimatedVisibility( - modifier = modifier, - visible = currentBlockedTimeRange != null - ) { - currentBlockedTimeRange?.let { - BlockedTimeRangeInfo(modifier = Modifier.fillMaxWidth(), blockedTimeRange = it) - } - } -} - -@Composable -private fun BlockedTimeRangeInfo( - blockedTimeRange: BlockedTimeRange, - modifier: Modifier = Modifier -) { - Text( - modifier = modifier - .background(color = Color.Blue.copy(0.8f)) - .padding(MaterialTheme.paddings.baseline), - text = "Reached a blocked segment! ${blockedTimeRange.reason}", - color = Color.White, - style = MaterialTheme.typography.labelSmall - ) -} - -@Preview(showBackground = true) -@Composable -private fun BlockedTimeRangeInfoPreview() { - val blockedTimeRange = BlockedTimeRange(start = 0, end = 0, reason = "GeoBlock") - PillarboxTheme { - BlockedTimeRangeInfo( - modifier = Modifier.fillMaxWidth(), - blockedTimeRange = blockedTimeRange - ) - } -} 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 0112777d5..fbf83c026 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,13 +4,18 @@ */ package ch.srgssr.pillarbox.demo.ui.player +import androidx.compose.animation.AnimatedVisibility +import androidx.compose.animation.fadeIn +import androidx.compose.animation.fadeOut 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 +import androidx.compose.foundation.selection.toggleable import androidx.compose.material3.CircularProgressIndicator import androidx.compose.material3.MaterialTheme import androidx.compose.runtime.Composable @@ -18,14 +23,18 @@ import androidx.compose.runtime.getValue import androidx.compose.runtime.remember 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.res.stringResource import androidx.compose.ui.semantics.semantics import androidx.compose.ui.semantics.stateDescription -import androidx.compose.ui.zIndex import androidx.media3.common.Player 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.rememberIsTalkBackEnabled import ch.srgssr.pillarbox.demo.shared.ui.settings.MetricsOverlayOptions import ch.srgssr.pillarbox.demo.ui.player.controls.PlayerControls import ch.srgssr.pillarbox.demo.ui.player.controls.PlayerError @@ -34,18 +43,19 @@ import ch.srgssr.pillarbox.demo.ui.player.controls.SkipButton import ch.srgssr.pillarbox.demo.ui.player.controls.rememberProgressTrackerState import ch.srgssr.pillarbox.demo.ui.theme.paddings import ch.srgssr.pillarbox.player.PillarboxExoPlayer +import ch.srgssr.pillarbox.player.asset.timeRange.Credit 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.getCurrentCreditAsState import ch.srgssr.pillarbox.ui.extension.getPeriodicallyCurrentMetricsAsState import ch.srgssr.pillarbox.ui.extension.hasMediaItemsAsState +import ch.srgssr.pillarbox.ui.extension.isPlayingAsState import ch.srgssr.pillarbox.ui.extension.playbackStateAsState import ch.srgssr.pillarbox.ui.extension.playerErrorAsState -import ch.srgssr.pillarbox.ui.widget.ToggleableBox import ch.srgssr.pillarbox.ui.widget.keepScreenOn import ch.srgssr.pillarbox.ui.widget.player.PlayerSurface -import ch.srgssr.pillarbox.ui.widget.rememberDelayedVisibilityState +import kotlin.time.Duration.Companion.ZERO import kotlin.time.Duration.Companion.milliseconds /** @@ -101,36 +111,30 @@ fun PlayerView( MutableInteractionSource() } val isSliderDragged by interactionSource.collectIsDraggedAsState() - val visibilityState = rememberDelayedVisibilityState( - player = player, - autoHideEnabled = !isSliderDragged, - visible = controlsVisible - ) - val currentCredit by player.getCurrentCreditAsState() - val controlsStateDescription = if (visibilityState.isVisible) { + val talkBackEnabled = rememberIsTalkBackEnabled() + 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) } - - ToggleableBox( - modifier = modifier.semantics { - stateDescription = controlsStateDescription - }, - toggleable = controlsToggleable, - visibilityState = visibilityState, - toggleableContent = { - PlayerControls( - player = player, - interactionSource = interactionSource, - progressTracker = progressTracker, - credit = currentCredit, - content = content + Box( + modifier = modifier + .toggleable( + value = controlsVisibility.visible, + enabled = controlsToggleable, + onValueChange = { + controlsVisibility.visible = !controlsVisibility.visible + } ) - } + .semantics { + stateDescription = controlsStateDescription + } ) { - val playbackState by player.playbackStateAsState() - val isBuffering = playbackState == Player.STATE_BUFFERING PlayerSurface( modifier = Modifier .fillMaxSize() @@ -138,27 +142,19 @@ fun PlayerView( player = player, scaleMode = scaleMode ) { - if (isBuffering && !isSliderDragged) { - Box(modifier = Modifier.fillMaxSize(), contentAlignment = Alignment.Center) { - CircularProgressIndicator(modifier = Modifier.align(Alignment.Center), color = Color.White) - } - } - ExoPlayerSubtitleView(player = player) - if (overlayEnabled && player is PillarboxExoPlayer) { - val currentMetrics by player.getPeriodicallyCurrentMetricsAsState(500.milliseconds) - currentMetrics?.let { - MetricsOverlay( - modifier = Modifier - .fillMaxSize() - .align(Alignment.TopStart), - playbackMetrics = it, - overlayOptions = overlayOptions, - ) - } - } + SurfaceOverlay( + player = player, + displayBuffering = isBuffering && !isSliderDragged, + overlayEnabled = overlayEnabled, + overlayOptions = overlayOptions, + ) } - - if (currentCredit != null && !visibilityState.isVisible) { + val currentCredit by player.getCurrentCreditAsState() + AnimatedVisibility( + visible = currentCredit != null && !controlsVisibility.visible, + enter = fadeIn(), + exit = fadeOut(), + ) { SkipButton( modifier = Modifier .align(Alignment.BottomEnd) @@ -167,19 +163,75 @@ fun PlayerView( ) } - BlockedTimeRangeWarning( - player = player, + DemoControls( modifier = Modifier - .align(Alignment.TopStart) - .zIndex(2f), + .matchParentSize() + .onFocusChanged { if (it.isFocused) controlsVisibility.reset() } + .onDpadEvent(onEnter = { + controlsVisibility.visible = !controlsVisibility.visible + true + }), + controlsVisible = controlsVisibility.visible, + player = player, + progressTracker = progressTracker, + interactionSource = interactionSource, + currentCredit = currentCredit, + content = content, ) + } +} - ChapterInfo( +@Composable +private fun BoxScope.SurfaceOverlay( + player: Player, + displayBuffering: Boolean, + overlayEnabled: Boolean, + overlayOptions: MetricsOverlayOptions, +) { + AnimatedVisibility( + displayBuffering, + enter = fadeIn(), + exit = fadeOut(), + ) { + Box(modifier = Modifier.fillMaxSize(), contentAlignment = Alignment.Center) { + CircularProgressIndicator(modifier = Modifier.align(Alignment.Center), color = Color.White) + } + } + ExoPlayerSubtitleView(player = player) + if (overlayEnabled && player is PillarboxExoPlayer) { + val currentMetrics by player.getPeriodicallyCurrentMetricsAsState(500.milliseconds) + currentMetrics?.let { + MetricsOverlay( + modifier = Modifier + .fillMaxSize() + .align(Alignment.TopStart), + playbackMetrics = it, + overlayOptions = overlayOptions, + ) + } + } +} + +@Composable +private fun DemoControls( + modifier: Modifier, + controlsVisible: Boolean, + player: Player, + progressTracker: ProgressTrackerState, + interactionSource: MutableInteractionSource, + currentCredit: Credit?, + content: @Composable ColumnScope.() -> Unit +) { + AnimatedVisibility( + modifier = modifier, + visible = controlsVisible + ) { + PlayerControls( player = player, - modifier = Modifier - .align(Alignment.BottomStart) - .zIndex(2f), - visible = !visibilityState.isVisible + interactionSource = interactionSource, + progressTracker = progressTracker, + credit = currentCredit, + content = content ) } } diff --git a/pillarbox-ui/build.gradle.kts b/pillarbox-ui/build.gradle.kts index d6de4fffe..8dd6d96af 100644 --- a/pillarbox-ui/build.gradle.kts +++ b/pillarbox-ui/build.gradle.kts @@ -14,8 +14,6 @@ dependencies { api(project(":pillarbox-player")) implementation(libs.androidx.annotation) - api(libs.androidx.compose.animation) - implementation(libs.androidx.compose.animation.core) implementation(platform(libs.androidx.compose.bom)) implementation(libs.androidx.compose.foundation) api(libs.androidx.compose.foundation.layout) diff --git a/pillarbox-ui/src/main/java/ch/srgssr/pillarbox/ui/widget/DelayedVisibilityState.kt b/pillarbox-ui/src/main/java/ch/srgssr/pillarbox/ui/widget/DelayedVisibilityState.kt deleted file mode 100644 index aec4cce17..000000000 --- a/pillarbox-ui/src/main/java/ch/srgssr/pillarbox/ui/widget/DelayedVisibilityState.kt +++ /dev/null @@ -1,291 +0,0 @@ -/* - * Copyright (c) SRG SSR. All rights reserved. - * License information is available from the LICENSE file. - */ -package ch.srgssr.pillarbox.ui.widget - -import android.content.Context -import android.os.Build -import android.view.accessibility.AccessibilityManager -import androidx.compose.foundation.Indication -import androidx.compose.foundation.focusable -import androidx.compose.foundation.interaction.Interaction -import androidx.compose.foundation.interaction.MutableInteractionSource -import androidx.compose.foundation.selection.toggleable -import androidx.compose.runtime.Composable -import androidx.compose.runtime.DisposableEffect -import androidx.compose.runtime.LaunchedEffect -import androidx.compose.runtime.Stable -import androidx.compose.runtime.collectAsState -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.focus.onFocusChanged -import androidx.compose.ui.input.key.Key -import androidx.compose.ui.input.key.KeyEventType -import androidx.compose.ui.input.key.key -import androidx.compose.ui.input.key.onPreviewKeyEvent -import androidx.compose.ui.input.key.type -import androidx.compose.ui.platform.LocalContext -import androidx.compose.ui.semantics.Role -import androidx.media3.common.Player -import ch.srgssr.pillarbox.player.playWhenReadyAsFlow -import ch.srgssr.pillarbox.ui.extension.playbackStateAsState -import kotlinx.coroutines.delay -import kotlin.time.Duration -import kotlin.time.Duration.Companion.INFINITE -import kotlin.time.Duration.Companion.ZERO -import kotlin.time.Duration.Companion.seconds - -/** - * Represents a state that manages visibility, with an optional delayed auto-hiding functionality. - * - * @param initialVisible The initial visibility state of the component. - * @param initialDuration The initial duration before auto-hiding. - */ -@Stable -class DelayedVisibilityState internal constructor( - initialVisible: Boolean = true, - initialDuration: Duration = DefaultDuration -) { - internal var autoHideResetTrigger by mutableStateOf(false) - private set - - /** - * Represents the visibility state of the component. - * - * This property is observable and changes to this property will trigger recomposition. - */ - var isVisible by mutableStateOf(initialVisible) - - /** - * Represents the delay until auto-hide is performed. - * - * This property is observable and changes to this property will trigger recomposition. - */ - var duration by mutableStateOf(initialDuration) - - /** - * Toggles the visibility of the component. - */ - fun toggle() { - this.isVisible = !isVisible - } - - /** - * Makes the component visible. - */ - fun show() { - this.isVisible = true - } - - /** - * Makes the component invisible. - */ - fun hide() { - this.isVisible = false - } - - /** - * Disables the auto-hide behavior of the component. - */ - fun disableAutoHide() { - duration = DisabledDuration - } - - /** - * Resets the auto-hide countdown. - */ - fun resetAutoHide() { - autoHideResetTrigger = !autoHideResetTrigger - } - - /** - * Checks if the auto-hide feature is enabled. - * - * @return `true` if auto-hide is enabled, `false` otherwise. - */ - fun isAutoHideEnabled(): Boolean { - return duration < INFINITE && duration > ZERO - } - - @Suppress("UndocumentedPublicClass") - companion object { - /** - * Default auto-hide duration. - */ - val DefaultDuration = 3.seconds - - /** - * Disabled auto-hide duration. - */ - val DisabledDuration = ZERO - } -} - -/** - * Makes a Composable toggleable, controlling the visibility state of a [DelayedVisibilityState]. - * - * @param enabled Whether to handle input events and appear enabled for semantics purposes. - * @param role The type of UI element. Accessibility services might use this to describe the element. - * @param delayedVisibilityState The [DelayedVisibilityState] instance to control the visibility of the component. - */ -fun Modifier.toggleable( - enabled: Boolean = true, - role: Role? = Role.Switch, - delayedVisibilityState: DelayedVisibilityState -): Modifier = toggleable( - enabled = enabled, - role = role, - interactionSource = null, - delayedVisibilityState = delayedVisibilityState, -) - -/** - * Makes a Composable toggleable, controlling the visibility state of a [DelayedVisibilityState]. - * - * @param enabled Whether to handle input events and appear enabled for semantics purposes. - * @param role The type of UI element. Accessibility services might use this to describe the element. - * @param indication Indication to be shown when the Composable is pressed. If `null`, no indication will be shown. - * @param interactionSource The [MutableInteractionSource] that will be used to dispatch [Interaction]s when this toggleable component is being - * interacted with. - * @param delayedVisibilityState The [DelayedVisibilityState] instance to control the visibility of the component. - */ -fun Modifier.toggleable( - enabled: Boolean = true, - role: Role? = Role.Switch, - indication: Indication? = null, - interactionSource: MutableInteractionSource?, - delayedVisibilityState: DelayedVisibilityState -): Modifier = this.then( - Modifier - .toggleable( - value = delayedVisibilityState.isVisible, - enabled = enabled, - indication = indication, - interactionSource = interactionSource, - role = role, - onValueChange = { - delayedVisibilityState.isVisible = it - } - ) - .onEnterPressed(delayedVisibilityState::toggle) - .focusable(enabled = enabled) -) - -/** - * Maintains a Composable visible when it gains focus. - * - * @param delayedVisibilityState The [DelayedVisibilityState] instance to control the visibility of the component. - */ -fun Modifier.maintainVisibleOnFocus(delayedVisibilityState: DelayedVisibilityState): Modifier { - return onFocusChanged { - if (it.isFocused) { - delayedVisibilityState.show() - } - } -} - -/** - * Remembers a [DelayedVisibilityState] for the provided [Player] - * - * @param player The [Player] to associate with the [DelayedVisibilityState]. - * @param visible Whether the component is initially visible. - * @param autoHideEnabled Whether auto-hiding is enabled. Auto-hiding is always disabled when accessibility is on. - * @param duration The duration to wait before hiding the component. - * - * @return A [DelayedVisibilityState] instance. - */ -@Composable -fun rememberDelayedVisibilityState( - player: Player, - visible: Boolean = true, - autoHideEnabled: Boolean = true, - duration: Duration = DelayedVisibilityState.DefaultDuration -): DelayedVisibilityState { - val playWhenReadyFlow = remember(player) { - player.playWhenReadyAsFlow() - } - val playbackState by player.playbackStateAsState() - val stateReady = playbackState == Player.STATE_READY || playbackState == Player.STATE_BUFFERING - val playWhenReady by playWhenReadyFlow.collectAsState(initial = player.playWhenReady) - return rememberDelayedVisibilityState(visible = visible, autoHideEnabled && playWhenReady && stateReady, duration) -} - -/** - * Remembers a [DelayedVisibilityState]. - * - * @param visible Whether the component is initially visible. - * @param autoHideEnabled Whether auto-hiding is enabled. Auto-hiding is always disabled when accessibility is on. - * @param duration The duration to wait before hiding the component. - * - * @return A [DelayedVisibilityState] instance. - */ -@Composable -fun rememberDelayedVisibilityState( - visible: Boolean = true, - autoHideEnabled: Boolean = true, - duration: Duration = DelayedVisibilityState.DefaultDuration -): DelayedVisibilityState { - val context = LocalContext.current - val ac = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) { - context.getSystemService(AccessibilityManager::class.java) - } else { - context.getSystemService(Context.ACCESSIBILITY_SERVICE) as AccessibilityManager - } - var isTalkBackEnabled by remember { - mutableStateOf(ac.isEnabled) - } - DisposableEffect(context) { - val l = AccessibilityManager.AccessibilityStateChangeListener { isTalkBackEnabled = ac.isEnabled } - ac.addAccessibilityStateChangeListener(l) - onDispose { - ac.removeAccessibilityStateChangeListener(l) - } - } - val autoHideEnabledAccessibility = autoHideEnabled && !isTalkBackEnabled - val delayedVisibilityState = remember { - DelayedVisibilityState(visible, duration).apply { - if (!autoHideEnabledAccessibility) { - disableAutoHide() - } - } - } - - LaunchedEffect(duration, autoHideEnabledAccessibility) { - if (autoHideEnabledAccessibility) { - delayedVisibilityState.duration = duration - } else { - delayedVisibilityState.disableAutoHide() - } - } - - LaunchedEffect(visible) { - delayedVisibilityState.isVisible = visible - } - - LaunchedEffect(delayedVisibilityState.isVisible, delayedVisibilityState.duration, delayedVisibilityState.autoHideResetTrigger) { - if (delayedVisibilityState.isVisible && delayedVisibilityState.isAutoHideEnabled()) { - delay(delayedVisibilityState.duration) - delayedVisibilityState.hide() - } - } - - return delayedVisibilityState -} - -private fun Modifier.onEnterPressed(action: () -> Unit): Modifier { - return this then Modifier.onPreviewKeyEvent { - val isEnterKey = it.key == Key.Enter || it.key == Key.DirectionCenter || it.key == Key.NumPadEnter - val isKeyUp = it.type == KeyEventType.KeyUp - - if (isEnterKey && isKeyUp) { - action() - true - } else { - false - } - } -} diff --git a/pillarbox-ui/src/main/java/ch/srgssr/pillarbox/ui/widget/ToggleableBox.kt b/pillarbox-ui/src/main/java/ch/srgssr/pillarbox/ui/widget/ToggleableBox.kt deleted file mode 100644 index 3a56a1e4d..000000000 --- a/pillarbox-ui/src/main/java/ch/srgssr/pillarbox/ui/widget/ToggleableBox.kt +++ /dev/null @@ -1,160 +0,0 @@ -/* - * Copyright (c) SRG SSR. All rights reserved. - * License information is available from the LICENSE file. - */ -package ch.srgssr.pillarbox.ui.widget - -import androidx.compose.animation.AnimatedVisibility -import androidx.compose.animation.AnimatedVisibilityScope -import androidx.compose.animation.EnterTransition -import androidx.compose.animation.ExitTransition -import androidx.compose.animation.expandVertically -import androidx.compose.animation.shrinkVertically -import androidx.compose.foundation.background -import androidx.compose.foundation.clickable -import androidx.compose.foundation.layout.Arrangement -import androidx.compose.foundation.layout.Box -import androidx.compose.foundation.layout.BoxScope -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.text.BasicText -import androidx.compose.runtime.Composable -import androidx.compose.runtime.getValue -import androidx.compose.runtime.mutableStateOf -import androidx.compose.runtime.remember -import androidx.compose.runtime.rememberCoroutineScope -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.tooling.preview.Preview -import androidx.compose.ui.unit.dp -import kotlinx.coroutines.launch -import kotlin.time.Duration - -/** - * A Composable function that provides a container with toggleable content. - * - * @param visibilityState The [DelayedVisibilityState] managing the visibility of the toggleable content. - * @param toggleableContent The content to show or hidde based on [DelayedVisibilityState.isVisible]. - * @param modifier The [Modifier] to apply to the layout. - * @param toggleable Whether the content is toggleable or not. - * @param contentAlignment The alignment of the content within the layout. - * @param propagateMinConstraints Whether the incoming minimum constraints should be passed to the content. - * @param enter The `enter` transition to be used when [toggleableContent] becomes visible. - * @param exit The `exit` transition to be used when [toggleableContent] becomes hidden. - * @param content The content to be displayed underneath [toggleableContent]. - */ -@Composable -fun ToggleableBox( - visibilityState: DelayedVisibilityState, - toggleableContent: @Composable AnimatedVisibilityScope.() -> Unit, - modifier: Modifier = Modifier, - toggleable: Boolean = true, - contentAlignment: Alignment = Alignment.TopStart, - propagateMinConstraints: Boolean = false, - enter: EnterTransition = expandVertically { it }, - exit: ExitTransition = shrinkVertically { it }, - content: @Composable BoxScope.() -> Unit -) { - Box( - modifier = modifier - .toggleable( - enabled = toggleable, - delayedVisibilityState = visibilityState - ), - contentAlignment = contentAlignment, - propagateMinConstraints = propagateMinConstraints - ) { - content(this) - val animatedModifier = if (toggleable) { - Modifier - .matchParentSize() - .maintainVisibleOnFocus(delayedVisibilityState = visibilityState) - } else { - Modifier.matchParentSize() - } - AnimatedVisibility( - modifier = animatedModifier, - visible = visibilityState.isVisible, - enter = enter, - exit = exit, - content = toggleableContent, - ) - } -} - -@Preview -@Composable -private fun TogglePreview() { - var delay by remember { - mutableStateOf(DelayedVisibilityState.DefaultDuration) - } - var toggleable by remember { - mutableStateOf(true) - } - val visibilityState = rememberDelayedVisibilityState(duration = delay) - val coroutineScope = rememberCoroutineScope() - Column { - ToggleableBox( - visibilityState = visibilityState, - modifier = Modifier.aspectRatio(16 / 9f), - toggleable = toggleable, - toggleableContent = { - BasicText(text = "Text to hide", color = { Color.Red }) - } - ) { - Box( - modifier = Modifier - .fillMaxSize() - .background(color = Color.White) - ) - } - Row(horizontalArrangement = Arrangement.spacedBy(4.dp)) { - BasicText( - text = "Show", - modifier = Modifier.clickable { - visibilityState.show() - } - ) - BasicText( - text = "Toggle", - modifier = Modifier.clickable { - visibilityState.toggle() - } - ) - BasicText( - text = "Hide", - modifier = Modifier.clickable { - visibilityState.hide() - } - ) - BasicText( - text = "Disable", - modifier = Modifier.clickable { - delay = Duration.ZERO - } - ) - BasicText( - text = "Enable", - modifier = Modifier.clickable { - coroutineScope.launch { - delay = DelayedVisibilityState.DefaultDuration - } - } - ) - } - Row { - BasicText( - text = "Toggleable", - modifier = Modifier.clickable { - coroutineScope.launch { - toggleable = !toggleable - } - } - ) - } - } -}