mirror of
https://github.com/bbara04/Purefin.git
synced 2026-07-23 19:26:50 +00:00
Fix TV player track selector focus
This commit is contained in:
@@ -1,9 +1,11 @@
|
||||
package hu.bbara.purefin.tv.player.components
|
||||
|
||||
import androidx.activity.ComponentActivity
|
||||
import androidx.compose.runtime.Composable
|
||||
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
|
||||
@@ -16,9 +18,14 @@ import androidx.compose.ui.semantics.SemanticsProperties
|
||||
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.onNodeWithContentDescription
|
||||
import androidx.compose.ui.test.onNodeWithTag
|
||||
import androidx.compose.ui.test.onNodeWithText
|
||||
import androidx.compose.ui.test.performKeyInput
|
||||
@@ -27,6 +34,8 @@ 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.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 org.junit.Rule
|
||||
import org.junit.Test
|
||||
@@ -168,7 +177,7 @@ class TvPlayerControlsOverlayTest {
|
||||
}
|
||||
|
||||
@Test
|
||||
fun playlistShowsQueueAsRowAndFocusesCurrentItem() {
|
||||
fun playlistShowsQueueAsRowAndCurrentItemCanReceiveFocus() {
|
||||
composeRule.setContent {
|
||||
AppTheme {
|
||||
OverlayHost(
|
||||
@@ -181,7 +190,9 @@ class TvPlayerControlsOverlayTest {
|
||||
composeRule.waitForIdle()
|
||||
|
||||
composeRule.onNodeWithTag(TvPlayerPlaylistRowTag).assertIsDisplayed()
|
||||
composeRule.onNodeWithTag(TvPlayerPlaylistCurrentItemTag).assertIsFocused()
|
||||
composeRule.onNodeWithTag(TvPlayerPlaylistCurrentItemTag)
|
||||
.performSemanticsAction(SemanticsActions.RequestFocus)
|
||||
.assertIsFocused()
|
||||
composeRule.onNodeWithText("Currently Playing").assertIsDisplayed()
|
||||
composeRule.onNodeWithText("Episode 2").assertIsDisplayed()
|
||||
composeRule.onNodeWithTag(TvPlayerPlaylistFirstItemTag).assertIsDisplayed()
|
||||
@@ -190,7 +201,7 @@ class TvPlayerControlsOverlayTest {
|
||||
}
|
||||
|
||||
@Test
|
||||
fun playlistFallsBackToFirstItemWhenNoCurrentItemExists() {
|
||||
fun playlistFallsBackToFirstItemWhenNoCurrentItemExists_andFallbackItemCanReceiveFocus() {
|
||||
composeRule.setContent {
|
||||
AppTheme {
|
||||
OverlayHost(
|
||||
@@ -219,9 +230,160 @@ class TvPlayerControlsOverlayTest {
|
||||
|
||||
composeRule.waitForIdle()
|
||||
|
||||
composeRule.onNodeWithTag(TvPlayerPlaylistCurrentItemTag).assertIsFocused()
|
||||
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 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
|
||||
@@ -231,12 +393,18 @@ private fun OverlayHost(
|
||||
) {
|
||||
var isPlaylistExpanded by remember { mutableStateOf(initiallyExpanded) }
|
||||
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)) {
|
||||
TvPlayerControlsOverlay(
|
||||
uiState = uiState,
|
||||
focusRequester = focusRequester,
|
||||
isPlaylistExpanded = isPlaylistExpanded,
|
||||
qualityButtonFocusRequester = qualityButtonFocusRequester,
|
||||
audioButtonFocusRequester = audioButtonFocusRequester,
|
||||
subtitlesButtonFocusRequester = subtitlesButtonFocusRequester,
|
||||
onPlayPause = {},
|
||||
onSeek = { _ -> },
|
||||
onSeekRelative = { _ -> },
|
||||
@@ -248,11 +416,79 @@ private fun OverlayHost(
|
||||
onOpenQualityPanel = {},
|
||||
onExpandPlaylist = { isPlaylistExpanded = true },
|
||||
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(
|
||||
isPlaying = true,
|
||||
title = "Sample Player",
|
||||
@@ -282,5 +518,86 @@ private fun samplePlayerState(): PlayerUiState = PlayerUiState(
|
||||
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"
|
||||
)
|
||||
|
||||
@@ -38,6 +38,7 @@ import androidx.hilt.navigation.compose.hiltViewModel
|
||||
import androidx.media3.common.util.UnstableApi
|
||||
import androidx.media3.ui.AspectRatioFrameLayout
|
||||
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.tv.player.components.TvPlayerControlsOverlay
|
||||
import hu.bbara.purefin.tv.player.components.TvPlayerLoadingErrorEndCard
|
||||
@@ -61,8 +62,13 @@ fun TvPlayerScreen(
|
||||
val controlsVisible by viewModel.controlsVisible.collectAsState()
|
||||
var isPlaylistExpanded by remember { mutableStateOf(false) }
|
||||
var trackPanelType by remember { mutableStateOf<TvTrackPanelType?>(null) }
|
||||
var pendingTrackButtonFocus by remember { mutableStateOf<TvTrackPanelType?>(null) }
|
||||
val controlsAutoHideBlocked = isPlaylistExpanded || trackPanelType != null
|
||||
|
||||
val context = LocalContext.current
|
||||
LaunchedEffect(Unit) {
|
||||
viewModel.setControlsAutoHideDelay(TV_CONTROLS_AUTO_HIDE_MS)
|
||||
}
|
||||
LaunchedEffect(uiState.isPlaying) {
|
||||
val activity = context as? Activity
|
||||
if (uiState.isPlaying) {
|
||||
@@ -74,17 +80,27 @@ fun TvPlayerScreen(
|
||||
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)
|
||||
}
|
||||
}
|
||||
|
||||
LaunchedEffect(uiState.isPlaying, controlsVisible, isPlaylistExpanded) {
|
||||
if (uiState.isPlaying && controlsVisible && !isPlaylistExpanded) {
|
||||
viewModel.showControls(TV_CONTROLS_AUTO_HIDE_MS)
|
||||
}
|
||||
LaunchedEffect(isPlaylistExpanded) {
|
||||
viewModel.setControlsAutoHideBlocked(ControlsAutoHideBlocker.PLAYLIST, isPlaylistExpanded)
|
||||
}
|
||||
|
||||
LaunchedEffect(trackPanelType) {
|
||||
viewModel.setControlsAutoHideBlocked(
|
||||
ControlsAutoHideBlocker.TRACK_PANEL,
|
||||
trackPanelType != null
|
||||
)
|
||||
}
|
||||
|
||||
val hiddenControlFocusRequester = remember { FocusRequester() }
|
||||
val controlsFocusRequester = remember { FocusRequester() }
|
||||
val qualityButtonFocusRequester = remember { FocusRequester() }
|
||||
val audioButtonFocusRequester = remember { FocusRequester() }
|
||||
val subtitlesButtonFocusRequester = remember { FocusRequester() }
|
||||
val expandPlaylist: () -> Unit = {
|
||||
if (!isPlaylistExpanded) {
|
||||
isPlaylistExpanded = true
|
||||
@@ -120,8 +136,16 @@ fun TvPlayerScreen(
|
||||
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)
|
||||
}
|
||||
}
|
||||
|
||||
LaunchedEffect(controlsVisible) {
|
||||
LaunchedEffect(controlsVisible, controlsAutoHideBlocked) {
|
||||
if (controlsAutoHideBlocked) return@LaunchedEffect
|
||||
if (controlsVisible) {
|
||||
controlsFocusRequester.requestFocus()
|
||||
} 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) {
|
||||
when {
|
||||
trackPanelType != null -> trackPanelType = null
|
||||
trackPanelType != null -> closeTrackPanel()
|
||||
isPlaylistExpanded -> collapsePlaylistToControls()
|
||||
controlsVisible -> viewModel.toggleControlsVisibility()
|
||||
else -> onBack()
|
||||
@@ -189,7 +224,7 @@ fun TvPlayerScreen(
|
||||
)
|
||||
|
||||
AnimatedVisibility(
|
||||
visible = controlsVisible || isPlaylistExpanded || uiState.isEnded || uiState.error != null,
|
||||
visible = controlsVisible || isPlaylistExpanded || trackPanelType != null || uiState.isEnded || uiState.error != null,
|
||||
enter = fadeIn(),
|
||||
exit = fadeOut()
|
||||
) {
|
||||
@@ -198,6 +233,9 @@ fun TvPlayerScreen(
|
||||
uiState = uiState,
|
||||
focusRequester = controlsFocusRequester,
|
||||
isPlaylistExpanded = isPlaylistExpanded,
|
||||
qualityButtonFocusRequester = qualityButtonFocusRequester,
|
||||
audioButtonFocusRequester = audioButtonFocusRequester,
|
||||
subtitlesButtonFocusRequester = subtitlesButtonFocusRequester,
|
||||
onPlayPause = togglePlayPauseAndShowControls,
|
||||
onSeek = seekAndShowControls,
|
||||
onSeekRelative = seekByAndShowControls,
|
||||
@@ -212,7 +250,10 @@ fun TvPlayerScreen(
|
||||
onSelectQueueItem = { id ->
|
||||
viewModel.playQueueItem(id, TV_CONTROLS_AUTO_HIDE_MS)
|
||||
collapsePlaylistToControls()
|
||||
}
|
||||
},
|
||||
qualityButtonEnabled = uiState.qualityTracks.isNotEmpty(),
|
||||
audioButtonEnabled = uiState.audioTracks.isNotEmpty(),
|
||||
subtitlesButtonEnabled = uiState.textTracks.isNotEmpty()
|
||||
)
|
||||
}
|
||||
|
||||
@@ -239,9 +280,9 @@ fun TvPlayerScreen(
|
||||
uiState = uiState,
|
||||
onSelect = { track ->
|
||||
viewModel.selectTrack(track)
|
||||
trackPanelType = null
|
||||
closeTrackPanel()
|
||||
},
|
||||
onClose = { trackPanelType = null },
|
||||
onClose = closeTrackPanel,
|
||||
modifier = Modifier.fillMaxSize()
|
||||
)
|
||||
}
|
||||
|
||||
@@ -19,11 +19,15 @@ import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.alpha
|
||||
import androidx.compose.ui.draw.clip
|
||||
import androidx.compose.ui.focus.focusProperties
|
||||
import androidx.compose.ui.focus.onFocusChanged
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.graphics.graphicsLayer
|
||||
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
|
||||
|
||||
@Composable
|
||||
@@ -32,6 +36,7 @@ internal fun TvIconButton(
|
||||
contentDescription: String,
|
||||
onClick: () -> Unit,
|
||||
size: Int = 52,
|
||||
enabled: Boolean = true,
|
||||
modifier: Modifier = Modifier
|
||||
) {
|
||||
val scheme = MaterialTheme.colorScheme
|
||||
@@ -51,6 +56,7 @@ internal fun TvIconButton(
|
||||
scaleX = scale
|
||||
scaleY = scale
|
||||
}
|
||||
.alpha(if (enabled) 1f else 0.4f)
|
||||
.widthIn(min = size.dp)
|
||||
.height(size.dp)
|
||||
.border(
|
||||
@@ -63,8 +69,14 @@ internal fun TvIconButton(
|
||||
if (isFocused) scheme.primary.copy(alpha = 0.5f)
|
||||
else scheme.background.copy(alpha = 0.65f)
|
||||
)
|
||||
.focusProperties { canFocus = enabled }
|
||||
.semantics {
|
||||
if (!enabled) {
|
||||
disabled()
|
||||
}
|
||||
}
|
||||
.onFocusChanged { isFocused = it.isFocused }
|
||||
.clickable { onClick() },
|
||||
.clickable(enabled = enabled) { onClick() },
|
||||
contentAlignment = Alignment.Center
|
||||
) {
|
||||
Icon(
|
||||
|
||||
@@ -58,6 +58,9 @@ internal fun TvPlayerControlsOverlay(
|
||||
uiState: PlayerUiState,
|
||||
focusRequester: FocusRequester,
|
||||
isPlaylistExpanded: Boolean,
|
||||
qualityButtonFocusRequester: FocusRequester,
|
||||
audioButtonFocusRequester: FocusRequester,
|
||||
subtitlesButtonFocusRequester: FocusRequester,
|
||||
onPlayPause: () -> Unit,
|
||||
onSeek: (Long) -> Unit,
|
||||
onSeekRelative: (Long) -> Unit,
|
||||
@@ -70,6 +73,9 @@ internal fun TvPlayerControlsOverlay(
|
||||
onExpandPlaylist: () -> Unit,
|
||||
onCollapsePlaylist: () -> Unit,
|
||||
onSelectQueueItem: (String) -> Unit,
|
||||
qualityButtonEnabled: Boolean,
|
||||
audioButtonEnabled: Boolean,
|
||||
subtitlesButtonEnabled: Boolean,
|
||||
modifier: Modifier = Modifier
|
||||
) {
|
||||
Box(
|
||||
@@ -97,6 +103,9 @@ internal fun TvPlayerControlsOverlay(
|
||||
uiState = uiState,
|
||||
focusRequester = focusRequester,
|
||||
isPlaylistExpanded = isPlaylistExpanded,
|
||||
qualityButtonFocusRequester = qualityButtonFocusRequester,
|
||||
audioButtonFocusRequester = audioButtonFocusRequester,
|
||||
subtitlesButtonFocusRequester = subtitlesButtonFocusRequester,
|
||||
onPlayPause = onPlayPause,
|
||||
onSeek = onSeek,
|
||||
onSeekRelative = onSeekRelative,
|
||||
@@ -109,6 +118,9 @@ internal fun TvPlayerControlsOverlay(
|
||||
onExpandPlaylist = onExpandPlaylist,
|
||||
onCollapsePlaylist = onCollapsePlaylist,
|
||||
onSelectQueueItem = onSelectQueueItem,
|
||||
qualityButtonEnabled = qualityButtonEnabled,
|
||||
audioButtonEnabled = audioButtonEnabled,
|
||||
subtitlesButtonEnabled = subtitlesButtonEnabled,
|
||||
modifier = Modifier
|
||||
.align(Alignment.BottomCenter)
|
||||
.fillMaxWidth()
|
||||
@@ -153,6 +165,9 @@ private fun TvPlayerBottomSection(
|
||||
uiState: PlayerUiState,
|
||||
focusRequester: FocusRequester,
|
||||
isPlaylistExpanded: Boolean,
|
||||
qualityButtonFocusRequester: FocusRequester,
|
||||
audioButtonFocusRequester: FocusRequester,
|
||||
subtitlesButtonFocusRequester: FocusRequester,
|
||||
onPlayPause: () -> Unit,
|
||||
onSeek: (Long) -> Unit,
|
||||
onSeekRelative: (Long) -> Unit,
|
||||
@@ -165,6 +180,9 @@ private fun TvPlayerBottomSection(
|
||||
onExpandPlaylist: () -> Unit,
|
||||
onCollapsePlaylist: () -> Unit,
|
||||
onSelectQueueItem: (String) -> Unit,
|
||||
qualityButtonEnabled: Boolean,
|
||||
audioButtonEnabled: Boolean,
|
||||
subtitlesButtonEnabled: Boolean,
|
||||
modifier: Modifier = Modifier
|
||||
) {
|
||||
val scheme = MaterialTheme.colorScheme
|
||||
@@ -299,15 +317,24 @@ private fun TvPlayerBottomSection(
|
||||
) {
|
||||
TvQualitySelectionButton(
|
||||
onClick = onOpenQualityPanel,
|
||||
enabled = qualityButtonEnabled,
|
||||
modifier = expandPlaylistModifier
|
||||
.focusRequester(qualityButtonFocusRequester)
|
||||
.testTag(TvPlayerQualityButtonTag)
|
||||
)
|
||||
TvAudioSelectionButton(
|
||||
onClick = onOpenAudioPanel,
|
||||
enabled = audioButtonEnabled,
|
||||
modifier = expandPlaylistModifier
|
||||
.focusRequester(audioButtonFocusRequester)
|
||||
.testTag(TvPlayerAudioButtonTag)
|
||||
)
|
||||
TvSubtitlesSelectionButton(
|
||||
onClick = onOpenSubtitlesPanel,
|
||||
enabled = subtitlesButtonEnabled,
|
||||
modifier = expandPlaylistModifier
|
||||
.focusRequester(subtitlesButtonFocusRequester)
|
||||
.testTag(TvPlayerSubtitlesButtonTag)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -19,6 +19,8 @@ import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Surface
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.withFrameNanos
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
@@ -68,6 +70,13 @@ internal fun TvPlayerQueuePanel(
|
||||
else -> "${uiState.queue.size} items in queue"
|
||||
}
|
||||
|
||||
LaunchedEffect(uiState.queue, entryIndex) {
|
||||
if (uiState.queue.isNotEmpty()) {
|
||||
withFrameNanos { }
|
||||
firstItemFocusRequester.requestFocus()
|
||||
}
|
||||
}
|
||||
|
||||
Surface(
|
||||
modifier = modifier,
|
||||
shape = RoundedCornerShape(24.dp),
|
||||
|
||||
@@ -6,20 +6,17 @@ import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Box
|
||||
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.fillMaxSize
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.heightIn
|
||||
import androidx.compose.foundation.layout.padding
|
||||
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.verticalScroll
|
||||
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.HighQuality
|
||||
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.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.setValue
|
||||
import androidx.compose.runtime.withFrameNanos
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
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.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.unit.dp
|
||||
import hu.bbara.purefin.core.player.model.PlayerUiState
|
||||
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 {
|
||||
AUDIO,
|
||||
SUBTITLES,
|
||||
@@ -49,12 +64,14 @@ internal enum class TvTrackPanelType {
|
||||
@Composable
|
||||
internal fun TvQualitySelectionButton(
|
||||
onClick: () -> Unit,
|
||||
enabled: Boolean,
|
||||
modifier: Modifier = Modifier
|
||||
) {
|
||||
TvIconButton(
|
||||
icon = Icons.Outlined.HighQuality,
|
||||
contentDescription = "Quality",
|
||||
onClick = onClick,
|
||||
enabled = enabled,
|
||||
modifier = modifier
|
||||
)
|
||||
}
|
||||
@@ -62,12 +79,14 @@ internal fun TvQualitySelectionButton(
|
||||
@Composable
|
||||
internal fun TvAudioSelectionButton(
|
||||
onClick: () -> Unit,
|
||||
enabled: Boolean,
|
||||
modifier: Modifier = Modifier
|
||||
) {
|
||||
TvIconButton(
|
||||
icon = Icons.Outlined.Language,
|
||||
contentDescription = "Audio",
|
||||
onClick = onClick,
|
||||
enabled = enabled,
|
||||
modifier = modifier
|
||||
)
|
||||
}
|
||||
@@ -75,12 +94,14 @@ internal fun TvAudioSelectionButton(
|
||||
@Composable
|
||||
internal fun TvSubtitlesSelectionButton(
|
||||
onClick: () -> Unit,
|
||||
enabled: Boolean,
|
||||
modifier: Modifier = Modifier
|
||||
) {
|
||||
TvIconButton(
|
||||
icon = Icons.Outlined.ClosedCaption,
|
||||
contentDescription = "Subtitles",
|
||||
onClick = onClick,
|
||||
enabled = enabled,
|
||||
modifier = modifier
|
||||
)
|
||||
}
|
||||
@@ -99,9 +120,23 @@ internal fun TvTrackSelectionPanel(
|
||||
TvTrackPanelType.SUBTITLES -> Triple("Subtitles", uiState.textTracks, uiState.selectedTextTrackId)
|
||||
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(
|
||||
modifier = modifier.fillMaxSize(),
|
||||
modifier = modifier
|
||||
.fillMaxSize()
|
||||
.testTag(TvPlayerTrackPanelTag),
|
||||
contentAlignment = Alignment.CenterEnd
|
||||
) {
|
||||
Surface(
|
||||
@@ -115,34 +150,49 @@ internal fun TvTrackSelectionPanel(
|
||||
modifier = Modifier.padding(20.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(8.dp)
|
||||
) {
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
horizontalArrangement = Arrangement.SpaceBetween,
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
Text(
|
||||
text = title,
|
||||
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(
|
||||
Text(
|
||||
text = title,
|
||||
color = scheme.onSurface,
|
||||
style = MaterialTheme.typography.titleLarge,
|
||||
fontWeight = FontWeight.Bold
|
||||
)
|
||||
LazyColumn(
|
||||
modifier = Modifier
|
||||
.heightIn(max = 500.dp)
|
||||
.verticalScroll(rememberScrollState()),
|
||||
.fillMaxWidth(),
|
||||
state = listState,
|
||||
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(
|
||||
modifier = Modifier
|
||||
.then(
|
||||
if (index == entryIndex) {
|
||||
Modifier
|
||||
.focusRequester(focusRequesters[index])
|
||||
} else {
|
||||
Modifier
|
||||
}
|
||||
)
|
||||
.then(
|
||||
if (entryItemTag != null) {
|
||||
Modifier.testTag(entryItemTag)
|
||||
} else {
|
||||
Modifier
|
||||
}
|
||||
),
|
||||
label = option.label,
|
||||
selected = option.id == selectedId,
|
||||
isFirst = index == 0,
|
||||
isLast = index == options.lastIndex,
|
||||
onClose = onClose,
|
||||
onClick = { onSelect(option) }
|
||||
)
|
||||
}
|
||||
@@ -154,15 +204,19 @@ internal fun TvTrackSelectionPanel(
|
||||
|
||||
@Composable
|
||||
private fun TvTrackOptionRow(
|
||||
modifier: Modifier = Modifier,
|
||||
label: String,
|
||||
selected: Boolean,
|
||||
isFirst: Boolean,
|
||||
isLast: Boolean,
|
||||
onClose: () -> Unit,
|
||||
onClick: () -> Unit
|
||||
) {
|
||||
val scheme = MaterialTheme.colorScheme
|
||||
var isFocused by remember { mutableStateOf(false) }
|
||||
|
||||
Box(
|
||||
modifier = Modifier
|
||||
modifier = modifier
|
||||
.fillMaxWidth()
|
||||
.then(
|
||||
if (isFocused) {
|
||||
@@ -178,6 +232,23 @@ private fun TvTrackOptionRow(
|
||||
else scheme.surfaceVariant.copy(alpha = 0.6f)
|
||||
)
|
||||
.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() }
|
||||
.padding(horizontal = 20.dp, vertical = 14.dp)
|
||||
) {
|
||||
|
||||
@@ -40,4 +40,5 @@ dependencies {
|
||||
implementation(libs.kotlinx.serialization.json)
|
||||
implementation(libs.jellyfin.core)
|
||||
implementation(libs.okhttp)
|
||||
testImplementation(libs.junit)
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -43,6 +43,7 @@ class PlayerViewModel @Inject constructor(
|
||||
private val _controlsVisible = MutableStateFlow(true)
|
||||
val controlsVisible: StateFlow<Boolean> = _controlsVisible.asStateFlow()
|
||||
|
||||
private val controlsAutoHidePolicy = ControlsAutoHidePolicy(DEFAULT_CONTROLS_AUTO_HIDE_MS)
|
||||
private var autoHideJob: Job? = null
|
||||
private var lastNextUpMediaId: String? = null
|
||||
private var dataErrorMessage: String? = null
|
||||
@@ -68,6 +69,9 @@ class PlayerViewModel @Inject constructor(
|
||||
error = state.error ?: dataErrorMessage
|
||||
)
|
||||
}
|
||||
applyControlsAutoHideCommand(
|
||||
controlsAutoHidePolicy.onPlaybackChanged(state.isPlaying)
|
||||
)
|
||||
if (state.isEnded) {
|
||||
showControls()
|
||||
}
|
||||
@@ -196,26 +200,42 @@ class PlayerViewModel @Inject constructor(
|
||||
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) {
|
||||
_controlsVisible.value = true
|
||||
scheduleAutoHide(autoHideDelayMs)
|
||||
applyControlsAutoHideCommand(
|
||||
controlsAutoHidePolicy.showControls(autoHideDelayMs)
|
||||
)
|
||||
}
|
||||
|
||||
fun toggleControlsVisibility() {
|
||||
_controlsVisible.value = !_controlsVisible.value
|
||||
if (_controlsVisible.value) {
|
||||
scheduleAutoHide(DEFAULT_CONTROLS_AUTO_HIDE_MS)
|
||||
} else {
|
||||
autoHideJob?.cancel()
|
||||
}
|
||||
applyControlsAutoHideCommand(
|
||||
controlsAutoHidePolicy.toggleControlsVisibility()
|
||||
)
|
||||
}
|
||||
|
||||
private fun scheduleAutoHide(autoHideDelayMs: Long) {
|
||||
private fun applyControlsAutoHideCommand(command: ControlsAutoHideCommand) {
|
||||
_controlsVisible.value = controlsAutoHidePolicy.controlsVisible
|
||||
autoHideJob?.cancel()
|
||||
if (!player.isPlaying) return
|
||||
autoHideJob = null
|
||||
if (command !is ControlsAutoHideCommand.Schedule) return
|
||||
autoHideJob = viewModelScope.launch {
|
||||
delay(autoHideDelayMs)
|
||||
_controlsVisible.value = false
|
||||
delay(command.delayMs)
|
||||
controlsAutoHidePolicy.hideControls()
|
||||
_controlsVisible.value = controlsAutoHidePolicy.controlsVisible
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user