feat: Enhance TV player controls with key event handling and overlay visibility management

This commit is contained in:
2026-04-10 15:49:34 +02:00
parent 7c30e12af7
commit 360cd51e23
3 changed files with 174 additions and 61 deletions

View File

@@ -13,6 +13,7 @@ import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier 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.Key
import androidx.compose.ui.input.key.onPreviewKeyEvent
import androidx.compose.ui.semantics.SemanticsActions import androidx.compose.ui.semantics.SemanticsActions
import androidx.compose.ui.semantics.SemanticsProperties import androidx.compose.ui.semantics.SemanticsProperties
import androidx.compose.ui.test.ExperimentalTestApi 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.QueueItemUi
import hu.bbara.purefin.core.player.model.TrackOption import hu.bbara.purefin.core.player.model.TrackOption
import hu.bbara.purefin.core.player.model.TrackType import hu.bbara.purefin.core.player.model.TrackType
import hu.bbara.purefin.tv.player.handleTvPlayerRootKeyEvent
import hu.bbara.purefin.ui.theme.AppTheme import hu.bbara.purefin.ui.theme.AppTheme
import org.junit.Rule import org.junit.Rule
import org.junit.Test import org.junit.Test
@@ -279,6 +281,48 @@ class TvPlayerControlsOverlayTest {
composeRule.onNodeWithTag(TvPlayerTrackFirstItemTag).assertIsFocused() 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 @Test
fun backOnTrackPanel_closesItAndRestoresFocusToOpeningButton() { fun backOnTrackPanel_closesItAndRestoresFocusToOpeningButton() {
composeRule.setContent { composeRule.setContent {
@@ -428,6 +472,7 @@ private fun OverlayHost(
private fun TrackPanelHost( private fun TrackPanelHost(
uiState: PlayerUiState uiState: PlayerUiState
) { ) {
var controlsVisible by remember { mutableStateOf(true) }
var trackPanelType by remember { mutableStateOf<TvTrackPanelType?>(null) } var trackPanelType by remember { mutableStateOf<TvTrackPanelType?>(null) }
var pendingTrackButtonFocus by remember { mutableStateOf<TvTrackPanelType?>(null) } var pendingTrackButtonFocus by remember { mutableStateOf<TvTrackPanelType?>(null) }
val controlsFocusRequester = remember { FocusRequester() } val controlsFocusRequester = remember { FocusRequester() }
@@ -438,6 +483,7 @@ private fun TrackPanelHost(
trackPanelType?.let { panelType -> trackPanelType?.let { panelType ->
pendingTrackButtonFocus = panelType pendingTrackButtonFocus = panelType
trackPanelType = null trackPanelType = null
controlsVisible = true
} }
} }
@@ -452,7 +498,25 @@ private fun TrackPanelHost(
pendingTrackButtonFocus = null pendingTrackButtonFocus = null
} }
Box(modifier = Modifier.size(width = 960.dp, height = 540.dp)) { 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( TvPlayerControlsOverlay(
uiState = uiState, uiState = uiState,
focusRequester = controlsFocusRequester, focusRequester = controlsFocusRequester,
@@ -466,9 +530,18 @@ private fun TrackPanelHost(
onSeekLiveEdge = {}, onSeekLiveEdge = {},
onNext = {}, onNext = {},
onPrevious = {}, onPrevious = {},
onOpenAudioPanel = { trackPanelType = TvTrackPanelType.AUDIO }, onOpenAudioPanel = {
onOpenSubtitlesPanel = { trackPanelType = TvTrackPanelType.SUBTITLES }, controlsVisible = true
onOpenQualityPanel = { trackPanelType = TvTrackPanelType.QUALITY }, trackPanelType = TvTrackPanelType.AUDIO
},
onOpenSubtitlesPanel = {
controlsVisible = true
trackPanelType = TvTrackPanelType.SUBTITLES
},
onOpenQualityPanel = {
controlsVisible = true
trackPanelType = TvTrackPanelType.QUALITY
},
onExpandPlaylist = {}, onExpandPlaylist = {},
onCollapsePlaylist = {}, onCollapsePlaylist = {},
onSelectQueueItem = { _ -> }, onSelectQueueItem = { _ -> },
@@ -476,13 +549,13 @@ private fun TrackPanelHost(
audioButtonEnabled = uiState.audioTracks.isNotEmpty(), audioButtonEnabled = uiState.audioTracks.isNotEmpty(),
subtitlesButtonEnabled = uiState.textTracks.isNotEmpty() subtitlesButtonEnabled = uiState.textTracks.isNotEmpty()
) )
}
trackPanelType?.let { panelType -> trackPanelType?.let { panelType ->
TvTrackSelectionPanel( TvTrackSelectionPanel(
panelType = panelType, panelType = panelType,
uiState = uiState, uiState = uiState,
onSelect = { closeTrackPanel() }, onSelect = { closeTrackPanel() },
onClose = closeTrackPanel,
modifier = Modifier.fillMaxSize() modifier = Modifier.fillMaxSize()
) )
} }

View File

@@ -179,33 +179,18 @@ fun TvPlayerScreen(
.background(Color.Black) .background(Color.Black)
.focusRequester(hiddenControlFocusRequester) .focusRequester(hiddenControlFocusRequester)
.onPreviewKeyEvent { event -> .onPreviewKeyEvent { event ->
if (!controlsVisible && event.type == KeyEventType.KeyDown) { handleTvPlayerRootKeyEvent(
when (event.key) { event = event,
Key.DirectionLeft -> { controlsVisible = controlsVisible,
seekByAndShowControls(-10_000) isPlaylistExpanded = isPlaylistExpanded,
true trackPanelType = trackPanelType,
} onCloseTrackPanel = closeTrackPanel,
onCollapsePlaylist = collapsePlaylistToControls,
Key.DirectionRight -> { onHideControls = { viewModel.toggleControlsVisibility() },
seekByAndShowControls(10_000) onSeekRelative = seekByAndShowControls,
true onShowControls = showTvControls,
} onTogglePlayPause = togglePlayPauseAndShowControls
)
Key.DirectionUp, Key.DirectionDown -> {
showTvControls()
true
}
Key.DirectionCenter, Key.Enter -> {
togglePlayPauseAndShowControls()
true
}
else -> false
}
} else {
false
}
} }
.focusable() .focusable()
) { ) {
@@ -282,7 +267,6 @@ fun TvPlayerScreen(
viewModel.selectTrack(track) viewModel.selectTrack(track)
closeTrackPanel() closeTrackPanel()
}, },
onClose = closeTrackPanel,
modifier = Modifier.fillMaxSize() 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
}

View File

@@ -111,7 +111,6 @@ internal fun TvTrackSelectionPanel(
panelType: TvTrackPanelType, panelType: TvTrackPanelType,
uiState: PlayerUiState, uiState: PlayerUiState,
onSelect: (TrackOption) -> Unit, onSelect: (TrackOption) -> Unit,
onClose: () -> Unit,
modifier: Modifier = Modifier modifier: Modifier = Modifier
) { ) {
val scheme = MaterialTheme.colorScheme val scheme = MaterialTheme.colorScheme
@@ -192,7 +191,6 @@ internal fun TvTrackSelectionPanel(
selected = option.id == selectedId, selected = option.id == selectedId,
isFirst = index == 0, isFirst = index == 0,
isLast = index == options.lastIndex, isLast = index == options.lastIndex,
onClose = onClose,
onClick = { onSelect(option) } onClick = { onSelect(option) }
) )
} }
@@ -209,7 +207,6 @@ private fun TvTrackOptionRow(
selected: Boolean, selected: Boolean,
isFirst: Boolean, isFirst: Boolean,
isLast: Boolean, isLast: Boolean,
onClose: () -> Unit,
onClick: () -> Unit onClick: () -> Unit
) { ) {
val scheme = MaterialTheme.colorScheme val scheme = MaterialTheme.colorScheme
@@ -237,11 +234,6 @@ private fun TvTrackOptionRow(
false false
} else { } else {
when (event.key) { when (event.key) {
Key.Back -> {
onClose()
true
}
Key.DirectionLeft, Key.DirectionRight -> true Key.DirectionLeft, Key.DirectionRight -> true
Key.DirectionUp -> isFirst Key.DirectionUp -> isFirst
Key.DirectionDown -> isLast Key.DirectionDown -> isLast