From 7c30e12af7e66c1ca400e766931020af2719c6d0 Mon Sep 17 00:00:00 2001 From: Barnabas Balogh Date: Thu, 9 Apr 2026 19:59:01 +0200 Subject: [PATCH] Fix TV player track selector focus --- .../components/TvPlayerControlsOverlayTest.kt | 331 +++++++++++++++++- .../bbara/purefin/tv/player/TvPlayerScreen.kt | 61 +++- .../tv/player/components/TvIconButton.kt | 14 +- .../components/TvPlayerControlsOverlay.kt | 27 ++ .../player/components/TvPlayerQueuePanel.kt | 9 + .../components/TvTrackSelectionButtons.kt | 129 +++++-- core/player/build.gradle.kts | 1 + .../viewmodel/ControlsAutoHidePolicy.kt | 71 ++++ .../core/player/viewmodel/PlayerViewModel.kt | 44 ++- .../viewmodel/ControlsAutoHidePolicyTest.kt | 90 +++++ 10 files changed, 718 insertions(+), 59 deletions(-) create mode 100644 core/player/src/main/java/hu/bbara/purefin/core/player/viewmodel/ControlsAutoHidePolicy.kt create mode 100644 core/player/src/test/java/hu/bbara/purefin/core/player/viewmodel/ControlsAutoHidePolicyTest.kt 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 9d8ae702..6a3c967d 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 @@ -1,9 +1,11 @@ package hu.bbara.purefin.tv.player.components import androidx.activity.ComponentActivity -import androidx.compose.runtime.Composable 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 @@ -16,9 +18,14 @@ import androidx.compose.ui.semantics.SemanticsProperties 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.onNodeWithContentDescription import androidx.compose.ui.test.onNodeWithTag import androidx.compose.ui.test.onNodeWithText import androidx.compose.ui.test.performKeyInput @@ -27,6 +34,8 @@ 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.QueueItemUi +import hu.bbara.purefin.core.player.model.TrackOption +import hu.bbara.purefin.core.player.model.TrackType import hu.bbara.purefin.ui.theme.AppTheme import org.junit.Rule import org.junit.Test @@ -168,7 +177,7 @@ class TvPlayerControlsOverlayTest { } @Test - fun playlistShowsQueueAsRowAndFocusesCurrentItem() { + fun playlistShowsQueueAsRowAndCurrentItemCanReceiveFocus() { composeRule.setContent { AppTheme { OverlayHost( @@ -181,7 +190,9 @@ class TvPlayerControlsOverlayTest { composeRule.waitForIdle() composeRule.onNodeWithTag(TvPlayerPlaylistRowTag).assertIsDisplayed() - composeRule.onNodeWithTag(TvPlayerPlaylistCurrentItemTag).assertIsFocused() + composeRule.onNodeWithTag(TvPlayerPlaylistCurrentItemTag) + .performSemanticsAction(SemanticsActions.RequestFocus) + .assertIsFocused() composeRule.onNodeWithText("Currently Playing").assertIsDisplayed() composeRule.onNodeWithText("Episode 2").assertIsDisplayed() composeRule.onNodeWithTag(TvPlayerPlaylistFirstItemTag).assertIsDisplayed() @@ -190,7 +201,7 @@ class TvPlayerControlsOverlayTest { } @Test - fun playlistFallsBackToFirstItemWhenNoCurrentItemExists() { + fun playlistFallsBackToFirstItemWhenNoCurrentItemExists_andFallbackItemCanReceiveFocus() { composeRule.setContent { AppTheme { OverlayHost( @@ -219,9 +230,160 @@ class TvPlayerControlsOverlayTest { composeRule.waitForIdle() - composeRule.onNodeWithTag(TvPlayerPlaylistCurrentItemTag).assertIsFocused() + 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 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 @@ -231,12 +393,18 @@ private fun OverlayHost( ) { var isPlaylistExpanded by remember { mutableStateOf(initiallyExpanded) } val focusRequester = remember { FocusRequester() } + val qualityButtonFocusRequester = remember { FocusRequester() } + val audioButtonFocusRequester = remember { FocusRequester() } + val subtitlesButtonFocusRequester = remember { FocusRequester() } 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 = { _ -> }, @@ -248,11 +416,79 @@ private fun OverlayHost( onOpenQualityPanel = {}, onExpandPlaylist = { isPlaylistExpanded = true }, onCollapsePlaylist = { isPlaylistExpanded = false }, - onSelectQueueItem = { _ -> } + onSelectQueueItem = { _ -> }, + qualityButtonEnabled = uiState.qualityTracks.isNotEmpty(), + audioButtonEnabled = uiState.audioTracks.isNotEmpty(), + subtitlesButtonEnabled = uiState.textTracks.isNotEmpty() ) } } +@Composable +private fun TrackPanelHost( + uiState: PlayerUiState +) { + var trackPanelType by remember { mutableStateOf(null) } + var pendingTrackButtonFocus by remember { mutableStateOf(null) } + 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 + } + } + + 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 + } + + 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() + ) + + trackPanelType?.let { panelType -> + TvTrackSelectionPanel( + panelType = panelType, + uiState = uiState, + onSelect = { closeTrackPanel() }, + onClose = closeTrackPanel, + modifier = Modifier.fillMaxSize() + ) + } + } +} + private fun samplePlayerState(): PlayerUiState = PlayerUiState( isPlaying = true, title = "Sample Player", @@ -282,5 +518,86 @@ private fun samplePlayerState(): PlayerUiState = PlayerUiState( 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/tv/player/TvPlayerScreen.kt b/app-tv/src/main/java/hu/bbara/purefin/tv/player/TvPlayerScreen.kt index 4e5ddc30..9e7762e8 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 @@ -38,6 +38,7 @@ import androidx.hilt.navigation.compose.hiltViewModel import androidx.media3.common.util.UnstableApi import androidx.media3.ui.AspectRatioFrameLayout import androidx.media3.ui.PlayerView +import hu.bbara.purefin.core.player.viewmodel.ControlsAutoHideBlocker import hu.bbara.purefin.core.player.viewmodel.PlayerViewModel import hu.bbara.purefin.tv.player.components.TvPlayerControlsOverlay import hu.bbara.purefin.tv.player.components.TvPlayerLoadingErrorEndCard @@ -61,8 +62,13 @@ fun TvPlayerScreen( val controlsVisible by viewModel.controlsVisible.collectAsState() var isPlaylistExpanded by remember { mutableStateOf(false) } var trackPanelType by remember { mutableStateOf(null) } + var pendingTrackButtonFocus by remember { mutableStateOf(null) } + val controlsAutoHideBlocked = isPlaylistExpanded || trackPanelType != null val context = LocalContext.current + LaunchedEffect(Unit) { + viewModel.setControlsAutoHideDelay(TV_CONTROLS_AUTO_HIDE_MS) + } LaunchedEffect(uiState.isPlaying) { val activity = context as? Activity if (uiState.isPlaying) { @@ -74,17 +80,27 @@ fun TvPlayerScreen( 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) } } - LaunchedEffect(uiState.isPlaying, controlsVisible, isPlaylistExpanded) { - if (uiState.isPlaying && controlsVisible && !isPlaylistExpanded) { - viewModel.showControls(TV_CONTROLS_AUTO_HIDE_MS) - } + LaunchedEffect(isPlaylistExpanded) { + viewModel.setControlsAutoHideBlocked(ControlsAutoHideBlocker.PLAYLIST, isPlaylistExpanded) + } + + LaunchedEffect(trackPanelType) { + viewModel.setControlsAutoHideBlocked( + ControlsAutoHideBlocker.TRACK_PANEL, + trackPanelType != null + ) } val hiddenControlFocusRequester = remember { FocusRequester() } val controlsFocusRequester = remember { FocusRequester() } + val qualityButtonFocusRequester = remember { FocusRequester() } + val audioButtonFocusRequester = remember { FocusRequester() } + val subtitlesButtonFocusRequester = remember { FocusRequester() } val expandPlaylist: () -> Unit = { if (!isPlaylistExpanded) { isPlaylistExpanded = true @@ -120,8 +136,16 @@ fun TvPlayerScreen( val previousAndShowControls: () -> Unit = { viewModel.previous(TV_CONTROLS_AUTO_HIDE_MS) } + val closeTrackPanel: () -> Unit = { + trackPanelType?.let { panelType -> + pendingTrackButtonFocus = panelType + trackPanelType = null + viewModel.showControls(TV_CONTROLS_AUTO_HIDE_MS) + } + } - LaunchedEffect(controlsVisible) { + LaunchedEffect(controlsVisible, controlsAutoHideBlocked) { + if (controlsAutoHideBlocked) return@LaunchedEffect if (controlsVisible) { controlsFocusRequester.requestFocus() } else { @@ -129,9 +153,20 @@ fun TvPlayerScreen( } } + 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 + } + BackHandler(enabled = true) { when { - trackPanelType != null -> trackPanelType = null + trackPanelType != null -> closeTrackPanel() isPlaylistExpanded -> collapsePlaylistToControls() controlsVisible -> viewModel.toggleControlsVisibility() else -> onBack() @@ -189,7 +224,7 @@ fun TvPlayerScreen( ) AnimatedVisibility( - visible = controlsVisible || isPlaylistExpanded || uiState.isEnded || uiState.error != null, + visible = controlsVisible || isPlaylistExpanded || trackPanelType != null || uiState.isEnded || uiState.error != null, enter = fadeIn(), exit = fadeOut() ) { @@ -198,6 +233,9 @@ fun TvPlayerScreen( uiState = uiState, focusRequester = controlsFocusRequester, isPlaylistExpanded = isPlaylistExpanded, + qualityButtonFocusRequester = qualityButtonFocusRequester, + audioButtonFocusRequester = audioButtonFocusRequester, + subtitlesButtonFocusRequester = subtitlesButtonFocusRequester, onPlayPause = togglePlayPauseAndShowControls, onSeek = seekAndShowControls, onSeekRelative = seekByAndShowControls, @@ -212,7 +250,10 @@ fun TvPlayerScreen( onSelectQueueItem = { id -> viewModel.playQueueItem(id, TV_CONTROLS_AUTO_HIDE_MS) collapsePlaylistToControls() - } + }, + qualityButtonEnabled = uiState.qualityTracks.isNotEmpty(), + audioButtonEnabled = uiState.audioTracks.isNotEmpty(), + subtitlesButtonEnabled = uiState.textTracks.isNotEmpty() ) } @@ -239,9 +280,9 @@ fun TvPlayerScreen( uiState = uiState, onSelect = { track -> viewModel.selectTrack(track) - trackPanelType = null + closeTrackPanel() }, - onClose = { trackPanelType = null }, + onClose = closeTrackPanel, modifier = Modifier.fillMaxSize() ) } diff --git a/app-tv/src/main/java/hu/bbara/purefin/tv/player/components/TvIconButton.kt b/app-tv/src/main/java/hu/bbara/purefin/tv/player/components/TvIconButton.kt index 2da3dbae..f46dfd4b 100644 --- a/app-tv/src/main/java/hu/bbara/purefin/tv/player/components/TvIconButton.kt +++ b/app-tv/src/main/java/hu/bbara/purefin/tv/player/components/TvIconButton.kt @@ -19,11 +19,15 @@ import androidx.compose.runtime.remember import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.alpha import androidx.compose.ui.draw.clip +import androidx.compose.ui.focus.focusProperties import androidx.compose.ui.focus.onFocusChanged import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.graphicsLayer import androidx.compose.ui.graphics.vector.ImageVector +import androidx.compose.ui.semantics.disabled +import androidx.compose.ui.semantics.semantics import androidx.compose.ui.unit.dp @Composable @@ -32,6 +36,7 @@ internal fun TvIconButton( contentDescription: String, onClick: () -> Unit, size: Int = 52, + enabled: Boolean = true, modifier: Modifier = Modifier ) { val scheme = MaterialTheme.colorScheme @@ -51,6 +56,7 @@ internal fun TvIconButton( scaleX = scale scaleY = scale } + .alpha(if (enabled) 1f else 0.4f) .widthIn(min = size.dp) .height(size.dp) .border( @@ -63,8 +69,14 @@ internal fun TvIconButton( if (isFocused) scheme.primary.copy(alpha = 0.5f) else scheme.background.copy(alpha = 0.65f) ) + .focusProperties { canFocus = enabled } + .semantics { + if (!enabled) { + disabled() + } + } .onFocusChanged { isFocused = it.isFocused } - .clickable { onClick() }, + .clickable(enabled = enabled) { onClick() }, contentAlignment = Alignment.Center ) { Icon( diff --git a/app-tv/src/main/java/hu/bbara/purefin/tv/player/components/TvPlayerControlsOverlay.kt b/app-tv/src/main/java/hu/bbara/purefin/tv/player/components/TvPlayerControlsOverlay.kt index 496f0275..ce2fe2e3 100644 --- a/app-tv/src/main/java/hu/bbara/purefin/tv/player/components/TvPlayerControlsOverlay.kt +++ b/app-tv/src/main/java/hu/bbara/purefin/tv/player/components/TvPlayerControlsOverlay.kt @@ -58,6 +58,9 @@ internal fun TvPlayerControlsOverlay( uiState: PlayerUiState, focusRequester: FocusRequester, isPlaylistExpanded: Boolean, + qualityButtonFocusRequester: FocusRequester, + audioButtonFocusRequester: FocusRequester, + subtitlesButtonFocusRequester: FocusRequester, onPlayPause: () -> Unit, onSeek: (Long) -> Unit, onSeekRelative: (Long) -> Unit, @@ -70,6 +73,9 @@ internal fun TvPlayerControlsOverlay( onExpandPlaylist: () -> Unit, onCollapsePlaylist: () -> Unit, onSelectQueueItem: (String) -> Unit, + qualityButtonEnabled: Boolean, + audioButtonEnabled: Boolean, + subtitlesButtonEnabled: Boolean, modifier: Modifier = Modifier ) { Box( @@ -97,6 +103,9 @@ internal fun TvPlayerControlsOverlay( uiState = uiState, focusRequester = focusRequester, isPlaylistExpanded = isPlaylistExpanded, + qualityButtonFocusRequester = qualityButtonFocusRequester, + audioButtonFocusRequester = audioButtonFocusRequester, + subtitlesButtonFocusRequester = subtitlesButtonFocusRequester, onPlayPause = onPlayPause, onSeek = onSeek, onSeekRelative = onSeekRelative, @@ -109,6 +118,9 @@ internal fun TvPlayerControlsOverlay( onExpandPlaylist = onExpandPlaylist, onCollapsePlaylist = onCollapsePlaylist, onSelectQueueItem = onSelectQueueItem, + qualityButtonEnabled = qualityButtonEnabled, + audioButtonEnabled = audioButtonEnabled, + subtitlesButtonEnabled = subtitlesButtonEnabled, modifier = Modifier .align(Alignment.BottomCenter) .fillMaxWidth() @@ -153,6 +165,9 @@ private fun TvPlayerBottomSection( uiState: PlayerUiState, focusRequester: FocusRequester, isPlaylistExpanded: Boolean, + qualityButtonFocusRequester: FocusRequester, + audioButtonFocusRequester: FocusRequester, + subtitlesButtonFocusRequester: FocusRequester, onPlayPause: () -> Unit, onSeek: (Long) -> Unit, onSeekRelative: (Long) -> Unit, @@ -165,6 +180,9 @@ private fun TvPlayerBottomSection( onExpandPlaylist: () -> Unit, onCollapsePlaylist: () -> Unit, onSelectQueueItem: (String) -> Unit, + qualityButtonEnabled: Boolean, + audioButtonEnabled: Boolean, + subtitlesButtonEnabled: Boolean, modifier: Modifier = Modifier ) { val scheme = MaterialTheme.colorScheme @@ -299,15 +317,24 @@ private fun TvPlayerBottomSection( ) { TvQualitySelectionButton( onClick = onOpenQualityPanel, + enabled = qualityButtonEnabled, modifier = expandPlaylistModifier + .focusRequester(qualityButtonFocusRequester) + .testTag(TvPlayerQualityButtonTag) ) TvAudioSelectionButton( onClick = onOpenAudioPanel, + enabled = audioButtonEnabled, modifier = expandPlaylistModifier + .focusRequester(audioButtonFocusRequester) + .testTag(TvPlayerAudioButtonTag) ) TvSubtitlesSelectionButton( onClick = onOpenSubtitlesPanel, + enabled = subtitlesButtonEnabled, modifier = expandPlaylistModifier + .focusRequester(subtitlesButtonFocusRequester) + .testTag(TvPlayerSubtitlesButtonTag) ) } } diff --git a/app-tv/src/main/java/hu/bbara/purefin/tv/player/components/TvPlayerQueuePanel.kt b/app-tv/src/main/java/hu/bbara/purefin/tv/player/components/TvPlayerQueuePanel.kt index d3b60212..2cd6aa38 100644 --- a/app-tv/src/main/java/hu/bbara/purefin/tv/player/components/TvPlayerQueuePanel.kt +++ b/app-tv/src/main/java/hu/bbara/purefin/tv/player/components/TvPlayerQueuePanel.kt @@ -19,6 +19,8 @@ import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Surface import androidx.compose.material3.Text import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.withFrameNanos import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember @@ -68,6 +70,13 @@ internal fun TvPlayerQueuePanel( else -> "${uiState.queue.size} items in queue" } + LaunchedEffect(uiState.queue, entryIndex) { + if (uiState.queue.isNotEmpty()) { + withFrameNanos { } + firstItemFocusRequester.requestFocus() + } + } + Surface( modifier = modifier, shape = RoundedCornerShape(24.dp), 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 a90c4756..6135b108 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 @@ -6,20 +6,17 @@ 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.Spacer import androidx.compose.foundation.layout.fillMaxHeight import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth -import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.heightIn import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.width -import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.itemsIndexed +import androidx.compose.foundation.lazy.rememberLazyListState import androidx.compose.foundation.shape.RoundedCornerShape -import androidx.compose.foundation.verticalScroll import androidx.compose.material.icons.Icons -import androidx.compose.material.icons.automirrored.outlined.ArrowBack import androidx.compose.material.icons.outlined.ClosedCaption import androidx.compose.material.icons.outlined.HighQuality import androidx.compose.material.icons.outlined.Language @@ -27,19 +24,37 @@ import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Surface 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.setValue +import androidx.compose.runtime.withFrameNanos import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip +import androidx.compose.ui.focus.FocusRequester +import androidx.compose.ui.focus.focusRequester 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.testTag import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.unit.dp import hu.bbara.purefin.core.player.model.PlayerUiState import hu.bbara.purefin.core.player.model.TrackOption +internal const val TvPlayerQualityButtonTag = "tv_player_quality_button" +internal const val TvPlayerAudioButtonTag = "tv_player_audio_button" +internal const val TvPlayerSubtitlesButtonTag = "tv_player_subtitles_button" +internal const val TvPlayerTrackPanelTag = "tv_player_track_panel" +internal const val TvPlayerTrackSelectedItemTag = "tv_player_track_selected_item" +internal const val TvPlayerTrackFirstItemTag = "tv_player_track_first_item" +internal const val TvPlayerTrackLastItemTag = "tv_player_track_last_item" + internal enum class TvTrackPanelType { AUDIO, SUBTITLES, @@ -49,12 +64,14 @@ internal enum class TvTrackPanelType { @Composable internal fun TvQualitySelectionButton( onClick: () -> Unit, + enabled: Boolean, modifier: Modifier = Modifier ) { TvIconButton( icon = Icons.Outlined.HighQuality, contentDescription = "Quality", onClick = onClick, + enabled = enabled, modifier = modifier ) } @@ -62,12 +79,14 @@ internal fun TvQualitySelectionButton( @Composable internal fun TvAudioSelectionButton( onClick: () -> Unit, + enabled: Boolean, modifier: Modifier = Modifier ) { TvIconButton( icon = Icons.Outlined.Language, contentDescription = "Audio", onClick = onClick, + enabled = enabled, modifier = modifier ) } @@ -75,12 +94,14 @@ internal fun TvAudioSelectionButton( @Composable internal fun TvSubtitlesSelectionButton( onClick: () -> Unit, + enabled: Boolean, modifier: Modifier = Modifier ) { TvIconButton( icon = Icons.Outlined.ClosedCaption, contentDescription = "Subtitles", onClick = onClick, + enabled = enabled, modifier = modifier ) } @@ -99,9 +120,23 @@ internal fun TvTrackSelectionPanel( TvTrackPanelType.SUBTITLES -> Triple("Subtitles", uiState.textTracks, uiState.selectedTextTrackId) TvTrackPanelType.QUALITY -> Triple("Quality", uiState.qualityTracks, uiState.selectedQualityTrackId) } + val entryIndex = options.indexOfFirst { it.id == selectedId }.takeIf { it >= 0 } ?: 0 + val focusRequesters = remember(options.map(TrackOption::id)) { + options.map { FocusRequester() } + } + val listState = rememberLazyListState( + initialFirstVisibleItemIndex = (entryIndex - 1).coerceAtLeast(0) + ) + + LaunchedEffect(entryIndex, focusRequesters) { + withFrameNanos { } + focusRequesters.getOrNull(entryIndex)?.requestFocus() + } Box( - modifier = modifier.fillMaxSize(), + modifier = modifier + .fillMaxSize() + .testTag(TvPlayerTrackPanelTag), contentAlignment = Alignment.CenterEnd ) { Surface( @@ -115,34 +150,49 @@ internal fun TvTrackSelectionPanel( modifier = Modifier.padding(20.dp), verticalArrangement = Arrangement.spacedBy(8.dp) ) { - Row( - modifier = Modifier.fillMaxWidth(), - horizontalArrangement = Arrangement.SpaceBetween, - verticalAlignment = Alignment.CenterVertically - ) { - Text( - text = title, - color = scheme.onSurface, - style = MaterialTheme.typography.titleLarge, - fontWeight = FontWeight.Bold - ) - TvIconButton( - icon = Icons.AutoMirrored.Outlined.ArrowBack, - contentDescription = "Close", - onClick = onClose - ) - } - Spacer(modifier = Modifier.height(4.dp)) - Column( + Text( + text = title, + color = scheme.onSurface, + style = MaterialTheme.typography.titleLarge, + fontWeight = FontWeight.Bold + ) + LazyColumn( modifier = Modifier .heightIn(max = 500.dp) - .verticalScroll(rememberScrollState()), + .fillMaxWidth(), + state = listState, verticalArrangement = Arrangement.spacedBy(6.dp) ) { - options.forEach { option -> + itemsIndexed(options, key = { _, option -> option.id }) { index, option -> + val entryItemTag = when { + selectedId == null && index == entryIndex -> TvPlayerTrackFirstItemTag + index == entryIndex -> TvPlayerTrackSelectedItemTag + index == 0 -> TvPlayerTrackFirstItemTag + index == options.lastIndex -> TvPlayerTrackLastItemTag + else -> null + } TvTrackOptionRow( + modifier = Modifier + .then( + if (index == entryIndex) { + Modifier + .focusRequester(focusRequesters[index]) + } else { + Modifier + } + ) + .then( + if (entryItemTag != null) { + Modifier.testTag(entryItemTag) + } else { + Modifier + } + ), label = option.label, selected = option.id == selectedId, + isFirst = index == 0, + isLast = index == options.lastIndex, + onClose = onClose, onClick = { onSelect(option) } ) } @@ -154,15 +204,19 @@ internal fun TvTrackSelectionPanel( @Composable private fun TvTrackOptionRow( + modifier: Modifier = Modifier, label: String, selected: Boolean, + isFirst: Boolean, + isLast: Boolean, + onClose: () -> Unit, onClick: () -> Unit ) { val scheme = MaterialTheme.colorScheme var isFocused by remember { mutableStateOf(false) } Box( - modifier = Modifier + modifier = modifier .fillMaxWidth() .then( if (isFocused) { @@ -178,6 +232,23 @@ private fun TvTrackOptionRow( else scheme.surfaceVariant.copy(alpha = 0.6f) ) .onFocusChanged { isFocused = it.isFocused } + .onPreviewKeyEvent { event -> + if (event.type != KeyEventType.KeyDown) { + false + } else { + when (event.key) { + Key.Back -> { + onClose() + true + } + + Key.DirectionLeft, Key.DirectionRight -> true + Key.DirectionUp -> isFirst + Key.DirectionDown -> isLast + else -> false + } + } + } .clickable { onClick() } .padding(horizontal = 20.dp, vertical = 14.dp) ) { diff --git a/core/player/build.gradle.kts b/core/player/build.gradle.kts index fffc7450..97550e92 100644 --- a/core/player/build.gradle.kts +++ b/core/player/build.gradle.kts @@ -40,4 +40,5 @@ dependencies { implementation(libs.kotlinx.serialization.json) implementation(libs.jellyfin.core) implementation(libs.okhttp) + testImplementation(libs.junit) } diff --git a/core/player/src/main/java/hu/bbara/purefin/core/player/viewmodel/ControlsAutoHidePolicy.kt b/core/player/src/main/java/hu/bbara/purefin/core/player/viewmodel/ControlsAutoHidePolicy.kt new file mode 100644 index 00000000..795d751b --- /dev/null +++ b/core/player/src/main/java/hu/bbara/purefin/core/player/viewmodel/ControlsAutoHidePolicy.kt @@ -0,0 +1,71 @@ +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/player/src/main/java/hu/bbara/purefin/core/player/viewmodel/PlayerViewModel.kt b/core/player/src/main/java/hu/bbara/purefin/core/player/viewmodel/PlayerViewModel.kt index da063e94..9ef602cb 100644 --- a/core/player/src/main/java/hu/bbara/purefin/core/player/viewmodel/PlayerViewModel.kt +++ b/core/player/src/main/java/hu/bbara/purefin/core/player/viewmodel/PlayerViewModel.kt @@ -43,6 +43,7 @@ class PlayerViewModel @Inject constructor( private val _controlsVisible = MutableStateFlow(true) val controlsVisible: StateFlow = _controlsVisible.asStateFlow() + private val controlsAutoHidePolicy = ControlsAutoHidePolicy(DEFAULT_CONTROLS_AUTO_HIDE_MS) private var autoHideJob: Job? = null private var lastNextUpMediaId: String? = null private var dataErrorMessage: String? = null @@ -68,6 +69,9 @@ class PlayerViewModel @Inject constructor( error = state.error ?: dataErrorMessage ) } + applyControlsAutoHideCommand( + controlsAutoHidePolicy.onPlaybackChanged(state.isPlaying) + ) if (state.isEnded) { showControls() } @@ -196,26 +200,42 @@ class PlayerViewModel @Inject constructor( playerManager.seekToLiveEdge() } + fun setControlsAutoHideDelay(autoHideDelayMs: Long) { + applyControlsAutoHideCommand( + controlsAutoHidePolicy.setAutoHideDelay(autoHideDelayMs) + ) + } + + fun setControlsAutoHideBlocked( + blocker: ControlsAutoHideBlocker, + blocked: Boolean + ) { + applyControlsAutoHideCommand( + controlsAutoHidePolicy.setBlocked(blocker, blocked) + ) + } + fun showControls(autoHideDelayMs: Long = DEFAULT_CONTROLS_AUTO_HIDE_MS) { - _controlsVisible.value = true - scheduleAutoHide(autoHideDelayMs) + applyControlsAutoHideCommand( + controlsAutoHidePolicy.showControls(autoHideDelayMs) + ) } fun toggleControlsVisibility() { - _controlsVisible.value = !_controlsVisible.value - if (_controlsVisible.value) { - scheduleAutoHide(DEFAULT_CONTROLS_AUTO_HIDE_MS) - } else { - autoHideJob?.cancel() - } + applyControlsAutoHideCommand( + controlsAutoHidePolicy.toggleControlsVisibility() + ) } - private fun scheduleAutoHide(autoHideDelayMs: Long) { + private fun applyControlsAutoHideCommand(command: ControlsAutoHideCommand) { + _controlsVisible.value = controlsAutoHidePolicy.controlsVisible autoHideJob?.cancel() - if (!player.isPlaying) return + autoHideJob = null + if (command !is ControlsAutoHideCommand.Schedule) return autoHideJob = viewModelScope.launch { - delay(autoHideDelayMs) - _controlsVisible.value = false + delay(command.delayMs) + controlsAutoHidePolicy.hideControls() + _controlsVisible.value = controlsAutoHidePolicy.controlsVisible } } diff --git a/core/player/src/test/java/hu/bbara/purefin/core/player/viewmodel/ControlsAutoHidePolicyTest.kt b/core/player/src/test/java/hu/bbara/purefin/core/player/viewmodel/ControlsAutoHidePolicyTest.kt new file mode 100644 index 00000000..f184b621 --- /dev/null +++ b/core/player/src/test/java/hu/bbara/purefin/core/player/viewmodel/ControlsAutoHidePolicyTest.kt @@ -0,0 +1,90 @@ +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) + } +}