Refine TV player inline playlist flow

Move the TV player queue into a simplified bottom inline playlist row and keep the existing seek bar -> controls -> playlist navigation hierarchy.

Focus playlist entry on the current queue item, keep focus pinned on the row at the horizontal edges, and return to the controls row on Up/Back.

Add compose coverage for playlist expansion, current-item focus, fallback focus, and row edge navigation.
This commit is contained in:
2026-04-09 18:21:49 +02:00
parent 54e79d8bee
commit d007c80af7
6 changed files with 633 additions and 151 deletions

View File

@@ -0,0 +1,286 @@
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.size
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.focus.FocusRequester
import androidx.compose.ui.input.key.Key
import androidx.compose.ui.semantics.SemanticsActions
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.assertIsDisplayed
import androidx.compose.ui.test.assertIsFocused
import androidx.compose.ui.test.junit4.createAndroidComposeRule
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.QueueItemUi
import hu.bbara.purefin.ui.theme.AppTheme
import org.junit.Rule
import org.junit.Test
@OptIn(ExperimentalTestApi::class)
class TvPlayerControlsOverlayTest {
@get:Rule
val composeRule = createAndroidComposeRule<ComponentActivity>()
@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 playlistShowsQueueAsRowAndFocusesCurrentItem() {
composeRule.setContent {
AppTheme {
OverlayHost(
uiState = samplePlayerState(),
initiallyExpanded = true
)
}
}
composeRule.waitForIdle()
composeRule.onNodeWithTag(TvPlayerPlaylistRowTag).assertIsDisplayed()
composeRule.onNodeWithTag(TvPlayerPlaylistCurrentItemTag).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() {
composeRule.setContent {
AppTheme {
OverlayHost(
uiState = samplePlayerState().copy(
queue = listOf(
QueueItemUi(
id = "first",
title = "Episode 1",
subtitle = "Fallback entry",
artworkUrl = null,
isCurrent = false
),
QueueItemUi(
id = "second",
title = "Episode 2",
subtitle = "Later",
artworkUrl = null,
isCurrent = false
)
)
),
initiallyExpanded = true
)
}
}
composeRule.waitForIdle()
composeRule.onNodeWithTag(TvPlayerPlaylistCurrentItemTag).assertIsFocused()
composeRule.onNodeWithText("Episode 1").assertIsDisplayed()
}
}
@Composable
private fun OverlayHost(
uiState: PlayerUiState,
initiallyExpanded: Boolean = false
) {
var isPlaylistExpanded by remember { mutableStateOf(initiallyExpanded) }
val focusRequester = remember { FocusRequester() }
Box(modifier = Modifier.size(width = 960.dp, height = 540.dp)) {
TvPlayerControlsOverlay(
uiState = uiState,
focusRequester = focusRequester,
isPlaylistExpanded = isPlaylistExpanded,
onPlayPause = {},
onSeek = { _ -> },
onSeekRelative = { _ -> },
onSeekLiveEdge = {},
onNext = {},
onPrevious = {},
onOpenAudioPanel = {},
onOpenSubtitlesPanel = {},
onOpenQualityPanel = {},
onExpandPlaylist = { isPlaylistExpanded = true },
onCollapsePlaylist = { isPlaylistExpanded = false },
onSelectQueueItem = { _ -> }
)
}
}
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(
QueueItemUi(
id = "played",
title = "Already Played",
subtitle = "Episode 0",
artworkUrl = null,
isCurrent = false
),
QueueItemUi(
id = "current",
title = "Currently Playing",
subtitle = "Episode 1",
artworkUrl = null,
isCurrent = true
),
QueueItemUi(
id = "next",
title = "Episode 2",
subtitle = "Up next",
artworkUrl = null,
isCurrent = false
)
)
)

View File

@@ -41,7 +41,6 @@ import androidx.media3.ui.PlayerView
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
import hu.bbara.purefin.tv.player.components.TvPlayerQueuePanel
import hu.bbara.purefin.tv.player.components.TvTrackPanelType import hu.bbara.purefin.tv.player.components.TvTrackPanelType
import hu.bbara.purefin.tv.player.components.TvTrackSelectionPanel import hu.bbara.purefin.tv.player.components.TvTrackSelectionPanel
@@ -60,7 +59,7 @@ fun TvPlayerScreen(
val uiState by viewModel.uiState.collectAsState() val uiState by viewModel.uiState.collectAsState()
val controlsVisible by viewModel.controlsVisible.collectAsState() val controlsVisible by viewModel.controlsVisible.collectAsState()
var showQueuePanel by remember { mutableStateOf(false) } var isPlaylistExpanded by remember { mutableStateOf(false) }
var trackPanelType by remember { mutableStateOf<TvTrackPanelType?>(null) } var trackPanelType by remember { mutableStateOf<TvTrackPanelType?>(null) }
val context = LocalContext.current val context = LocalContext.current
@@ -78,18 +77,25 @@ fun TvPlayerScreen(
} }
} }
BackHandler(enabled = controlsVisible) { LaunchedEffect(uiState.isPlaying, controlsVisible, isPlaylistExpanded) {
viewModel.toggleControlsVisibility() if (uiState.isPlaying && controlsVisible && !isPlaylistExpanded) {
}
LaunchedEffect(uiState.isPlaying) {
if (uiState.isPlaying && controlsVisible) {
viewModel.showControls(TV_CONTROLS_AUTO_HIDE_MS) viewModel.showControls(TV_CONTROLS_AUTO_HIDE_MS)
} }
} }
val hiddenControlFocusRequester = remember { FocusRequester() } val hiddenControlFocusRequester = remember { FocusRequester() }
val controlsFocusRequester = remember { FocusRequester() } val controlsFocusRequester = remember { FocusRequester() }
val expandPlaylist: () -> Unit = {
if (!isPlaylistExpanded) {
isPlaylistExpanded = true
}
}
val collapsePlaylistToControls: () -> Unit = {
if (isPlaylistExpanded) {
isPlaylistExpanded = false
viewModel.showControls(TV_CONTROLS_AUTO_HIDE_MS)
}
}
val showTvControls: () -> Unit = { val showTvControls: () -> Unit = {
viewModel.showControls(TV_CONTROLS_AUTO_HIDE_MS) viewModel.showControls(TV_CONTROLS_AUTO_HIDE_MS)
} }
@@ -124,11 +130,12 @@ fun TvPlayerScreen(
} }
BackHandler(enabled = true) { BackHandler(enabled = true) {
if (controlsVisible) { when {
viewModel.toggleControlsVisibility() trackPanelType != null -> trackPanelType = null
return@BackHandler isPlaylistExpanded -> collapsePlaylistToControls()
controlsVisible -> viewModel.toggleControlsVisibility()
else -> onBack()
} }
onBack()
} }
Box( Box(
@@ -182,7 +189,7 @@ fun TvPlayerScreen(
) )
AnimatedVisibility( AnimatedVisibility(
visible = controlsVisible || uiState.isEnded || uiState.error != null, visible = controlsVisible || isPlaylistExpanded || uiState.isEnded || uiState.error != null,
enter = fadeIn(), enter = fadeIn(),
exit = fadeOut() exit = fadeOut()
) { ) {
@@ -190,6 +197,7 @@ fun TvPlayerScreen(
modifier = Modifier.fillMaxSize(), modifier = Modifier.fillMaxSize(),
uiState = uiState, uiState = uiState,
focusRequester = controlsFocusRequester, focusRequester = controlsFocusRequester,
isPlaylistExpanded = isPlaylistExpanded,
onPlayPause = togglePlayPauseAndShowControls, onPlayPause = togglePlayPauseAndShowControls,
onSeek = seekAndShowControls, onSeek = seekAndShowControls,
onSeekRelative = seekByAndShowControls, onSeekRelative = seekByAndShowControls,
@@ -199,7 +207,12 @@ fun TvPlayerScreen(
onOpenAudioPanel = { trackPanelType = TvTrackPanelType.AUDIO }, onOpenAudioPanel = { trackPanelType = TvTrackPanelType.AUDIO },
onOpenSubtitlesPanel = { trackPanelType = TvTrackPanelType.SUBTITLES }, onOpenSubtitlesPanel = { trackPanelType = TvTrackPanelType.SUBTITLES },
onOpenQualityPanel = { trackPanelType = TvTrackPanelType.QUALITY }, onOpenQualityPanel = { trackPanelType = TvTrackPanelType.QUALITY },
onOpenQueue = { showQueuePanel = true } onExpandPlaylist = expandPlaylist,
onCollapsePlaylist = collapsePlaylistToControls,
onSelectQueueItem = { id ->
viewModel.playQueueItem(id, TV_CONTROLS_AUTO_HIDE_MS)
collapsePlaylistToControls()
}
) )
} }
@@ -234,20 +247,5 @@ fun TvPlayerScreen(
} }
} }
AnimatedVisibility(
visible = showQueuePanel,
enter = slideInHorizontally { it },
exit = slideOutHorizontally { it }
) {
TvPlayerQueuePanel(
uiState = uiState,
onSelect = { id ->
viewModel.playQueueItem(id, TV_CONTROLS_AUTO_HIDE_MS)
showQueuePanel = false
},
onClose = { showQueuePanel = false },
modifier = Modifier.fillMaxSize()
)
}
} }
} }

View File

@@ -1,5 +1,10 @@
package hu.bbara.purefin.tv.player.components package hu.bbara.purefin.tv.player.components
import androidx.compose.animation.AnimatedVisibility
import androidx.compose.animation.expandVertically
import androidx.compose.animation.fadeIn
import androidx.compose.animation.fadeOut
import androidx.compose.animation.shrinkVertically
import androidx.compose.foundation.background import androidx.compose.foundation.background
import androidx.compose.foundation.clickable import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Arrangement
@@ -12,7 +17,6 @@ import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.padding
import androidx.compose.material.icons.Icons import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.outlined.PlaylistPlay
import androidx.compose.material.icons.outlined.Forward30 import androidx.compose.material.icons.outlined.Forward30
import androidx.compose.material.icons.outlined.Pause import androidx.compose.material.icons.outlined.Pause
import androidx.compose.material.icons.outlined.PlayArrow import androidx.compose.material.icons.outlined.PlayArrow
@@ -22,19 +26,38 @@ import androidx.compose.material.icons.outlined.SkipPrevious
import androidx.compose.material3.MaterialTheme import androidx.compose.material3.MaterialTheme
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.mutableStateOf
import androidx.compose.runtime.remember
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.focus.FocusRequester import androidx.compose.ui.focus.FocusRequester
import androidx.compose.ui.focus.focusRequester
import androidx.compose.ui.graphics.Brush import androidx.compose.ui.graphics.Brush
import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.Color
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.semantics.semantics
import androidx.compose.ui.semantics.stateDescription
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
internal const val TvPlayerPlaylistStateTag = "tv_player_playlist_state"
internal const val TvPlayerPlayPauseButtonTag = "tv_player_play_pause_button"
internal const val TvPlayerSeekBarTag = "tv_player_seek_bar"
@Composable @Composable
internal fun TvPlayerControlsOverlay( internal fun TvPlayerControlsOverlay(
uiState: PlayerUiState, uiState: PlayerUiState,
focusRequester: FocusRequester, focusRequester: FocusRequester,
isPlaylistExpanded: Boolean,
onPlayPause: () -> Unit, onPlayPause: () -> Unit,
onSeek: (Long) -> Unit, onSeek: (Long) -> Unit,
onSeekRelative: (Long) -> Unit, onSeekRelative: (Long) -> Unit,
@@ -44,7 +67,9 @@ internal fun TvPlayerControlsOverlay(
onOpenAudioPanel: () -> Unit, onOpenAudioPanel: () -> Unit,
onOpenSubtitlesPanel: () -> Unit, onOpenSubtitlesPanel: () -> Unit,
onOpenQualityPanel: () -> Unit, onOpenQualityPanel: () -> Unit,
onOpenQueue: () -> Unit, onExpandPlaylist: () -> Unit,
onCollapsePlaylist: () -> Unit,
onSelectQueueItem: (String) -> Unit,
modifier: Modifier = Modifier modifier: Modifier = Modifier
) { ) {
Box( Box(
@@ -63,7 +88,6 @@ internal fun TvPlayerControlsOverlay(
TvPlayerTopBar( TvPlayerTopBar(
title = uiState.title ?: "Playing", title = uiState.title ?: "Playing",
subtitle = uiState.subtitle, subtitle = uiState.subtitle,
onOpenQueue = onOpenQueue,
modifier = Modifier modifier = Modifier
.align(Alignment.TopCenter) .align(Alignment.TopCenter)
.fillMaxWidth() .fillMaxWidth()
@@ -72,6 +96,7 @@ internal fun TvPlayerControlsOverlay(
TvPlayerBottomSection( TvPlayerBottomSection(
uiState = uiState, uiState = uiState,
focusRequester = focusRequester, focusRequester = focusRequester,
isPlaylistExpanded = isPlaylistExpanded,
onPlayPause = onPlayPause, onPlayPause = onPlayPause,
onSeek = onSeek, onSeek = onSeek,
onSeekRelative = onSeekRelative, onSeekRelative = onSeekRelative,
@@ -81,6 +106,9 @@ internal fun TvPlayerControlsOverlay(
onOpenAudioPanel = onOpenAudioPanel, onOpenAudioPanel = onOpenAudioPanel,
onOpenSubtitlesPanel = onOpenSubtitlesPanel, onOpenSubtitlesPanel = onOpenSubtitlesPanel,
onOpenQualityPanel = onOpenQualityPanel, onOpenQualityPanel = onOpenQualityPanel,
onExpandPlaylist = onExpandPlaylist,
onCollapsePlaylist = onCollapsePlaylist,
onSelectQueueItem = onSelectQueueItem,
modifier = Modifier modifier = Modifier
.align(Alignment.BottomCenter) .align(Alignment.BottomCenter)
.fillMaxWidth() .fillMaxWidth()
@@ -93,7 +121,6 @@ internal fun TvPlayerControlsOverlay(
private fun TvPlayerTopBar( private fun TvPlayerTopBar(
title: String, title: String,
subtitle: String?, subtitle: String?,
onOpenQueue: () -> Unit,
modifier: Modifier = Modifier modifier: Modifier = Modifier
) { ) {
val scheme = MaterialTheme.colorScheme val scheme = MaterialTheme.colorScheme
@@ -118,11 +145,6 @@ private fun TvPlayerTopBar(
) )
} }
} }
TvIconButton(
icon = Icons.AutoMirrored.Outlined.PlaylistPlay,
contentDescription = "Queue",
onClick = onOpenQueue
)
} }
} }
@@ -130,6 +152,7 @@ private fun TvPlayerTopBar(
private fun TvPlayerBottomSection( private fun TvPlayerBottomSection(
uiState: PlayerUiState, uiState: PlayerUiState,
focusRequester: FocusRequester, focusRequester: FocusRequester,
isPlaylistExpanded: Boolean,
onPlayPause: () -> Unit, onPlayPause: () -> Unit,
onSeek: (Long) -> Unit, onSeek: (Long) -> Unit,
onSeekRelative: (Long) -> Unit, onSeekRelative: (Long) -> Unit,
@@ -139,11 +162,35 @@ private fun TvPlayerBottomSection(
onOpenAudioPanel: () -> Unit, onOpenAudioPanel: () -> Unit,
onOpenSubtitlesPanel: () -> Unit, onOpenSubtitlesPanel: () -> Unit,
onOpenQualityPanel: () -> Unit, onOpenQualityPanel: () -> Unit,
onExpandPlaylist: () -> Unit,
onCollapsePlaylist: () -> Unit,
onSelectQueueItem: (String) -> Unit,
modifier: Modifier = Modifier modifier: Modifier = Modifier
) { ) {
val scheme = MaterialTheme.colorScheme val scheme = MaterialTheme.colorScheme
val playPauseFocusRequester = remember { FocusRequester() }
val playlistFocusRequester = remember { FocusRequester() }
var wasPlaylistExpanded by remember { mutableStateOf(isPlaylistExpanded) }
Column(modifier = modifier) { LaunchedEffect(isPlaylistExpanded) {
if (isPlaylistExpanded && uiState.queue.isNotEmpty()) {
playlistFocusRequester.requestFocus()
} else if (wasPlaylistExpanded) {
playPauseFocusRequester.requestFocus()
}
wasPlaylistExpanded = isPlaylistExpanded
}
val playlistExpandState = if (isPlaylistExpanded) "expanded" else "collapsed"
val expandPlaylistModifier = Modifier.onPreviewKeyEvent { event ->
handleExpandPlaylistKey(event, onExpandPlaylist)
}
Column(
modifier = modifier
.testTag(TvPlayerPlaylistStateTag)
.semantics { stateDescription = playlistExpandState }
) {
Row( Row(
modifier = Modifier modifier = Modifier
.fillMaxWidth() .fillMaxWidth()
@@ -189,7 +236,12 @@ private fun TvPlayerBottomSection(
onSeek = onSeek, onSeek = onSeek,
onSeekRelative = onSeekRelative, onSeekRelative = onSeekRelative,
togglePlayState = onPlayPause, togglePlayState = onPlayPause,
focusRequester = focusRequester onMoveDown = {
playPauseFocusRequester.requestFocus()
true
},
focusRequester = focusRequester,
modifier = Modifier.testTag(TvPlayerSeekBarTag)
) )
Spacer(modifier = Modifier.height(8.dp)) Spacer(modifier = Modifier.height(8.dp))
Box( Box(
@@ -206,31 +258,38 @@ private fun TvPlayerBottomSection(
icon = Icons.Outlined.SkipPrevious, icon = Icons.Outlined.SkipPrevious,
contentDescription = "Previous", contentDescription = "Previous",
onClick = onPrevious, onClick = onPrevious,
size = 64 size = 64,
modifier = expandPlaylistModifier
) )
TvIconButton( TvIconButton(
icon = Icons.Outlined.Replay10, icon = Icons.Outlined.Replay10,
contentDescription = "Seek backward 10 seconds", contentDescription = "Seek backward 10 seconds",
onClick = { onSeekRelative(-10_000) }, onClick = { onSeekRelative(-10_000) },
size = 64 size = 64,
modifier = expandPlaylistModifier
) )
TvIconButton( TvIconButton(
icon = if (uiState.isPlaying) Icons.Outlined.Pause else Icons.Outlined.PlayArrow, icon = if (uiState.isPlaying) Icons.Outlined.Pause else Icons.Outlined.PlayArrow,
contentDescription = if (uiState.isPlaying) "Pause" else "Play", contentDescription = if (uiState.isPlaying) "Pause" else "Play",
onClick = onPlayPause, onClick = onPlayPause,
size = 72 size = 72,
modifier = expandPlaylistModifier
.focusRequester(playPauseFocusRequester)
.testTag(TvPlayerPlayPauseButtonTag)
) )
TvIconButton( TvIconButton(
icon = Icons.Outlined.Forward30, icon = Icons.Outlined.Forward30,
contentDescription = "Seek forward 30 seconds", contentDescription = "Seek forward 30 seconds",
onClick = { onSeekRelative(30_000) }, onClick = { onSeekRelative(30_000) },
size = 64 size = 64,
modifier = expandPlaylistModifier
) )
TvIconButton( TvIconButton(
icon = Icons.Outlined.SkipNext, icon = Icons.Outlined.SkipNext,
contentDescription = "Next", contentDescription = "Next",
onClick = onNext, onClick = onNext,
size = 64 size = 64,
modifier = expandPlaylistModifier
) )
} }
Row( Row(
@@ -238,15 +297,50 @@ private fun TvPlayerBottomSection(
horizontalArrangement = Arrangement.spacedBy(8.dp), horizontalArrangement = Arrangement.spacedBy(8.dp),
verticalAlignment = Alignment.CenterVertically verticalAlignment = Alignment.CenterVertically
) { ) {
TvQualitySelectionButton(onClick = onOpenQualityPanel) TvQualitySelectionButton(
TvAudioSelectionButton(onClick = onOpenAudioPanel) onClick = onOpenQualityPanel,
TvSubtitlesSelectionButton(onClick = onOpenSubtitlesPanel) modifier = expandPlaylistModifier
)
TvAudioSelectionButton(
onClick = onOpenAudioPanel,
modifier = expandPlaylistModifier
)
TvSubtitlesSelectionButton(
onClick = onOpenSubtitlesPanel,
modifier = expandPlaylistModifier
)
} }
} }
Spacer(modifier = Modifier.height(8.dp)) AnimatedVisibility(
visible = isPlaylistExpanded,
enter = fadeIn() + expandVertically(expandFrom = Alignment.Top),
exit = fadeOut() + shrinkVertically(shrinkTowards = Alignment.Top)
) {
TvPlayerQueuePanel(
uiState = uiState,
firstItemFocusRequester = playlistFocusRequester,
onSelect = onSelectQueueItem,
onReturnToControls = onCollapsePlaylist,
modifier = Modifier
.fillMaxWidth()
.padding(top = 18.dp)
)
}
Spacer(modifier = Modifier.height(if (isPlaylistExpanded) 12.dp else 8.dp))
} }
} }
private fun handleExpandPlaylistKey(
event: androidx.compose.ui.input.key.KeyEvent,
onExpand: () -> Unit
): Boolean {
if (event.type == KeyEventType.KeyDown && event.key == Key.DirectionDown) {
onExpand()
return true
}
return false
}
private fun formatTime(positionMs: Long): String { private fun formatTime(positionMs: Long): String {
val totalSeconds = positionMs / 1000 val totalSeconds = positionMs / 1000
val seconds = (totalSeconds % 60).toInt() val seconds = (totalSeconds % 60).toInt()

View File

@@ -1,24 +1,20 @@
package hu.bbara.purefin.tv.player.components package hu.bbara.purefin.tv.player.components
import androidx.compose.animation.AnimatedVisibility
import androidx.compose.foundation.background import androidx.compose.foundation.background
import androidx.compose.foundation.border import androidx.compose.foundation.border
import androidx.compose.foundation.clickable 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.PaddingValues
import androidx.compose.foundation.layout.aspectRatio import androidx.compose.foundation.layout.aspectRatio
import androidx.compose.foundation.layout.fillMaxHeight
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.fillMaxWidth
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.lazy.LazyColumn import androidx.compose.foundation.lazy.LazyRow
import androidx.compose.foundation.lazy.items 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.material.icons.Icons
import androidx.compose.material.icons.automirrored.outlined.ArrowBack
import androidx.compose.material3.MaterialTheme 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
@@ -27,144 +23,209 @@ 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.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.graphics.painter.ColorPainter
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.platform.testTag
import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import hu.bbara.purefin.common.ui.components.PurefinAsyncImage
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 coil3.compose.AsyncImage
internal const val TvPlayerPlaylistRowTag = "tv_player_playlist_row"
internal const val TvPlayerPlaylistCurrentItemTag = "tv_player_playlist_current_item"
internal const val TvPlayerPlaylistFirstItemTag = "tv_player_playlist_first_item"
internal const val TvPlayerPlaylistLastItemTag = "tv_player_playlist_last_item"
@Composable @Composable
internal fun TvPlayerQueuePanel( internal fun TvPlayerQueuePanel(
uiState: PlayerUiState, uiState: PlayerUiState,
firstItemFocusRequester: FocusRequester,
onSelect: (String) -> Unit, onSelect: (String) -> Unit,
onClose: () -> Unit, onReturnToControls: () -> Unit,
modifier: Modifier = Modifier modifier: Modifier = Modifier
) { ) {
val scheme = MaterialTheme.colorScheme val scheme = MaterialTheme.colorScheme
val currentIndex = uiState.queue.indexOfFirst { it.isCurrent }
val entryIndex = if (currentIndex >= 0) currentIndex else 0
val listState = rememberLazyListState(
initialFirstVisibleItemIndex = (entryIndex - 1).coerceAtLeast(0)
)
val queueCountLabel = when (uiState.queue.size) {
0 -> "No items in queue"
1 -> "1 item in queue"
else -> "${uiState.queue.size} items in queue"
}
Box( Surface(
modifier = modifier.fillMaxSize(), modifier = modifier,
contentAlignment = Alignment.CenterEnd shape = RoundedCornerShape(24.dp),
color = scheme.surface.copy(alpha = 0.94f)
) { ) {
Surface( Column(
modifier = Modifier modifier = Modifier.padding(horizontal = 22.dp, vertical = 20.dp),
.fillMaxHeight() verticalArrangement = Arrangement.spacedBy(16.dp)
.width(320.dp)
.clip(RoundedCornerShape(topStart = 20.dp, bottomStart = 20.dp)),
color = scheme.surface.copy(alpha = 0.97f)
) { ) {
Column( Column(verticalArrangement = Arrangement.spacedBy(4.dp)) {
modifier = Modifier.padding(20.dp), Text(
verticalArrangement = Arrangement.spacedBy(12.dp) text = "Playlist",
) { color = scheme.onSurface,
Row( style = MaterialTheme.typography.titleLarge,
modifier = Modifier.fillMaxWidth(), fontWeight = FontWeight.Bold
horizontalArrangement = Arrangement.SpaceBetween, )
verticalAlignment = Alignment.CenterVertically Text(
text = queueCountLabel,
color = scheme.onSurfaceVariant,
style = MaterialTheme.typography.bodyMedium
)
}
if (uiState.queue.isEmpty()) {
Text(
text = "Add something to the queue to browse it here.",
color = scheme.onSurfaceVariant,
style = MaterialTheme.typography.bodyMedium
)
} else {
LazyRow(
modifier = Modifier
.fillMaxWidth()
.testTag(TvPlayerPlaylistRowTag),
state = listState,
horizontalArrangement = Arrangement.spacedBy(14.dp),
contentPadding = PaddingValues(end = 4.dp)
) { ) {
Text( itemsIndexed(uiState.queue, key = { _, item -> item.id }) { index, item ->
text = "Up next", val isEntryItem = index == entryIndex
color = scheme.onSurface, TvQueueRowCard(
style = MaterialTheme.typography.titleLarge, item = item,
fontWeight = FontWeight.Bold isCurrent = item.isCurrent,
) isFirst = index == 0,
TvIconButton( isLast = index == uiState.queue.lastIndex,
icon = Icons.AutoMirrored.Outlined.ArrowBack, onClick = { onSelect(item.id) },
contentDescription = "Close", onReturnToControls = onReturnToControls,
onClick = onClose modifier = Modifier
) .width(228.dp)
} .then(
AnimatedVisibility(visible = uiState.queue.isNotEmpty()) { if (isEntryItem) {
LazyColumn(verticalArrangement = Arrangement.spacedBy(10.dp)) { Modifier
items(uiState.queue) { item -> .focusRequester(firstItemFocusRequester)
TvQueueRow( .testTag(TvPlayerPlaylistCurrentItemTag)
title = item.title, } else {
subtitle = item.subtitle, Modifier
artworkUrl = item.artworkUrl, }
isCurrent = item.isCurrent, )
onClick = { onSelect(item.id) } .then(
) if (index == 0 && !isEntryItem) {
} Modifier.testTag(TvPlayerPlaylistFirstItemTag)
} else {
Modifier
}
)
.then(
if (index == uiState.queue.lastIndex && !isEntryItem) {
Modifier.testTag(TvPlayerPlaylistLastItemTag)
} else {
Modifier
}
)
)
} }
} }
if (uiState.queue.isEmpty()) {
Text(
text = "No items in queue",
color = scheme.onSurfaceVariant,
style = MaterialTheme.typography.bodyMedium
)
}
} }
} }
} }
} }
@Composable @Composable
private fun TvQueueRow( private fun TvQueueRowCard(
title: String, item: QueueItemUi,
subtitle: String?,
artworkUrl: String?,
isCurrent: Boolean, isCurrent: Boolean,
onClick: () -> Unit isFirst: Boolean,
isLast: Boolean,
onClick: () -> Unit,
onReturnToControls: () -> Unit,
modifier: Modifier = Modifier
) { ) {
val scheme = MaterialTheme.colorScheme val scheme = MaterialTheme.colorScheme
var isFocused by remember { mutableStateOf(false) } var isFocused by remember { mutableStateOf(false) }
val borderColor = when {
isFocused -> scheme.primary
isCurrent -> scheme.primary.copy(alpha = 0.55f)
else -> scheme.outlineVariant.copy(alpha = 0.35f)
}
val backgroundColor = when {
isFocused -> scheme.primary.copy(alpha = 0.18f)
isCurrent -> scheme.surfaceVariant.copy(alpha = 0.78f)
else -> scheme.surfaceVariant.copy(alpha = 0.62f)
}
Row( Column(
modifier = Modifier modifier = modifier
.fillMaxWidth() .border(
.then( width = if (isFocused) 2.dp else 1.dp,
if (isFocused) { color = borderColor,
Modifier.border(2.dp, scheme.primary, RoundedCornerShape(12.dp)) shape = RoundedCornerShape(18.dp)
} else {
Modifier
}
)
.clip(RoundedCornerShape(12.dp))
.background(
if (isFocused) scheme.primary.copy(alpha = 0.35f)
else if (isCurrent) scheme.primary.copy(alpha = 0.15f)
else scheme.surfaceVariant.copy(alpha = 0.8f)
) )
.clip(RoundedCornerShape(18.dp))
.background(backgroundColor)
.onFocusChanged { isFocused = it.isFocused } .onFocusChanged { isFocused = it.isFocused }
.onPreviewKeyEvent { event ->
if (event.type != KeyEventType.KeyDown) {
false
} else {
when (event.key) {
Key.DirectionUp -> {
onReturnToControls()
true
}
Key.DirectionLeft -> isFirst
Key.DirectionRight -> isLast
else -> false
}
}
}
.clickable { onClick() } .clickable { onClick() }
.padding(12.dp), .padding(12.dp),
verticalAlignment = Alignment.CenterVertically, verticalArrangement = Arrangement.spacedBy(10.dp)
horizontalArrangement = Arrangement.spacedBy(12.dp)
) { ) {
Box( TvQueueArtwork(
modifier = Modifier artworkUrl = item.artworkUrl,
.width(72.dp) modifier = Modifier.fillMaxWidth()
.clip(RoundedCornerShape(10.dp)) )
.background(scheme.surfaceVariant) Column(verticalArrangement = Arrangement.spacedBy(4.dp)) {
) { if (isCurrent) {
if (artworkUrl != null) { Text(
PurefinAsyncImage( text = "Current",
model = artworkUrl, color = scheme.primary,
contentDescription = null, style = MaterialTheme.typography.labelMedium,
modifier = Modifier fontWeight = FontWeight.SemiBold
.fillMaxWidth()
.aspectRatio(4f / 3f)
) )
} }
}
Column(modifier = Modifier.weight(1f)) {
Text( Text(
text = title, text = item.title,
color = scheme.onSurface, color = scheme.onSurface,
style = MaterialTheme.typography.bodyMedium, style = MaterialTheme.typography.bodyLarge,
fontWeight = if (isCurrent) FontWeight.Bold else FontWeight.Medium, fontWeight = FontWeight.SemiBold,
maxLines = 2, maxLines = 2,
overflow = TextOverflow.Ellipsis overflow = TextOverflow.Ellipsis
) )
subtitle?.let { value -> item.subtitle?.takeIf { it.isNotBlank() }?.let { subtitle ->
Text( Text(
text = value, text = subtitle,
color = scheme.onSurface.copy(alpha = 0.7f), color = scheme.onSurfaceVariant,
style = MaterialTheme.typography.bodySmall, style = MaterialTheme.typography.bodySmall,
maxLines = 1, maxLines = 1,
overflow = TextOverflow.Ellipsis overflow = TextOverflow.Ellipsis
@@ -173,3 +234,39 @@ private fun TvQueueRow(
} }
} }
} }
@Composable
private fun TvQueueArtwork(
artworkUrl: String?,
modifier: Modifier = Modifier
) {
val scheme = MaterialTheme.colorScheme
val placeholderPainter = ColorPainter(scheme.surfaceVariant)
Box(
modifier = modifier
.clip(RoundedCornerShape(14.dp))
.background(scheme.surfaceContainerHigh)
) {
if (artworkUrl != null) {
AsyncImage(
model = artworkUrl.takeIf { it.isNotEmpty() },
contentDescription = null,
modifier = Modifier
.fillMaxWidth()
.aspectRatio(16f / 9f),
contentScale = ContentScale.Crop,
placeholder = placeholderPainter,
error = placeholderPainter,
fallback = placeholderPainter
)
} else {
Box(
modifier = Modifier
.fillMaxWidth()
.aspectRatio(16f / 9f)
.background(scheme.surfaceContainerHigh)
)
}
}
}

View File

@@ -39,6 +39,7 @@ internal fun TvPlayerSeekBar(
onSeek: (Long) -> Unit, onSeek: (Long) -> Unit,
onSeekRelative: (Long) -> Unit, onSeekRelative: (Long) -> Unit,
togglePlayState: () -> Unit, togglePlayState: () -> Unit,
onMoveDown: (() -> Boolean)? = null,
focusRequester: FocusRequester = remember { FocusRequester() }, focusRequester: FocusRequester = remember { FocusRequester() },
modifier: Modifier = Modifier modifier: Modifier = Modifier
) { ) {
@@ -61,6 +62,8 @@ internal fun TvPlayerSeekBar(
.onPreviewKeyEvent { event -> .onPreviewKeyEvent { event ->
if (event.type == KeyEventType.KeyDown) { if (event.type == KeyEventType.KeyDown) {
when (event.key) { when (event.key) {
Key.DirectionDown -> onMoveDown?.invoke() ?: false
Key.DirectionLeft -> { Key.DirectionLeft -> {
onSeekRelative(-10_000) onSeekRelative(-10_000)
true true

View File

@@ -203,7 +203,11 @@ class PlayerViewModel @Inject constructor(
fun toggleControlsVisibility() { fun toggleControlsVisibility() {
_controlsVisible.value = !_controlsVisible.value _controlsVisible.value = !_controlsVisible.value
if (_controlsVisible.value) scheduleAutoHide(DEFAULT_CONTROLS_AUTO_HIDE_MS) if (_controlsVisible.value) {
scheduleAutoHide(DEFAULT_CONTROLS_AUTO_HIDE_MS)
} else {
autoHideJob?.cancel()
}
} }
private fun scheduleAutoHide(autoHideDelayMs: Long) { private fun scheduleAutoHide(autoHideDelayMs: Long) {