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,118 +133,73 @@ 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)
}
LaunchedEffect(trackPanelType) {
viewModel.setControlsAutoHideBlocked(
ControlsAutoHideBlocker.TRACK_PANEL,
trackPanelType != null
)
}
val expandPlaylist: () -> Unit = {
if (!isPlaylistExpanded) {
fun expandPlaylist() {
// TODO: focus management for playlist expansion
isPlaylistExpanded = true
}
}
val collapsePlaylistToControls: () -> Unit = {
if (isPlaylistExpanded) {
fun closePlaylist() {
// TODO focus
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)
}
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
val showHiddenSeekPreview = !controlsVisible && hiddenSeekPreviewPositionMs != null
LaunchedEffect(showSkipIntroButton, showNextEpisodeOverlay) {
rootFocusRequester = when {
showSkipIntroButton -> {
skipButtonFocusRequester
}
showNextEpisodeOverlay -> {
nextEpisodeFocusRequester
}
else -> {
backgroundFocusRequester
}
}
if (!controlsVisible) {
rootFocusRequester.requestFocus()
}
}
LaunchedEffect(
controlsVisible,
controlsAutoHideBlocked,
uiState.activeSkippableSegmentEndMs,
showNextEpisodeOverlay
) {
if (controlsAutoHideBlocked) return@LaunchedEffect
if (controlsVisible) {
controlsFocusRequester.requestFocus()
} else {
if (uiState.activeSkippableSegmentEndMs != null) {
skipButtonFocusRequester.requestFocus()
return@LaunchedEffect
}
if (showNextEpisodeOverlay) {
nextEpisodeFocusRequester.requestFocus()
return@LaunchedEffect
}
focusManager.clearFocus()
}
}
LaunchedEffect(trackPanelType, pendingTrackButtonFocus) {
val pendingFocus = pendingTrackButtonFocus ?: 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)
) {

View File

@@ -24,10 +24,10 @@ import androidx.compose.material.icons.outlined.VolumeUp
import androidx.compose.material3.MaterialTheme
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.rememberCoroutineScope
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
@@ -45,23 +45,27 @@ import hu.bbara.purefin.core.player.model.TimedMarker
import hu.bbara.purefin.core.player.viewmodel.PlayerViewModel
import hu.bbara.purefin.ui.common.visual.EmptyValueTimedVisibility
import hu.bbara.purefin.ui.common.visual.ValueChangeTimedVisibility
import hu.bbara.purefin.ui.screen.player.components.NextEpisodeOverlay
import hu.bbara.purefin.ui.screen.player.components.PersistentOverlayContainer
import hu.bbara.purefin.ui.screen.player.components.PlayerAdjustmentIndicator
import hu.bbara.purefin.ui.screen.player.components.PlayerControlsOverlay
import hu.bbara.purefin.ui.screen.player.components.PlayerGesturesLayer
import hu.bbara.purefin.ui.screen.player.components.PlayerLoadingErrorEndCard
import hu.bbara.purefin.ui.screen.player.components.PlayerQueuePanel
import hu.bbara.purefin.ui.screen.player.components.NextEpisodeOverlay
import hu.bbara.purefin.ui.screen.player.components.PlayerSeekBarTrack
import hu.bbara.purefin.ui.screen.player.components.PlayerTimeRow
import hu.bbara.purefin.ui.screen.player.components.SkipSegmentButton
import hu.bbara.purefin.ui.screen.player.components.rememberPersistentOverlayController
import kotlinx.coroutines.Job
import kotlinx.coroutines.delay
import kotlinx.coroutines.launch
import kotlin.math.abs
import kotlin.math.roundToInt
private const val CONTROLS_VISIBLE_SUBTITLE_BOTTOM_PADDING_FRACTION = 0.32f
/** Small negative band below zero that represents adaptive/auto brightness mode. */
private const val AUTO_BRIGHTNESS_THRESHOLD = -0.15f
private const val CONTROLS_AUTO_HIDE_MS = 3_000L
@OptIn(UnstableApi::class)
@Composable
@@ -69,8 +73,13 @@ fun PlayerScreen(
viewModel: PlayerViewModel,
onBack: () -> Unit
) {
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 controlsHasPopupOpen by remember { mutableStateOf(false) }
val context = LocalContext.current
val activity = context as? Activity
@@ -83,20 +92,43 @@ fun PlayerScreen(
var horizontalSeekPreviewPositionMs by remember { mutableStateOf<Long?>(null) }
val overlayController = rememberPersistentOverlayController()
LaunchedEffect(uiState.isPlaying) {
if (uiState.isPlaying) {
showQueuePanel = false
}
}
val playerControlsVisible = controlsVisible || uiState.isEnded || uiState.error != null
val subtitleBottomPaddingFraction =
if (playerControlsVisible) {
if (controlsVisible) {
CONTROLS_VISIBLE_SUBTITLE_BOTTOM_PADDING_FRACTION
} else {
SubtitleView.DEFAULT_BOTTOM_PADDING_FRACTION
}
val showSkipIntroButton = !controlsVisible && uiState.activeSkippableSegmentEndMs != null
val showNextEpisodeOverlay = !controlsVisible
&& uiState.nextEpisode != null
&& uiState.durationMs > 0L
&& (uiState.durationMs - uiState.positionMs) <= 60_000L
&& !uiState.isEnded
fun toggleControlsVisibility() {
controlsVisible = !controlsVisible
}
fun hideControls() {
controlsVisible = false
}
fun hideControlsWithTimeout() {
hideControlsJob?.cancel()
if (controlsVisible && !controlsHasPopupOpen && !uiState.isEnded && uiState.error == null) {
hideControlsJob = scope.launch {
delay(CONTROLS_AUTO_HIDE_MS)
hideControls()
}
}
}
fun onScreenTap() {
toggleControlsVisibility()
hideControlsWithTimeout()
}
Box(
modifier = Modifier
.fillMaxSize()
@@ -121,7 +153,7 @@ fun PlayerScreen(
PlayerGesturesLayer(
modifier = Modifier.align(Alignment.BottomCenter),
onTap = { viewModel.toggleControlsVisibility() },
onTap = ::onScreenTap,
onDoubleTapRight = { viewModel.seekBy(30_000) },
onDoubleTapLeft = { viewModel.seekBy(-10_000) },
onDoubleTapCenter = { viewModel.togglePlayPause() },
@@ -152,7 +184,7 @@ fun PlayerScreen(
onHorizontalDragSeekTo = {
viewModel.seekTo(it)
},
currentPositionProvider = { uiState.positionMs }
currentPositionProvider = { uiState.positionMs },
)
EmptyValueTimedVisibility(
@@ -170,7 +202,7 @@ fun PlayerScreen(
value = horizontalSeekPreviewPositionMs,
hideAfterMillis = 1_000
) { previewPositionMs ->
if (!playerControlsVisible) {
if (controlsVisible) {
HiddenSeekTimeline(
positionMs = previewPositionMs,
durationMs = uiState.durationMs,
@@ -225,14 +257,13 @@ fun PlayerScreen(
}
AnimatedVisibility(
visible = playerControlsVisible,
visible = controlsVisible,
enter = fadeIn(),
exit = fadeOut()
) {
PlayerControlsOverlay(
modifier = Modifier.fillMaxSize(),
uiState = uiState,
showControls = controlsVisible,
overlayController = overlayController,
onBack = onBack,
onPlayPause = { viewModel.togglePlayPause() },
@@ -243,13 +274,12 @@ fun PlayerScreen(
onNext = { viewModel.next() },
onPrevious = { viewModel.previous() },
onSelectTrack = { viewModel.selectTrack(it) },
onQueueSelected = { viewModel.playQueueItem(it) },
onOpenQueue = { showQueuePanel = true }
)
}
AnimatedVisibility(
visible = !playerControlsVisible && uiState.activeSkippableSegmentEndMs != null,
visible = showSkipIntroButton,
enter = fadeIn(),
exit = fadeOut(),
modifier = Modifier
@@ -262,11 +292,7 @@ fun PlayerScreen(
SkipSegmentButton(onClick = { viewModel.skipActiveSegment() })
}
val showNextEpisodeOverlay = !playerControlsVisible
&& uiState.nextEpisode != null
&& uiState.durationMs > 0L
&& (uiState.durationMs - uiState.positionMs) <= 60_000L
&& !uiState.isEnded
AnimatedVisibility(
visible = showNextEpisodeOverlay,
enter = fadeIn(),

View File

@@ -32,10 +32,6 @@ import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
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.graphics.Brush
@@ -51,7 +47,6 @@ import hu.bbara.purefin.ui.common.button.PurefinIconButton
@Composable
fun PlayerControlsOverlay(
uiState: PlayerUiState,
showControls: Boolean,
overlayController: PersistentOverlayController,
onBack: () -> Unit,
onPlayPause: () -> Unit,
@@ -62,11 +57,9 @@ fun PlayerControlsOverlay(
onNext: () -> Unit,
onPrevious: () -> Unit,
onSelectTrack: (TrackOption) -> Unit,
onQueueSelected: (String) -> Unit,
onOpenQueue: () -> Unit,
modifier: Modifier = Modifier
modifier: Modifier = Modifier,
) {
var scrubbing by remember { mutableStateOf(false) }
Box(
modifier = modifier
@@ -98,11 +91,8 @@ fun PlayerControlsOverlay(
)
BottomSection(
uiState = uiState,
scrubbing = scrubbing,
overlayController = overlayController,
onScrubStart = { scrubbing = true },
onScrub = onSeek,
onScrubFinished = { scrubbing = false },
onNext = onNext,
onPrevious = onPrevious,
onPlayPause = onPlayPause,
@@ -111,7 +101,6 @@ fun PlayerControlsOverlay(
onSeekLiveEdge = onSeekLiveEdge,
onSkipSegment = onSkipSegment,
onSelectTrack = onSelectTrack,
onQueueSelected = onQueueSelected,
modifier = Modifier.align(Alignment.BottomCenter)
)
}
@@ -163,11 +152,8 @@ private fun TopBar(
@Composable
private fun BottomSection(
uiState: PlayerUiState,
scrubbing: Boolean,
overlayController: PersistentOverlayController,
onScrubStart: () -> Unit,
onScrub: (Long) -> Unit,
onScrubFinished: () -> Unit,
onNext: () -> Unit,
onPrevious: () -> Unit,
onPlayPause: () -> Unit,
@@ -176,7 +162,6 @@ private fun BottomSection(
onSeekLiveEdge: () -> Unit,
onSkipSegment: () -> Unit,
onSelectTrack: (TrackOption) -> Unit,
onQueueSelected: (String) -> Unit,
modifier: Modifier = Modifier
) {
Column(modifier = modifier) {
@@ -194,8 +179,6 @@ private fun BottomSection(
chapterMarkers = uiState.chapters,
adMarkers = uiState.ads,
onSeek = onScrub,
onScrubStarted = onScrubStart,
onScrubFinished = onScrubFinished,
modifier = Modifier.testTag(PlayerSeekBarTag)
)
Spacer(modifier = Modifier.height(8.dp))

View File

@@ -27,8 +27,6 @@ fun PlayerSeekBar(
chapterMarkers: List<TimedMarker>,
adMarkers: List<TimedMarker>,
onSeek: (Long) -> Unit,
onScrubStarted: () -> Unit,
onScrubFinished: () -> Unit,
modifier: Modifier = Modifier
) {
val safeDuration = durationMs.takeIf { it > 0 } ?: 1L
@@ -61,7 +59,6 @@ fun PlayerSeekBar(
onValueChange = { newValue ->
if (!isScrubbing) {
isScrubbing = true
onScrubStarted()
}
sliderPosition = newValue
},
@@ -69,7 +66,6 @@ fun PlayerSeekBar(
val targetPosition = sliderPosition.toLong().coerceIn(0L, safeDuration)
isScrubbing = false
onSeek(targetPosition)
onScrubFinished()
},
valueRange = 0f..safeDuration.toFloat(),
colors = SliderDefaults.colors(

View File

@@ -1,71 +0,0 @@
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<ControlsAutoHideBlocker>()
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)
}
}
}

View File

@@ -14,7 +14,6 @@ import hu.bbara.purefin.core.player.model.PlaylistElementUiModel
import hu.bbara.purefin.core.player.model.TrackOption
import hu.bbara.purefin.model.PlayableMedia
import kotlinx.coroutines.Job
import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
@@ -22,8 +21,8 @@ import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.flow.update
import kotlinx.coroutines.launch
import timber.log.Timber
import kotlinx.serialization.InternalSerializationApi
import timber.log.Timber
import java.util.UUID
import javax.inject.Inject
@@ -46,10 +45,6 @@ class PlayerViewModel @Inject constructor(
private val _uiState = MutableStateFlow(PlayerUiState())
val uiState: StateFlow<PlayerUiState> = _uiState.asStateFlow()
private val _controlsVisible = MutableStateFlow(true)
val controlsVisible: StateFlow<Boolean> = _controlsVisible.asStateFlow()
private val controlsAutoHidePolicy = ControlsAutoHidePolicy(DEFAULT_CONTROLS_AUTO_HIDE_MS)
private val seekByCollector = SeekByCollector(viewModelScope) { deltaMs ->
playerManager.seekBy(deltaMs)
}
@@ -77,12 +72,6 @@ class PlayerViewModel @Inject constructor(
error = state.error ?: dataErrorMessage
)
}
applyControlsAutoHideCommand(
controlsAutoHidePolicy.onPlaybackChanged(state.isPlaying)
)
if (state.isEnded) {
showControls()
}
}
}
@@ -188,9 +177,8 @@ class PlayerViewModel @Inject constructor(
}
}
fun togglePlayPause(autoHideDelayMs: Long = DEFAULT_CONTROLS_AUTO_HIDE_MS) {
fun togglePlayPause() {
playerManager.togglePlayPause()
showControls(autoHideDelayMs)
}
fun pausePlayback() {
@@ -220,49 +208,14 @@ class PlayerViewModel @Inject constructor(
playerManager.skipActiveSegment()
}
fun setControlsAutoHideBlocked(
blocker: ControlsAutoHideBlocker,
blocked: Boolean
) {
applyControlsAutoHideCommand(
controlsAutoHidePolicy.setBlocked(blocker, blocked)
)
}
fun showControls(autoHideDelayMs: Long = DEFAULT_CONTROLS_AUTO_HIDE_MS) {
applyControlsAutoHideCommand(
controlsAutoHidePolicy.showControls(autoHideDelayMs)
)
}
fun toggleControlsVisibility() {
applyControlsAutoHideCommand(
controlsAutoHidePolicy.toggleControlsVisibility()
)
}
private fun applyControlsAutoHideCommand(command: ControlsAutoHideCommand) {
_controlsVisible.value = controlsAutoHidePolicy.controlsVisible
autoHideJob?.cancel()
autoHideJob = null
if (command !is ControlsAutoHideCommand.Schedule) return
autoHideJob = viewModelScope.launch {
delay(command.delayMs)
controlsAutoHidePolicy.hideControls()
_controlsVisible.value = controlsAutoHidePolicy.controlsVisible
}
}
fun next(autoHideDelayMs: Long = DEFAULT_CONTROLS_AUTO_HIDE_MS) {
fun next() {
seekByCollector.clear()
playerManager.next()
showControls(autoHideDelayMs)
}
fun previous(autoHideDelayMs: Long = DEFAULT_CONTROLS_AUTO_HIDE_MS) {
fun previous() {
seekByCollector.clear()
playerManager.previous()
showControls(autoHideDelayMs)
}
fun selectTrack(option: TrackOption) {
@@ -276,7 +229,6 @@ class PlayerViewModel @Inject constructor(
fun playQueueItem(id: String) {
seekByCollector.clear()
playerManager.play(id.toUuidOrNull() ?: return)
showControls()
}
fun clearError() {

View File

@@ -1,90 +0,0 @@
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)
}
}