From 1bd51d8a9ffac25ee25c374fb9237503ebae68bf Mon Sep 17 00:00:00 2001 From: Barnabas Balogh Date: Thu, 18 Jun 2026 20:05:32 +0000 Subject: [PATCH] refactor(player): move controls auto-hide from ViewModel to composable layer --- .../components/TvPlayerControlsOverlayTest.kt | 979 ------------------ .../ui/screen/player/TvPlayerScreen.kt | 283 +++-- .../player/components/TvNextEpisodeOverlay.kt | 12 - .../purefin/ui/screen/player/PlayerScreen.kt | 72 +- .../components/PlayerControlsOverlay.kt | 19 +- .../screen/player/components/PlayerSeekBar.kt | 4 - .../viewmodel/ControlsAutoHidePolicy.kt | 71 -- .../core/player/viewmodel/PlayerViewModel.kt | 56 +- .../viewmodel/ControlsAutoHidePolicyTest.kt | 90 -- 9 files changed, 181 insertions(+), 1405 deletions(-) delete mode 100644 app-tv/src/androidTest/java/hu/bbara/purefin/ui/screen/player/components/TvPlayerControlsOverlayTest.kt delete mode 100644 core/src/main/java/hu/bbara/purefin/core/player/viewmodel/ControlsAutoHidePolicy.kt delete mode 100644 core/src/test/java/hu/bbara/purefin/core/player/viewmodel/ControlsAutoHidePolicyTest.kt diff --git a/app-tv/src/androidTest/java/hu/bbara/purefin/ui/screen/player/components/TvPlayerControlsOverlayTest.kt b/app-tv/src/androidTest/java/hu/bbara/purefin/ui/screen/player/components/TvPlayerControlsOverlayTest.kt deleted file mode 100644 index bb2f8383..00000000 --- a/app-tv/src/androidTest/java/hu/bbara/purefin/ui/screen/player/components/TvPlayerControlsOverlayTest.kt +++ /dev/null @@ -1,979 +0,0 @@ -package hu.bbara.purefin.ui.screen.player.components - -import androidx.activity.ComponentActivity -import androidx.compose.animation.AnimatedVisibility -import androidx.compose.foundation.focusable -import androidx.compose.foundation.layout.Box -import androidx.compose.foundation.layout.fillMaxSize -import androidx.compose.foundation.layout.size -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.focus.FocusRequester -import androidx.compose.ui.focus.focusRequester -import androidx.compose.ui.input.key.Key -import androidx.compose.ui.input.key.onPreviewKeyEvent -import androidx.compose.ui.platform.testTag -import androidx.compose.ui.semantics.SemanticsActions -import androidx.compose.ui.semantics.SemanticsProperties -import androidx.compose.ui.semantics.semantics -import androidx.compose.ui.semantics.stateDescription -import androidx.compose.ui.test.ExperimentalTestApi -import androidx.compose.ui.test.SemanticsMatcher -import androidx.compose.ui.test.assert -import androidx.compose.ui.test.assertCountEquals -import androidx.compose.ui.test.assertIsDisplayed -import androidx.compose.ui.test.assertIsFocused -import androidx.compose.ui.test.assertIsNotEnabled -import androidx.compose.ui.test.junit4.createAndroidComposeRule -import androidx.compose.ui.test.onAllNodesWithContentDescription -import androidx.compose.ui.test.onAllNodesWithTag -import androidx.compose.ui.test.onNodeWithTag -import androidx.compose.ui.test.onNodeWithText -import androidx.compose.ui.test.performKeyInput -import androidx.compose.ui.test.performSemanticsAction -import androidx.compose.ui.test.pressKey -import androidx.compose.ui.unit.dp -import hu.bbara.purefin.core.player.model.PlayerUiState -import hu.bbara.purefin.core.player.model.PlaylistElementUiModel -import hu.bbara.purefin.core.player.model.TrackOption -import hu.bbara.purefin.core.player.model.TrackType -import hu.bbara.purefin.ui.screen.player.TV_HIDDEN_STOP_FEEDBACK_MS -import hu.bbara.purefin.ui.screen.player.TvPlayerHiddenStopFeedback -import hu.bbara.purefin.ui.screen.player.TvPlayerHiddenStopFeedbackTag -import hu.bbara.purefin.ui.screen.player.handleTvPlayerRootKeyEvent -import hu.bbara.purefin.ui.theme.AppTheme -import kotlinx.coroutines.delay -import org.junit.Rule -import org.junit.Test - -private const val TvPlayerRootTag = "tv_player_root" -private const val TvPlayerHiddenPauseCountTag = "tv_player_hidden_pause_count" -private const val TvPlayerHiddenResumeCountTag = "tv_player_hidden_resume_count" -private const val TvPlayerHiddenSeekDeltaTag = "tv_player_hidden_seek_delta" - -@OptIn(ExperimentalTestApi::class) -class TvPlayerControlsOverlayTest { - - @get:Rule - val composeRule = createAndroidComposeRule() - - @Test - fun requestingControlsFocus_focusesPlayPauseInsteadOfSeekBar() { - composeRule.setContent { - AppTheme { - OverlayHost( - uiState = samplePlayerState(), - requestFocus = true - ) - } - } - - composeRule.waitForIdle() - - composeRule.onNodeWithTag(TvPlayerPlayPauseButtonTag).assertIsFocused() - composeRule.onNodeWithTag(TvPlayerSeekBarTag) - .assert( - SemanticsMatcher.expectValue( - SemanticsProperties.Focused, - false - ) - ) - } - - @Test - fun downFromSeekBar_movesFocusToControlButtonsBeforePlaylist() { - composeRule.setContent { - AppTheme { - OverlayHost(uiState = samplePlayerState()) - } - } - - composeRule.onNodeWithTag(TvPlayerSeekBarTag) - .performSemanticsAction(SemanticsActions.RequestFocus) - .assertIsFocused() - .performKeyInput { - pressKey(Key.DirectionDown) - } - - composeRule.waitForIdle() - - composeRule.onNodeWithTag(TvPlayerPlaylistStateTag) - .assert( - SemanticsMatcher.expectValue( - SemanticsProperties.StateDescription, - "collapsed" - ) - ) - composeRule.onNodeWithTag(TvPlayerPlayPauseButtonTag).assertIsFocused() - } - - @Test - fun downFromControls_expandsPlaylistAndMovesFocusToCurrentQueueItem() { - composeRule.setContent { - AppTheme { - OverlayHost(uiState = samplePlayerState()) - } - } - - composeRule.onNodeWithTag(TvPlayerPlayPauseButtonTag) - .performSemanticsAction(SemanticsActions.RequestFocus) - .assertIsFocused() - .performKeyInput { - pressKey(Key.DirectionDown) - } - - composeRule.waitForIdle() - - composeRule.onNodeWithTag(TvPlayerPlaylistStateTag) - .assert( - SemanticsMatcher.expectValue( - SemanticsProperties.StateDescription, - "expanded" - ) - ) - composeRule.onNodeWithTag(TvPlayerPlaylistCurrentItemTag).assertIsFocused() - } - - @Test - fun upFromCurrentQueueItem_collapsesPlaylistAndRestoresFocusedControl() { - composeRule.setContent { - AppTheme { - OverlayHost(uiState = samplePlayerState()) - } - } - - composeRule.onNodeWithTag(TvPlayerPlayPauseButtonTag) - .performSemanticsAction(SemanticsActions.RequestFocus) - .performKeyInput { - pressKey(Key.DirectionDown) - } - - composeRule.waitForIdle() - - composeRule.onNodeWithTag(TvPlayerPlaylistCurrentItemTag) - .assertIsFocused() - .performKeyInput { - pressKey(Key.DirectionUp) - } - - composeRule.waitForIdle() - - composeRule.onNodeWithTag(TvPlayerPlaylistStateTag) - .assert( - SemanticsMatcher.expectValue( - SemanticsProperties.StateDescription, - "collapsed" - ) - ) - composeRule.onNodeWithTag(TvPlayerPlayPauseButtonTag).assertIsFocused() - } - - @Test - fun rightOnLastPlaylistItem_keepsFocusInPlaylist() { - composeRule.setContent { - AppTheme { - OverlayHost(uiState = samplePlayerState()) - } - } - - composeRule.onNodeWithTag(TvPlayerPlayPauseButtonTag) - .performSemanticsAction(SemanticsActions.RequestFocus) - .performKeyInput { - pressKey(Key.DirectionDown) - } - - composeRule.waitForIdle() - - composeRule.onNodeWithTag(TvPlayerPlaylistCurrentItemTag) - .assertIsFocused() - .performKeyInput { - pressKey(Key.DirectionRight) - } - - composeRule.waitForIdle() - - composeRule.onNodeWithTag(TvPlayerPlaylistLastItemTag) - .assertIsFocused() - .performKeyInput { - pressKey(Key.DirectionRight) - } - - composeRule.waitForIdle() - - composeRule.onNodeWithTag(TvPlayerPlaylistStateTag) - .assert( - SemanticsMatcher.expectValue( - SemanticsProperties.StateDescription, - "expanded" - ) - ) - composeRule.onNodeWithTag(TvPlayerPlaylistLastItemTag).assertIsFocused() - } - - @Test - fun playlistShowsQueueAsRowAndCurrentItemCanReceiveFocus() { - composeRule.setContent { - AppTheme { - OverlayHost( - uiState = samplePlayerState(), - initiallyExpanded = true - ) - } - } - - composeRule.waitForIdle() - - composeRule.onNodeWithTag(TvPlayerPlaylistRowTag).assertIsDisplayed() - composeRule.onNodeWithTag(TvPlayerPlaylistCurrentItemTag) - .performSemanticsAction(SemanticsActions.RequestFocus) - .assertIsFocused() - composeRule.onNodeWithText("Currently Playing").assertIsDisplayed() - composeRule.onNodeWithText("Episode 2").assertIsDisplayed() - composeRule.onNodeWithTag(TvPlayerPlaylistFirstItemTag).assertIsDisplayed() - composeRule.onNodeWithText("Current").assertIsDisplayed() - composeRule.onNodeWithText("Already Played").assertIsDisplayed() - } - - @Test - fun playlistFallsBackToFirstItemWhenNoCurrentItemExists_andFallbackItemCanReceiveFocus() { - composeRule.setContent { - AppTheme { - OverlayHost( - uiState = samplePlayerState().copy( - queue = listOf( - PlaylistElementUiModel( - id = "first", - title = "Episode 1", - subtitle = "Fallback entry", - artworkUrl = null, - isCurrent = false - ), - PlaylistElementUiModel( - id = "second", - title = "Episode 2", - subtitle = "Later", - artworkUrl = null, - isCurrent = false - ) - ) - ), - initiallyExpanded = true - ) - } - } - - composeRule.waitForIdle() - - composeRule.onNodeWithTag(TvPlayerPlaylistCurrentItemTag) - .performSemanticsAction(SemanticsActions.RequestFocus) - .assertIsFocused() - composeRule.onNodeWithText("Episode 1").assertIsDisplayed() - } - - @Test - fun openingTrackPanel_focusesSelectedTrackAndHasNoCloseButton() { - composeRule.setContent { - AppTheme { - TrackPanelHost(uiState = samplePlayerState()) - } - } - - composeRule.onNodeWithTag(TvPlayerAudioButtonTag) - .performSemanticsAction(SemanticsActions.RequestFocus) - .assertIsFocused() - .performKeyInput { - pressKey(Key.DirectionCenter) - } - - composeRule.waitForIdle() - - composeRule.onNodeWithTag(TvPlayerTrackPanelTag).assertIsDisplayed() - composeRule.onNodeWithTag(TvPlayerTrackSelectedItemTag).assertIsFocused() - composeRule.onAllNodesWithContentDescription("Close").assertCountEquals(0) - } - - @Test - fun openingTrackPanelWithoutSelection_focusesFirstTrack() { - composeRule.setContent { - AppTheme { - TrackPanelHost( - uiState = samplePlayerState().copy(selectedAudioTrackId = null) - ) - } - } - - composeRule.onNodeWithTag(TvPlayerAudioButtonTag) - .performSemanticsAction(SemanticsActions.RequestFocus) - .performKeyInput { - pressKey(Key.DirectionCenter) - } - - composeRule.waitForIdle() - - composeRule.onNodeWithTag(TvPlayerTrackFirstItemTag).assertIsFocused() - } - - @Test - fun backOnVisibleControls_hidesOverlayImmediately() { - composeRule.setContent { - AppTheme { - TrackPanelHost(uiState = samplePlayerState()) - } - } - - composeRule.onNodeWithTag(TvPlayerPlayPauseButtonTag) - .performSemanticsAction(SemanticsActions.RequestFocus) - .assertIsFocused() - .performKeyInput { - pressKey(Key.Back) - } - - composeRule.waitForIdle() - - composeRule.onAllNodesWithTag(TvPlayerPlayPauseButtonTag).assertCountEquals(0) - composeRule.onAllNodesWithTag(TvPlayerTrackPanelTag).assertCountEquals(0) - } - - @Test - fun escapeOnVisibleControls_hidesOverlayImmediately() { - composeRule.setContent { - AppTheme { - TrackPanelHost(uiState = samplePlayerState()) - } - } - - composeRule.onNodeWithTag(TvPlayerPlayPauseButtonTag) - .performSemanticsAction(SemanticsActions.RequestFocus) - .assertIsFocused() - .performKeyInput { - pressKey(Key.Escape) - } - - composeRule.waitForIdle() - - composeRule.onAllNodesWithTag(TvPlayerPlayPauseButtonTag).assertCountEquals(0) - composeRule.onAllNodesWithTag(TvPlayerTrackPanelTag).assertCountEquals(0) - } - - @Test - fun hiddenControls_centerWhilePlaying_pausesWithoutShowingControlsAndShowsStopFeedback() { - composeRule.setContent { - AppTheme { - TrackPanelHost( - uiState = samplePlayerState(), - initialControlsVisible = false - ) - } - } - - composeRule.onNodeWithTag(TvPlayerRootTag) - .performSemanticsAction(SemanticsActions.RequestFocus) - .performKeyInput { - pressKey(Key.DirectionCenter) - } - - composeRule.waitForIdle() - - composeRule.onAllNodesWithTag(TvPlayerPlayPauseButtonTag).assertCountEquals(0) - composeRule.onNodeWithTag(TvPlayerHiddenStopFeedbackTag).assertIsDisplayed() - composeRule.onNodeWithTag(TvPlayerHiddenPauseCountTag) - .assert( - SemanticsMatcher.expectValue( - SemanticsProperties.StateDescription, - "1" - ) - ) - } - - @Test - fun hiddenControls_enterWhilePlaying_pausesWithoutShowingControlsAndShowsStopFeedback() { - composeRule.setContent { - AppTheme { - TrackPanelHost( - uiState = samplePlayerState(), - initialControlsVisible = false - ) - } - } - - composeRule.onNodeWithTag(TvPlayerRootTag) - .performSemanticsAction(SemanticsActions.RequestFocus) - .performKeyInput { - pressKey(Key.Enter) - } - - composeRule.waitForIdle() - - composeRule.onAllNodesWithTag(TvPlayerPlayPauseButtonTag).assertCountEquals(0) - composeRule.onNodeWithTag(TvPlayerHiddenStopFeedbackTag).assertIsDisplayed() - composeRule.onNodeWithTag(TvPlayerHiddenPauseCountTag) - .assert( - SemanticsMatcher.expectValue( - SemanticsProperties.StateDescription, - "1" - ) - ) - } - - @Test - fun hiddenControls_stopFeedbackAutoHidesAfterTimeout() { - composeRule.mainClock.autoAdvance = false - - composeRule.setContent { - AppTheme { - TrackPanelHost( - uiState = samplePlayerState(), - initialControlsVisible = false - ) - } - } - - composeRule.onNodeWithTag(TvPlayerRootTag) - .performSemanticsAction(SemanticsActions.RequestFocus) - .performKeyInput { - pressKey(Key.DirectionCenter) - } - - composeRule.mainClock.advanceTimeByFrame() - composeRule.onNodeWithTag(TvPlayerHiddenStopFeedbackTag).assertIsDisplayed() - - composeRule.mainClock.advanceTimeBy(TV_HIDDEN_STOP_FEEDBACK_MS - 1) - composeRule.mainClock.advanceTimeByFrame() - composeRule.onNodeWithTag(TvPlayerHiddenStopFeedbackTag).assertIsDisplayed() - - composeRule.mainClock.advanceTimeBy(1) - composeRule.mainClock.advanceTimeBy(300) - composeRule.mainClock.advanceTimeByFrame() - composeRule.onAllNodesWithTag(TvPlayerHiddenStopFeedbackTag).assertCountEquals(0) - - composeRule.mainClock.autoAdvance = true - } - - @Test - fun hiddenControls_centerWhilePaused_resumesWithoutShowingControls() { - composeRule.setContent { - AppTheme { - TrackPanelHost( - uiState = samplePlayerState().copy(isPlaying = false), - initialControlsVisible = false - ) - } - } - - composeRule.onNodeWithTag(TvPlayerRootTag) - .performSemanticsAction(SemanticsActions.RequestFocus) - .performKeyInput { - pressKey(Key.DirectionCenter) - } - - composeRule.waitForIdle() - - composeRule.onAllNodesWithTag(TvPlayerPlayPauseButtonTag).assertCountEquals(0) - composeRule.onAllNodesWithTag(TvPlayerHiddenStopFeedbackTag).assertCountEquals(0) - composeRule.onNodeWithTag(TvPlayerHiddenPauseCountTag) - .assert( - SemanticsMatcher.expectValue( - SemanticsProperties.StateDescription, - "0" - ) - ) - composeRule.onNodeWithTag(TvPlayerHiddenResumeCountTag) - .assert( - SemanticsMatcher.expectValue( - SemanticsProperties.StateDescription, - "1" - ) - ) - } - - @Test - fun hiddenControls_leftSeeksWithoutShowingControls() { - composeRule.setContent { - AppTheme { - TrackPanelHost( - uiState = samplePlayerState(), - initialControlsVisible = false - ) - } - } - - composeRule.onNodeWithTag(TvPlayerRootTag) - .performSemanticsAction(SemanticsActions.RequestFocus) - .performKeyInput { - pressKey(Key.DirectionLeft) - } - - composeRule.waitForIdle() - - composeRule.onAllNodesWithTag(TvPlayerPlayPauseButtonTag).assertCountEquals(0) - composeRule.onAllNodesWithTag(TvPlayerHiddenStopFeedbackTag).assertCountEquals(0) - composeRule.onNodeWithTag(TvPlayerHiddenSeekDeltaTag) - .assert( - SemanticsMatcher.expectValue( - SemanticsProperties.StateDescription, - "-10000" - ) - ) - } - - @Test - fun hiddenControls_rightSeeksWithoutShowingControls() { - composeRule.setContent { - AppTheme { - TrackPanelHost( - uiState = samplePlayerState(), - initialControlsVisible = false - ) - } - } - - composeRule.onNodeWithTag(TvPlayerRootTag) - .performSemanticsAction(SemanticsActions.RequestFocus) - .performKeyInput { - pressKey(Key.DirectionRight) - } - - composeRule.waitForIdle() - - composeRule.onAllNodesWithTag(TvPlayerPlayPauseButtonTag).assertCountEquals(0) - composeRule.onAllNodesWithTag(TvPlayerHiddenStopFeedbackTag).assertCountEquals(0) - composeRule.onNodeWithTag(TvPlayerHiddenSeekDeltaTag) - .assert( - SemanticsMatcher.expectValue( - SemanticsProperties.StateDescription, - "10000" - ) - ) - } - - @Test - fun backOnTrackPanel_closesItAndRestoresFocusToOpeningButton() { - composeRule.setContent { - AppTheme { - TrackPanelHost(uiState = samplePlayerState()) - } - } - - composeRule.onNodeWithTag(TvPlayerAudioButtonTag) - .performSemanticsAction(SemanticsActions.RequestFocus) - .performKeyInput { - pressKey(Key.DirectionCenter) - } - - composeRule.waitForIdle() - - composeRule.onNodeWithTag(TvPlayerTrackSelectedItemTag) - .assertIsFocused() - .performKeyInput { - pressKey(Key.Back) - } - - composeRule.waitForIdle() - - composeRule.onAllNodesWithTag(TvPlayerTrackPanelTag).assertCountEquals(0) - composeRule.onNodeWithTag(TvPlayerAudioButtonTag).assertIsFocused() - } - - @Test - fun leftOnTrackPanel_keepsFocusInsidePanel() { - composeRule.setContent { - AppTheme { - TrackPanelHost(uiState = samplePlayerState()) - } - } - - composeRule.onNodeWithTag(TvPlayerAudioButtonTag) - .performSemanticsAction(SemanticsActions.RequestFocus) - .performKeyInput { - pressKey(Key.DirectionCenter) - } - - composeRule.waitForIdle() - - composeRule.onNodeWithTag(TvPlayerTrackSelectedItemTag) - .assertIsFocused() - .performKeyInput { - pressKey(Key.DirectionLeft) - } - - composeRule.waitForIdle() - - composeRule.onNodeWithTag(TvPlayerTrackPanelTag).assertIsDisplayed() - composeRule.onNodeWithTag(TvPlayerTrackSelectedItemTag).assertIsFocused() - } - - @Test - fun selectingTrack_closesPanelAndRestoresFocusToOpeningButton() { - composeRule.setContent { - AppTheme { - TrackPanelHost(uiState = samplePlayerState()) - } - } - - composeRule.onNodeWithTag(TvPlayerQualityButtonTag) - .performSemanticsAction(SemanticsActions.RequestFocus) - .performKeyInput { - pressKey(Key.DirectionCenter) - } - - composeRule.waitForIdle() - - composeRule.onNodeWithTag(TvPlayerTrackSelectedItemTag) - .assertIsFocused() - .performKeyInput { - pressKey(Key.DirectionCenter) - } - - composeRule.waitForIdle() - - composeRule.onAllNodesWithTag(TvPlayerTrackPanelTag).assertCountEquals(0) - composeRule.onNodeWithTag(TvPlayerQualityButtonTag).assertIsFocused() - } - - @Test - fun trackButtonsWithoutOptions_areDisabled() { - composeRule.setContent { - AppTheme { - TrackPanelHost( - uiState = samplePlayerState().copy( - audioTracks = emptyList(), - textTracks = emptyList(), - qualityTracks = emptyList(), - selectedAudioTrackId = null, - selectedTextTrackId = null, - selectedQualityTrackId = null - ) - ) - } - } - - composeRule.onNodeWithTag(TvPlayerAudioButtonTag).assertIsNotEnabled() - composeRule.onNodeWithTag(TvPlayerSubtitlesButtonTag).assertIsNotEnabled() - composeRule.onNodeWithTag(TvPlayerQualityButtonTag).assertIsNotEnabled() - } -} - -@Composable -private fun OverlayHost( - uiState: PlayerUiState, - initiallyExpanded: Boolean = false, - requestFocus: Boolean = false -) { - var isPlaylistExpanded by remember { mutableStateOf(initiallyExpanded) } - val focusRequester = remember { FocusRequester() } - val qualityButtonFocusRequester = remember { FocusRequester() } - val audioButtonFocusRequester = remember { FocusRequester() } - val subtitlesButtonFocusRequester = remember { FocusRequester() } - - LaunchedEffect(requestFocus) { - if (requestFocus) { - focusRequester.requestFocus() - } - } - - Box(modifier = Modifier.size(width = 960.dp, height = 540.dp)) { - TvPlayerControlsOverlay( - uiState = uiState, - focusRequester = focusRequester, - isPlaylistExpanded = isPlaylistExpanded, - qualityButtonFocusRequester = qualityButtonFocusRequester, - audioButtonFocusRequester = audioButtonFocusRequester, - subtitlesButtonFocusRequester = subtitlesButtonFocusRequester, - onPlayPause = {}, - onSeek = { _ -> }, - onSeekRelative = { _ -> }, - onSeekLiveEdge = {}, - onSkipSegment = {}, - onNext = {}, - onPrevious = {}, - onOpenAudioPanel = {}, - onOpenSubtitlesPanel = {}, - onOpenQualityPanel = {}, - onExpandPlaylist = { isPlaylistExpanded = true }, - onCollapsePlaylist = { isPlaylistExpanded = false }, - onSelectQueueItem = { _ -> }, - qualityButtonEnabled = uiState.qualityTracks.isNotEmpty(), - audioButtonEnabled = uiState.audioTracks.isNotEmpty(), - subtitlesButtonEnabled = uiState.textTracks.isNotEmpty() - ) - } -} - -@Composable -private fun TrackPanelHost( - uiState: PlayerUiState, - initialControlsVisible: Boolean = true -) { - var controlsVisible by remember { mutableStateOf(initialControlsVisible) } - var trackPanelType by remember { mutableStateOf(null) } - var pendingTrackButtonFocus by remember { mutableStateOf(null) } - var stopFeedbackVisible by remember { mutableStateOf(false) } - var stopFeedbackRequestId by remember { mutableStateOf(0) } - var pauseWithoutControlsCount by remember { mutableStateOf(0) } - var resumeWithoutControlsCount by remember { mutableStateOf(0) } - var lastHiddenSeekDeltaMs by remember { mutableStateOf(0L) } - val rootFocusRequester = remember { FocusRequester() } - val controlsFocusRequester = remember { FocusRequester() } - val qualityButtonFocusRequester = remember { FocusRequester() } - val audioButtonFocusRequester = remember { FocusRequester() } - val subtitlesButtonFocusRequester = remember { FocusRequester() } - val closeTrackPanel: () -> Unit = { - trackPanelType?.let { panelType -> - pendingTrackButtonFocus = panelType - trackPanelType = null - controlsVisible = true - } - } - val pausePlaybackWithoutShowingControls: () -> Unit = { - pauseWithoutControlsCount += 1 - stopFeedbackVisible = true - stopFeedbackRequestId += 1 - } - val resumePlaybackWithoutShowingControls: () -> Unit = { - resumeWithoutControlsCount += 1 - stopFeedbackVisible = false - } - val seekWithoutShowingControls: (Long) -> Unit = { deltaMs -> - lastHiddenSeekDeltaMs = deltaMs - stopFeedbackVisible = false - } - - LaunchedEffect(trackPanelType, pendingTrackButtonFocus) { - val pendingFocus = pendingTrackButtonFocus ?: return@LaunchedEffect - if (trackPanelType != null) return@LaunchedEffect - when (pendingFocus) { - TvTrackPanelType.AUDIO -> audioButtonFocusRequester.requestFocus() - TvTrackPanelType.SUBTITLES -> subtitlesButtonFocusRequester.requestFocus() - TvTrackPanelType.QUALITY -> qualityButtonFocusRequester.requestFocus() - } - pendingTrackButtonFocus = null - } - - LaunchedEffect(stopFeedbackRequestId) { - if (stopFeedbackRequestId == 0) return@LaunchedEffect - delay(TV_HIDDEN_STOP_FEEDBACK_MS) - stopFeedbackVisible = false - } - - LaunchedEffect(controlsVisible, trackPanelType) { - if (controlsVisible || trackPanelType != null) { - stopFeedbackVisible = false - } else { - rootFocusRequester.requestFocus() - } - } - - Box( - modifier = Modifier - .size(width = 960.dp, height = 540.dp) - .focusRequester(rootFocusRequester) - .focusable() - .testTag(TvPlayerRootTag) - .onPreviewKeyEvent { event -> - handleTvPlayerRootKeyEvent( - event = event, - isPlaying = uiState.isPlaying, - controlsVisible = controlsVisible, - isPlaylistExpanded = false, - trackPanelType = trackPanelType, - onCloseTrackPanel = closeTrackPanel, - onCollapsePlaylist = { controlsVisible = true }, - onHideControls = { controlsVisible = false }, - onPausePlaybackWithoutShowingControls = pausePlaybackWithoutShowingControls, - onResumePlaybackWithoutShowingControls = resumePlaybackWithoutShowingControls, - onSeekRelative = seekWithoutShowingControls, - onShowControls = { controlsVisible = true }, - onTogglePlayPause = { controlsVisible = true } - ) - } - ) { - if (controlsVisible || trackPanelType != null) { - TvPlayerControlsOverlay( - uiState = uiState, - focusRequester = controlsFocusRequester, - isPlaylistExpanded = false, - qualityButtonFocusRequester = qualityButtonFocusRequester, - audioButtonFocusRequester = audioButtonFocusRequester, - subtitlesButtonFocusRequester = subtitlesButtonFocusRequester, - onPlayPause = {}, - onSeek = { _ -> }, - onSeekRelative = { _ -> }, - onSeekLiveEdge = {}, - onSkipSegment = {}, - onNext = {}, - onPrevious = {}, - onOpenAudioPanel = { - controlsVisible = true - trackPanelType = TvTrackPanelType.AUDIO - }, - onOpenSubtitlesPanel = { - controlsVisible = true - trackPanelType = TvTrackPanelType.SUBTITLES - }, - onOpenQualityPanel = { - controlsVisible = true - trackPanelType = TvTrackPanelType.QUALITY - }, - onExpandPlaylist = {}, - onCollapsePlaylist = {}, - onSelectQueueItem = { _ -> }, - qualityButtonEnabled = uiState.qualityTracks.isNotEmpty(), - audioButtonEnabled = uiState.audioTracks.isNotEmpty(), - subtitlesButtonEnabled = uiState.textTracks.isNotEmpty() - ) - } - - AnimatedVisibility( - visible = stopFeedbackVisible, - modifier = Modifier.align(Alignment.Center) - ) { - TvPlayerHiddenStopFeedback() - } - - Box( - modifier = Modifier - .testTag(TvPlayerHiddenPauseCountTag) - .semantics { stateDescription = pauseWithoutControlsCount.toString() } - ) - - Box( - modifier = Modifier - .testTag(TvPlayerHiddenResumeCountTag) - .semantics { stateDescription = resumeWithoutControlsCount.toString() } - ) - - Box( - modifier = Modifier - .testTag(TvPlayerHiddenSeekDeltaTag) - .semantics { stateDescription = lastHiddenSeekDeltaMs.toString() } - ) - - trackPanelType?.let { panelType -> - TvTrackSelectionPanel( - panelType = panelType, - uiState = uiState, - onSelect = { closeTrackPanel() }, - modifier = Modifier.fillMaxSize() - ) - } - } -} - -private fun samplePlayerState(): PlayerUiState = PlayerUiState( - isPlaying = true, - title = "Sample Player", - subtitle = "Season 1", - durationMs = 3_600_000, - positionMs = 120_000, - bufferedMs = 240_000, - queue = listOf( - PlaylistElementUiModel( - id = "played", - title = "Already Played", - subtitle = "Episode 0", - artworkUrl = null, - isCurrent = false - ), - PlaylistElementUiModel( - id = "current", - title = "Currently Playing", - subtitle = "Episode 1", - artworkUrl = null, - isCurrent = true - ), - PlaylistElementUiModel( - id = "next", - title = "Episode 2", - subtitle = "Up next", - artworkUrl = null, - isCurrent = false - ) - ), - audioTracks = listOf( - TrackOption( - id = "audio_en", - label = "English 5.1", - language = "en", - bitrate = null, - channelCount = 6, - height = null, - groupIndex = 0, - trackIndex = 0, - type = TrackType.AUDIO, - isOff = false - ), - TrackOption( - id = "audio_hu", - label = "Hungarian Stereo", - language = "hu", - bitrate = null, - channelCount = 2, - height = null, - groupIndex = 0, - trackIndex = 1, - type = TrackType.AUDIO, - isOff = false - ) - ), - textTracks = listOf( - TrackOption( - id = "text_off", - label = "Off", - language = null, - bitrate = null, - channelCount = null, - height = null, - groupIndex = 1, - trackIndex = -1, - type = TrackType.TEXT, - isOff = true - ), - TrackOption( - id = "text_en", - label = "English CC", - language = "en", - bitrate = null, - channelCount = null, - height = null, - groupIndex = 1, - trackIndex = 0, - type = TrackType.TEXT, - isOff = false - ) - ), - qualityTracks = listOf( - TrackOption( - id = "quality_auto", - label = "Auto", - language = null, - bitrate = null, - channelCount = null, - height = 1080, - groupIndex = 2, - trackIndex = 0, - type = TrackType.VIDEO, - isOff = false - ), - TrackOption( - id = "quality_720", - label = "720p", - language = null, - bitrate = null, - channelCount = null, - height = 720, - groupIndex = 2, - trackIndex = 1, - type = TrackType.VIDEO, - isOff = false - ) - ), - selectedAudioTrackId = "audio_hu", - selectedTextTrackId = "text_en", - selectedQualityTrackId = "quality_auto" -) diff --git a/app-tv/src/main/java/hu/bbara/purefin/ui/screen/player/TvPlayerScreen.kt b/app-tv/src/main/java/hu/bbara/purefin/ui/screen/player/TvPlayerScreen.kt index 431b100a..3d89f009 100644 --- a/app-tv/src/main/java/hu/bbara/purefin/ui/screen/player/TvPlayerScreen.kt +++ b/app-tv/src/main/java/hu/bbara/purefin/ui/screen/player/TvPlayerScreen.kt @@ -30,9 +30,10 @@ import androidx.compose.runtime.Composable import androidx.compose.runtime.DisposableEffect import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue -import androidx.lifecycle.compose.collectAsStateWithLifecycle +import androidx.compose.runtime.mutableIntStateOf 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 @@ -41,34 +42,36 @@ import androidx.compose.ui.focus.FocusRequester import androidx.compose.ui.focus.focusRequester import androidx.compose.ui.graphics.Color import androidx.compose.ui.input.key.Key +import androidx.compose.ui.input.key.KeyEvent 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.platform.LocalFocusManager import androidx.compose.ui.platform.testTag import androidx.compose.ui.unit.dp import androidx.compose.ui.viewinterop.AndroidView import androidx.hilt.navigation.compose.hiltViewModel import androidx.lifecycle.Lifecycle import androidx.lifecycle.compose.LifecycleEventEffect +import androidx.lifecycle.compose.collectAsStateWithLifecycle import androidx.media3.common.util.UnstableApi import androidx.media3.ui.AspectRatioFrameLayout import androidx.media3.ui.PlayerView import androidx.media3.ui.SubtitleView import hu.bbara.purefin.core.player.model.TimedMarker -import hu.bbara.purefin.core.player.viewmodel.ControlsAutoHideBlocker import hu.bbara.purefin.core.player.viewmodel.PlayerViewModel import hu.bbara.purefin.ui.screen.player.components.PlayerSeekBarTrack import hu.bbara.purefin.ui.screen.player.components.TvIconButton import hu.bbara.purefin.ui.screen.player.components.TvNextEpisodeOverlay -import hu.bbara.purefin.ui.screen.player.components.TvPlayerTimeRow import hu.bbara.purefin.ui.screen.player.components.TvPlayerControlsOverlay import hu.bbara.purefin.ui.screen.player.components.TvPlayerLoadingErrorEndCard +import hu.bbara.purefin.ui.screen.player.components.TvPlayerTimeRow import hu.bbara.purefin.ui.screen.player.components.TvTrackPanelType import hu.bbara.purefin.ui.screen.player.components.TvTrackSelectionPanel +import kotlinx.coroutines.Job import kotlinx.coroutines.delay +import kotlinx.coroutines.launch private const val TV_CONTROLS_AUTO_HIDE_MS = 5_000L private const val CONTROLS_VISIBLE_SUBTITLE_BOTTOM_PADDING_FRACTION = 0.22f @@ -86,21 +89,28 @@ fun TvPlayerScreen( viewModel.loadMedia(mediaId) } + val scope = rememberCoroutineScope() + var hideControlsJob: Job? by remember { mutableStateOf(null) } + val uiState by viewModel.uiState.collectAsStateWithLifecycle() - val controlsVisible by viewModel.controlsVisible.collectAsStateWithLifecycle() + var controlsVisible by remember { mutableStateOf(true) } var isPlaylistExpanded by remember { mutableStateOf(false) } var trackPanelType by remember { mutableStateOf(null) } + // TODO why this is needed??? var pendingTrackButtonFocus by remember { mutableStateOf(null) } var stopFeedbackVisible by remember { mutableStateOf(false) } - var stopFeedbackRequestId by remember { mutableStateOf(0) } + var stopFeedbackRequestId by remember { mutableIntStateOf(0) } var hiddenSeekPreviewPositionMs by remember { mutableStateOf(null) } - var hiddenSeekRequestId by remember { mutableStateOf(0) } - val controlsAutoHideBlocked = isPlaylistExpanded || trackPanelType != null + var hiddenSeekRequestId by remember { mutableIntStateOf(0) } val context = LocalContext.current - val focusManager = LocalFocusManager.current - val rootFocusRequester = remember { FocusRequester() } - val controlsFocusRequester = remember { FocusRequester() } + + val backgroundFocusRequester = remember { FocusRequester() } + + // Main section focus requesters + var rootFocusRequester by remember { mutableStateOf(backgroundFocusRequester) } + var controlsFocusRequester by remember { mutableStateOf(FocusRequester() ) } + val qualityButtonFocusRequester = remember { FocusRequester() } val audioButtonFocusRequester = remember { FocusRequester() } val subtitlesButtonFocusRequester = remember { FocusRequester() } @@ -110,7 +120,11 @@ fun TvPlayerScreen( LifecycleEventEffect(Lifecycle.Event.ON_STOP) { viewModel.pausePlayback() } - + DisposableEffect(Unit) { + onDispose { + (context as? Activity)?.window?.clearFlags(WindowManager.LayoutParams.FLAG_KEEP_SCREEN_ON) + } + } LaunchedEffect(uiState.isPlaying) { val activity = context as? Activity if (uiState.isPlaying) { @@ -119,119 +133,74 @@ fun TvPlayerScreen( activity?.window?.clearFlags(WindowManager.LayoutParams.FLAG_KEEP_SCREEN_ON) } } - DisposableEffect(Unit) { - onDispose { - (context as? Activity)?.window?.clearFlags(WindowManager.LayoutParams.FLAG_KEEP_SCREEN_ON) - viewModel.setControlsAutoHideBlocked(ControlsAutoHideBlocker.PLAYLIST, false) - viewModel.setControlsAutoHideBlocked(ControlsAutoHideBlocker.TRACK_PANEL, false) + + fun showControls() { + controlsVisible = true + } + fun hideControls() { + controlsVisible = false + } + fun hideControlsWithTimeout() { + hideControlsJob?.cancel() + if (controlsVisible && !isPlaylistExpanded && trackPanelType == null && !uiState.isEnded && uiState.error == null) { + hideControlsJob = scope.launch { + delay(TV_CONTROLS_AUTO_HIDE_MS) + hideControls() + } + } + } + // Needed because of composition scheduling + LaunchedEffect(controlsVisible) { + if (controlsVisible) { + controlsFocusRequester.requestFocus() + } else { + rootFocusRequester.requestFocus() } } - LaunchedEffect(isPlaylistExpanded) { - viewModel.setControlsAutoHideBlocked(ControlsAutoHideBlocker.PLAYLIST, isPlaylistExpanded) + fun expandPlaylist() { + // TODO: focus management for playlist expansion + isPlaylistExpanded = true } - - LaunchedEffect(trackPanelType) { - viewModel.setControlsAutoHideBlocked( - ControlsAutoHideBlocker.TRACK_PANEL, - trackPanelType != null - ) - } - - val expandPlaylist: () -> Unit = { - if (!isPlaylistExpanded) { - isPlaylistExpanded = true - } - } - val collapsePlaylistToControls: () -> Unit = { - if (isPlaylistExpanded) { - isPlaylistExpanded = false - viewModel.showControls(TV_CONTROLS_AUTO_HIDE_MS) - } - } - val showTvControls: () -> Unit = { - viewModel.showControls(TV_CONTROLS_AUTO_HIDE_MS) - } - val togglePlayPauseAndShowControls: () -> Unit = { - viewModel.togglePlayPause(TV_CONTROLS_AUTO_HIDE_MS) - } - val pausePlaybackWithoutShowingControls: () -> Unit = { - viewModel.pausePlayback() - stopFeedbackVisible = true - stopFeedbackRequestId += 1 - } - val resumePlaybackWithoutShowingControls: () -> Unit = { - viewModel.resumePlayback() - stopFeedbackVisible = false - } - val seekAndShowControls: (Long) -> Unit = { positionMs -> - viewModel.seekTo(positionMs) - showTvControls() - } - val seekByAndShowControls: (Long) -> Unit = { deltaMs -> - viewModel.seekBy(deltaMs) - showTvControls() - } - val seekByWithoutShowingControls: (Long) -> Unit = { deltaMs -> - val basePositionMs = hiddenSeekPreviewPositionMs ?: uiState.positionMs - hiddenSeekPreviewPositionMs = (basePositionMs + deltaMs).coerceSeekPosition(uiState.durationMs) - hiddenSeekRequestId += 1 - viewModel.seekBy(deltaMs) - stopFeedbackVisible = false - } - val seekToLiveEdgeAndShowControls: () -> Unit = { - viewModel.seekToLiveEdge() - showTvControls() - } - val skipSegmentAndShowControls: () -> Unit = { - viewModel.skipActiveSegment() - showTvControls() - } - val nextAndShowControls: () -> Unit = { - viewModel.next(TV_CONTROLS_AUTO_HIDE_MS) - } - val previousAndShowControls: () -> Unit = { - viewModel.previous(TV_CONTROLS_AUTO_HIDE_MS) + fun closePlaylist() { + // TODO focus + isPlaylistExpanded = false } val closeTrackPanel: () -> Unit = { trackPanelType?.let { panelType -> pendingTrackButtonFocus = panelType trackPanelType = null - viewModel.showControls(TV_CONTROLS_AUTO_HIDE_MS) } } - val playerControlsVisible = - controlsVisible || isPlaylistExpanded || trackPanelType != null || uiState.isEnded || uiState.error != null - val showNextEpisodeOverlay = !playerControlsVisible + val showSkipIntroButton = !controlsVisible + && uiState.activeSkippableSegmentEndMs != null + && !uiState.isEnded + val showNextEpisodeOverlay = !controlsVisible && uiState.nextEpisode != null && uiState.durationMs > 0L && (uiState.durationMs - uiState.positionMs) <= 60_000L && !uiState.isEnded - - LaunchedEffect( - controlsVisible, - controlsAutoHideBlocked, - uiState.activeSkippableSegmentEndMs, - showNextEpisodeOverlay - ) { - if (controlsAutoHideBlocked) return@LaunchedEffect - if (controlsVisible) { - controlsFocusRequester.requestFocus() - } else { - if (uiState.activeSkippableSegmentEndMs != null) { - skipButtonFocusRequester.requestFocus() - return@LaunchedEffect + val showHiddenSeekPreview = !controlsVisible && hiddenSeekPreviewPositionMs != null + LaunchedEffect(showSkipIntroButton, showNextEpisodeOverlay) { + rootFocusRequester = when { + showSkipIntroButton -> { + skipButtonFocusRequester } - if (showNextEpisodeOverlay) { - nextEpisodeFocusRequester.requestFocus() - return@LaunchedEffect + showNextEpisodeOverlay -> { + nextEpisodeFocusRequester } - focusManager.clearFocus() + else -> { + backgroundFocusRequester + } + } + if (!controlsVisible) { + rootFocusRequester.requestFocus() } } + LaunchedEffect(trackPanelType, pendingTrackButtonFocus) { val pendingFocus = pendingTrackButtonFocus ?: return@LaunchedEffect if (trackPanelType != null) return@LaunchedEffect @@ -243,6 +212,7 @@ fun TvPlayerScreen( pendingTrackButtonFocus = null } + // TODO: use animated visibility for this type of feedback that is already implemented LaunchedEffect(stopFeedbackRequestId) { if (stopFeedbackRequestId == 0) return@LaunchedEffect delay(TV_HIDDEN_STOP_FEEDBACK_MS) @@ -263,7 +233,7 @@ fun TvPlayerScreen( } val subtitleBottomPaddingFraction = - if (playerControlsVisible) { + if (controlsVisible) { CONTROLS_VISIBLE_SUBTITLE_BOTTOM_PADDING_FRACTION } else { SubtitleView.DEFAULT_BOTTOM_PADDING_FRACTION @@ -272,8 +242,12 @@ fun TvPlayerScreen( BackHandler(enabled = true) { when { trackPanelType != null -> closeTrackPanel() - isPlaylistExpanded -> collapsePlaylistToControls() - controlsVisible -> viewModel.toggleControlsVisibility() + isPlaylistExpanded -> { + closePlaylist() + } + controlsVisible -> { + hideControls() + } else -> onBack() } } @@ -284,23 +258,28 @@ fun TvPlayerScreen( .background(Color.Black) .focusRequester(rootFocusRequester) .onPreviewKeyEvent { event -> - handleTvPlayerRootKeyEvent( + val handled = handleTvPlayerRootKeyEvent( event = event, isPlaying = uiState.isPlaying, controlsVisible = controlsVisible, + onShowControls = ::showControls, isPlaylistExpanded = isPlaylistExpanded, trackPanelType = trackPanelType, onCloseTrackPanel = closeTrackPanel, - onCollapsePlaylist = collapsePlaylistToControls, - onHideControls = { viewModel.toggleControlsVisibility() }, - onPausePlaybackWithoutShowingControls = pausePlaybackWithoutShowingControls, - onResumePlaybackWithoutShowingControls = resumePlaybackWithoutShowingControls, - onSeekRelative = seekByWithoutShowingControls, - onShowControls = showTvControls, - onSkipSegment = skipSegmentAndShowControls, - hasSkippableSegment = uiState.activeSkippableSegmentEndMs != null, - onTogglePlayPause = togglePlayPauseAndShowControls + onCollapsePlaylist = {}, + onHideControls = ::hideControls, + onPausePlayback = { viewModel.pausePlayback() }, + onResumePlayback = { viewModel.resumePlayback() }, + onSeekRelative = { viewModel.seekBy(it) }, + onSkipSegment = { + viewModel.skipActiveSegment() + }, + hasSkippableSegment = uiState.activeSkippableSegmentEndMs != null ) + if (event.type == KeyEventType.KeyDown) { + hideControlsWithTimeout() + } + handled } .focusable() ) { @@ -323,7 +302,7 @@ fun TvPlayerScreen( ) AnimatedVisibility( - visible = playerControlsVisible, + visible = controlsVisible, enter = fadeIn(), exit = fadeOut() ) { @@ -335,21 +314,28 @@ fun TvPlayerScreen( qualityButtonFocusRequester = qualityButtonFocusRequester, audioButtonFocusRequester = audioButtonFocusRequester, subtitlesButtonFocusRequester = subtitlesButtonFocusRequester, - onPlayPause = togglePlayPauseAndShowControls, - onSeek = seekAndShowControls, - onSeekRelative = seekByAndShowControls, - onSeekLiveEdge = seekToLiveEdgeAndShowControls, - onSkipSegment = skipSegmentAndShowControls, - onNext = nextAndShowControls, - onPrevious = previousAndShowControls, + onPlayPause = { viewModel.togglePlayPause() }, + onSeek = { positionMs -> + viewModel.seekTo(positionMs) + }, + onSeekRelative = { deltaMs -> + viewModel.seekBy(deltaMs) + }, + onSeekLiveEdge = { + viewModel.seekToLiveEdge() + }, + onSkipSegment = { + viewModel.skipActiveSegment() + }, + onNext = { viewModel.next() }, + onPrevious = { viewModel.previous() }, onOpenAudioPanel = { trackPanelType = TvTrackPanelType.AUDIO }, onOpenSubtitlesPanel = { trackPanelType = TvTrackPanelType.SUBTITLES }, onOpenQualityPanel = { trackPanelType = TvTrackPanelType.QUALITY }, - onExpandPlaylist = expandPlaylist, - onCollapsePlaylist = collapsePlaylistToControls, + onExpandPlaylist = ::expandPlaylist, + onCollapsePlaylist = ::closePlaylist, onSelectQueueItem = { id -> viewModel.playQueueItem(id) - collapsePlaylistToControls() }, qualityButtonEnabled = uiState.qualityTracks.isNotEmpty(), audioButtonEnabled = uiState.audioTracks.isNotEmpty(), @@ -358,9 +344,7 @@ fun TvPlayerScreen( } AnimatedVisibility( - visible = !playerControlsVisible && uiState.activeSkippableSegmentEndMs != null, - enter = fadeIn(), - exit = fadeOut(), + visible = showSkipIntroButton, modifier = Modifier .align(Alignment.BottomEnd) .padding( @@ -371,7 +355,7 @@ fun TvPlayerScreen( TvIconButton( icon = Icons.Outlined.SkipNext, contentDescription = "Skip segment", - onClick = skipSegmentAndShowControls, + onClick = { viewModel.skipActiveSegment() }, size = 64, label = "Skip", modifier = Modifier.focusRequester(skipButtonFocusRequester) @@ -380,8 +364,6 @@ fun TvPlayerScreen( AnimatedVisibility( visible = showNextEpisodeOverlay, - enter = fadeIn(), - exit = fadeOut(), modifier = Modifier .align(Alignment.BottomEnd) .padding( @@ -392,17 +374,15 @@ fun TvPlayerScreen( uiState.nextEpisode?.let { nextEpisode -> TvNextEpisodeOverlay( nextEpisode = nextEpisode, - onClick = { nextAndShowControls() }, - onUp = { showTvControls() }, + onClick = { viewModel.next() }, modifier = Modifier.focusRequester(nextEpisodeFocusRequester) ) } } + // TODO: use TimedVisibility AnimatedVisibility( - visible = !playerControlsVisible && hiddenSeekPreviewPositionMs != null, - enter = fadeIn(), - exit = fadeOut(), + visible = showHiddenSeekPreview, modifier = Modifier .align(Alignment.BottomCenter) .padding(horizontal = 32.dp, vertical = 28.dp) @@ -420,18 +400,17 @@ fun TvPlayerScreen( modifier = Modifier.align(Alignment.Center), uiState = uiState, onRetry = { viewModel.retry() }, - onNext = nextAndShowControls, + onNext = { viewModel.next() }, onReplay = { viewModel.seekTo(0L) - togglePlayPauseAndShowControls() + viewModel.resumePlayback() }, onDismissError = { viewModel.clearError() } ) + // TODO: use TimedVisibility AnimatedVisibility( visible = stopFeedbackVisible, - enter = fadeIn(), - exit = fadeOut(), modifier = Modifier.align(Alignment.Center) ) { TvPlayerHiddenStopFeedback() @@ -498,15 +477,8 @@ private fun HiddenTvSeekTimeline( } } -private fun Long.coerceSeekPosition(durationMs: Long): Long = - if (durationMs > 0L) { - coerceIn(0L, durationMs) - } else { - coerceAtLeast(0L) - } - internal fun handleTvPlayerRootKeyEvent( - event: androidx.compose.ui.input.key.KeyEvent, + event: KeyEvent, isPlaying: Boolean, controlsVisible: Boolean, isPlaylistExpanded: Boolean, @@ -514,13 +486,12 @@ internal fun handleTvPlayerRootKeyEvent( onCloseTrackPanel: () -> Unit, onCollapsePlaylist: () -> Unit, onHideControls: () -> Unit, - onPausePlaybackWithoutShowingControls: () -> Unit, - onResumePlaybackWithoutShowingControls: () -> Unit, + onPausePlayback: () -> Unit, + onResumePlayback: () -> Unit, onSeekRelative: (Long) -> Unit, onShowControls: () -> Unit, onSkipSegment: () -> Unit = {}, - hasSkippableSegment: Boolean = false, - onTogglePlayPause: () -> Unit + hasSkippableSegment: Boolean = false ): Boolean { if (event.type != KeyEventType.KeyDown) return false @@ -566,9 +537,9 @@ internal fun handleTvPlayerRootKeyEvent( if (hasSkippableSegment) { onSkipSegment() } else if (isPlaying) { - onPausePlaybackWithoutShowingControls() + onPausePlayback() } else { - onResumePlaybackWithoutShowingControls() + onResumePlayback() } true } diff --git a/app-tv/src/main/java/hu/bbara/purefin/ui/screen/player/components/TvNextEpisodeOverlay.kt b/app-tv/src/main/java/hu/bbara/purefin/ui/screen/player/components/TvNextEpisodeOverlay.kt index 2bef4e3e..94afdc23 100644 --- a/app-tv/src/main/java/hu/bbara/purefin/ui/screen/player/components/TvNextEpisodeOverlay.kt +++ b/app-tv/src/main/java/hu/bbara/purefin/ui/screen/player/components/TvNextEpisodeOverlay.kt @@ -29,11 +29,6 @@ import androidx.compose.ui.focus.onFocusChanged import androidx.compose.ui.graphics.Brush import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.graphicsLayer -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.layout.ContentScale import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.style.TextOverflow @@ -50,7 +45,6 @@ import hu.bbara.purefin.core.player.model.PlaylistElementUiModel fun TvNextEpisodeOverlay( nextEpisode: PlaylistElementUiModel, onClick: () -> Unit, - onUp: () -> Unit, modifier: Modifier = Modifier ) { val scheme = MaterialTheme.colorScheme @@ -88,12 +82,6 @@ fun TvNextEpisodeOverlay( .background(backgroundColor) .focusable() .onFocusChanged { isFocused = it.isFocused } - .onPreviewKeyEvent { event -> - if (event.type == KeyEventType.KeyDown && event.key == Key.DirectionUp) { - onUp() - true - } else false - } .clickable { onClick() }, verticalArrangement = Arrangement.spacedBy(0.dp) ) { diff --git a/app/src/main/java/hu/bbara/purefin/ui/screen/player/PlayerScreen.kt b/app/src/main/java/hu/bbara/purefin/ui/screen/player/PlayerScreen.kt index 1318220a..db978a39 100644 --- a/app/src/main/java/hu/bbara/purefin/ui/screen/player/PlayerScreen.kt +++ b/app/src/main/java/hu/bbara/purefin/ui/screen/player/PlayerScreen.kt @@ -24,10 +24,10 @@ import androidx.compose.material.icons.outlined.VolumeUp import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Text 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.rememberCoroutineScope import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier @@ -45,23 +45,27 @@ import hu.bbara.purefin.core.player.model.TimedMarker import hu.bbara.purefin.core.player.viewmodel.PlayerViewModel import hu.bbara.purefin.ui.common.visual.EmptyValueTimedVisibility import hu.bbara.purefin.ui.common.visual.ValueChangeTimedVisibility +import hu.bbara.purefin.ui.screen.player.components.NextEpisodeOverlay import hu.bbara.purefin.ui.screen.player.components.PersistentOverlayContainer import hu.bbara.purefin.ui.screen.player.components.PlayerAdjustmentIndicator import hu.bbara.purefin.ui.screen.player.components.PlayerControlsOverlay import hu.bbara.purefin.ui.screen.player.components.PlayerGesturesLayer import hu.bbara.purefin.ui.screen.player.components.PlayerLoadingErrorEndCard import hu.bbara.purefin.ui.screen.player.components.PlayerQueuePanel -import hu.bbara.purefin.ui.screen.player.components.NextEpisodeOverlay import hu.bbara.purefin.ui.screen.player.components.PlayerSeekBarTrack import hu.bbara.purefin.ui.screen.player.components.PlayerTimeRow import hu.bbara.purefin.ui.screen.player.components.SkipSegmentButton import hu.bbara.purefin.ui.screen.player.components.rememberPersistentOverlayController +import kotlinx.coroutines.Job +import kotlinx.coroutines.delay +import kotlinx.coroutines.launch import kotlin.math.abs import kotlin.math.roundToInt private const val CONTROLS_VISIBLE_SUBTITLE_BOTTOM_PADDING_FRACTION = 0.32f /** Small negative band below zero that represents adaptive/auto brightness mode. */ private const val AUTO_BRIGHTNESS_THRESHOLD = -0.15f +private const val CONTROLS_AUTO_HIDE_MS = 3_000L @OptIn(UnstableApi::class) @Composable @@ -69,8 +73,13 @@ fun PlayerScreen( viewModel: PlayerViewModel, onBack: () -> Unit ) { + val scope = rememberCoroutineScope() + var hideControlsJob: Job? by remember { mutableStateOf(null) } + val uiState by viewModel.uiState.collectAsStateWithLifecycle() - val controlsVisible by viewModel.controlsVisible.collectAsStateWithLifecycle() + var controlsVisible by remember { mutableStateOf(true) } + var controlsHasPopupOpen by remember { mutableStateOf(false) } + val context = LocalContext.current val activity = context as? Activity @@ -83,20 +92,43 @@ fun PlayerScreen( var horizontalSeekPreviewPositionMs by remember { mutableStateOf(null) } val overlayController = rememberPersistentOverlayController() - LaunchedEffect(uiState.isPlaying) { - if (uiState.isPlaying) { - showQueuePanel = false - } - } - - val playerControlsVisible = controlsVisible || uiState.isEnded || uiState.error != null val subtitleBottomPaddingFraction = - if (playerControlsVisible) { + if (controlsVisible) { CONTROLS_VISIBLE_SUBTITLE_BOTTOM_PADDING_FRACTION } else { SubtitleView.DEFAULT_BOTTOM_PADDING_FRACTION } + + val showSkipIntroButton = !controlsVisible && uiState.activeSkippableSegmentEndMs != null + val showNextEpisodeOverlay = !controlsVisible + && uiState.nextEpisode != null + && uiState.durationMs > 0L + && (uiState.durationMs - uiState.positionMs) <= 60_000L + && !uiState.isEnded + + + fun toggleControlsVisibility() { + controlsVisible = !controlsVisible + } + fun hideControls() { + controlsVisible = false + } + fun hideControlsWithTimeout() { + hideControlsJob?.cancel() + if (controlsVisible && !controlsHasPopupOpen && !uiState.isEnded && uiState.error == null) { + hideControlsJob = scope.launch { + delay(CONTROLS_AUTO_HIDE_MS) + hideControls() + } + } + } + fun onScreenTap() { + toggleControlsVisibility() + hideControlsWithTimeout() + } + + Box( modifier = Modifier .fillMaxSize() @@ -121,7 +153,7 @@ fun PlayerScreen( PlayerGesturesLayer( modifier = Modifier.align(Alignment.BottomCenter), - onTap = { viewModel.toggleControlsVisibility() }, + onTap = ::onScreenTap, onDoubleTapRight = { viewModel.seekBy(30_000) }, onDoubleTapLeft = { viewModel.seekBy(-10_000) }, onDoubleTapCenter = { viewModel.togglePlayPause() }, @@ -152,7 +184,7 @@ fun PlayerScreen( onHorizontalDragSeekTo = { viewModel.seekTo(it) }, - currentPositionProvider = { uiState.positionMs } + currentPositionProvider = { uiState.positionMs }, ) EmptyValueTimedVisibility( @@ -170,7 +202,7 @@ fun PlayerScreen( value = horizontalSeekPreviewPositionMs, hideAfterMillis = 1_000 ) { previewPositionMs -> - if (!playerControlsVisible) { + if (controlsVisible) { HiddenSeekTimeline( positionMs = previewPositionMs, durationMs = uiState.durationMs, @@ -225,14 +257,13 @@ fun PlayerScreen( } AnimatedVisibility( - visible = playerControlsVisible, + visible = controlsVisible, enter = fadeIn(), exit = fadeOut() ) { PlayerControlsOverlay( modifier = Modifier.fillMaxSize(), uiState = uiState, - showControls = controlsVisible, overlayController = overlayController, onBack = onBack, onPlayPause = { viewModel.togglePlayPause() }, @@ -243,13 +274,12 @@ fun PlayerScreen( onNext = { viewModel.next() }, onPrevious = { viewModel.previous() }, onSelectTrack = { viewModel.selectTrack(it) }, - onQueueSelected = { viewModel.playQueueItem(it) }, onOpenQueue = { showQueuePanel = true } ) } AnimatedVisibility( - visible = !playerControlsVisible && uiState.activeSkippableSegmentEndMs != null, + visible = showSkipIntroButton, enter = fadeIn(), exit = fadeOut(), modifier = Modifier @@ -262,11 +292,7 @@ fun PlayerScreen( SkipSegmentButton(onClick = { viewModel.skipActiveSegment() }) } - val showNextEpisodeOverlay = !playerControlsVisible - && uiState.nextEpisode != null - && uiState.durationMs > 0L - && (uiState.durationMs - uiState.positionMs) <= 60_000L - && !uiState.isEnded + AnimatedVisibility( visible = showNextEpisodeOverlay, enter = fadeIn(), diff --git a/app/src/main/java/hu/bbara/purefin/ui/screen/player/components/PlayerControlsOverlay.kt b/app/src/main/java/hu/bbara/purefin/ui/screen/player/components/PlayerControlsOverlay.kt index 927f5f08..e2bdcd60 100644 --- a/app/src/main/java/hu/bbara/purefin/ui/screen/player/components/PlayerControlsOverlay.kt +++ b/app/src/main/java/hu/bbara/purefin/ui/screen/player/components/PlayerControlsOverlay.kt @@ -32,10 +32,6 @@ import androidx.compose.material3.Icon import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Text import androidx.compose.runtime.Composable -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.Brush @@ -51,7 +47,6 @@ import hu.bbara.purefin.ui.common.button.PurefinIconButton @Composable fun PlayerControlsOverlay( uiState: PlayerUiState, - showControls: Boolean, overlayController: PersistentOverlayController, onBack: () -> Unit, onPlayPause: () -> Unit, @@ -62,11 +57,9 @@ fun PlayerControlsOverlay( onNext: () -> Unit, onPrevious: () -> Unit, onSelectTrack: (TrackOption) -> Unit, - onQueueSelected: (String) -> Unit, onOpenQueue: () -> Unit, - modifier: Modifier = Modifier + modifier: Modifier = Modifier, ) { - var scrubbing by remember { mutableStateOf(false) } Box( modifier = modifier @@ -98,11 +91,8 @@ fun PlayerControlsOverlay( ) BottomSection( uiState = uiState, - scrubbing = scrubbing, overlayController = overlayController, - onScrubStart = { scrubbing = true }, onScrub = onSeek, - onScrubFinished = { scrubbing = false }, onNext = onNext, onPrevious = onPrevious, onPlayPause = onPlayPause, @@ -111,7 +101,6 @@ fun PlayerControlsOverlay( onSeekLiveEdge = onSeekLiveEdge, onSkipSegment = onSkipSegment, onSelectTrack = onSelectTrack, - onQueueSelected = onQueueSelected, modifier = Modifier.align(Alignment.BottomCenter) ) } @@ -163,11 +152,8 @@ private fun TopBar( @Composable private fun BottomSection( uiState: PlayerUiState, - scrubbing: Boolean, overlayController: PersistentOverlayController, - onScrubStart: () -> Unit, onScrub: (Long) -> Unit, - onScrubFinished: () -> Unit, onNext: () -> Unit, onPrevious: () -> Unit, onPlayPause: () -> Unit, @@ -176,7 +162,6 @@ private fun BottomSection( onSeekLiveEdge: () -> Unit, onSkipSegment: () -> Unit, onSelectTrack: (TrackOption) -> Unit, - onQueueSelected: (String) -> Unit, modifier: Modifier = Modifier ) { Column(modifier = modifier) { @@ -194,8 +179,6 @@ private fun BottomSection( chapterMarkers = uiState.chapters, adMarkers = uiState.ads, onSeek = onScrub, - onScrubStarted = onScrubStart, - onScrubFinished = onScrubFinished, modifier = Modifier.testTag(PlayerSeekBarTag) ) Spacer(modifier = Modifier.height(8.dp)) diff --git a/app/src/main/java/hu/bbara/purefin/ui/screen/player/components/PlayerSeekBar.kt b/app/src/main/java/hu/bbara/purefin/ui/screen/player/components/PlayerSeekBar.kt index 1c52d617..50665b56 100644 --- a/app/src/main/java/hu/bbara/purefin/ui/screen/player/components/PlayerSeekBar.kt +++ b/app/src/main/java/hu/bbara/purefin/ui/screen/player/components/PlayerSeekBar.kt @@ -27,8 +27,6 @@ fun PlayerSeekBar( chapterMarkers: List, adMarkers: List, onSeek: (Long) -> Unit, - onScrubStarted: () -> Unit, - onScrubFinished: () -> Unit, modifier: Modifier = Modifier ) { val safeDuration = durationMs.takeIf { it > 0 } ?: 1L @@ -61,7 +59,6 @@ fun PlayerSeekBar( onValueChange = { newValue -> if (!isScrubbing) { isScrubbing = true - onScrubStarted() } sliderPosition = newValue }, @@ -69,7 +66,6 @@ fun PlayerSeekBar( val targetPosition = sliderPosition.toLong().coerceIn(0L, safeDuration) isScrubbing = false onSeek(targetPosition) - onScrubFinished() }, valueRange = 0f..safeDuration.toFloat(), colors = SliderDefaults.colors( diff --git a/core/src/main/java/hu/bbara/purefin/core/player/viewmodel/ControlsAutoHidePolicy.kt b/core/src/main/java/hu/bbara/purefin/core/player/viewmodel/ControlsAutoHidePolicy.kt deleted file mode 100644 index 795d751b..00000000 --- a/core/src/main/java/hu/bbara/purefin/core/player/viewmodel/ControlsAutoHidePolicy.kt +++ /dev/null @@ -1,71 +0,0 @@ -package hu.bbara.purefin.core.player.viewmodel - -enum class ControlsAutoHideBlocker { - PLAYLIST, - TRACK_PANEL -} - -internal sealed interface ControlsAutoHideCommand { - data object Cancel : ControlsAutoHideCommand - data class Schedule(val delayMs: Long) : ControlsAutoHideCommand -} - -internal class ControlsAutoHidePolicy( - private val defaultDelayMs: Long -) { - private val blockers = mutableSetOf() - private var isPlaying = false - - var controlsVisible: Boolean = true - private set - - var lastAutoHideDelayMs: Long = defaultDelayMs - private set - - fun onPlaybackChanged(isPlaying: Boolean): ControlsAutoHideCommand { - this.isPlaying = isPlaying - return nextCommand() - } - - fun setAutoHideDelay(delayMs: Long): ControlsAutoHideCommand { - lastAutoHideDelayMs = delayMs - return nextCommand() - } - - fun showControls(delayMs: Long? = null): ControlsAutoHideCommand { - delayMs?.let { lastAutoHideDelayMs = it } - controlsVisible = true - return nextCommand() - } - - fun toggleControlsVisibility(): ControlsAutoHideCommand { - controlsVisible = !controlsVisible - return nextCommand() - } - - fun hideControls(): ControlsAutoHideCommand { - controlsVisible = false - return ControlsAutoHideCommand.Cancel - } - - fun setBlocked( - blocker: ControlsAutoHideBlocker, - blocked: Boolean - ): ControlsAutoHideCommand { - if (blocked) { - blockers += blocker - controlsVisible = true - } else { - blockers -= blocker - } - return nextCommand() - } - - private fun nextCommand(): ControlsAutoHideCommand { - return if (!controlsVisible || !isPlaying || blockers.isNotEmpty()) { - ControlsAutoHideCommand.Cancel - } else { - ControlsAutoHideCommand.Schedule(lastAutoHideDelayMs) - } - } -} diff --git a/core/src/main/java/hu/bbara/purefin/core/player/viewmodel/PlayerViewModel.kt b/core/src/main/java/hu/bbara/purefin/core/player/viewmodel/PlayerViewModel.kt index a24fb283..5ab8f258 100644 --- a/core/src/main/java/hu/bbara/purefin/core/player/viewmodel/PlayerViewModel.kt +++ b/core/src/main/java/hu/bbara/purefin/core/player/viewmodel/PlayerViewModel.kt @@ -14,7 +14,6 @@ import hu.bbara.purefin.core.player.model.PlaylistElementUiModel import hu.bbara.purefin.core.player.model.TrackOption import hu.bbara.purefin.model.PlayableMedia import kotlinx.coroutines.Job -import kotlinx.coroutines.delay import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.asStateFlow @@ -22,8 +21,8 @@ import kotlinx.coroutines.flow.combine import kotlinx.coroutines.flow.first import kotlinx.coroutines.flow.update import kotlinx.coroutines.launch -import timber.log.Timber import kotlinx.serialization.InternalSerializationApi +import timber.log.Timber import java.util.UUID import javax.inject.Inject @@ -46,10 +45,6 @@ class PlayerViewModel @Inject constructor( private val _uiState = MutableStateFlow(PlayerUiState()) val uiState: StateFlow = _uiState.asStateFlow() - private val _controlsVisible = MutableStateFlow(true) - val controlsVisible: StateFlow = _controlsVisible.asStateFlow() - - private val controlsAutoHidePolicy = ControlsAutoHidePolicy(DEFAULT_CONTROLS_AUTO_HIDE_MS) private val seekByCollector = SeekByCollector(viewModelScope) { deltaMs -> playerManager.seekBy(deltaMs) } @@ -77,12 +72,6 @@ class PlayerViewModel @Inject constructor( error = state.error ?: dataErrorMessage ) } - applyControlsAutoHideCommand( - controlsAutoHidePolicy.onPlaybackChanged(state.isPlaying) - ) - if (state.isEnded) { - showControls() - } } } @@ -188,9 +177,8 @@ class PlayerViewModel @Inject constructor( } } - fun togglePlayPause(autoHideDelayMs: Long = DEFAULT_CONTROLS_AUTO_HIDE_MS) { + fun togglePlayPause() { playerManager.togglePlayPause() - showControls(autoHideDelayMs) } fun pausePlayback() { @@ -220,49 +208,14 @@ class PlayerViewModel @Inject constructor( playerManager.skipActiveSegment() } - fun setControlsAutoHideBlocked( - blocker: ControlsAutoHideBlocker, - blocked: Boolean - ) { - applyControlsAutoHideCommand( - controlsAutoHidePolicy.setBlocked(blocker, blocked) - ) - } - - fun showControls(autoHideDelayMs: Long = DEFAULT_CONTROLS_AUTO_HIDE_MS) { - applyControlsAutoHideCommand( - controlsAutoHidePolicy.showControls(autoHideDelayMs) - ) - } - - fun toggleControlsVisibility() { - applyControlsAutoHideCommand( - controlsAutoHidePolicy.toggleControlsVisibility() - ) - } - - private fun applyControlsAutoHideCommand(command: ControlsAutoHideCommand) { - _controlsVisible.value = controlsAutoHidePolicy.controlsVisible - autoHideJob?.cancel() - autoHideJob = null - if (command !is ControlsAutoHideCommand.Schedule) return - autoHideJob = viewModelScope.launch { - delay(command.delayMs) - controlsAutoHidePolicy.hideControls() - _controlsVisible.value = controlsAutoHidePolicy.controlsVisible - } - } - - fun next(autoHideDelayMs: Long = DEFAULT_CONTROLS_AUTO_HIDE_MS) { + fun next() { seekByCollector.clear() playerManager.next() - showControls(autoHideDelayMs) } - fun previous(autoHideDelayMs: Long = DEFAULT_CONTROLS_AUTO_HIDE_MS) { + fun previous() { seekByCollector.clear() playerManager.previous() - showControls(autoHideDelayMs) } fun selectTrack(option: TrackOption) { @@ -276,7 +229,6 @@ class PlayerViewModel @Inject constructor( fun playQueueItem(id: String) { seekByCollector.clear() playerManager.play(id.toUuidOrNull() ?: return) - showControls() } fun clearError() { diff --git a/core/src/test/java/hu/bbara/purefin/core/player/viewmodel/ControlsAutoHidePolicyTest.kt b/core/src/test/java/hu/bbara/purefin/core/player/viewmodel/ControlsAutoHidePolicyTest.kt deleted file mode 100644 index f184b621..00000000 --- a/core/src/test/java/hu/bbara/purefin/core/player/viewmodel/ControlsAutoHidePolicyTest.kt +++ /dev/null @@ -1,90 +0,0 @@ -package hu.bbara.purefin.core.player.viewmodel - -import org.junit.Assert.assertEquals -import org.junit.Assert.assertTrue -import org.junit.Test - -class ControlsAutoHidePolicyTest { - - @Test - fun activeBlockerCancelsScheduledAutoHide() { - val policy = ControlsAutoHidePolicy(defaultDelayMs = 3_500L) - - assertEquals( - ControlsAutoHideCommand.Schedule(3_500L), - policy.onPlaybackChanged(isPlaying = true) - ) - assertEquals( - ControlsAutoHideCommand.Cancel, - policy.setBlocked(ControlsAutoHideBlocker.TRACK_PANEL, blocked = true) - ) - assertTrue(policy.controlsVisible) - } - - @Test - fun playlistBlockerPreventsAutoHideUntilCleared() { - val policy = ControlsAutoHidePolicy(defaultDelayMs = 3_500L) - - policy.onPlaybackChanged(isPlaying = true) - policy.showControls(delayMs = 5_000L) - - assertEquals( - ControlsAutoHideCommand.Cancel, - policy.setBlocked(ControlsAutoHideBlocker.PLAYLIST, blocked = true) - ) - assertEquals( - ControlsAutoHideCommand.Schedule(5_000L), - policy.setBlocked(ControlsAutoHideBlocker.PLAYLIST, blocked = false) - ) - } - - @Test - fun trackPanelBlockerUsesRememberedDelayWhenRemoved() { - val policy = ControlsAutoHidePolicy(defaultDelayMs = 3_500L) - - policy.onPlaybackChanged(isPlaying = true) - assertEquals( - ControlsAutoHideCommand.Cancel, - policy.setBlocked(ControlsAutoHideBlocker.TRACK_PANEL, blocked = true) - ) - policy.showControls(delayMs = 5_000L) - - assertEquals( - ControlsAutoHideCommand.Schedule(5_000L), - policy.setBlocked(ControlsAutoHideBlocker.TRACK_PANEL, blocked = false) - ) - } - - @Test - fun autoHideResumesOnlyAfterLastBlockerIsCleared() { - val policy = ControlsAutoHidePolicy(defaultDelayMs = 3_500L) - - policy.onPlaybackChanged(isPlaying = true) - policy.showControls(delayMs = 5_000L) - policy.setBlocked(ControlsAutoHideBlocker.PLAYLIST, blocked = true) - policy.setBlocked(ControlsAutoHideBlocker.TRACK_PANEL, blocked = true) - - assertEquals( - ControlsAutoHideCommand.Cancel, - policy.setBlocked(ControlsAutoHideBlocker.PLAYLIST, blocked = false) - ) - assertEquals( - ControlsAutoHideCommand.Schedule(5_000L), - policy.setBlocked(ControlsAutoHideBlocker.TRACK_PANEL, blocked = false) - ) - } - - @Test - fun blockingStateMakesHiddenControlsVisibleAgain() { - val policy = ControlsAutoHidePolicy(defaultDelayMs = 3_500L) - - policy.onPlaybackChanged(isPlaying = true) - policy.toggleControlsVisibility() - - assertEquals( - ControlsAutoHideCommand.Cancel, - policy.setBlocked(ControlsAutoHideBlocker.PLAYLIST, blocked = true) - ) - assertTrue(policy.controlsVisible) - } -}