From 360cd51e232300b70388528f28da1056136b4739 Mon Sep 17 00:00:00 2001 From: Barnabas Balogh Date: Fri, 10 Apr 2026 15:49:34 +0200 Subject: [PATCH] feat: Enhance TV player controls with key event handling and overlay visibility management --- .../components/TvPlayerControlsOverlayTest.kt | 123 ++++++++++++++---- .../bbara/purefin/tv/player/TvPlayerScreen.kt | 104 +++++++++++---- .../components/TvTrackSelectionButtons.kt | 8 -- 3 files changed, 174 insertions(+), 61 deletions(-) diff --git a/app-tv/src/androidTest/java/hu/bbara/purefin/tv/player/components/TvPlayerControlsOverlayTest.kt b/app-tv/src/androidTest/java/hu/bbara/purefin/tv/player/components/TvPlayerControlsOverlayTest.kt index 6a3c967d..9f51f471 100644 --- a/app-tv/src/androidTest/java/hu/bbara/purefin/tv/player/components/TvPlayerControlsOverlayTest.kt +++ b/app-tv/src/androidTest/java/hu/bbara/purefin/tv/player/components/TvPlayerControlsOverlayTest.kt @@ -13,6 +13,7 @@ import androidx.compose.runtime.setValue import androidx.compose.ui.Modifier import androidx.compose.ui.focus.FocusRequester import androidx.compose.ui.input.key.Key +import androidx.compose.ui.input.key.onPreviewKeyEvent import androidx.compose.ui.semantics.SemanticsActions import androidx.compose.ui.semantics.SemanticsProperties import androidx.compose.ui.test.ExperimentalTestApi @@ -36,6 +37,7 @@ import hu.bbara.purefin.core.player.model.PlayerUiState import hu.bbara.purefin.core.player.model.QueueItemUi import hu.bbara.purefin.core.player.model.TrackOption import hu.bbara.purefin.core.player.model.TrackType +import hu.bbara.purefin.tv.player.handleTvPlayerRootKeyEvent import hu.bbara.purefin.ui.theme.AppTheme import org.junit.Rule import org.junit.Test @@ -279,6 +281,48 @@ class TvPlayerControlsOverlayTest { 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 backOnTrackPanel_closesItAndRestoresFocusToOpeningButton() { composeRule.setContent { @@ -428,6 +472,7 @@ private fun OverlayHost( private fun TrackPanelHost( uiState: PlayerUiState ) { + var controlsVisible by remember { mutableStateOf(true) } var trackPanelType by remember { mutableStateOf(null) } var pendingTrackButtonFocus by remember { mutableStateOf(null) } val controlsFocusRequester = remember { FocusRequester() } @@ -438,6 +483,7 @@ private fun TrackPanelHost( trackPanelType?.let { panelType -> pendingTrackButtonFocus = panelType trackPanelType = null + controlsVisible = true } } @@ -452,37 +498,64 @@ private fun TrackPanelHost( pendingTrackButtonFocus = null } - Box(modifier = Modifier.size(width = 960.dp, height = 540.dp)) { - TvPlayerControlsOverlay( - uiState = uiState, - focusRequester = controlsFocusRequester, - isPlaylistExpanded = false, - qualityButtonFocusRequester = qualityButtonFocusRequester, - audioButtonFocusRequester = audioButtonFocusRequester, - subtitlesButtonFocusRequester = subtitlesButtonFocusRequester, - onPlayPause = {}, - onSeek = { _ -> }, - onSeekRelative = { _ -> }, - onSeekLiveEdge = {}, - onNext = {}, - onPrevious = {}, - onOpenAudioPanel = { trackPanelType = TvTrackPanelType.AUDIO }, - onOpenSubtitlesPanel = { trackPanelType = TvTrackPanelType.SUBTITLES }, - onOpenQualityPanel = { trackPanelType = TvTrackPanelType.QUALITY }, - onExpandPlaylist = {}, - onCollapsePlaylist = {}, - onSelectQueueItem = { _ -> }, - qualityButtonEnabled = uiState.qualityTracks.isNotEmpty(), - audioButtonEnabled = uiState.audioTracks.isNotEmpty(), - subtitlesButtonEnabled = uiState.textTracks.isNotEmpty() - ) + Box( + modifier = Modifier + .size(width = 960.dp, height = 540.dp) + .onPreviewKeyEvent { event -> + handleTvPlayerRootKeyEvent( + event = event, + controlsVisible = controlsVisible, + isPlaylistExpanded = false, + trackPanelType = trackPanelType, + onCloseTrackPanel = closeTrackPanel, + onCollapsePlaylist = { controlsVisible = true }, + onHideControls = { controlsVisible = false }, + onSeekRelative = { _ -> controlsVisible = true }, + 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 = {}, + 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() + ) + } trackPanelType?.let { panelType -> TvTrackSelectionPanel( panelType = panelType, uiState = uiState, onSelect = { closeTrackPanel() }, - onClose = closeTrackPanel, modifier = Modifier.fillMaxSize() ) } diff --git a/app-tv/src/main/java/hu/bbara/purefin/tv/player/TvPlayerScreen.kt b/app-tv/src/main/java/hu/bbara/purefin/tv/player/TvPlayerScreen.kt index 9e7762e8..614e21b1 100644 --- a/app-tv/src/main/java/hu/bbara/purefin/tv/player/TvPlayerScreen.kt +++ b/app-tv/src/main/java/hu/bbara/purefin/tv/player/TvPlayerScreen.kt @@ -179,33 +179,18 @@ fun TvPlayerScreen( .background(Color.Black) .focusRequester(hiddenControlFocusRequester) .onPreviewKeyEvent { event -> - if (!controlsVisible && event.type == KeyEventType.KeyDown) { - when (event.key) { - Key.DirectionLeft -> { - seekByAndShowControls(-10_000) - true - } - - Key.DirectionRight -> { - seekByAndShowControls(10_000) - true - } - - Key.DirectionUp, Key.DirectionDown -> { - showTvControls() - true - } - - Key.DirectionCenter, Key.Enter -> { - togglePlayPauseAndShowControls() - true - } - - else -> false - } - } else { - false - } + handleTvPlayerRootKeyEvent( + event = event, + controlsVisible = controlsVisible, + isPlaylistExpanded = isPlaylistExpanded, + trackPanelType = trackPanelType, + onCloseTrackPanel = closeTrackPanel, + onCollapsePlaylist = collapsePlaylistToControls, + onHideControls = { viewModel.toggleControlsVisibility() }, + onSeekRelative = seekByAndShowControls, + onShowControls = showTvControls, + onTogglePlayPause = togglePlayPauseAndShowControls + ) } .focusable() ) { @@ -282,7 +267,6 @@ fun TvPlayerScreen( viewModel.selectTrack(track) closeTrackPanel() }, - onClose = closeTrackPanel, modifier = Modifier.fillMaxSize() ) } @@ -290,3 +274,67 @@ fun TvPlayerScreen( } } + +internal fun handleTvPlayerRootKeyEvent( + event: androidx.compose.ui.input.key.KeyEvent, + controlsVisible: Boolean, + isPlaylistExpanded: Boolean, + trackPanelType: TvTrackPanelType?, + onCloseTrackPanel: () -> Unit, + onCollapsePlaylist: () -> Unit, + onHideControls: () -> Unit, + onSeekRelative: (Long) -> Unit, + onShowControls: () -> Unit, + onTogglePlayPause: () -> Unit +): Boolean { + if (event.type != KeyEventType.KeyDown) return false + + if (event.key == Key.Back || event.key == Key.Escape) { + return when { + trackPanelType != null -> { + onCloseTrackPanel() + true + } + + isPlaylistExpanded -> { + onCollapsePlaylist() + true + } + + controlsVisible -> { + onHideControls() + true + } + + else -> false + } + } + + if (!controlsVisible) { + return when (event.key) { + Key.DirectionLeft -> { + onSeekRelative(-10_000) + true + } + + Key.DirectionRight -> { + onSeekRelative(10_000) + true + } + + Key.DirectionUp, Key.DirectionDown -> { + onShowControls() + true + } + + Key.DirectionCenter, Key.Enter -> { + onTogglePlayPause() + true + } + + else -> false + } + } + + return false +} diff --git a/app-tv/src/main/java/hu/bbara/purefin/tv/player/components/TvTrackSelectionButtons.kt b/app-tv/src/main/java/hu/bbara/purefin/tv/player/components/TvTrackSelectionButtons.kt index 6135b108..15552ba2 100644 --- a/app-tv/src/main/java/hu/bbara/purefin/tv/player/components/TvTrackSelectionButtons.kt +++ b/app-tv/src/main/java/hu/bbara/purefin/tv/player/components/TvTrackSelectionButtons.kt @@ -111,7 +111,6 @@ internal fun TvTrackSelectionPanel( panelType: TvTrackPanelType, uiState: PlayerUiState, onSelect: (TrackOption) -> Unit, - onClose: () -> Unit, modifier: Modifier = Modifier ) { val scheme = MaterialTheme.colorScheme @@ -192,7 +191,6 @@ internal fun TvTrackSelectionPanel( selected = option.id == selectedId, isFirst = index == 0, isLast = index == options.lastIndex, - onClose = onClose, onClick = { onSelect(option) } ) } @@ -209,7 +207,6 @@ private fun TvTrackOptionRow( selected: Boolean, isFirst: Boolean, isLast: Boolean, - onClose: () -> Unit, onClick: () -> Unit ) { val scheme = MaterialTheme.colorScheme @@ -237,11 +234,6 @@ private fun TvTrackOptionRow( false } else { when (event.key) { - Key.Back -> { - onClose() - true - } - Key.DirectionLeft, Key.DirectionRight -> true Key.DirectionUp -> isFirst Key.DirectionDown -> isLast