mirror of
https://github.com/bbara04/Purefin.git
synced 2026-07-23 19:26:50 +00:00
feat: Enhance TV player controls with key event handling and overlay visibility management
This commit is contained in:
@@ -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,37 +498,64 @@ private fun TrackPanelHost(
|
|||||||
pendingTrackButtonFocus = null
|
pendingTrackButtonFocus = null
|
||||||
}
|
}
|
||||||
|
|
||||||
Box(modifier = Modifier.size(width = 960.dp, height = 540.dp)) {
|
Box(
|
||||||
TvPlayerControlsOverlay(
|
modifier = Modifier
|
||||||
uiState = uiState,
|
.size(width = 960.dp, height = 540.dp)
|
||||||
focusRequester = controlsFocusRequester,
|
.onPreviewKeyEvent { event ->
|
||||||
isPlaylistExpanded = false,
|
handleTvPlayerRootKeyEvent(
|
||||||
qualityButtonFocusRequester = qualityButtonFocusRequester,
|
event = event,
|
||||||
audioButtonFocusRequester = audioButtonFocusRequester,
|
controlsVisible = controlsVisible,
|
||||||
subtitlesButtonFocusRequester = subtitlesButtonFocusRequester,
|
isPlaylistExpanded = false,
|
||||||
onPlayPause = {},
|
trackPanelType = trackPanelType,
|
||||||
onSeek = { _ -> },
|
onCloseTrackPanel = closeTrackPanel,
|
||||||
onSeekRelative = { _ -> },
|
onCollapsePlaylist = { controlsVisible = true },
|
||||||
onSeekLiveEdge = {},
|
onHideControls = { controlsVisible = false },
|
||||||
onNext = {},
|
onSeekRelative = { _ -> controlsVisible = true },
|
||||||
onPrevious = {},
|
onShowControls = { controlsVisible = true },
|
||||||
onOpenAudioPanel = { trackPanelType = TvTrackPanelType.AUDIO },
|
onTogglePlayPause = { controlsVisible = true }
|
||||||
onOpenSubtitlesPanel = { trackPanelType = TvTrackPanelType.SUBTITLES },
|
)
|
||||||
onOpenQualityPanel = { trackPanelType = TvTrackPanelType.QUALITY },
|
}
|
||||||
onExpandPlaylist = {},
|
) {
|
||||||
onCollapsePlaylist = {},
|
if (controlsVisible || trackPanelType != null) {
|
||||||
onSelectQueueItem = { _ -> },
|
TvPlayerControlsOverlay(
|
||||||
qualityButtonEnabled = uiState.qualityTracks.isNotEmpty(),
|
uiState = uiState,
|
||||||
audioButtonEnabled = uiState.audioTracks.isNotEmpty(),
|
focusRequester = controlsFocusRequester,
|
||||||
subtitlesButtonEnabled = uiState.textTracks.isNotEmpty()
|
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 ->
|
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()
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
|
||||||
|
}
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
Reference in New Issue
Block a user