refactor(player): move controls auto-hide from ViewModel to composable layer

This commit is contained in:
2026-06-18 20:05:32 +00:00
parent 615e95db5c
commit 1bd51d8a9f
9 changed files with 181 additions and 1405 deletions

View File

@@ -1,979 +0,0 @@
package hu.bbara.purefin.ui.screen.player.components
import androidx.activity.ComponentActivity
import androidx.compose.animation.AnimatedVisibility
import androidx.compose.foundation.focusable
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
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
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.onPreviewKeyEvent
import androidx.compose.ui.platform.testTag
import androidx.compose.ui.semantics.SemanticsActions
import androidx.compose.ui.semantics.SemanticsProperties
import androidx.compose.ui.semantics.semantics
import androidx.compose.ui.semantics.stateDescription
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.onNodeWithTag
import androidx.compose.ui.test.onNodeWithText
import androidx.compose.ui.test.performKeyInput
import androidx.compose.ui.test.performSemanticsAction
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.PlaylistElementUiModel
import hu.bbara.purefin.core.player.model.TrackOption
import hu.bbara.purefin.core.player.model.TrackType
import hu.bbara.purefin.ui.screen.player.TV_HIDDEN_STOP_FEEDBACK_MS
import hu.bbara.purefin.ui.screen.player.TvPlayerHiddenStopFeedback
import hu.bbara.purefin.ui.screen.player.TvPlayerHiddenStopFeedbackTag
import hu.bbara.purefin.ui.screen.player.handleTvPlayerRootKeyEvent
import hu.bbara.purefin.ui.theme.AppTheme
import kotlinx.coroutines.delay
import org.junit.Rule
import org.junit.Test
private const val TvPlayerRootTag = "tv_player_root"
private const val TvPlayerHiddenPauseCountTag = "tv_player_hidden_pause_count"
private const val TvPlayerHiddenResumeCountTag = "tv_player_hidden_resume_count"
private const val TvPlayerHiddenSeekDeltaTag = "tv_player_hidden_seek_delta"
@OptIn(ExperimentalTestApi::class)
class TvPlayerControlsOverlayTest {
@get:Rule
val composeRule = createAndroidComposeRule<ComponentActivity>()
@Test
fun requestingControlsFocus_focusesPlayPauseInsteadOfSeekBar() {
composeRule.setContent {
AppTheme {
OverlayHost(
uiState = samplePlayerState(),
requestFocus = true
)
}
}
composeRule.waitForIdle()
composeRule.onNodeWithTag(TvPlayerPlayPauseButtonTag).assertIsFocused()
composeRule.onNodeWithTag(TvPlayerSeekBarTag)
.assert(
SemanticsMatcher.expectValue(
SemanticsProperties.Focused,
false
)
)
}
@Test
fun downFromSeekBar_movesFocusToControlButtonsBeforePlaylist() {
composeRule.setContent {
AppTheme {
OverlayHost(uiState = samplePlayerState())
}
}
composeRule.onNodeWithTag(TvPlayerSeekBarTag)
.performSemanticsAction(SemanticsActions.RequestFocus)
.assertIsFocused()
.performKeyInput {
pressKey(Key.DirectionDown)
}
composeRule.waitForIdle()
composeRule.onNodeWithTag(TvPlayerPlaylistStateTag)
.assert(
SemanticsMatcher.expectValue(
SemanticsProperties.StateDescription,
"collapsed"
)
)
composeRule.onNodeWithTag(TvPlayerPlayPauseButtonTag).assertIsFocused()
}
@Test
fun downFromControls_expandsPlaylistAndMovesFocusToCurrentQueueItem() {
composeRule.setContent {
AppTheme {
OverlayHost(uiState = samplePlayerState())
}
}
composeRule.onNodeWithTag(TvPlayerPlayPauseButtonTag)
.performSemanticsAction(SemanticsActions.RequestFocus)
.assertIsFocused()
.performKeyInput {
pressKey(Key.DirectionDown)
}
composeRule.waitForIdle()
composeRule.onNodeWithTag(TvPlayerPlaylistStateTag)
.assert(
SemanticsMatcher.expectValue(
SemanticsProperties.StateDescription,
"expanded"
)
)
composeRule.onNodeWithTag(TvPlayerPlaylistCurrentItemTag).assertIsFocused()
}
@Test
fun upFromCurrentQueueItem_collapsesPlaylistAndRestoresFocusedControl() {
composeRule.setContent {
AppTheme {
OverlayHost(uiState = samplePlayerState())
}
}
composeRule.onNodeWithTag(TvPlayerPlayPauseButtonTag)
.performSemanticsAction(SemanticsActions.RequestFocus)
.performKeyInput {
pressKey(Key.DirectionDown)
}
composeRule.waitForIdle()
composeRule.onNodeWithTag(TvPlayerPlaylistCurrentItemTag)
.assertIsFocused()
.performKeyInput {
pressKey(Key.DirectionUp)
}
composeRule.waitForIdle()
composeRule.onNodeWithTag(TvPlayerPlaylistStateTag)
.assert(
SemanticsMatcher.expectValue(
SemanticsProperties.StateDescription,
"collapsed"
)
)
composeRule.onNodeWithTag(TvPlayerPlayPauseButtonTag).assertIsFocused()
}
@Test
fun rightOnLastPlaylistItem_keepsFocusInPlaylist() {
composeRule.setContent {
AppTheme {
OverlayHost(uiState = samplePlayerState())
}
}
composeRule.onNodeWithTag(TvPlayerPlayPauseButtonTag)
.performSemanticsAction(SemanticsActions.RequestFocus)
.performKeyInput {
pressKey(Key.DirectionDown)
}
composeRule.waitForIdle()
composeRule.onNodeWithTag(TvPlayerPlaylistCurrentItemTag)
.assertIsFocused()
.performKeyInput {
pressKey(Key.DirectionRight)
}
composeRule.waitForIdle()
composeRule.onNodeWithTag(TvPlayerPlaylistLastItemTag)
.assertIsFocused()
.performKeyInput {
pressKey(Key.DirectionRight)
}
composeRule.waitForIdle()
composeRule.onNodeWithTag(TvPlayerPlaylistStateTag)
.assert(
SemanticsMatcher.expectValue(
SemanticsProperties.StateDescription,
"expanded"
)
)
composeRule.onNodeWithTag(TvPlayerPlaylistLastItemTag).assertIsFocused()
}
@Test
fun playlistShowsQueueAsRowAndCurrentItemCanReceiveFocus() {
composeRule.setContent {
AppTheme {
OverlayHost(
uiState = samplePlayerState(),
initiallyExpanded = true
)
}
}
composeRule.waitForIdle()
composeRule.onNodeWithTag(TvPlayerPlaylistRowTag).assertIsDisplayed()
composeRule.onNodeWithTag(TvPlayerPlaylistCurrentItemTag)
.performSemanticsAction(SemanticsActions.RequestFocus)
.assertIsFocused()
composeRule.onNodeWithText("Currently Playing").assertIsDisplayed()
composeRule.onNodeWithText("Episode 2").assertIsDisplayed()
composeRule.onNodeWithTag(TvPlayerPlaylistFirstItemTag).assertIsDisplayed()
composeRule.onNodeWithText("Current").assertIsDisplayed()
composeRule.onNodeWithText("Already Played").assertIsDisplayed()
}
@Test
fun playlistFallsBackToFirstItemWhenNoCurrentItemExists_andFallbackItemCanReceiveFocus() {
composeRule.setContent {
AppTheme {
OverlayHost(
uiState = samplePlayerState().copy(
queue = listOf(
PlaylistElementUiModel(
id = "first",
title = "Episode 1",
subtitle = "Fallback entry",
artworkUrl = null,
isCurrent = false
),
PlaylistElementUiModel(
id = "second",
title = "Episode 2",
subtitle = "Later",
artworkUrl = null,
isCurrent = false
)
)
),
initiallyExpanded = true
)
}
}
composeRule.waitForIdle()
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 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 hiddenControls_centerWhilePlaying_pausesWithoutShowingControlsAndShowsStopFeedback() {
composeRule.setContent {
AppTheme {
TrackPanelHost(
uiState = samplePlayerState(),
initialControlsVisible = false
)
}
}
composeRule.onNodeWithTag(TvPlayerRootTag)
.performSemanticsAction(SemanticsActions.RequestFocus)
.performKeyInput {
pressKey(Key.DirectionCenter)
}
composeRule.waitForIdle()
composeRule.onAllNodesWithTag(TvPlayerPlayPauseButtonTag).assertCountEquals(0)
composeRule.onNodeWithTag(TvPlayerHiddenStopFeedbackTag).assertIsDisplayed()
composeRule.onNodeWithTag(TvPlayerHiddenPauseCountTag)
.assert(
SemanticsMatcher.expectValue(
SemanticsProperties.StateDescription,
"1"
)
)
}
@Test
fun hiddenControls_enterWhilePlaying_pausesWithoutShowingControlsAndShowsStopFeedback() {
composeRule.setContent {
AppTheme {
TrackPanelHost(
uiState = samplePlayerState(),
initialControlsVisible = false
)
}
}
composeRule.onNodeWithTag(TvPlayerRootTag)
.performSemanticsAction(SemanticsActions.RequestFocus)
.performKeyInput {
pressKey(Key.Enter)
}
composeRule.waitForIdle()
composeRule.onAllNodesWithTag(TvPlayerPlayPauseButtonTag).assertCountEquals(0)
composeRule.onNodeWithTag(TvPlayerHiddenStopFeedbackTag).assertIsDisplayed()
composeRule.onNodeWithTag(TvPlayerHiddenPauseCountTag)
.assert(
SemanticsMatcher.expectValue(
SemanticsProperties.StateDescription,
"1"
)
)
}
@Test
fun hiddenControls_stopFeedbackAutoHidesAfterTimeout() {
composeRule.mainClock.autoAdvance = false
composeRule.setContent {
AppTheme {
TrackPanelHost(
uiState = samplePlayerState(),
initialControlsVisible = false
)
}
}
composeRule.onNodeWithTag(TvPlayerRootTag)
.performSemanticsAction(SemanticsActions.RequestFocus)
.performKeyInput {
pressKey(Key.DirectionCenter)
}
composeRule.mainClock.advanceTimeByFrame()
composeRule.onNodeWithTag(TvPlayerHiddenStopFeedbackTag).assertIsDisplayed()
composeRule.mainClock.advanceTimeBy(TV_HIDDEN_STOP_FEEDBACK_MS - 1)
composeRule.mainClock.advanceTimeByFrame()
composeRule.onNodeWithTag(TvPlayerHiddenStopFeedbackTag).assertIsDisplayed()
composeRule.mainClock.advanceTimeBy(1)
composeRule.mainClock.advanceTimeBy(300)
composeRule.mainClock.advanceTimeByFrame()
composeRule.onAllNodesWithTag(TvPlayerHiddenStopFeedbackTag).assertCountEquals(0)
composeRule.mainClock.autoAdvance = true
}
@Test
fun hiddenControls_centerWhilePaused_resumesWithoutShowingControls() {
composeRule.setContent {
AppTheme {
TrackPanelHost(
uiState = samplePlayerState().copy(isPlaying = false),
initialControlsVisible = false
)
}
}
composeRule.onNodeWithTag(TvPlayerRootTag)
.performSemanticsAction(SemanticsActions.RequestFocus)
.performKeyInput {
pressKey(Key.DirectionCenter)
}
composeRule.waitForIdle()
composeRule.onAllNodesWithTag(TvPlayerPlayPauseButtonTag).assertCountEquals(0)
composeRule.onAllNodesWithTag(TvPlayerHiddenStopFeedbackTag).assertCountEquals(0)
composeRule.onNodeWithTag(TvPlayerHiddenPauseCountTag)
.assert(
SemanticsMatcher.expectValue(
SemanticsProperties.StateDescription,
"0"
)
)
composeRule.onNodeWithTag(TvPlayerHiddenResumeCountTag)
.assert(
SemanticsMatcher.expectValue(
SemanticsProperties.StateDescription,
"1"
)
)
}
@Test
fun hiddenControls_leftSeeksWithoutShowingControls() {
composeRule.setContent {
AppTheme {
TrackPanelHost(
uiState = samplePlayerState(),
initialControlsVisible = false
)
}
}
composeRule.onNodeWithTag(TvPlayerRootTag)
.performSemanticsAction(SemanticsActions.RequestFocus)
.performKeyInput {
pressKey(Key.DirectionLeft)
}
composeRule.waitForIdle()
composeRule.onAllNodesWithTag(TvPlayerPlayPauseButtonTag).assertCountEquals(0)
composeRule.onAllNodesWithTag(TvPlayerHiddenStopFeedbackTag).assertCountEquals(0)
composeRule.onNodeWithTag(TvPlayerHiddenSeekDeltaTag)
.assert(
SemanticsMatcher.expectValue(
SemanticsProperties.StateDescription,
"-10000"
)
)
}
@Test
fun hiddenControls_rightSeeksWithoutShowingControls() {
composeRule.setContent {
AppTheme {
TrackPanelHost(
uiState = samplePlayerState(),
initialControlsVisible = false
)
}
}
composeRule.onNodeWithTag(TvPlayerRootTag)
.performSemanticsAction(SemanticsActions.RequestFocus)
.performKeyInput {
pressKey(Key.DirectionRight)
}
composeRule.waitForIdle()
composeRule.onAllNodesWithTag(TvPlayerPlayPauseButtonTag).assertCountEquals(0)
composeRule.onAllNodesWithTag(TvPlayerHiddenStopFeedbackTag).assertCountEquals(0)
composeRule.onNodeWithTag(TvPlayerHiddenSeekDeltaTag)
.assert(
SemanticsMatcher.expectValue(
SemanticsProperties.StateDescription,
"10000"
)
)
}
@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
private fun OverlayHost(
uiState: PlayerUiState,
initiallyExpanded: Boolean = false,
requestFocus: Boolean = false
) {
var isPlaylistExpanded by remember { mutableStateOf(initiallyExpanded) }
val focusRequester = remember { FocusRequester() }
val qualityButtonFocusRequester = remember { FocusRequester() }
val audioButtonFocusRequester = remember { FocusRequester() }
val subtitlesButtonFocusRequester = remember { FocusRequester() }
LaunchedEffect(requestFocus) {
if (requestFocus) {
focusRequester.requestFocus()
}
}
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 = { _ -> },
onSeekLiveEdge = {},
onSkipSegment = {},
onNext = {},
onPrevious = {},
onOpenAudioPanel = {},
onOpenSubtitlesPanel = {},
onOpenQualityPanel = {},
onExpandPlaylist = { isPlaylistExpanded = true },
onCollapsePlaylist = { isPlaylistExpanded = false },
onSelectQueueItem = { _ -> },
qualityButtonEnabled = uiState.qualityTracks.isNotEmpty(),
audioButtonEnabled = uiState.audioTracks.isNotEmpty(),
subtitlesButtonEnabled = uiState.textTracks.isNotEmpty()
)
}
}
@Composable
private fun TrackPanelHost(
uiState: PlayerUiState,
initialControlsVisible: Boolean = true
) {
var controlsVisible by remember { mutableStateOf(initialControlsVisible) }
var trackPanelType by remember { mutableStateOf<TvTrackPanelType?>(null) }
var pendingTrackButtonFocus by remember { mutableStateOf<TvTrackPanelType?>(null) }
var stopFeedbackVisible by remember { mutableStateOf(false) }
var stopFeedbackRequestId by remember { mutableStateOf(0) }
var pauseWithoutControlsCount by remember { mutableStateOf(0) }
var resumeWithoutControlsCount by remember { mutableStateOf(0) }
var lastHiddenSeekDeltaMs by remember { mutableStateOf(0L) }
val rootFocusRequester = remember { FocusRequester() }
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
controlsVisible = true
}
}
val pausePlaybackWithoutShowingControls: () -> Unit = {
pauseWithoutControlsCount += 1
stopFeedbackVisible = true
stopFeedbackRequestId += 1
}
val resumePlaybackWithoutShowingControls: () -> Unit = {
resumeWithoutControlsCount += 1
stopFeedbackVisible = false
}
val seekWithoutShowingControls: (Long) -> Unit = { deltaMs ->
lastHiddenSeekDeltaMs = deltaMs
stopFeedbackVisible = false
}
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
}
LaunchedEffect(stopFeedbackRequestId) {
if (stopFeedbackRequestId == 0) return@LaunchedEffect
delay(TV_HIDDEN_STOP_FEEDBACK_MS)
stopFeedbackVisible = false
}
LaunchedEffect(controlsVisible, trackPanelType) {
if (controlsVisible || trackPanelType != null) {
stopFeedbackVisible = false
} else {
rootFocusRequester.requestFocus()
}
}
Box(
modifier = Modifier
.size(width = 960.dp, height = 540.dp)
.focusRequester(rootFocusRequester)
.focusable()
.testTag(TvPlayerRootTag)
.onPreviewKeyEvent { event ->
handleTvPlayerRootKeyEvent(
event = event,
isPlaying = uiState.isPlaying,
controlsVisible = controlsVisible,
isPlaylistExpanded = false,
trackPanelType = trackPanelType,
onCloseTrackPanel = closeTrackPanel,
onCollapsePlaylist = { controlsVisible = true },
onHideControls = { controlsVisible = false },
onPausePlaybackWithoutShowingControls = pausePlaybackWithoutShowingControls,
onResumePlaybackWithoutShowingControls = resumePlaybackWithoutShowingControls,
onSeekRelative = seekWithoutShowingControls,
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 = {},
onSkipSegment = {},
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()
)
}
AnimatedVisibility(
visible = stopFeedbackVisible,
modifier = Modifier.align(Alignment.Center)
) {
TvPlayerHiddenStopFeedback()
}
Box(
modifier = Modifier
.testTag(TvPlayerHiddenPauseCountTag)
.semantics { stateDescription = pauseWithoutControlsCount.toString() }
)
Box(
modifier = Modifier
.testTag(TvPlayerHiddenResumeCountTag)
.semantics { stateDescription = resumeWithoutControlsCount.toString() }
)
Box(
modifier = Modifier
.testTag(TvPlayerHiddenSeekDeltaTag)
.semantics { stateDescription = lastHiddenSeekDeltaMs.toString() }
)
trackPanelType?.let { panelType ->
TvTrackSelectionPanel(
panelType = panelType,
uiState = uiState,
onSelect = { closeTrackPanel() },
modifier = Modifier.fillMaxSize()
)
}
}
}
private fun samplePlayerState(): PlayerUiState = PlayerUiState(
isPlaying = true,
title = "Sample Player",
subtitle = "Season 1",
durationMs = 3_600_000,
positionMs = 120_000,
bufferedMs = 240_000,
queue = listOf(
PlaylistElementUiModel(
id = "played",
title = "Already Played",
subtitle = "Episode 0",
artworkUrl = null,
isCurrent = false
),
PlaylistElementUiModel(
id = "current",
title = "Currently Playing",
subtitle = "Episode 1",
artworkUrl = null,
isCurrent = true
),
PlaylistElementUiModel(
id = "next",
title = "Episode 2",
subtitle = "Up next",
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"
)

View File

@@ -30,9 +30,10 @@ import androidx.compose.runtime.Composable
import androidx.compose.runtime.DisposableEffect
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import androidx.compose.runtime.mutableIntStateOf
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
@@ -41,34 +42,36 @@ import androidx.compose.ui.focus.FocusRequester
import androidx.compose.ui.focus.focusRequester
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.input.key.Key
import androidx.compose.ui.input.key.KeyEvent
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.LocalContext
import androidx.compose.ui.platform.LocalFocusManager
import androidx.compose.ui.platform.testTag
import androidx.compose.ui.unit.dp
import androidx.compose.ui.viewinterop.AndroidView
import androidx.hilt.navigation.compose.hiltViewModel
import androidx.lifecycle.Lifecycle
import androidx.lifecycle.compose.LifecycleEventEffect
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import androidx.media3.common.util.UnstableApi
import androidx.media3.ui.AspectRatioFrameLayout
import androidx.media3.ui.PlayerView
import androidx.media3.ui.SubtitleView
import hu.bbara.purefin.core.player.model.TimedMarker
import hu.bbara.purefin.core.player.viewmodel.ControlsAutoHideBlocker
import hu.bbara.purefin.core.player.viewmodel.PlayerViewModel
import hu.bbara.purefin.ui.screen.player.components.PlayerSeekBarTrack
import hu.bbara.purefin.ui.screen.player.components.TvIconButton
import hu.bbara.purefin.ui.screen.player.components.TvNextEpisodeOverlay
import hu.bbara.purefin.ui.screen.player.components.TvPlayerTimeRow
import hu.bbara.purefin.ui.screen.player.components.TvPlayerControlsOverlay
import hu.bbara.purefin.ui.screen.player.components.TvPlayerLoadingErrorEndCard
import hu.bbara.purefin.ui.screen.player.components.TvPlayerTimeRow
import hu.bbara.purefin.ui.screen.player.components.TvTrackPanelType
import hu.bbara.purefin.ui.screen.player.components.TvTrackSelectionPanel
import kotlinx.coroutines.Job
import kotlinx.coroutines.delay
import kotlinx.coroutines.launch
private const val TV_CONTROLS_AUTO_HIDE_MS = 5_000L
private const val CONTROLS_VISIBLE_SUBTITLE_BOTTOM_PADDING_FRACTION = 0.22f
@@ -86,21 +89,28 @@ fun TvPlayerScreen(
viewModel.loadMedia(mediaId)
}
val scope = rememberCoroutineScope()
var hideControlsJob: Job? by remember { mutableStateOf(null) }
val uiState by viewModel.uiState.collectAsStateWithLifecycle()
val controlsVisible by viewModel.controlsVisible.collectAsStateWithLifecycle()
var controlsVisible by remember { mutableStateOf(true) }
var isPlaylistExpanded by remember { mutableStateOf(false) }
var trackPanelType by remember { mutableStateOf<TvTrackPanelType?>(null) }
// TODO why this is needed???
var pendingTrackButtonFocus by remember { mutableStateOf<TvTrackPanelType?>(null) }
var stopFeedbackVisible by remember { mutableStateOf(false) }
var stopFeedbackRequestId by remember { mutableStateOf(0) }
var stopFeedbackRequestId by remember { mutableIntStateOf(0) }
var hiddenSeekPreviewPositionMs by remember { mutableStateOf<Long?>(null) }
var hiddenSeekRequestId by remember { mutableStateOf(0) }
val controlsAutoHideBlocked = isPlaylistExpanded || trackPanelType != null
var hiddenSeekRequestId by remember { mutableIntStateOf(0) }
val context = LocalContext.current
val focusManager = LocalFocusManager.current
val rootFocusRequester = remember { FocusRequester() }
val controlsFocusRequester = remember { FocusRequester() }
val backgroundFocusRequester = remember { FocusRequester() }
// Main section focus requesters
var rootFocusRequester by remember { mutableStateOf(backgroundFocusRequester) }
var controlsFocusRequester by remember { mutableStateOf(FocusRequester() ) }
val qualityButtonFocusRequester = remember { FocusRequester() }
val audioButtonFocusRequester = remember { FocusRequester() }
val subtitlesButtonFocusRequester = remember { FocusRequester() }
@@ -110,7 +120,11 @@ fun TvPlayerScreen(
LifecycleEventEffect(Lifecycle.Event.ON_STOP) {
viewModel.pausePlayback()
}
DisposableEffect(Unit) {
onDispose {
(context as? Activity)?.window?.clearFlags(WindowManager.LayoutParams.FLAG_KEEP_SCREEN_ON)
}
}
LaunchedEffect(uiState.isPlaying) {
val activity = context as? Activity
if (uiState.isPlaying) {
@@ -119,119 +133,74 @@ fun TvPlayerScreen(
activity?.window?.clearFlags(WindowManager.LayoutParams.FLAG_KEEP_SCREEN_ON)
}
}
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)
fun showControls() {
controlsVisible = true
}
fun hideControls() {
controlsVisible = false
}
fun hideControlsWithTimeout() {
hideControlsJob?.cancel()
if (controlsVisible && !isPlaylistExpanded && trackPanelType == null && !uiState.isEnded && uiState.error == null) {
hideControlsJob = scope.launch {
delay(TV_CONTROLS_AUTO_HIDE_MS)
hideControls()
}
}
}
// Needed because of composition scheduling
LaunchedEffect(controlsVisible) {
if (controlsVisible) {
controlsFocusRequester.requestFocus()
} else {
rootFocusRequester.requestFocus()
}
}
LaunchedEffect(isPlaylistExpanded) {
viewModel.setControlsAutoHideBlocked(ControlsAutoHideBlocker.PLAYLIST, isPlaylistExpanded)
fun expandPlaylist() {
// TODO: focus management for playlist expansion
isPlaylistExpanded = true
}
LaunchedEffect(trackPanelType) {
viewModel.setControlsAutoHideBlocked(
ControlsAutoHideBlocker.TRACK_PANEL,
trackPanelType != null
)
}
val expandPlaylist: () -> Unit = {
if (!isPlaylistExpanded) {
isPlaylistExpanded = true
}
}
val collapsePlaylistToControls: () -> Unit = {
if (isPlaylistExpanded) {
isPlaylistExpanded = false
viewModel.showControls(TV_CONTROLS_AUTO_HIDE_MS)
}
}
val showTvControls: () -> Unit = {
viewModel.showControls(TV_CONTROLS_AUTO_HIDE_MS)
}
val togglePlayPauseAndShowControls: () -> Unit = {
viewModel.togglePlayPause(TV_CONTROLS_AUTO_HIDE_MS)
}
val pausePlaybackWithoutShowingControls: () -> Unit = {
viewModel.pausePlayback()
stopFeedbackVisible = true
stopFeedbackRequestId += 1
}
val resumePlaybackWithoutShowingControls: () -> Unit = {
viewModel.resumePlayback()
stopFeedbackVisible = false
}
val seekAndShowControls: (Long) -> Unit = { positionMs ->
viewModel.seekTo(positionMs)
showTvControls()
}
val seekByAndShowControls: (Long) -> Unit = { deltaMs ->
viewModel.seekBy(deltaMs)
showTvControls()
}
val seekByWithoutShowingControls: (Long) -> Unit = { deltaMs ->
val basePositionMs = hiddenSeekPreviewPositionMs ?: uiState.positionMs
hiddenSeekPreviewPositionMs = (basePositionMs + deltaMs).coerceSeekPosition(uiState.durationMs)
hiddenSeekRequestId += 1
viewModel.seekBy(deltaMs)
stopFeedbackVisible = false
}
val seekToLiveEdgeAndShowControls: () -> Unit = {
viewModel.seekToLiveEdge()
showTvControls()
}
val skipSegmentAndShowControls: () -> Unit = {
viewModel.skipActiveSegment()
showTvControls()
}
val nextAndShowControls: () -> Unit = {
viewModel.next(TV_CONTROLS_AUTO_HIDE_MS)
}
val previousAndShowControls: () -> Unit = {
viewModel.previous(TV_CONTROLS_AUTO_HIDE_MS)
fun closePlaylist() {
// TODO focus
isPlaylistExpanded = false
}
val closeTrackPanel: () -> Unit = {
trackPanelType?.let { panelType ->
pendingTrackButtonFocus = panelType
trackPanelType = null
viewModel.showControls(TV_CONTROLS_AUTO_HIDE_MS)
}
}
val playerControlsVisible =
controlsVisible || isPlaylistExpanded || trackPanelType != null || uiState.isEnded || uiState.error != null
val showNextEpisodeOverlay = !playerControlsVisible
val showSkipIntroButton = !controlsVisible
&& uiState.activeSkippableSegmentEndMs != null
&& !uiState.isEnded
val showNextEpisodeOverlay = !controlsVisible
&& uiState.nextEpisode != null
&& uiState.durationMs > 0L
&& (uiState.durationMs - uiState.positionMs) <= 60_000L
&& !uiState.isEnded
LaunchedEffect(
controlsVisible,
controlsAutoHideBlocked,
uiState.activeSkippableSegmentEndMs,
showNextEpisodeOverlay
) {
if (controlsAutoHideBlocked) return@LaunchedEffect
if (controlsVisible) {
controlsFocusRequester.requestFocus()
} else {
if (uiState.activeSkippableSegmentEndMs != null) {
skipButtonFocusRequester.requestFocus()
return@LaunchedEffect
val showHiddenSeekPreview = !controlsVisible && hiddenSeekPreviewPositionMs != null
LaunchedEffect(showSkipIntroButton, showNextEpisodeOverlay) {
rootFocusRequester = when {
showSkipIntroButton -> {
skipButtonFocusRequester
}
if (showNextEpisodeOverlay) {
nextEpisodeFocusRequester.requestFocus()
return@LaunchedEffect
showNextEpisodeOverlay -> {
nextEpisodeFocusRequester
}
focusManager.clearFocus()
else -> {
backgroundFocusRequester
}
}
if (!controlsVisible) {
rootFocusRequester.requestFocus()
}
}
LaunchedEffect(trackPanelType, pendingTrackButtonFocus) {
val pendingFocus = pendingTrackButtonFocus ?: return@LaunchedEffect
if (trackPanelType != null) return@LaunchedEffect
@@ -243,6 +212,7 @@ fun TvPlayerScreen(
pendingTrackButtonFocus = null
}
// TODO: use animated visibility for this type of feedback that is already implemented
LaunchedEffect(stopFeedbackRequestId) {
if (stopFeedbackRequestId == 0) return@LaunchedEffect
delay(TV_HIDDEN_STOP_FEEDBACK_MS)
@@ -263,7 +233,7 @@ fun TvPlayerScreen(
}
val subtitleBottomPaddingFraction =
if (playerControlsVisible) {
if (controlsVisible) {
CONTROLS_VISIBLE_SUBTITLE_BOTTOM_PADDING_FRACTION
} else {
SubtitleView.DEFAULT_BOTTOM_PADDING_FRACTION
@@ -272,8 +242,12 @@ fun TvPlayerScreen(
BackHandler(enabled = true) {
when {
trackPanelType != null -> closeTrackPanel()
isPlaylistExpanded -> collapsePlaylistToControls()
controlsVisible -> viewModel.toggleControlsVisibility()
isPlaylistExpanded -> {
closePlaylist()
}
controlsVisible -> {
hideControls()
}
else -> onBack()
}
}
@@ -284,23 +258,28 @@ fun TvPlayerScreen(
.background(Color.Black)
.focusRequester(rootFocusRequester)
.onPreviewKeyEvent { event ->
handleTvPlayerRootKeyEvent(
val handled = handleTvPlayerRootKeyEvent(
event = event,
isPlaying = uiState.isPlaying,
controlsVisible = controlsVisible,
onShowControls = ::showControls,
isPlaylistExpanded = isPlaylistExpanded,
trackPanelType = trackPanelType,
onCloseTrackPanel = closeTrackPanel,
onCollapsePlaylist = collapsePlaylistToControls,
onHideControls = { viewModel.toggleControlsVisibility() },
onPausePlaybackWithoutShowingControls = pausePlaybackWithoutShowingControls,
onResumePlaybackWithoutShowingControls = resumePlaybackWithoutShowingControls,
onSeekRelative = seekByWithoutShowingControls,
onShowControls = showTvControls,
onSkipSegment = skipSegmentAndShowControls,
hasSkippableSegment = uiState.activeSkippableSegmentEndMs != null,
onTogglePlayPause = togglePlayPauseAndShowControls
onCollapsePlaylist = {},
onHideControls = ::hideControls,
onPausePlayback = { viewModel.pausePlayback() },
onResumePlayback = { viewModel.resumePlayback() },
onSeekRelative = { viewModel.seekBy(it) },
onSkipSegment = {
viewModel.skipActiveSegment()
},
hasSkippableSegment = uiState.activeSkippableSegmentEndMs != null
)
if (event.type == KeyEventType.KeyDown) {
hideControlsWithTimeout()
}
handled
}
.focusable()
) {
@@ -323,7 +302,7 @@ fun TvPlayerScreen(
)
AnimatedVisibility(
visible = playerControlsVisible,
visible = controlsVisible,
enter = fadeIn(),
exit = fadeOut()
) {
@@ -335,21 +314,28 @@ fun TvPlayerScreen(
qualityButtonFocusRequester = qualityButtonFocusRequester,
audioButtonFocusRequester = audioButtonFocusRequester,
subtitlesButtonFocusRequester = subtitlesButtonFocusRequester,
onPlayPause = togglePlayPauseAndShowControls,
onSeek = seekAndShowControls,
onSeekRelative = seekByAndShowControls,
onSeekLiveEdge = seekToLiveEdgeAndShowControls,
onSkipSegment = skipSegmentAndShowControls,
onNext = nextAndShowControls,
onPrevious = previousAndShowControls,
onPlayPause = { viewModel.togglePlayPause() },
onSeek = { positionMs ->
viewModel.seekTo(positionMs)
},
onSeekRelative = { deltaMs ->
viewModel.seekBy(deltaMs)
},
onSeekLiveEdge = {
viewModel.seekToLiveEdge()
},
onSkipSegment = {
viewModel.skipActiveSegment()
},
onNext = { viewModel.next() },
onPrevious = { viewModel.previous() },
onOpenAudioPanel = { trackPanelType = TvTrackPanelType.AUDIO },
onOpenSubtitlesPanel = { trackPanelType = TvTrackPanelType.SUBTITLES },
onOpenQualityPanel = { trackPanelType = TvTrackPanelType.QUALITY },
onExpandPlaylist = expandPlaylist,
onCollapsePlaylist = collapsePlaylistToControls,
onExpandPlaylist = ::expandPlaylist,
onCollapsePlaylist = ::closePlaylist,
onSelectQueueItem = { id ->
viewModel.playQueueItem(id)
collapsePlaylistToControls()
},
qualityButtonEnabled = uiState.qualityTracks.isNotEmpty(),
audioButtonEnabled = uiState.audioTracks.isNotEmpty(),
@@ -358,9 +344,7 @@ fun TvPlayerScreen(
}
AnimatedVisibility(
visible = !playerControlsVisible && uiState.activeSkippableSegmentEndMs != null,
enter = fadeIn(),
exit = fadeOut(),
visible = showSkipIntroButton,
modifier = Modifier
.align(Alignment.BottomEnd)
.padding(
@@ -371,7 +355,7 @@ fun TvPlayerScreen(
TvIconButton(
icon = Icons.Outlined.SkipNext,
contentDescription = "Skip segment",
onClick = skipSegmentAndShowControls,
onClick = { viewModel.skipActiveSegment() },
size = 64,
label = "Skip",
modifier = Modifier.focusRequester(skipButtonFocusRequester)
@@ -380,8 +364,6 @@ fun TvPlayerScreen(
AnimatedVisibility(
visible = showNextEpisodeOverlay,
enter = fadeIn(),
exit = fadeOut(),
modifier = Modifier
.align(Alignment.BottomEnd)
.padding(
@@ -392,17 +374,15 @@ fun TvPlayerScreen(
uiState.nextEpisode?.let { nextEpisode ->
TvNextEpisodeOverlay(
nextEpisode = nextEpisode,
onClick = { nextAndShowControls() },
onUp = { showTvControls() },
onClick = { viewModel.next() },
modifier = Modifier.focusRequester(nextEpisodeFocusRequester)
)
}
}
// TODO: use TimedVisibility
AnimatedVisibility(
visible = !playerControlsVisible && hiddenSeekPreviewPositionMs != null,
enter = fadeIn(),
exit = fadeOut(),
visible = showHiddenSeekPreview,
modifier = Modifier
.align(Alignment.BottomCenter)
.padding(horizontal = 32.dp, vertical = 28.dp)
@@ -420,18 +400,17 @@ fun TvPlayerScreen(
modifier = Modifier.align(Alignment.Center),
uiState = uiState,
onRetry = { viewModel.retry() },
onNext = nextAndShowControls,
onNext = { viewModel.next() },
onReplay = {
viewModel.seekTo(0L)
togglePlayPauseAndShowControls()
viewModel.resumePlayback()
},
onDismissError = { viewModel.clearError() }
)
// TODO: use TimedVisibility
AnimatedVisibility(
visible = stopFeedbackVisible,
enter = fadeIn(),
exit = fadeOut(),
modifier = Modifier.align(Alignment.Center)
) {
TvPlayerHiddenStopFeedback()
@@ -498,15 +477,8 @@ private fun HiddenTvSeekTimeline(
}
}
private fun Long.coerceSeekPosition(durationMs: Long): Long =
if (durationMs > 0L) {
coerceIn(0L, durationMs)
} else {
coerceAtLeast(0L)
}
internal fun handleTvPlayerRootKeyEvent(
event: androidx.compose.ui.input.key.KeyEvent,
event: KeyEvent,
isPlaying: Boolean,
controlsVisible: Boolean,
isPlaylistExpanded: Boolean,
@@ -514,13 +486,12 @@ internal fun handleTvPlayerRootKeyEvent(
onCloseTrackPanel: () -> Unit,
onCollapsePlaylist: () -> Unit,
onHideControls: () -> Unit,
onPausePlaybackWithoutShowingControls: () -> Unit,
onResumePlaybackWithoutShowingControls: () -> Unit,
onPausePlayback: () -> Unit,
onResumePlayback: () -> Unit,
onSeekRelative: (Long) -> Unit,
onShowControls: () -> Unit,
onSkipSegment: () -> Unit = {},
hasSkippableSegment: Boolean = false,
onTogglePlayPause: () -> Unit
hasSkippableSegment: Boolean = false
): Boolean {
if (event.type != KeyEventType.KeyDown) return false
@@ -566,9 +537,9 @@ internal fun handleTvPlayerRootKeyEvent(
if (hasSkippableSegment) {
onSkipSegment()
} else if (isPlaying) {
onPausePlaybackWithoutShowingControls()
onPausePlayback()
} else {
onResumePlaybackWithoutShowingControls()
onResumePlayback()
}
true
}

View File

@@ -29,11 +29,6 @@ import androidx.compose.ui.focus.onFocusChanged
import androidx.compose.ui.graphics.Brush
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.graphicsLayer
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.layout.ContentScale
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextOverflow
@@ -50,7 +45,6 @@ import hu.bbara.purefin.core.player.model.PlaylistElementUiModel
fun TvNextEpisodeOverlay(
nextEpisode: PlaylistElementUiModel,
onClick: () -> Unit,
onUp: () -> Unit,
modifier: Modifier = Modifier
) {
val scheme = MaterialTheme.colorScheme
@@ -88,12 +82,6 @@ fun TvNextEpisodeOverlay(
.background(backgroundColor)
.focusable()
.onFocusChanged { isFocused = it.isFocused }
.onPreviewKeyEvent { event ->
if (event.type == KeyEventType.KeyDown && event.key == Key.DirectionUp) {
onUp()
true
} else false
}
.clickable { onClick() },
verticalArrangement = Arrangement.spacedBy(0.dp)
) {