diff --git a/app-tv/src/androidTest/java/hu/bbara/purefin/tv/player/components/TvPlayerControlsOverlayTest.kt b/app-tv/src/androidTest/java/hu/bbara/purefin/tv/player/components/TvPlayerControlsOverlayTest.kt new file mode 100644 index 00000000..9d8ae702 --- /dev/null +++ b/app-tv/src/androidTest/java/hu/bbara/purefin/tv/player/components/TvPlayerControlsOverlayTest.kt @@ -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() + + @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 + ) + ) +) diff --git a/app-tv/src/main/java/hu/bbara/purefin/tv/player/TvPlayerScreen.kt b/app-tv/src/main/java/hu/bbara/purefin/tv/player/TvPlayerScreen.kt index 7ecc39f5..4e5ddc30 100644 --- a/app-tv/src/main/java/hu/bbara/purefin/tv/player/TvPlayerScreen.kt +++ b/app-tv/src/main/java/hu/bbara/purefin/tv/player/TvPlayerScreen.kt @@ -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(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() - ) - } } } diff --git a/app-tv/src/main/java/hu/bbara/purefin/tv/player/components/TvPlayerControlsOverlay.kt b/app-tv/src/main/java/hu/bbara/purefin/tv/player/components/TvPlayerControlsOverlay.kt index 80521b6f..496f0275 100644 --- a/app-tv/src/main/java/hu/bbara/purefin/tv/player/components/TvPlayerControlsOverlay.kt +++ b/app-tv/src/main/java/hu/bbara/purefin/tv/player/components/TvPlayerControlsOverlay.kt @@ -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,15 +297,50 @@ 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 { val totalSeconds = positionMs / 1000 val seconds = (totalSeconds % 60).toInt() diff --git a/app-tv/src/main/java/hu/bbara/purefin/tv/player/components/TvPlayerQueuePanel.kt b/app-tv/src/main/java/hu/bbara/purefin/tv/player/components/TvPlayerQueuePanel.kt index 6c7478bd..d3b60212 100644 --- a/app-tv/src/main/java/hu/bbara/purefin/tv/player/components/TvPlayerQueuePanel.kt +++ b/app-tv/src/main/java/hu/bbara/purefin/tv/player/components/TvPlayerQueuePanel.kt @@ -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, + shape = RoundedCornerShape(24.dp), + color = scheme.surface.copy(alpha = 0.94f) ) { - Surface( - modifier = Modifier - .fillMaxHeight() - .width(320.dp) - .clip(RoundedCornerShape(topStart = 20.dp, bottomStart = 20.dp)), - color = scheme.surface.copy(alpha = 0.97f) + Column( + modifier = Modifier.padding(horizontal = 22.dp, vertical = 20.dp), + verticalArrangement = Arrangement.spacedBy(16.dp) ) { - Column( - modifier = Modifier.padding(20.dp), - verticalArrangement = Arrangement.spacedBy(12.dp) - ) { - Row( - modifier = Modifier.fillMaxWidth(), - horizontalArrangement = Arrangement.SpaceBetween, - verticalAlignment = Alignment.CenterVertically + Column(verticalArrangement = Arrangement.spacedBy(4.dp)) { + Text( + text = "Playlist", + color = scheme.onSurface, + style = MaterialTheme.typography.titleLarge, + fontWeight = FontWeight.Bold + ) + 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( - text = "Up next", - 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) } - ) - } + 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 + } + ) + ) } } - if (uiState.queue.isEmpty()) { - Text( - text = "No items in queue", - color = scheme.onSurfaceVariant, - style = MaterialTheme.typography.bodyMedium - ) - } } } } } @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) } + 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( - modifier = Modifier - .fillMaxWidth() - .then( - if (isFocused) { - Modifier.border(2.dp, scheme.primary, RoundedCornerShape(12.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) + Column( + modifier = modifier + .border( + width = if (isFocused) 2.dp else 1.dp, + color = borderColor, + shape = RoundedCornerShape(18.dp) ) + .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) + ) + } + } +} diff --git a/app-tv/src/main/java/hu/bbara/purefin/tv/player/components/TvPlayerSeekBar.kt b/app-tv/src/main/java/hu/bbara/purefin/tv/player/components/TvPlayerSeekBar.kt index ee7f5555..8f71d8c3 100644 --- a/app-tv/src/main/java/hu/bbara/purefin/tv/player/components/TvPlayerSeekBar.kt +++ b/app-tv/src/main/java/hu/bbara/purefin/tv/player/components/TvPlayerSeekBar.kt @@ -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 diff --git a/core/player/src/main/java/hu/bbara/purefin/core/player/viewmodel/PlayerViewModel.kt b/core/player/src/main/java/hu/bbara/purefin/core/player/viewmodel/PlayerViewModel.kt index 71aa3fc2..da063e94 100644 --- a/core/player/src/main/java/hu/bbara/purefin/core/player/viewmodel/PlayerViewModel.kt +++ b/core/player/src/main/java/hu/bbara/purefin/core/player/viewmodel/PlayerViewModel.kt @@ -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) {