Fix TV player track selector focus

This commit is contained in:
2026-04-09 19:59:01 +02:00
parent 53f7cb58f3
commit 7c30e12af7
10 changed files with 718 additions and 59 deletions

View File

@@ -1,9 +1,11 @@
package hu.bbara.purefin.tv.player.components package hu.bbara.purefin.tv.player.components
import androidx.activity.ComponentActivity import androidx.activity.ComponentActivity
import androidx.compose.runtime.Composable
import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.size 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.getValue
import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember import androidx.compose.runtime.remember
@@ -16,9 +18,14 @@ import androidx.compose.ui.semantics.SemanticsProperties
import androidx.compose.ui.test.ExperimentalTestApi import androidx.compose.ui.test.ExperimentalTestApi
import androidx.compose.ui.test.SemanticsMatcher import androidx.compose.ui.test.SemanticsMatcher
import androidx.compose.ui.test.assert import androidx.compose.ui.test.assert
import androidx.compose.ui.test.assertCountEquals
import androidx.compose.ui.test.assertIsDisplayed import androidx.compose.ui.test.assertIsDisplayed
import androidx.compose.ui.test.assertIsFocused import androidx.compose.ui.test.assertIsFocused
import androidx.compose.ui.test.assertIsNotEnabled
import androidx.compose.ui.test.junit4.createAndroidComposeRule import androidx.compose.ui.test.junit4.createAndroidComposeRule
import androidx.compose.ui.test.onAllNodesWithContentDescription
import androidx.compose.ui.test.onAllNodesWithTag
import androidx.compose.ui.test.onNodeWithContentDescription
import androidx.compose.ui.test.onNodeWithTag import androidx.compose.ui.test.onNodeWithTag
import androidx.compose.ui.test.onNodeWithText import androidx.compose.ui.test.onNodeWithText
import androidx.compose.ui.test.performKeyInput import androidx.compose.ui.test.performKeyInput
@@ -27,6 +34,8 @@ import androidx.compose.ui.test.pressKey
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import hu.bbara.purefin.core.player.model.PlayerUiState import hu.bbara.purefin.core.player.model.PlayerUiState
import hu.bbara.purefin.core.player.model.QueueItemUi import hu.bbara.purefin.core.player.model.QueueItemUi
import hu.bbara.purefin.core.player.model.TrackOption
import hu.bbara.purefin.core.player.model.TrackType
import hu.bbara.purefin.ui.theme.AppTheme import hu.bbara.purefin.ui.theme.AppTheme
import org.junit.Rule import org.junit.Rule
import org.junit.Test import org.junit.Test
@@ -168,7 +177,7 @@ class TvPlayerControlsOverlayTest {
} }
@Test @Test
fun playlistShowsQueueAsRowAndFocusesCurrentItem() { fun playlistShowsQueueAsRowAndCurrentItemCanReceiveFocus() {
composeRule.setContent { composeRule.setContent {
AppTheme { AppTheme {
OverlayHost( OverlayHost(
@@ -181,7 +190,9 @@ class TvPlayerControlsOverlayTest {
composeRule.waitForIdle() composeRule.waitForIdle()
composeRule.onNodeWithTag(TvPlayerPlaylistRowTag).assertIsDisplayed() composeRule.onNodeWithTag(TvPlayerPlaylistRowTag).assertIsDisplayed()
composeRule.onNodeWithTag(TvPlayerPlaylistCurrentItemTag).assertIsFocused() composeRule.onNodeWithTag(TvPlayerPlaylistCurrentItemTag)
.performSemanticsAction(SemanticsActions.RequestFocus)
.assertIsFocused()
composeRule.onNodeWithText("Currently Playing").assertIsDisplayed() composeRule.onNodeWithText("Currently Playing").assertIsDisplayed()
composeRule.onNodeWithText("Episode 2").assertIsDisplayed() composeRule.onNodeWithText("Episode 2").assertIsDisplayed()
composeRule.onNodeWithTag(TvPlayerPlaylistFirstItemTag).assertIsDisplayed() composeRule.onNodeWithTag(TvPlayerPlaylistFirstItemTag).assertIsDisplayed()
@@ -190,7 +201,7 @@ class TvPlayerControlsOverlayTest {
} }
@Test @Test
fun playlistFallsBackToFirstItemWhenNoCurrentItemExists() { fun playlistFallsBackToFirstItemWhenNoCurrentItemExists_andFallbackItemCanReceiveFocus() {
composeRule.setContent { composeRule.setContent {
AppTheme { AppTheme {
OverlayHost( OverlayHost(
@@ -219,9 +230,160 @@ class TvPlayerControlsOverlayTest {
composeRule.waitForIdle() composeRule.waitForIdle()
composeRule.onNodeWithTag(TvPlayerPlaylistCurrentItemTag).assertIsFocused() composeRule.onNodeWithTag(TvPlayerPlaylistCurrentItemTag)
.performSemanticsAction(SemanticsActions.RequestFocus)
.assertIsFocused()
composeRule.onNodeWithText("Episode 1").assertIsDisplayed() 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 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 @Composable
@@ -231,12 +393,18 @@ private fun OverlayHost(
) { ) {
var isPlaylistExpanded by remember { mutableStateOf(initiallyExpanded) } var isPlaylistExpanded by remember { mutableStateOf(initiallyExpanded) }
val focusRequester = remember { FocusRequester() } val focusRequester = remember { FocusRequester() }
val qualityButtonFocusRequester = remember { FocusRequester() }
val audioButtonFocusRequester = remember { FocusRequester() }
val subtitlesButtonFocusRequester = remember { FocusRequester() }
Box(modifier = Modifier.size(width = 960.dp, height = 540.dp)) { Box(modifier = Modifier.size(width = 960.dp, height = 540.dp)) {
TvPlayerControlsOverlay( TvPlayerControlsOverlay(
uiState = uiState, uiState = uiState,
focusRequester = focusRequester, focusRequester = focusRequester,
isPlaylistExpanded = isPlaylistExpanded, isPlaylistExpanded = isPlaylistExpanded,
qualityButtonFocusRequester = qualityButtonFocusRequester,
audioButtonFocusRequester = audioButtonFocusRequester,
subtitlesButtonFocusRequester = subtitlesButtonFocusRequester,
onPlayPause = {}, onPlayPause = {},
onSeek = { _ -> }, onSeek = { _ -> },
onSeekRelative = { _ -> }, onSeekRelative = { _ -> },
@@ -248,11 +416,79 @@ private fun OverlayHost(
onOpenQualityPanel = {}, onOpenQualityPanel = {},
onExpandPlaylist = { isPlaylistExpanded = true }, onExpandPlaylist = { isPlaylistExpanded = true },
onCollapsePlaylist = { isPlaylistExpanded = false }, onCollapsePlaylist = { isPlaylistExpanded = false },
onSelectQueueItem = { _ -> } onSelectQueueItem = { _ -> },
qualityButtonEnabled = uiState.qualityTracks.isNotEmpty(),
audioButtonEnabled = uiState.audioTracks.isNotEmpty(),
subtitlesButtonEnabled = uiState.textTracks.isNotEmpty()
) )
} }
} }
@Composable
private fun TrackPanelHost(
uiState: PlayerUiState
) {
var trackPanelType by remember { mutableStateOf<TvTrackPanelType?>(null) }
var pendingTrackButtonFocus by remember { mutableStateOf<TvTrackPanelType?>(null) }
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
}
}
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
}
Box(modifier = Modifier.size(width = 960.dp, height = 540.dp)) {
TvPlayerControlsOverlay(
uiState = uiState,
focusRequester = controlsFocusRequester,
isPlaylistExpanded = false,
qualityButtonFocusRequester = qualityButtonFocusRequester,
audioButtonFocusRequester = audioButtonFocusRequester,
subtitlesButtonFocusRequester = subtitlesButtonFocusRequester,
onPlayPause = {},
onSeek = { _ -> },
onSeekRelative = { _ -> },
onSeekLiveEdge = {},
onNext = {},
onPrevious = {},
onOpenAudioPanel = { trackPanelType = TvTrackPanelType.AUDIO },
onOpenSubtitlesPanel = { trackPanelType = TvTrackPanelType.SUBTITLES },
onOpenQualityPanel = { trackPanelType = TvTrackPanelType.QUALITY },
onExpandPlaylist = {},
onCollapsePlaylist = {},
onSelectQueueItem = { _ -> },
qualityButtonEnabled = uiState.qualityTracks.isNotEmpty(),
audioButtonEnabled = uiState.audioTracks.isNotEmpty(),
subtitlesButtonEnabled = uiState.textTracks.isNotEmpty()
)
trackPanelType?.let { panelType ->
TvTrackSelectionPanel(
panelType = panelType,
uiState = uiState,
onSelect = { closeTrackPanel() },
onClose = closeTrackPanel,
modifier = Modifier.fillMaxSize()
)
}
}
}
private fun samplePlayerState(): PlayerUiState = PlayerUiState( private fun samplePlayerState(): PlayerUiState = PlayerUiState(
isPlaying = true, isPlaying = true,
title = "Sample Player", title = "Sample Player",
@@ -282,5 +518,86 @@ private fun samplePlayerState(): PlayerUiState = PlayerUiState(
artworkUrl = null, artworkUrl = null,
isCurrent = false 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

@@ -38,6 +38,7 @@ import androidx.hilt.navigation.compose.hiltViewModel
import androidx.media3.common.util.UnstableApi import androidx.media3.common.util.UnstableApi
import androidx.media3.ui.AspectRatioFrameLayout import androidx.media3.ui.AspectRatioFrameLayout
import androidx.media3.ui.PlayerView import androidx.media3.ui.PlayerView
import hu.bbara.purefin.core.player.viewmodel.ControlsAutoHideBlocker
import hu.bbara.purefin.core.player.viewmodel.PlayerViewModel import hu.bbara.purefin.core.player.viewmodel.PlayerViewModel
import hu.bbara.purefin.tv.player.components.TvPlayerControlsOverlay import hu.bbara.purefin.tv.player.components.TvPlayerControlsOverlay
import hu.bbara.purefin.tv.player.components.TvPlayerLoadingErrorEndCard import hu.bbara.purefin.tv.player.components.TvPlayerLoadingErrorEndCard
@@ -61,8 +62,13 @@ fun TvPlayerScreen(
val controlsVisible by viewModel.controlsVisible.collectAsState() val controlsVisible by viewModel.controlsVisible.collectAsState()
var isPlaylistExpanded by remember { mutableStateOf(false) } var isPlaylistExpanded by remember { mutableStateOf(false) }
var trackPanelType by remember { mutableStateOf<TvTrackPanelType?>(null) } var trackPanelType by remember { mutableStateOf<TvTrackPanelType?>(null) }
var pendingTrackButtonFocus by remember { mutableStateOf<TvTrackPanelType?>(null) }
val controlsAutoHideBlocked = isPlaylistExpanded || trackPanelType != null
val context = LocalContext.current val context = LocalContext.current
LaunchedEffect(Unit) {
viewModel.setControlsAutoHideDelay(TV_CONTROLS_AUTO_HIDE_MS)
}
LaunchedEffect(uiState.isPlaying) { LaunchedEffect(uiState.isPlaying) {
val activity = context as? Activity val activity = context as? Activity
if (uiState.isPlaying) { if (uiState.isPlaying) {
@@ -74,17 +80,27 @@ fun TvPlayerScreen(
DisposableEffect(Unit) { DisposableEffect(Unit) {
onDispose { onDispose {
(context as? Activity)?.window?.clearFlags(WindowManager.LayoutParams.FLAG_KEEP_SCREEN_ON) (context as? Activity)?.window?.clearFlags(WindowManager.LayoutParams.FLAG_KEEP_SCREEN_ON)
viewModel.setControlsAutoHideBlocked(ControlsAutoHideBlocker.PLAYLIST, false)
viewModel.setControlsAutoHideBlocked(ControlsAutoHideBlocker.TRACK_PANEL, false)
} }
} }
LaunchedEffect(uiState.isPlaying, controlsVisible, isPlaylistExpanded) { LaunchedEffect(isPlaylistExpanded) {
if (uiState.isPlaying && controlsVisible && !isPlaylistExpanded) { viewModel.setControlsAutoHideBlocked(ControlsAutoHideBlocker.PLAYLIST, isPlaylistExpanded)
viewModel.showControls(TV_CONTROLS_AUTO_HIDE_MS) }
}
LaunchedEffect(trackPanelType) {
viewModel.setControlsAutoHideBlocked(
ControlsAutoHideBlocker.TRACK_PANEL,
trackPanelType != null
)
} }
val hiddenControlFocusRequester = remember { FocusRequester() } val hiddenControlFocusRequester = remember { FocusRequester() }
val controlsFocusRequester = remember { FocusRequester() } val controlsFocusRequester = remember { FocusRequester() }
val qualityButtonFocusRequester = remember { FocusRequester() }
val audioButtonFocusRequester = remember { FocusRequester() }
val subtitlesButtonFocusRequester = remember { FocusRequester() }
val expandPlaylist: () -> Unit = { val expandPlaylist: () -> Unit = {
if (!isPlaylistExpanded) { if (!isPlaylistExpanded) {
isPlaylistExpanded = true isPlaylistExpanded = true
@@ -120,8 +136,16 @@ fun TvPlayerScreen(
val previousAndShowControls: () -> Unit = { val previousAndShowControls: () -> Unit = {
viewModel.previous(TV_CONTROLS_AUTO_HIDE_MS) viewModel.previous(TV_CONTROLS_AUTO_HIDE_MS)
} }
val closeTrackPanel: () -> Unit = {
trackPanelType?.let { panelType ->
pendingTrackButtonFocus = panelType
trackPanelType = null
viewModel.showControls(TV_CONTROLS_AUTO_HIDE_MS)
}
}
LaunchedEffect(controlsVisible) { LaunchedEffect(controlsVisible, controlsAutoHideBlocked) {
if (controlsAutoHideBlocked) return@LaunchedEffect
if (controlsVisible) { if (controlsVisible) {
controlsFocusRequester.requestFocus() controlsFocusRequester.requestFocus()
} else { } else {
@@ -129,9 +153,20 @@ fun TvPlayerScreen(
} }
} }
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
}
BackHandler(enabled = true) { BackHandler(enabled = true) {
when { when {
trackPanelType != null -> trackPanelType = null trackPanelType != null -> closeTrackPanel()
isPlaylistExpanded -> collapsePlaylistToControls() isPlaylistExpanded -> collapsePlaylistToControls()
controlsVisible -> viewModel.toggleControlsVisibility() controlsVisible -> viewModel.toggleControlsVisibility()
else -> onBack() else -> onBack()
@@ -189,7 +224,7 @@ fun TvPlayerScreen(
) )
AnimatedVisibility( AnimatedVisibility(
visible = controlsVisible || isPlaylistExpanded || uiState.isEnded || uiState.error != null, visible = controlsVisible || isPlaylistExpanded || trackPanelType != null || uiState.isEnded || uiState.error != null,
enter = fadeIn(), enter = fadeIn(),
exit = fadeOut() exit = fadeOut()
) { ) {
@@ -198,6 +233,9 @@ fun TvPlayerScreen(
uiState = uiState, uiState = uiState,
focusRequester = controlsFocusRequester, focusRequester = controlsFocusRequester,
isPlaylistExpanded = isPlaylistExpanded, isPlaylistExpanded = isPlaylistExpanded,
qualityButtonFocusRequester = qualityButtonFocusRequester,
audioButtonFocusRequester = audioButtonFocusRequester,
subtitlesButtonFocusRequester = subtitlesButtonFocusRequester,
onPlayPause = togglePlayPauseAndShowControls, onPlayPause = togglePlayPauseAndShowControls,
onSeek = seekAndShowControls, onSeek = seekAndShowControls,
onSeekRelative = seekByAndShowControls, onSeekRelative = seekByAndShowControls,
@@ -212,7 +250,10 @@ fun TvPlayerScreen(
onSelectQueueItem = { id -> onSelectQueueItem = { id ->
viewModel.playQueueItem(id, TV_CONTROLS_AUTO_HIDE_MS) viewModel.playQueueItem(id, TV_CONTROLS_AUTO_HIDE_MS)
collapsePlaylistToControls() collapsePlaylistToControls()
} },
qualityButtonEnabled = uiState.qualityTracks.isNotEmpty(),
audioButtonEnabled = uiState.audioTracks.isNotEmpty(),
subtitlesButtonEnabled = uiState.textTracks.isNotEmpty()
) )
} }
@@ -239,9 +280,9 @@ fun TvPlayerScreen(
uiState = uiState, uiState = uiState,
onSelect = { track -> onSelect = { track ->
viewModel.selectTrack(track) viewModel.selectTrack(track)
trackPanelType = null closeTrackPanel()
}, },
onClose = { trackPanelType = null }, onClose = closeTrackPanel,
modifier = Modifier.fillMaxSize() modifier = Modifier.fillMaxSize()
) )
} }

View File

@@ -19,11 +19,15 @@ import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.alpha
import androidx.compose.ui.draw.clip import androidx.compose.ui.draw.clip
import androidx.compose.ui.focus.focusProperties
import androidx.compose.ui.focus.onFocusChanged import androidx.compose.ui.focus.onFocusChanged
import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.graphicsLayer import androidx.compose.ui.graphics.graphicsLayer
import androidx.compose.ui.graphics.vector.ImageVector import androidx.compose.ui.graphics.vector.ImageVector
import androidx.compose.ui.semantics.disabled
import androidx.compose.ui.semantics.semantics
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
@Composable @Composable
@@ -32,6 +36,7 @@ internal fun TvIconButton(
contentDescription: String, contentDescription: String,
onClick: () -> Unit, onClick: () -> Unit,
size: Int = 52, size: Int = 52,
enabled: Boolean = true,
modifier: Modifier = Modifier modifier: Modifier = Modifier
) { ) {
val scheme = MaterialTheme.colorScheme val scheme = MaterialTheme.colorScheme
@@ -51,6 +56,7 @@ internal fun TvIconButton(
scaleX = scale scaleX = scale
scaleY = scale scaleY = scale
} }
.alpha(if (enabled) 1f else 0.4f)
.widthIn(min = size.dp) .widthIn(min = size.dp)
.height(size.dp) .height(size.dp)
.border( .border(
@@ -63,8 +69,14 @@ internal fun TvIconButton(
if (isFocused) scheme.primary.copy(alpha = 0.5f) if (isFocused) scheme.primary.copy(alpha = 0.5f)
else scheme.background.copy(alpha = 0.65f) else scheme.background.copy(alpha = 0.65f)
) )
.focusProperties { canFocus = enabled }
.semantics {
if (!enabled) {
disabled()
}
}
.onFocusChanged { isFocused = it.isFocused } .onFocusChanged { isFocused = it.isFocused }
.clickable { onClick() }, .clickable(enabled = enabled) { onClick() },
contentAlignment = Alignment.Center contentAlignment = Alignment.Center
) { ) {
Icon( Icon(

View File

@@ -58,6 +58,9 @@ internal fun TvPlayerControlsOverlay(
uiState: PlayerUiState, uiState: PlayerUiState,
focusRequester: FocusRequester, focusRequester: FocusRequester,
isPlaylistExpanded: Boolean, isPlaylistExpanded: Boolean,
qualityButtonFocusRequester: FocusRequester,
audioButtonFocusRequester: FocusRequester,
subtitlesButtonFocusRequester: FocusRequester,
onPlayPause: () -> Unit, onPlayPause: () -> Unit,
onSeek: (Long) -> Unit, onSeek: (Long) -> Unit,
onSeekRelative: (Long) -> Unit, onSeekRelative: (Long) -> Unit,
@@ -70,6 +73,9 @@ internal fun TvPlayerControlsOverlay(
onExpandPlaylist: () -> Unit, onExpandPlaylist: () -> Unit,
onCollapsePlaylist: () -> Unit, onCollapsePlaylist: () -> Unit,
onSelectQueueItem: (String) -> Unit, onSelectQueueItem: (String) -> Unit,
qualityButtonEnabled: Boolean,
audioButtonEnabled: Boolean,
subtitlesButtonEnabled: Boolean,
modifier: Modifier = Modifier modifier: Modifier = Modifier
) { ) {
Box( Box(
@@ -97,6 +103,9 @@ internal fun TvPlayerControlsOverlay(
uiState = uiState, uiState = uiState,
focusRequester = focusRequester, focusRequester = focusRequester,
isPlaylistExpanded = isPlaylistExpanded, isPlaylistExpanded = isPlaylistExpanded,
qualityButtonFocusRequester = qualityButtonFocusRequester,
audioButtonFocusRequester = audioButtonFocusRequester,
subtitlesButtonFocusRequester = subtitlesButtonFocusRequester,
onPlayPause = onPlayPause, onPlayPause = onPlayPause,
onSeek = onSeek, onSeek = onSeek,
onSeekRelative = onSeekRelative, onSeekRelative = onSeekRelative,
@@ -109,6 +118,9 @@ internal fun TvPlayerControlsOverlay(
onExpandPlaylist = onExpandPlaylist, onExpandPlaylist = onExpandPlaylist,
onCollapsePlaylist = onCollapsePlaylist, onCollapsePlaylist = onCollapsePlaylist,
onSelectQueueItem = onSelectQueueItem, onSelectQueueItem = onSelectQueueItem,
qualityButtonEnabled = qualityButtonEnabled,
audioButtonEnabled = audioButtonEnabled,
subtitlesButtonEnabled = subtitlesButtonEnabled,
modifier = Modifier modifier = Modifier
.align(Alignment.BottomCenter) .align(Alignment.BottomCenter)
.fillMaxWidth() .fillMaxWidth()
@@ -153,6 +165,9 @@ private fun TvPlayerBottomSection(
uiState: PlayerUiState, uiState: PlayerUiState,
focusRequester: FocusRequester, focusRequester: FocusRequester,
isPlaylistExpanded: Boolean, isPlaylistExpanded: Boolean,
qualityButtonFocusRequester: FocusRequester,
audioButtonFocusRequester: FocusRequester,
subtitlesButtonFocusRequester: FocusRequester,
onPlayPause: () -> Unit, onPlayPause: () -> Unit,
onSeek: (Long) -> Unit, onSeek: (Long) -> Unit,
onSeekRelative: (Long) -> Unit, onSeekRelative: (Long) -> Unit,
@@ -165,6 +180,9 @@ private fun TvPlayerBottomSection(
onExpandPlaylist: () -> Unit, onExpandPlaylist: () -> Unit,
onCollapsePlaylist: () -> Unit, onCollapsePlaylist: () -> Unit,
onSelectQueueItem: (String) -> Unit, onSelectQueueItem: (String) -> Unit,
qualityButtonEnabled: Boolean,
audioButtonEnabled: Boolean,
subtitlesButtonEnabled: Boolean,
modifier: Modifier = Modifier modifier: Modifier = Modifier
) { ) {
val scheme = MaterialTheme.colorScheme val scheme = MaterialTheme.colorScheme
@@ -299,15 +317,24 @@ private fun TvPlayerBottomSection(
) { ) {
TvQualitySelectionButton( TvQualitySelectionButton(
onClick = onOpenQualityPanel, onClick = onOpenQualityPanel,
enabled = qualityButtonEnabled,
modifier = expandPlaylistModifier modifier = expandPlaylistModifier
.focusRequester(qualityButtonFocusRequester)
.testTag(TvPlayerQualityButtonTag)
) )
TvAudioSelectionButton( TvAudioSelectionButton(
onClick = onOpenAudioPanel, onClick = onOpenAudioPanel,
enabled = audioButtonEnabled,
modifier = expandPlaylistModifier modifier = expandPlaylistModifier
.focusRequester(audioButtonFocusRequester)
.testTag(TvPlayerAudioButtonTag)
) )
TvSubtitlesSelectionButton( TvSubtitlesSelectionButton(
onClick = onOpenSubtitlesPanel, onClick = onOpenSubtitlesPanel,
enabled = subtitlesButtonEnabled,
modifier = expandPlaylistModifier modifier = expandPlaylistModifier
.focusRequester(subtitlesButtonFocusRequester)
.testTag(TvPlayerSubtitlesButtonTag)
) )
} }
} }

View File

@@ -19,6 +19,8 @@ import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Surface import androidx.compose.material3.Surface
import androidx.compose.material3.Text import androidx.compose.material3.Text
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.withFrameNanos
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember import androidx.compose.runtime.remember
@@ -68,6 +70,13 @@ internal fun TvPlayerQueuePanel(
else -> "${uiState.queue.size} items in queue" else -> "${uiState.queue.size} items in queue"
} }
LaunchedEffect(uiState.queue, entryIndex) {
if (uiState.queue.isNotEmpty()) {
withFrameNanos { }
firstItemFocusRequester.requestFocus()
}
}
Surface( Surface(
modifier = modifier, modifier = modifier,
shape = RoundedCornerShape(24.dp), shape = RoundedCornerShape(24.dp),

View File

@@ -6,20 +6,17 @@ import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxHeight import androidx.compose.foundation.layout.fillMaxHeight
import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.heightIn import androidx.compose.foundation.layout.heightIn
import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.width import androidx.compose.foundation.layout.width
import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.itemsIndexed
import androidx.compose.foundation.lazy.rememberLazyListState
import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.foundation.verticalScroll
import androidx.compose.material.icons.Icons import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.outlined.ArrowBack
import androidx.compose.material.icons.outlined.ClosedCaption import androidx.compose.material.icons.outlined.ClosedCaption
import androidx.compose.material.icons.outlined.HighQuality import androidx.compose.material.icons.outlined.HighQuality
import androidx.compose.material.icons.outlined.Language import androidx.compose.material.icons.outlined.Language
@@ -27,19 +24,37 @@ import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Surface import androidx.compose.material3.Surface
import androidx.compose.material3.Text import androidx.compose.material3.Text
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue import androidx.compose.runtime.setValue
import androidx.compose.runtime.withFrameNanos
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip import androidx.compose.ui.draw.clip
import androidx.compose.ui.focus.FocusRequester
import androidx.compose.ui.focus.focusRequester
import androidx.compose.ui.focus.onFocusChanged import androidx.compose.ui.focus.onFocusChanged
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.platform.testTag
import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import hu.bbara.purefin.core.player.model.PlayerUiState import hu.bbara.purefin.core.player.model.PlayerUiState
import hu.bbara.purefin.core.player.model.TrackOption import hu.bbara.purefin.core.player.model.TrackOption
internal const val TvPlayerQualityButtonTag = "tv_player_quality_button"
internal const val TvPlayerAudioButtonTag = "tv_player_audio_button"
internal const val TvPlayerSubtitlesButtonTag = "tv_player_subtitles_button"
internal const val TvPlayerTrackPanelTag = "tv_player_track_panel"
internal const val TvPlayerTrackSelectedItemTag = "tv_player_track_selected_item"
internal const val TvPlayerTrackFirstItemTag = "tv_player_track_first_item"
internal const val TvPlayerTrackLastItemTag = "tv_player_track_last_item"
internal enum class TvTrackPanelType { internal enum class TvTrackPanelType {
AUDIO, AUDIO,
SUBTITLES, SUBTITLES,
@@ -49,12 +64,14 @@ internal enum class TvTrackPanelType {
@Composable @Composable
internal fun TvQualitySelectionButton( internal fun TvQualitySelectionButton(
onClick: () -> Unit, onClick: () -> Unit,
enabled: Boolean,
modifier: Modifier = Modifier modifier: Modifier = Modifier
) { ) {
TvIconButton( TvIconButton(
icon = Icons.Outlined.HighQuality, icon = Icons.Outlined.HighQuality,
contentDescription = "Quality", contentDescription = "Quality",
onClick = onClick, onClick = onClick,
enabled = enabled,
modifier = modifier modifier = modifier
) )
} }
@@ -62,12 +79,14 @@ internal fun TvQualitySelectionButton(
@Composable @Composable
internal fun TvAudioSelectionButton( internal fun TvAudioSelectionButton(
onClick: () -> Unit, onClick: () -> Unit,
enabled: Boolean,
modifier: Modifier = Modifier modifier: Modifier = Modifier
) { ) {
TvIconButton( TvIconButton(
icon = Icons.Outlined.Language, icon = Icons.Outlined.Language,
contentDescription = "Audio", contentDescription = "Audio",
onClick = onClick, onClick = onClick,
enabled = enabled,
modifier = modifier modifier = modifier
) )
} }
@@ -75,12 +94,14 @@ internal fun TvAudioSelectionButton(
@Composable @Composable
internal fun TvSubtitlesSelectionButton( internal fun TvSubtitlesSelectionButton(
onClick: () -> Unit, onClick: () -> Unit,
enabled: Boolean,
modifier: Modifier = Modifier modifier: Modifier = Modifier
) { ) {
TvIconButton( TvIconButton(
icon = Icons.Outlined.ClosedCaption, icon = Icons.Outlined.ClosedCaption,
contentDescription = "Subtitles", contentDescription = "Subtitles",
onClick = onClick, onClick = onClick,
enabled = enabled,
modifier = modifier modifier = modifier
) )
} }
@@ -99,9 +120,23 @@ internal fun TvTrackSelectionPanel(
TvTrackPanelType.SUBTITLES -> Triple("Subtitles", uiState.textTracks, uiState.selectedTextTrackId) TvTrackPanelType.SUBTITLES -> Triple("Subtitles", uiState.textTracks, uiState.selectedTextTrackId)
TvTrackPanelType.QUALITY -> Triple("Quality", uiState.qualityTracks, uiState.selectedQualityTrackId) TvTrackPanelType.QUALITY -> Triple("Quality", uiState.qualityTracks, uiState.selectedQualityTrackId)
} }
val entryIndex = options.indexOfFirst { it.id == selectedId }.takeIf { it >= 0 } ?: 0
val focusRequesters = remember(options.map(TrackOption::id)) {
options.map { FocusRequester() }
}
val listState = rememberLazyListState(
initialFirstVisibleItemIndex = (entryIndex - 1).coerceAtLeast(0)
)
LaunchedEffect(entryIndex, focusRequesters) {
withFrameNanos { }
focusRequesters.getOrNull(entryIndex)?.requestFocus()
}
Box( Box(
modifier = modifier.fillMaxSize(), modifier = modifier
.fillMaxSize()
.testTag(TvPlayerTrackPanelTag),
contentAlignment = Alignment.CenterEnd contentAlignment = Alignment.CenterEnd
) { ) {
Surface( Surface(
@@ -115,34 +150,49 @@ internal fun TvTrackSelectionPanel(
modifier = Modifier.padding(20.dp), modifier = Modifier.padding(20.dp),
verticalArrangement = Arrangement.spacedBy(8.dp) verticalArrangement = Arrangement.spacedBy(8.dp)
) { ) {
Row( Text(
modifier = Modifier.fillMaxWidth(), text = title,
horizontalArrangement = Arrangement.SpaceBetween, color = scheme.onSurface,
verticalAlignment = Alignment.CenterVertically style = MaterialTheme.typography.titleLarge,
) { fontWeight = FontWeight.Bold
Text( )
text = title, LazyColumn(
color = scheme.onSurface,
style = MaterialTheme.typography.titleLarge,
fontWeight = FontWeight.Bold
)
TvIconButton(
icon = Icons.AutoMirrored.Outlined.ArrowBack,
contentDescription = "Close",
onClick = onClose
)
}
Spacer(modifier = Modifier.height(4.dp))
Column(
modifier = Modifier modifier = Modifier
.heightIn(max = 500.dp) .heightIn(max = 500.dp)
.verticalScroll(rememberScrollState()), .fillMaxWidth(),
state = listState,
verticalArrangement = Arrangement.spacedBy(6.dp) verticalArrangement = Arrangement.spacedBy(6.dp)
) { ) {
options.forEach { option -> itemsIndexed(options, key = { _, option -> option.id }) { index, option ->
val entryItemTag = when {
selectedId == null && index == entryIndex -> TvPlayerTrackFirstItemTag
index == entryIndex -> TvPlayerTrackSelectedItemTag
index == 0 -> TvPlayerTrackFirstItemTag
index == options.lastIndex -> TvPlayerTrackLastItemTag
else -> null
}
TvTrackOptionRow( TvTrackOptionRow(
modifier = Modifier
.then(
if (index == entryIndex) {
Modifier
.focusRequester(focusRequesters[index])
} else {
Modifier
}
)
.then(
if (entryItemTag != null) {
Modifier.testTag(entryItemTag)
} else {
Modifier
}
),
label = option.label, label = option.label,
selected = option.id == selectedId, selected = option.id == selectedId,
isFirst = index == 0,
isLast = index == options.lastIndex,
onClose = onClose,
onClick = { onSelect(option) } onClick = { onSelect(option) }
) )
} }
@@ -154,15 +204,19 @@ internal fun TvTrackSelectionPanel(
@Composable @Composable
private fun TvTrackOptionRow( private fun TvTrackOptionRow(
modifier: Modifier = Modifier,
label: String, label: String,
selected: Boolean, selected: Boolean,
isFirst: Boolean,
isLast: Boolean,
onClose: () -> Unit,
onClick: () -> Unit onClick: () -> Unit
) { ) {
val scheme = MaterialTheme.colorScheme val scheme = MaterialTheme.colorScheme
var isFocused by remember { mutableStateOf(false) } var isFocused by remember { mutableStateOf(false) }
Box( Box(
modifier = Modifier modifier = modifier
.fillMaxWidth() .fillMaxWidth()
.then( .then(
if (isFocused) { if (isFocused) {
@@ -178,6 +232,23 @@ private fun TvTrackOptionRow(
else scheme.surfaceVariant.copy(alpha = 0.6f) else scheme.surfaceVariant.copy(alpha = 0.6f)
) )
.onFocusChanged { isFocused = it.isFocused } .onFocusChanged { isFocused = it.isFocused }
.onPreviewKeyEvent { event ->
if (event.type != KeyEventType.KeyDown) {
false
} else {
when (event.key) {
Key.Back -> {
onClose()
true
}
Key.DirectionLeft, Key.DirectionRight -> true
Key.DirectionUp -> isFirst
Key.DirectionDown -> isLast
else -> false
}
}
}
.clickable { onClick() } .clickable { onClick() }
.padding(horizontal = 20.dp, vertical = 14.dp) .padding(horizontal = 20.dp, vertical = 14.dp)
) { ) {

View File

@@ -40,4 +40,5 @@ dependencies {
implementation(libs.kotlinx.serialization.json) implementation(libs.kotlinx.serialization.json)
implementation(libs.jellyfin.core) implementation(libs.jellyfin.core)
implementation(libs.okhttp) implementation(libs.okhttp)
testImplementation(libs.junit)
} }

View File

@@ -0,0 +1,71 @@
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

@@ -43,6 +43,7 @@ class PlayerViewModel @Inject constructor(
private val _controlsVisible = MutableStateFlow(true) private val _controlsVisible = MutableStateFlow(true)
val controlsVisible: StateFlow<Boolean> = _controlsVisible.asStateFlow() val controlsVisible: StateFlow<Boolean> = _controlsVisible.asStateFlow()
private val controlsAutoHidePolicy = ControlsAutoHidePolicy(DEFAULT_CONTROLS_AUTO_HIDE_MS)
private var autoHideJob: Job? = null private var autoHideJob: Job? = null
private var lastNextUpMediaId: String? = null private var lastNextUpMediaId: String? = null
private var dataErrorMessage: String? = null private var dataErrorMessage: String? = null
@@ -68,6 +69,9 @@ class PlayerViewModel @Inject constructor(
error = state.error ?: dataErrorMessage error = state.error ?: dataErrorMessage
) )
} }
applyControlsAutoHideCommand(
controlsAutoHidePolicy.onPlaybackChanged(state.isPlaying)
)
if (state.isEnded) { if (state.isEnded) {
showControls() showControls()
} }
@@ -196,26 +200,42 @@ class PlayerViewModel @Inject constructor(
playerManager.seekToLiveEdge() playerManager.seekToLiveEdge()
} }
fun setControlsAutoHideDelay(autoHideDelayMs: Long) {
applyControlsAutoHideCommand(
controlsAutoHidePolicy.setAutoHideDelay(autoHideDelayMs)
)
}
fun setControlsAutoHideBlocked(
blocker: ControlsAutoHideBlocker,
blocked: Boolean
) {
applyControlsAutoHideCommand(
controlsAutoHidePolicy.setBlocked(blocker, blocked)
)
}
fun showControls(autoHideDelayMs: Long = DEFAULT_CONTROLS_AUTO_HIDE_MS) { fun showControls(autoHideDelayMs: Long = DEFAULT_CONTROLS_AUTO_HIDE_MS) {
_controlsVisible.value = true applyControlsAutoHideCommand(
scheduleAutoHide(autoHideDelayMs) controlsAutoHidePolicy.showControls(autoHideDelayMs)
)
} }
fun toggleControlsVisibility() { fun toggleControlsVisibility() {
_controlsVisible.value = !_controlsVisible.value applyControlsAutoHideCommand(
if (_controlsVisible.value) { controlsAutoHidePolicy.toggleControlsVisibility()
scheduleAutoHide(DEFAULT_CONTROLS_AUTO_HIDE_MS) )
} else {
autoHideJob?.cancel()
}
} }
private fun scheduleAutoHide(autoHideDelayMs: Long) { private fun applyControlsAutoHideCommand(command: ControlsAutoHideCommand) {
_controlsVisible.value = controlsAutoHidePolicy.controlsVisible
autoHideJob?.cancel() autoHideJob?.cancel()
if (!player.isPlaying) return autoHideJob = null
if (command !is ControlsAutoHideCommand.Schedule) return
autoHideJob = viewModelScope.launch { autoHideJob = viewModelScope.launch {
delay(autoHideDelayMs) delay(command.delayMs)
_controlsVisible.value = false controlsAutoHidePolicy.hideControls()
_controlsVisible.value = controlsAutoHidePolicy.controlsVisible
} }
} }

View File

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