mirror of
https://github.com/bbara04/Purefin.git
synced 2026-07-24 03:36:51 +00:00
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:
@@ -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
|
||||
)
|
||||
)
|
||||
)
|
||||
@@ -41,7 +41,6 @@ import androidx.media3.ui.PlayerView
|
||||
import hu.bbara.purefin.core.player.viewmodel.PlayerViewModel
|
||||
import hu.bbara.purefin.tv.player.components.TvPlayerControlsOverlay
|
||||
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.TvTrackSelectionPanel
|
||||
|
||||
@@ -60,7 +59,7 @@ fun TvPlayerScreen(
|
||||
|
||||
val uiState by viewModel.uiState.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) }
|
||||
|
||||
val context = LocalContext.current
|
||||
@@ -78,18 +77,25 @@ fun TvPlayerScreen(
|
||||
}
|
||||
}
|
||||
|
||||
BackHandler(enabled = controlsVisible) {
|
||||
viewModel.toggleControlsVisibility()
|
||||
}
|
||||
|
||||
LaunchedEffect(uiState.isPlaying) {
|
||||
if (uiState.isPlaying && controlsVisible) {
|
||||
LaunchedEffect(uiState.isPlaying, controlsVisible, isPlaylistExpanded) {
|
||||
if (uiState.isPlaying && controlsVisible && !isPlaylistExpanded) {
|
||||
viewModel.showControls(TV_CONTROLS_AUTO_HIDE_MS)
|
||||
}
|
||||
}
|
||||
|
||||
val hiddenControlFocusRequester = 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 = {
|
||||
viewModel.showControls(TV_CONTROLS_AUTO_HIDE_MS)
|
||||
}
|
||||
@@ -124,11 +130,12 @@ fun TvPlayerScreen(
|
||||
}
|
||||
|
||||
BackHandler(enabled = true) {
|
||||
if (controlsVisible) {
|
||||
viewModel.toggleControlsVisibility()
|
||||
return@BackHandler
|
||||
when {
|
||||
trackPanelType != null -> trackPanelType = null
|
||||
isPlaylistExpanded -> collapsePlaylistToControls()
|
||||
controlsVisible -> viewModel.toggleControlsVisibility()
|
||||
else -> onBack()
|
||||
}
|
||||
onBack()
|
||||
}
|
||||
|
||||
Box(
|
||||
@@ -182,7 +189,7 @@ fun TvPlayerScreen(
|
||||
)
|
||||
|
||||
AnimatedVisibility(
|
||||
visible = controlsVisible || uiState.isEnded || uiState.error != null,
|
||||
visible = controlsVisible || isPlaylistExpanded || uiState.isEnded || uiState.error != null,
|
||||
enter = fadeIn(),
|
||||
exit = fadeOut()
|
||||
) {
|
||||
@@ -190,6 +197,7 @@ fun TvPlayerScreen(
|
||||
modifier = Modifier.fillMaxSize(),
|
||||
uiState = uiState,
|
||||
focusRequester = controlsFocusRequester,
|
||||
isPlaylistExpanded = isPlaylistExpanded,
|
||||
onPlayPause = togglePlayPauseAndShowControls,
|
||||
onSeek = seekAndShowControls,
|
||||
onSeekRelative = seekByAndShowControls,
|
||||
@@ -199,7 +207,12 @@ fun TvPlayerScreen(
|
||||
onOpenAudioPanel = { trackPanelType = TvTrackPanelType.AUDIO },
|
||||
onOpenSubtitlesPanel = { trackPanelType = TvTrackPanelType.SUBTITLES },
|
||||
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()
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,5 +1,10 @@
|
||||
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.clickable
|
||||
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.padding
|
||||
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.Pause
|
||||
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.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.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.focus.FocusRequester
|
||||
import androidx.compose.ui.focus.focusRequester
|
||||
import androidx.compose.ui.graphics.Brush
|
||||
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.unit.dp
|
||||
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
|
||||
internal fun TvPlayerControlsOverlay(
|
||||
uiState: PlayerUiState,
|
||||
focusRequester: FocusRequester,
|
||||
isPlaylistExpanded: Boolean,
|
||||
onPlayPause: () -> Unit,
|
||||
onSeek: (Long) -> Unit,
|
||||
onSeekRelative: (Long) -> Unit,
|
||||
@@ -44,7 +67,9 @@ internal fun TvPlayerControlsOverlay(
|
||||
onOpenAudioPanel: () -> Unit,
|
||||
onOpenSubtitlesPanel: () -> Unit,
|
||||
onOpenQualityPanel: () -> Unit,
|
||||
onOpenQueue: () -> Unit,
|
||||
onExpandPlaylist: () -> Unit,
|
||||
onCollapsePlaylist: () -> Unit,
|
||||
onSelectQueueItem: (String) -> Unit,
|
||||
modifier: Modifier = Modifier
|
||||
) {
|
||||
Box(
|
||||
@@ -63,7 +88,6 @@ internal fun TvPlayerControlsOverlay(
|
||||
TvPlayerTopBar(
|
||||
title = uiState.title ?: "Playing",
|
||||
subtitle = uiState.subtitle,
|
||||
onOpenQueue = onOpenQueue,
|
||||
modifier = Modifier
|
||||
.align(Alignment.TopCenter)
|
||||
.fillMaxWidth()
|
||||
@@ -72,6 +96,7 @@ internal fun TvPlayerControlsOverlay(
|
||||
TvPlayerBottomSection(
|
||||
uiState = uiState,
|
||||
focusRequester = focusRequester,
|
||||
isPlaylistExpanded = isPlaylistExpanded,
|
||||
onPlayPause = onPlayPause,
|
||||
onSeek = onSeek,
|
||||
onSeekRelative = onSeekRelative,
|
||||
@@ -81,6 +106,9 @@ internal fun TvPlayerControlsOverlay(
|
||||
onOpenAudioPanel = onOpenAudioPanel,
|
||||
onOpenSubtitlesPanel = onOpenSubtitlesPanel,
|
||||
onOpenQualityPanel = onOpenQualityPanel,
|
||||
onExpandPlaylist = onExpandPlaylist,
|
||||
onCollapsePlaylist = onCollapsePlaylist,
|
||||
onSelectQueueItem = onSelectQueueItem,
|
||||
modifier = Modifier
|
||||
.align(Alignment.BottomCenter)
|
||||
.fillMaxWidth()
|
||||
@@ -93,7 +121,6 @@ internal fun TvPlayerControlsOverlay(
|
||||
private fun TvPlayerTopBar(
|
||||
title: String,
|
||||
subtitle: String?,
|
||||
onOpenQueue: () -> Unit,
|
||||
modifier: Modifier = Modifier
|
||||
) {
|
||||
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(
|
||||
uiState: PlayerUiState,
|
||||
focusRequester: FocusRequester,
|
||||
isPlaylistExpanded: Boolean,
|
||||
onPlayPause: () -> Unit,
|
||||
onSeek: (Long) -> Unit,
|
||||
onSeekRelative: (Long) -> Unit,
|
||||
@@ -139,11 +162,35 @@ private fun TvPlayerBottomSection(
|
||||
onOpenAudioPanel: () -> Unit,
|
||||
onOpenSubtitlesPanel: () -> Unit,
|
||||
onOpenQualityPanel: () -> Unit,
|
||||
onExpandPlaylist: () -> Unit,
|
||||
onCollapsePlaylist: () -> Unit,
|
||||
onSelectQueueItem: (String) -> Unit,
|
||||
modifier: Modifier = Modifier
|
||||
) {
|
||||
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(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
@@ -189,7 +236,12 @@ private fun TvPlayerBottomSection(
|
||||
onSeek = onSeek,
|
||||
onSeekRelative = onSeekRelative,
|
||||
togglePlayState = onPlayPause,
|
||||
focusRequester = focusRequester
|
||||
onMoveDown = {
|
||||
playPauseFocusRequester.requestFocus()
|
||||
true
|
||||
},
|
||||
focusRequester = focusRequester,
|
||||
modifier = Modifier.testTag(TvPlayerSeekBarTag)
|
||||
)
|
||||
Spacer(modifier = Modifier.height(8.dp))
|
||||
Box(
|
||||
@@ -206,31 +258,38 @@ private fun TvPlayerBottomSection(
|
||||
icon = Icons.Outlined.SkipPrevious,
|
||||
contentDescription = "Previous",
|
||||
onClick = onPrevious,
|
||||
size = 64
|
||||
size = 64,
|
||||
modifier = expandPlaylistModifier
|
||||
)
|
||||
TvIconButton(
|
||||
icon = Icons.Outlined.Replay10,
|
||||
contentDescription = "Seek backward 10 seconds",
|
||||
onClick = { onSeekRelative(-10_000) },
|
||||
size = 64
|
||||
size = 64,
|
||||
modifier = expandPlaylistModifier
|
||||
)
|
||||
TvIconButton(
|
||||
icon = if (uiState.isPlaying) Icons.Outlined.Pause else Icons.Outlined.PlayArrow,
|
||||
contentDescription = if (uiState.isPlaying) "Pause" else "Play",
|
||||
onClick = onPlayPause,
|
||||
size = 72
|
||||
size = 72,
|
||||
modifier = expandPlaylistModifier
|
||||
.focusRequester(playPauseFocusRequester)
|
||||
.testTag(TvPlayerPlayPauseButtonTag)
|
||||
)
|
||||
TvIconButton(
|
||||
icon = Icons.Outlined.Forward30,
|
||||
contentDescription = "Seek forward 30 seconds",
|
||||
onClick = { onSeekRelative(30_000) },
|
||||
size = 64
|
||||
size = 64,
|
||||
modifier = expandPlaylistModifier
|
||||
)
|
||||
TvIconButton(
|
||||
icon = Icons.Outlined.SkipNext,
|
||||
contentDescription = "Next",
|
||||
onClick = onNext,
|
||||
size = 64
|
||||
size = 64,
|
||||
modifier = expandPlaylistModifier
|
||||
)
|
||||
}
|
||||
Row(
|
||||
@@ -238,13 +297,48 @@ private fun TvPlayerBottomSection(
|
||||
horizontalArrangement = Arrangement.spacedBy(8.dp),
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
TvQualitySelectionButton(onClick = onOpenQualityPanel)
|
||||
TvAudioSelectionButton(onClick = onOpenAudioPanel)
|
||||
TvSubtitlesSelectionButton(onClick = onOpenSubtitlesPanel)
|
||||
TvQualitySelectionButton(
|
||||
onClick = onOpenQualityPanel,
|
||||
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 {
|
||||
|
||||
@@ -1,24 +1,20 @@
|
||||
package hu.bbara.purefin.tv.player.components
|
||||
|
||||
import androidx.compose.animation.AnimatedVisibility
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.border
|
||||
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.PaddingValues
|
||||
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.padding
|
||||
import androidx.compose.foundation.layout.width
|
||||
import androidx.compose.foundation.lazy.LazyColumn
|
||||
import androidx.compose.foundation.lazy.items
|
||||
import androidx.compose.foundation.lazy.LazyRow
|
||||
import androidx.compose.foundation.lazy.itemsIndexed
|
||||
import androidx.compose.foundation.lazy.rememberLazyListState
|
||||
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.Surface
|
||||
import androidx.compose.material3.Text
|
||||
@@ -27,144 +23,209 @@ import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.clip
|
||||
import androidx.compose.ui.focus.FocusRequester
|
||||
import androidx.compose.ui.focus.focusRequester
|
||||
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.style.TextOverflow
|
||||
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.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
|
||||
internal fun TvPlayerQueuePanel(
|
||||
uiState: PlayerUiState,
|
||||
firstItemFocusRequester: FocusRequester,
|
||||
onSelect: (String) -> Unit,
|
||||
onClose: () -> Unit,
|
||||
onReturnToControls: () -> Unit,
|
||||
modifier: Modifier = Modifier
|
||||
) {
|
||||
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(
|
||||
modifier = modifier.fillMaxSize(),
|
||||
contentAlignment = Alignment.CenterEnd
|
||||
) {
|
||||
Surface(
|
||||
modifier = Modifier
|
||||
.fillMaxHeight()
|
||||
.width(320.dp)
|
||||
.clip(RoundedCornerShape(topStart = 20.dp, bottomStart = 20.dp)),
|
||||
color = scheme.surface.copy(alpha = 0.97f)
|
||||
modifier = modifier,
|
||||
shape = RoundedCornerShape(24.dp),
|
||||
color = scheme.surface.copy(alpha = 0.94f)
|
||||
) {
|
||||
Column(
|
||||
modifier = Modifier.padding(20.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(12.dp)
|
||||
) {
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
horizontalArrangement = Arrangement.SpaceBetween,
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
modifier = Modifier.padding(horizontal = 22.dp, vertical = 20.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(16.dp)
|
||||
) {
|
||||
Column(verticalArrangement = Arrangement.spacedBy(4.dp)) {
|
||||
Text(
|
||||
text = "Up next",
|
||||
text = "Playlist",
|
||||
color = scheme.onSurface,
|
||||
style = MaterialTheme.typography.titleLarge,
|
||||
fontWeight = FontWeight.Bold
|
||||
)
|
||||
TvIconButton(
|
||||
icon = Icons.AutoMirrored.Outlined.ArrowBack,
|
||||
contentDescription = "Close",
|
||||
onClick = onClose
|
||||
)
|
||||
}
|
||||
AnimatedVisibility(visible = uiState.queue.isNotEmpty()) {
|
||||
LazyColumn(verticalArrangement = Arrangement.spacedBy(10.dp)) {
|
||||
items(uiState.queue) { item ->
|
||||
TvQueueRow(
|
||||
title = item.title,
|
||||
subtitle = item.subtitle,
|
||||
artworkUrl = item.artworkUrl,
|
||||
isCurrent = item.isCurrent,
|
||||
onClick = { onSelect(item.id) }
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
if (uiState.queue.isEmpty()) {
|
||||
Text(
|
||||
text = "No items in queue",
|
||||
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)
|
||||
) {
|
||||
itemsIndexed(uiState.queue, key = { _, item -> item.id }) { index, item ->
|
||||
val isEntryItem = index == entryIndex
|
||||
TvQueueRowCard(
|
||||
item = item,
|
||||
isCurrent = item.isCurrent,
|
||||
isFirst = index == 0,
|
||||
isLast = index == uiState.queue.lastIndex,
|
||||
onClick = { onSelect(item.id) },
|
||||
onReturnToControls = onReturnToControls,
|
||||
modifier = Modifier
|
||||
.width(228.dp)
|
||||
.then(
|
||||
if (isEntryItem) {
|
||||
Modifier
|
||||
.focusRequester(firstItemFocusRequester)
|
||||
.testTag(TvPlayerPlaylistCurrentItemTag)
|
||||
} else {
|
||||
Modifier
|
||||
}
|
||||
)
|
||||
.then(
|
||||
if (index == 0 && !isEntryItem) {
|
||||
Modifier.testTag(TvPlayerPlaylistFirstItemTag)
|
||||
} else {
|
||||
Modifier
|
||||
}
|
||||
)
|
||||
.then(
|
||||
if (index == uiState.queue.lastIndex && !isEntryItem) {
|
||||
Modifier.testTag(TvPlayerPlaylistLastItemTag)
|
||||
} else {
|
||||
Modifier
|
||||
}
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun TvQueueRow(
|
||||
title: String,
|
||||
subtitle: String?,
|
||||
artworkUrl: String?,
|
||||
private fun TvQueueRowCard(
|
||||
item: QueueItemUi,
|
||||
isCurrent: Boolean,
|
||||
onClick: () -> Unit
|
||||
isFirst: Boolean,
|
||||
isLast: Boolean,
|
||||
onClick: () -> Unit,
|
||||
onReturnToControls: () -> Unit,
|
||||
modifier: Modifier = Modifier
|
||||
) {
|
||||
val scheme = MaterialTheme.colorScheme
|
||||
var isFocused by remember { mutableStateOf(false) }
|
||||
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.then(
|
||||
if (isFocused) {
|
||||
Modifier.border(2.dp, scheme.primary, RoundedCornerShape(12.dp))
|
||||
} else {
|
||||
Modifier
|
||||
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)
|
||||
}
|
||||
|
||||
Column(
|
||||
modifier = modifier
|
||||
.border(
|
||||
width = if (isFocused) 2.dp else 1.dp,
|
||||
color = borderColor,
|
||||
shape = RoundedCornerShape(18.dp)
|
||||
)
|
||||
.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 }
|
||||
.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() }
|
||||
.padding(12.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.spacedBy(12.dp)
|
||||
verticalArrangement = Arrangement.spacedBy(10.dp)
|
||||
) {
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.width(72.dp)
|
||||
.clip(RoundedCornerShape(10.dp))
|
||||
.background(scheme.surfaceVariant)
|
||||
) {
|
||||
if (artworkUrl != null) {
|
||||
PurefinAsyncImage(
|
||||
model = artworkUrl,
|
||||
contentDescription = null,
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.aspectRatio(4f / 3f)
|
||||
TvQueueArtwork(
|
||||
artworkUrl = item.artworkUrl,
|
||||
modifier = Modifier.fillMaxWidth()
|
||||
)
|
||||
Column(verticalArrangement = Arrangement.spacedBy(4.dp)) {
|
||||
if (isCurrent) {
|
||||
Text(
|
||||
text = "Current",
|
||||
color = scheme.primary,
|
||||
style = MaterialTheme.typography.labelMedium,
|
||||
fontWeight = FontWeight.SemiBold
|
||||
)
|
||||
}
|
||||
}
|
||||
Column(modifier = Modifier.weight(1f)) {
|
||||
Text(
|
||||
text = title,
|
||||
text = item.title,
|
||||
color = scheme.onSurface,
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
fontWeight = if (isCurrent) FontWeight.Bold else FontWeight.Medium,
|
||||
style = MaterialTheme.typography.bodyLarge,
|
||||
fontWeight = FontWeight.SemiBold,
|
||||
maxLines = 2,
|
||||
overflow = TextOverflow.Ellipsis
|
||||
)
|
||||
subtitle?.let { value ->
|
||||
item.subtitle?.takeIf { it.isNotBlank() }?.let { subtitle ->
|
||||
Text(
|
||||
text = value,
|
||||
color = scheme.onSurface.copy(alpha = 0.7f),
|
||||
text = subtitle,
|
||||
color = scheme.onSurfaceVariant,
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
maxLines = 1,
|
||||
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)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -39,6 +39,7 @@ internal fun TvPlayerSeekBar(
|
||||
onSeek: (Long) -> Unit,
|
||||
onSeekRelative: (Long) -> Unit,
|
||||
togglePlayState: () -> Unit,
|
||||
onMoveDown: (() -> Boolean)? = null,
|
||||
focusRequester: FocusRequester = remember { FocusRequester() },
|
||||
modifier: Modifier = Modifier
|
||||
) {
|
||||
@@ -61,6 +62,8 @@ internal fun TvPlayerSeekBar(
|
||||
.onPreviewKeyEvent { event ->
|
||||
if (event.type == KeyEventType.KeyDown) {
|
||||
when (event.key) {
|
||||
Key.DirectionDown -> onMoveDown?.invoke() ?: false
|
||||
|
||||
Key.DirectionLeft -> {
|
||||
onSeekRelative(-10_000)
|
||||
true
|
||||
|
||||
@@ -203,7 +203,11 @@ class PlayerViewModel @Inject constructor(
|
||||
|
||||
fun toggleControlsVisibility() {
|
||||
_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) {
|
||||
|
||||
Reference in New Issue
Block a user