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.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<TvTrackPanelType?>(null) }
var pendingTrackButtonFocus by remember { mutableStateOf<TvTrackPanelType?>(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()
)
}

View File

@@ -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
}

View File

@@ -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