Compare commits

..

9 Commits

Author SHA1 Message Date
7c30e12af7 Fix TV player track selector focus 2026-04-09 19:59:01 +02:00
53f7cb58f3 feat: Update background image URL handling in TvEpisodeScreen 2026-04-09 19:28:17 +02:00
313a971684 feat: Rename screen and component classes for TV context 2026-04-09 19:01:16 +02:00
512f7bbb5b feat: Enable ASS direct play in TV device profile 2026-04-09 18:54:56 +02:00
58980f4ac7 feat: Update episode retrieval counts in API client and media repository 2026-04-09 18:45:21 +02:00
d007c80af7 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.
2026-04-09 18:22:21 +02:00
54e79d8bee feat: Extend play toggle functionality to include additional enter key inputs 2026-04-09 17:03:16 +02:00
4ef78b8ec6 feat: Implement back button behavior for TV player controls 2026-04-09 16:57:30 +02:00
d253a6c8d0 feat: Add HLS support to ExoPlayer implementation 2026-04-09 14:03:53 +02:00
24 changed files with 1386 additions and 233 deletions

View File

@@ -31,7 +31,7 @@ class EpisodeScreenContentTest {
fun episodeScreenContent_showsSeriesContext_andFocusesPlayButton() { fun episodeScreenContent_showsSeriesContext_andFocusesPlayButton() {
composeRule.setContent { composeRule.setContent {
AppTheme { AppTheme {
EpisodeScreenContent( TvEpisodeScreenContent(
episode = sampleEpisode(progress = 63.0), episode = sampleEpisode(progress = 63.0),
seriesTitle = "Severance", seriesTitle = "Severance",
onPlay = {} onPlay = {}
@@ -55,7 +55,7 @@ class EpisodeScreenContentTest {
fun episodeScreenContent_hidesSeriesShortcut_whenShortcutUiIsUnavailable() { fun episodeScreenContent_hidesSeriesShortcut_whenShortcutUiIsUnavailable() {
composeRule.setContent { composeRule.setContent {
AppTheme { AppTheme {
EpisodeScreenContent( TvEpisodeScreenContent(
episode = sampleEpisode(progress = null), episode = sampleEpisode(progress = null),
seriesTitle = "Severance", seriesTitle = "Severance",
onPlay = {} onPlay = {}
@@ -77,7 +77,7 @@ class EpisodeScreenContentTest {
composeRule.setContent { composeRule.setContent {
AppTheme { AppTheme {
EpisodeScreenContent( TvEpisodeScreenContent(
episode = sampleEpisode(progress = 63.0), episode = sampleEpisode(progress = 63.0),
seriesTitle = "Severance", seriesTitle = "Severance",
onPlay = { playCount++ } onPlay = { playCount++ }

View File

@@ -31,7 +31,7 @@ class MovieScreenContentTest {
fun movieScreenContent_focusesPlayButton() { fun movieScreenContent_focusesPlayButton() {
composeRule.setContent { composeRule.setContent {
AppTheme { AppTheme {
MovieScreenContent( TvMovieScreenContent(
movie = sampleMovie(progress = 42.0), movie = sampleMovie(progress = 42.0),
onPlay = {} onPlay = {}
) )
@@ -54,7 +54,7 @@ class MovieScreenContentTest {
composeRule.setContent { composeRule.setContent {
AppTheme { AppTheme {
MovieScreenContent( TvMovieScreenContent(
movie = sampleMovie(progress = 42.0), movie = sampleMovie(progress = 42.0),
onPlay = { playCount++ } onPlay = { playCount++ }
) )

View File

@@ -31,7 +31,7 @@ class SeriesScreenContentTest {
fun seriesScreenContent_focusesPrimaryAction_whenNextUpExists() { fun seriesScreenContent_focusesPrimaryAction_whenNextUpExists() {
composeRule.setContent { composeRule.setContent {
AppTheme { AppTheme {
SeriesScreenContent( TvSeriesScreenContent(
series = sampleSeriesWithEpisodes(), series = sampleSeriesWithEpisodes(),
onPlayEpisode = {} onPlayEpisode = {}
) )
@@ -54,7 +54,7 @@ class SeriesScreenContentTest {
fun seriesScreenContent_focusesFirstSeason_whenNoPlayableEpisodeExists() { fun seriesScreenContent_focusesFirstSeason_whenNoPlayableEpisodeExists() {
composeRule.setContent { composeRule.setContent {
AppTheme { AppTheme {
SeriesScreenContent( TvSeriesScreenContent(
series = sampleSeriesWithoutEpisodes(), series = sampleSeriesWithoutEpisodes(),
onPlayEpisode = {} onPlayEpisode = {}
) )
@@ -76,7 +76,7 @@ class SeriesScreenContentTest {
composeRule.setContent { composeRule.setContent {
AppTheme { AppTheme {
SeriesScreenContent( TvSeriesScreenContent(
series = sampleSeriesWithEpisodes(), series = sampleSeriesWithEpisodes(),
onPlayEpisode = { playCount++ } onPlayEpisode = { playCount++ }
) )

View File

@@ -0,0 +1,603 @@
package hu.bbara.purefin.tv.player.components
import androidx.activity.ComponentActivity
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.size
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
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.assertCountEquals
import androidx.compose.ui.test.assertIsDisplayed
import androidx.compose.ui.test.assertIsFocused
import androidx.compose.ui.test.assertIsNotEnabled
import androidx.compose.ui.test.junit4.createAndroidComposeRule
import androidx.compose.ui.test.onAllNodesWithContentDescription
import androidx.compose.ui.test.onAllNodesWithTag
import androidx.compose.ui.test.onNodeWithContentDescription
import androidx.compose.ui.test.onNodeWithTag
import androidx.compose.ui.test.onNodeWithText
import androidx.compose.ui.test.performKeyInput
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.core.player.model.TrackOption
import hu.bbara.purefin.core.player.model.TrackType
import hu.bbara.purefin.ui.theme.AppTheme
import org.junit.Rule
import org.junit.Test
@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 playlistShowsQueueAsRowAndCurrentItemCanReceiveFocus() {
composeRule.setContent {
AppTheme {
OverlayHost(
uiState = samplePlayerState(),
initiallyExpanded = true
)
}
}
composeRule.waitForIdle()
composeRule.onNodeWithTag(TvPlayerPlaylistRowTag).assertIsDisplayed()
composeRule.onNodeWithTag(TvPlayerPlaylistCurrentItemTag)
.performSemanticsAction(SemanticsActions.RequestFocus)
.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_andFallbackItemCanReceiveFocus() {
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)
.performSemanticsAction(SemanticsActions.RequestFocus)
.assertIsFocused()
composeRule.onNodeWithText("Episode 1").assertIsDisplayed()
}
@Test
fun openingTrackPanel_focusesSelectedTrackAndHasNoCloseButton() {
composeRule.setContent {
AppTheme {
TrackPanelHost(uiState = samplePlayerState())
}
}
composeRule.onNodeWithTag(TvPlayerAudioButtonTag)
.performSemanticsAction(SemanticsActions.RequestFocus)
.assertIsFocused()
.performKeyInput {
pressKey(Key.DirectionCenter)
}
composeRule.waitForIdle()
composeRule.onNodeWithTag(TvPlayerTrackPanelTag).assertIsDisplayed()
composeRule.onNodeWithTag(TvPlayerTrackSelectedItemTag).assertIsFocused()
composeRule.onAllNodesWithContentDescription("Close").assertCountEquals(0)
}
@Test
fun openingTrackPanelWithoutSelection_focusesFirstTrack() {
composeRule.setContent {
AppTheme {
TrackPanelHost(
uiState = samplePlayerState().copy(selectedAudioTrackId = null)
)
}
}
composeRule.onNodeWithTag(TvPlayerAudioButtonTag)
.performSemanticsAction(SemanticsActions.RequestFocus)
.performKeyInput {
pressKey(Key.DirectionCenter)
}
composeRule.waitForIdle()
composeRule.onNodeWithTag(TvPlayerTrackFirstItemTag).assertIsFocused()
}
@Test
fun backOnTrackPanel_closesItAndRestoresFocusToOpeningButton() {
composeRule.setContent {
AppTheme {
TrackPanelHost(uiState = samplePlayerState())
}
}
composeRule.onNodeWithTag(TvPlayerAudioButtonTag)
.performSemanticsAction(SemanticsActions.RequestFocus)
.performKeyInput {
pressKey(Key.DirectionCenter)
}
composeRule.waitForIdle()
composeRule.onNodeWithTag(TvPlayerTrackSelectedItemTag)
.assertIsFocused()
.performKeyInput {
pressKey(Key.Back)
}
composeRule.waitForIdle()
composeRule.onAllNodesWithTag(TvPlayerTrackPanelTag).assertCountEquals(0)
composeRule.onNodeWithTag(TvPlayerAudioButtonTag).assertIsFocused()
}
@Test
fun leftOnTrackPanel_keepsFocusInsidePanel() {
composeRule.setContent {
AppTheme {
TrackPanelHost(uiState = samplePlayerState())
}
}
composeRule.onNodeWithTag(TvPlayerAudioButtonTag)
.performSemanticsAction(SemanticsActions.RequestFocus)
.performKeyInput {
pressKey(Key.DirectionCenter)
}
composeRule.waitForIdle()
composeRule.onNodeWithTag(TvPlayerTrackSelectedItemTag)
.assertIsFocused()
.performKeyInput {
pressKey(Key.DirectionLeft)
}
composeRule.waitForIdle()
composeRule.onNodeWithTag(TvPlayerTrackPanelTag).assertIsDisplayed()
composeRule.onNodeWithTag(TvPlayerTrackSelectedItemTag).assertIsFocused()
}
@Test
fun selectingTrack_closesPanelAndRestoresFocusToOpeningButton() {
composeRule.setContent {
AppTheme {
TrackPanelHost(uiState = samplePlayerState())
}
}
composeRule.onNodeWithTag(TvPlayerQualityButtonTag)
.performSemanticsAction(SemanticsActions.RequestFocus)
.performKeyInput {
pressKey(Key.DirectionCenter)
}
composeRule.waitForIdle()
composeRule.onNodeWithTag(TvPlayerTrackSelectedItemTag)
.assertIsFocused()
.performKeyInput {
pressKey(Key.DirectionCenter)
}
composeRule.waitForIdle()
composeRule.onAllNodesWithTag(TvPlayerTrackPanelTag).assertCountEquals(0)
composeRule.onNodeWithTag(TvPlayerQualityButtonTag).assertIsFocused()
}
@Test
fun trackButtonsWithoutOptions_areDisabled() {
composeRule.setContent {
AppTheme {
TrackPanelHost(
uiState = samplePlayerState().copy(
audioTracks = emptyList(),
textTracks = emptyList(),
qualityTracks = emptyList(),
selectedAudioTrackId = null,
selectedTextTrackId = null,
selectedQualityTrackId = null
)
)
}
}
composeRule.onNodeWithTag(TvPlayerAudioButtonTag).assertIsNotEnabled()
composeRule.onNodeWithTag(TvPlayerSubtitlesButtonTag).assertIsNotEnabled()
composeRule.onNodeWithTag(TvPlayerQualityButtonTag).assertIsNotEnabled()
}
}
@Composable
private fun OverlayHost(
uiState: PlayerUiState,
initiallyExpanded: Boolean = false
) {
var isPlaylistExpanded by remember { mutableStateOf(initiallyExpanded) }
val focusRequester = remember { FocusRequester() }
val qualityButtonFocusRequester = remember { FocusRequester() }
val audioButtonFocusRequester = remember { FocusRequester() }
val subtitlesButtonFocusRequester = remember { FocusRequester() }
Box(modifier = Modifier.size(width = 960.dp, height = 540.dp)) {
TvPlayerControlsOverlay(
uiState = uiState,
focusRequester = focusRequester,
isPlaylistExpanded = isPlaylistExpanded,
qualityButtonFocusRequester = qualityButtonFocusRequester,
audioButtonFocusRequester = audioButtonFocusRequester,
subtitlesButtonFocusRequester = subtitlesButtonFocusRequester,
onPlayPause = {},
onSeek = { _ -> },
onSeekRelative = { _ -> },
onSeekLiveEdge = {},
onNext = {},
onPrevious = {},
onOpenAudioPanel = {},
onOpenSubtitlesPanel = {},
onOpenQualityPanel = {},
onExpandPlaylist = { isPlaylistExpanded = true },
onCollapsePlaylist = { isPlaylistExpanded = false },
onSelectQueueItem = { _ -> },
qualityButtonEnabled = uiState.qualityTracks.isNotEmpty(),
audioButtonEnabled = uiState.audioTracks.isNotEmpty(),
subtitlesButtonEnabled = uiState.textTracks.isNotEmpty()
)
}
}
@Composable
private fun TrackPanelHost(
uiState: PlayerUiState
) {
var trackPanelType by remember { mutableStateOf<TvTrackPanelType?>(null) }
var pendingTrackButtonFocus by remember { mutableStateOf<TvTrackPanelType?>(null) }
val controlsFocusRequester = remember { FocusRequester() }
val qualityButtonFocusRequester = remember { FocusRequester() }
val audioButtonFocusRequester = remember { FocusRequester() }
val subtitlesButtonFocusRequester = remember { FocusRequester() }
val closeTrackPanel: () -> Unit = {
trackPanelType?.let { panelType ->
pendingTrackButtonFocus = panelType
trackPanelType = null
}
}
LaunchedEffect(trackPanelType, pendingTrackButtonFocus) {
val pendingFocus = pendingTrackButtonFocus ?: return@LaunchedEffect
if (trackPanelType != null) return@LaunchedEffect
when (pendingFocus) {
TvTrackPanelType.AUDIO -> audioButtonFocusRequester.requestFocus()
TvTrackPanelType.SUBTITLES -> subtitlesButtonFocusRequester.requestFocus()
TvTrackPanelType.QUALITY -> qualityButtonFocusRequester.requestFocus()
}
pendingTrackButtonFocus = null
}
Box(modifier = Modifier.size(width = 960.dp, height = 540.dp)) {
TvPlayerControlsOverlay(
uiState = uiState,
focusRequester = controlsFocusRequester,
isPlaylistExpanded = false,
qualityButtonFocusRequester = qualityButtonFocusRequester,
audioButtonFocusRequester = audioButtonFocusRequester,
subtitlesButtonFocusRequester = subtitlesButtonFocusRequester,
onPlayPause = {},
onSeek = { _ -> },
onSeekRelative = { _ -> },
onSeekLiveEdge = {},
onNext = {},
onPrevious = {},
onOpenAudioPanel = { trackPanelType = TvTrackPanelType.AUDIO },
onOpenSubtitlesPanel = { trackPanelType = TvTrackPanelType.SUBTITLES },
onOpenQualityPanel = { trackPanelType = TvTrackPanelType.QUALITY },
onExpandPlaylist = {},
onCollapsePlaylist = {},
onSelectQueueItem = { _ -> },
qualityButtonEnabled = uiState.qualityTracks.isNotEmpty(),
audioButtonEnabled = uiState.audioTracks.isNotEmpty(),
subtitlesButtonEnabled = uiState.textTracks.isNotEmpty()
)
trackPanelType?.let { panelType ->
TvTrackSelectionPanel(
panelType = panelType,
uiState = uiState,
onSelect = { closeTrackPanel() },
onClose = closeTrackPanel,
modifier = Modifier.fillMaxSize()
)
}
}
}
private fun samplePlayerState(): PlayerUiState = PlayerUiState(
isPlaying = true,
title = "Sample Player",
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
)
),
audioTracks = listOf(
TrackOption(
id = "audio_en",
label = "English 5.1",
language = "en",
bitrate = null,
channelCount = 6,
height = null,
groupIndex = 0,
trackIndex = 0,
type = TrackType.AUDIO,
isOff = false
),
TrackOption(
id = "audio_hu",
label = "Hungarian Stereo",
language = "hu",
bitrate = null,
channelCount = 2,
height = null,
groupIndex = 0,
trackIndex = 1,
type = TrackType.AUDIO,
isOff = false
)
),
textTracks = listOf(
TrackOption(
id = "text_off",
label = "Off",
language = null,
bitrate = null,
channelCount = null,
height = null,
groupIndex = 1,
trackIndex = -1,
type = TrackType.TEXT,
isOff = true
),
TrackOption(
id = "text_en",
label = "English CC",
language = "en",
bitrate = null,
channelCount = null,
height = null,
groupIndex = 1,
trackIndex = 0,
type = TrackType.TEXT,
isOff = false
)
),
qualityTracks = listOf(
TrackOption(
id = "quality_auto",
label = "Auto",
language = null,
bitrate = null,
channelCount = null,
height = 1080,
groupIndex = 2,
trackIndex = 0,
type = TrackType.VIDEO,
isOff = false
),
TrackOption(
id = "quality_720",
label = "720p",
language = null,
bitrate = null,
channelCount = null,
height = 720,
groupIndex = 2,
trackIndex = 1,
type = TrackType.VIDEO,
isOff = false
)
),
selectedAudioTrackId = "audio_hu",
selectedTextTrackId = "text_en",
selectedQualityTrackId = "quality_auto"
)

View File

@@ -28,7 +28,7 @@ internal const val EpisodePlayButtonTag = "episode-play-button"
@OptIn(ExperimentalLayoutApi::class) @OptIn(ExperimentalLayoutApi::class)
@Composable @Composable
internal fun EpisodeHeroSection( internal fun TvEpisodeHeroSection(
episode: Episode, episode: Episode,
seriesTitle: String?, seriesTitle: String?,
onPlay: () -> Unit, onPlay: () -> Unit,

View File

@@ -20,15 +20,16 @@ import hu.bbara.purefin.common.ui.components.MediaDetailPlaybackSection
import hu.bbara.purefin.common.ui.components.MediaDetailSectionTitle import hu.bbara.purefin.common.ui.components.MediaDetailSectionTitle
import hu.bbara.purefin.common.ui.components.TvMediaDetailBodyBox import hu.bbara.purefin.common.ui.components.TvMediaDetailBodyBox
import hu.bbara.purefin.common.ui.components.TvMediaDetailScaffold import hu.bbara.purefin.common.ui.components.TvMediaDetailScaffold
import hu.bbara.purefin.core.data.image.JellyfinImageHelper
import hu.bbara.purefin.core.data.navigation.EpisodeDto
import hu.bbara.purefin.core.data.navigation.LocalNavigationManager import hu.bbara.purefin.core.data.navigation.LocalNavigationManager
import hu.bbara.purefin.core.data.navigation.Route import hu.bbara.purefin.core.data.navigation.Route
import hu.bbara.purefin.common.ui.components.tvMediaDetailBackgroundImageUrl
import hu.bbara.purefin.core.data.navigation.EpisodeDto
import hu.bbara.purefin.core.model.Episode import hu.bbara.purefin.core.model.Episode
import hu.bbara.purefin.feature.shared.content.episode.EpisodeScreenViewModel import hu.bbara.purefin.feature.shared.content.episode.EpisodeScreenViewModel
import org.jellyfin.sdk.model.api.ImageType
@Composable @Composable
fun EpisodeScreen( fun TvEpisodeScreen(
episode: EpisodeDto, episode: EpisodeDto,
viewModel: EpisodeScreenViewModel = hiltViewModel(), viewModel: EpisodeScreenViewModel = hiltViewModel(),
modifier: Modifier = Modifier modifier: Modifier = Modifier
@@ -52,7 +53,7 @@ fun EpisodeScreen(
return return
} }
EpisodeScreenContent( TvEpisodeScreenContent(
episode = selectedEpisode, episode = selectedEpisode,
seriesTitle = seriesTitle.value, seriesTitle = seriesTitle.value,
onPlay = remember(selectedEpisode.id, navigationManager) { onPlay = remember(selectedEpisode.id, navigationManager) {
@@ -67,7 +68,7 @@ fun EpisodeScreen(
} }
@Composable @Composable
internal fun EpisodeScreenContent( internal fun TvEpisodeScreenContent(
episode: Episode, episode: Episode,
seriesTitle: String?, seriesTitle: String?,
onPlay: () -> Unit, onPlay: () -> Unit,
@@ -86,10 +87,10 @@ internal fun EpisodeScreenContent(
) { ) {
item { item {
TvMediaDetailBodyBox( TvMediaDetailBodyBox(
backgroundImageUrl = tvMediaDetailBackgroundImageUrl(episode.imageUrlPrefix), backgroundImageUrl = JellyfinImageHelper.finishImageUrl(episode.imageUrlPrefix, ImageType.PRIMARY),
modifier = it modifier = it
) { ) {
EpisodeHeroSection( TvEpisodeHeroSection(
episode = episode, episode = episode,
seriesTitle = seriesTitle, seriesTitle = seriesTitle,
onPlay = onPlay, onPlay = onPlay,

View File

@@ -28,7 +28,7 @@ internal const val MoviePlayButtonTag = "movie-play-button"
@OptIn(ExperimentalLayoutApi::class) @OptIn(ExperimentalLayoutApi::class)
@Composable @Composable
internal fun MovieHeroSection( internal fun TvMovieHeroSection(
movie: Movie, movie: Movie,
onPlay: () -> Unit, onPlay: () -> Unit,
playFocusRequester: FocusRequester, playFocusRequester: FocusRequester,

View File

@@ -26,7 +26,7 @@ import hu.bbara.purefin.core.model.Movie
import hu.bbara.purefin.feature.shared.content.movie.MovieScreenViewModel import hu.bbara.purefin.feature.shared.content.movie.MovieScreenViewModel
@Composable @Composable
fun MovieScreen( fun TvMovieScreen(
movie: MovieDto, viewModel: MovieScreenViewModel = hiltViewModel(), modifier: Modifier = Modifier movie: MovieDto, viewModel: MovieScreenViewModel = hiltViewModel(), modifier: Modifier = Modifier
) { ) {
LaunchedEffect(movie.id) { LaunchedEffect(movie.id) {
@@ -36,7 +36,7 @@ fun MovieScreen(
val movieItem = viewModel.movie.collectAsState() val movieItem = viewModel.movie.collectAsState()
if (movieItem.value != null) { if (movieItem.value != null) {
MovieScreenContent( TvMovieScreenContent(
movie = movieItem.value!!, movie = movieItem.value!!,
onPlay = viewModel::onPlay, onPlay = viewModel::onPlay,
modifier = modifier modifier = modifier
@@ -47,7 +47,7 @@ fun MovieScreen(
} }
@Composable @Composable
internal fun MovieScreenContent( internal fun TvMovieScreenContent(
movie: Movie, movie: Movie,
onPlay: () -> Unit, onPlay: () -> Unit,
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
@@ -68,7 +68,7 @@ internal fun MovieScreenContent(
backgroundImageUrl = tvMediaDetailBackgroundImageUrl(movie.imageUrlPrefix), backgroundImageUrl = tvMediaDetailBackgroundImageUrl(movie.imageUrlPrefix),
modifier = it modifier = it
) { ) {
MovieHeroSection( TvMovieHeroSection(
movie = movie, movie = movie,
onPlay = onPlay, onPlay = onPlay,
playFocusRequester = playFocusRequester, playFocusRequester = playFocusRequester,

View File

@@ -72,7 +72,7 @@ internal const val SeriesFirstSeasonTabTag = "series-first-season-tab"
@OptIn(ExperimentalLayoutApi::class) @OptIn(ExperimentalLayoutApi::class)
@Composable @Composable
internal fun SeriesMetaChips(series: Series) { internal fun TvSeriesMetaChips(series: Series) {
val scheme = MaterialTheme.colorScheme val scheme = MaterialTheme.colorScheme
FlowRow( FlowRow(
horizontalArrangement = Arrangement.spacedBy(8.dp), horizontalArrangement = Arrangement.spacedBy(8.dp),
@@ -84,7 +84,7 @@ internal fun SeriesMetaChips(series: Series) {
} }
@Composable @Composable
internal fun SeasonTabs( internal fun TvSeasonTabs(
seasons: List<Season>, seasons: List<Season>,
selectedSeason: Season?, selectedSeason: Season?,
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
@@ -99,7 +99,7 @@ internal fun SeasonTabs(
horizontalArrangement = Arrangement.spacedBy(20.dp) horizontalArrangement = Arrangement.spacedBy(20.dp)
) { ) {
seasons.forEachIndexed { index, season -> seasons.forEachIndexed { index, season ->
SeasonTab( TvSeasonTab(
name = season.name, name = season.name,
isSelected = season == selectedSeason, isSelected = season == selectedSeason,
onSelect = { onSelect(season) }, onSelect = { onSelect(season) },
@@ -124,7 +124,7 @@ internal fun SeasonTabs(
} }
@Composable @Composable
private fun SeasonTab( private fun TvSeasonTab(
name: String, name: String,
isSelected: Boolean, isSelected: Boolean,
onSelect: () -> Unit, onSelect: () -> Unit,
@@ -160,7 +160,7 @@ private fun SeasonTab(
} }
@Composable @Composable
internal fun EpisodeCarousel(episodes: List<Episode>, modifier: Modifier = Modifier) { internal fun TvEpisodeCarousel(episodes: List<Episode>, modifier: Modifier = Modifier) {
val listState = rememberLazyListState() val listState = rememberLazyListState()
LaunchedEffect(episodes) { LaunchedEffect(episodes) {
@@ -178,13 +178,13 @@ internal fun EpisodeCarousel(episodes: List<Episode>, modifier: Modifier = Modif
horizontalArrangement = Arrangement.spacedBy(16.dp) horizontalArrangement = Arrangement.spacedBy(16.dp)
) { ) {
items(episodes) { episode -> items(episodes) { episode ->
EpisodeCard(episode = episode) TvEpisodeCard(episode = episode)
} }
} }
} }
@Composable @Composable
internal fun SeriesHeroSection( internal fun TvSeriesHeroSection(
series: Series, series: Series,
nextUpEpisode: Episode?, nextUpEpisode: Episode?,
onPlayEpisode: (Episode) -> Unit, onPlayEpisode: (Episode) -> Unit,
@@ -210,7 +210,7 @@ internal fun SeriesHeroSection(
overflow = TextOverflow.Ellipsis overflow = TextOverflow.Ellipsis
) )
Spacer(modifier = Modifier.height(18.dp)) Spacer(modifier = Modifier.height(18.dp))
SeriesMetaChips(series = series) TvSeriesMetaChips(series = series)
Spacer(modifier = Modifier.height(24.dp)) Spacer(modifier = Modifier.height(24.dp))
if (nextUpEpisode != null) { if (nextUpEpisode != null) {
Text( Text(
@@ -262,7 +262,7 @@ internal fun SeriesHeroSection(
} }
@Composable @Composable
private fun EpisodeCard( private fun TvEpisodeCard(
viewModel: SeriesViewModel = hiltViewModel(), viewModel: SeriesViewModel = hiltViewModel(),
episode: Episode episode: Episode
) { ) {

View File

@@ -29,7 +29,7 @@ import hu.bbara.purefin.feature.shared.content.series.SeriesViewModel
import org.jellyfin.sdk.model.UUID import org.jellyfin.sdk.model.UUID
@Composable @Composable
fun SeriesScreen( fun TvSeriesScreen(
series: SeriesDto, series: SeriesDto,
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
viewModel: SeriesViewModel = hiltViewModel() viewModel: SeriesViewModel = hiltViewModel()
@@ -42,7 +42,7 @@ fun SeriesScreen(
val seriesData = series.value val seriesData = series.value
if (seriesData != null && seriesData.seasons.isNotEmpty()) { if (seriesData != null && seriesData.seasons.isNotEmpty()) {
SeriesScreenContent( TvSeriesScreenContent(
series = seriesData, series = seriesData,
onPlayEpisode = viewModel::onPlayEpisode, onPlayEpisode = viewModel::onPlayEpisode,
modifier = modifier modifier = modifier
@@ -53,7 +53,7 @@ fun SeriesScreen(
} }
@Composable @Composable
internal fun SeriesScreenContent( internal fun TvSeriesScreenContent(
series: Series, series: Series,
onPlayEpisode: (UUID) -> Unit, onPlayEpisode: (UUID) -> Unit,
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
@@ -81,7 +81,7 @@ internal fun SeriesScreenContent(
backgroundImageUrl = tvMediaDetailBackgroundImageUrl(series.imageUrlPrefix), backgroundImageUrl = tvMediaDetailBackgroundImageUrl(series.imageUrlPrefix),
modifier = it modifier = it
) { ) {
SeriesHeroSection( TvSeriesHeroSection(
series = series, series = series,
nextUpEpisode = nextUpEpisode, nextUpEpisode = nextUpEpisode,
onPlayEpisode = { onPlayEpisode(it.id) }, onPlayEpisode = { onPlayEpisode(it.id) },
@@ -103,7 +103,7 @@ internal fun SeriesScreenContent(
} }
} }
item { item {
SeasonTabs( TvSeasonTabs(
seasons = series.seasons, seasons = series.seasons,
selectedSeason = selectedSeason, selectedSeason = selectedSeason,
firstItemFocusRequester = firstContentFocusRequester, firstItemFocusRequester = firstContentFocusRequester,
@@ -114,7 +114,7 @@ internal fun SeriesScreenContent(
} }
item { item {
Spacer(modifier = Modifier.height(20.dp)) Spacer(modifier = Modifier.height(20.dp))
EpisodeCarousel( TvEpisodeCarousel(
episodes = selectedSeason.episodes, episodes = selectedSeason.episodes,
modifier = it modifier = it
) )

View File

@@ -1,9 +1,9 @@
package hu.bbara.purefin.tv.navigation package hu.bbara.purefin.tv.navigation
import androidx.navigation3.runtime.EntryProviderScope import androidx.navigation3.runtime.EntryProviderScope
import hu.bbara.purefin.app.content.episode.EpisodeScreen import hu.bbara.purefin.app.content.episode.TvEpisodeScreen
import hu.bbara.purefin.app.content.movie.MovieScreen import hu.bbara.purefin.app.content.movie.TvMovieScreen
import hu.bbara.purefin.app.content.series.SeriesScreen import hu.bbara.purefin.app.content.series.TvSeriesScreen
import hu.bbara.purefin.core.data.navigation.LocalNavigationManager import hu.bbara.purefin.core.data.navigation.LocalNavigationManager
import hu.bbara.purefin.core.data.navigation.Route import hu.bbara.purefin.core.data.navigation.Route
import hu.bbara.purefin.login.ui.LoginScreen import hu.bbara.purefin.login.ui.LoginScreen
@@ -25,19 +25,19 @@ fun EntryProviderScope<Route>.tvLoginSection() {
fun EntryProviderScope<Route>.tvMovieSection() { fun EntryProviderScope<Route>.tvMovieSection() {
entry<Route.MovieRoute> { route -> entry<Route.MovieRoute> { route ->
MovieScreen(movie = route.item) TvMovieScreen(movie = route.item)
} }
} }
fun EntryProviderScope<Route>.tvSeriesSection() { fun EntryProviderScope<Route>.tvSeriesSection() {
entry<Route.SeriesRoute> { route -> entry<Route.SeriesRoute> { route ->
SeriesScreen(series = route.item) TvSeriesScreen(series = route.item)
} }
} }
fun EntryProviderScope<Route>.tvEpisodeSection() { fun EntryProviderScope<Route>.tvEpisodeSection() {
entry<Route.EpisodeRoute> { route -> entry<Route.EpisodeRoute> { route ->
EpisodeScreen(episode = route.item) TvEpisodeScreen(episode = route.item)
} }
} }

View File

@@ -38,10 +38,10 @@ import androidx.hilt.navigation.compose.hiltViewModel
import androidx.media3.common.util.UnstableApi import androidx.media3.common.util.UnstableApi
import androidx.media3.ui.AspectRatioFrameLayout import androidx.media3.ui.AspectRatioFrameLayout
import androidx.media3.ui.PlayerView import androidx.media3.ui.PlayerView
import hu.bbara.purefin.core.player.viewmodel.ControlsAutoHideBlocker
import hu.bbara.purefin.core.player.viewmodel.PlayerViewModel import hu.bbara.purefin.core.player.viewmodel.PlayerViewModel
import hu.bbara.purefin.tv.player.components.TvPlayerControlsOverlay import hu.bbara.purefin.tv.player.components.TvPlayerControlsOverlay
import hu.bbara.purefin.tv.player.components.TvPlayerLoadingErrorEndCard import hu.bbara.purefin.tv.player.components.TvPlayerLoadingErrorEndCard
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,10 +60,15 @@ 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) }
var pendingTrackButtonFocus by remember { mutableStateOf<TvTrackPanelType?>(null) }
val controlsAutoHideBlocked = isPlaylistExpanded || trackPanelType != null
val context = LocalContext.current val context = LocalContext.current
LaunchedEffect(Unit) {
viewModel.setControlsAutoHideDelay(TV_CONTROLS_AUTO_HIDE_MS)
}
LaunchedEffect(uiState.isPlaying) { LaunchedEffect(uiState.isPlaying) {
val activity = context as? Activity val activity = context as? Activity
if (uiState.isPlaying) { if (uiState.isPlaying) {
@@ -75,21 +80,38 @@ fun TvPlayerScreen(
DisposableEffect(Unit) { DisposableEffect(Unit) {
onDispose { onDispose {
(context as? Activity)?.window?.clearFlags(WindowManager.LayoutParams.FLAG_KEEP_SCREEN_ON) (context as? Activity)?.window?.clearFlags(WindowManager.LayoutParams.FLAG_KEEP_SCREEN_ON)
viewModel.setControlsAutoHideBlocked(ControlsAutoHideBlocker.PLAYLIST, false)
viewModel.setControlsAutoHideBlocked(ControlsAutoHideBlocker.TRACK_PANEL, false)
} }
} }
BackHandler(enabled = controlsVisible) { LaunchedEffect(isPlaylistExpanded) {
viewModel.toggleControlsVisibility() viewModel.setControlsAutoHideBlocked(ControlsAutoHideBlocker.PLAYLIST, isPlaylistExpanded)
} }
LaunchedEffect(uiState.isPlaying) { LaunchedEffect(trackPanelType) {
if (uiState.isPlaying && controlsVisible) { viewModel.setControlsAutoHideBlocked(
viewModel.showControls(TV_CONTROLS_AUTO_HIDE_MS) ControlsAutoHideBlocker.TRACK_PANEL,
} trackPanelType != null
)
} }
val hiddenControlFocusRequester = remember { FocusRequester() } val hiddenControlFocusRequester = remember { FocusRequester() }
val controlsFocusRequester = remember { FocusRequester() } val controlsFocusRequester = remember { FocusRequester() }
val qualityButtonFocusRequester = remember { FocusRequester() }
val audioButtonFocusRequester = remember { FocusRequester() }
val subtitlesButtonFocusRequester = remember { FocusRequester() }
val expandPlaylist: () -> Unit = {
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)
} }
@@ -114,8 +136,16 @@ fun TvPlayerScreen(
val previousAndShowControls: () -> Unit = { val previousAndShowControls: () -> Unit = {
viewModel.previous(TV_CONTROLS_AUTO_HIDE_MS) viewModel.previous(TV_CONTROLS_AUTO_HIDE_MS)
} }
val closeTrackPanel: () -> Unit = {
trackPanelType?.let { panelType ->
pendingTrackButtonFocus = panelType
trackPanelType = null
viewModel.showControls(TV_CONTROLS_AUTO_HIDE_MS)
}
}
LaunchedEffect(controlsVisible) { LaunchedEffect(controlsVisible, controlsAutoHideBlocked) {
if (controlsAutoHideBlocked) return@LaunchedEffect
if (controlsVisible) { if (controlsVisible) {
controlsFocusRequester.requestFocus() controlsFocusRequester.requestFocus()
} else { } else {
@@ -123,6 +153,26 @@ fun TvPlayerScreen(
} }
} }
LaunchedEffect(trackPanelType, pendingTrackButtonFocus) {
val pendingFocus = pendingTrackButtonFocus ?: return@LaunchedEffect
if (trackPanelType != null) return@LaunchedEffect
when (pendingFocus) {
TvTrackPanelType.AUDIO -> audioButtonFocusRequester.requestFocus()
TvTrackPanelType.SUBTITLES -> subtitlesButtonFocusRequester.requestFocus()
TvTrackPanelType.QUALITY -> qualityButtonFocusRequester.requestFocus()
}
pendingTrackButtonFocus = null
}
BackHandler(enabled = true) {
when {
trackPanelType != null -> closeTrackPanel()
isPlaylistExpanded -> collapsePlaylistToControls()
controlsVisible -> viewModel.toggleControlsVisibility()
else -> onBack()
}
}
Box( Box(
modifier = Modifier modifier = Modifier
.fillMaxSize() .fillMaxSize()
@@ -174,7 +224,7 @@ fun TvPlayerScreen(
) )
AnimatedVisibility( AnimatedVisibility(
visible = controlsVisible || uiState.isEnded || uiState.error != null, visible = controlsVisible || isPlaylistExpanded || trackPanelType != null || uiState.isEnded || uiState.error != null,
enter = fadeIn(), enter = fadeIn(),
exit = fadeOut() exit = fadeOut()
) { ) {
@@ -182,6 +232,10 @@ fun TvPlayerScreen(
modifier = Modifier.fillMaxSize(), modifier = Modifier.fillMaxSize(),
uiState = uiState, uiState = uiState,
focusRequester = controlsFocusRequester, focusRequester = controlsFocusRequester,
isPlaylistExpanded = isPlaylistExpanded,
qualityButtonFocusRequester = qualityButtonFocusRequester,
audioButtonFocusRequester = audioButtonFocusRequester,
subtitlesButtonFocusRequester = subtitlesButtonFocusRequester,
onPlayPause = togglePlayPauseAndShowControls, onPlayPause = togglePlayPauseAndShowControls,
onSeek = seekAndShowControls, onSeek = seekAndShowControls,
onSeekRelative = seekByAndShowControls, onSeekRelative = seekByAndShowControls,
@@ -191,7 +245,15 @@ 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()
},
qualityButtonEnabled = uiState.qualityTracks.isNotEmpty(),
audioButtonEnabled = uiState.audioTracks.isNotEmpty(),
subtitlesButtonEnabled = uiState.textTracks.isNotEmpty()
) )
} }
@@ -218,28 +280,13 @@ fun TvPlayerScreen(
uiState = uiState, uiState = uiState,
onSelect = { track -> onSelect = { track ->
viewModel.selectTrack(track) viewModel.selectTrack(track)
trackPanelType = null closeTrackPanel()
}, },
onClose = { trackPanelType = null }, onClose = closeTrackPanel,
modifier = Modifier.fillMaxSize() modifier = Modifier.fillMaxSize()
) )
} }
} }
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

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

View File

@@ -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,41 @@ 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,
qualityButtonFocusRequester: FocusRequester,
audioButtonFocusRequester: FocusRequester,
subtitlesButtonFocusRequester: FocusRequester,
onPlayPause: () -> Unit, onPlayPause: () -> Unit,
onSeek: (Long) -> Unit, onSeek: (Long) -> Unit,
onSeekRelative: (Long) -> Unit, onSeekRelative: (Long) -> Unit,
@@ -44,7 +70,12 @@ internal fun TvPlayerControlsOverlay(
onOpenAudioPanel: () -> Unit, onOpenAudioPanel: () -> Unit,
onOpenSubtitlesPanel: () -> Unit, onOpenSubtitlesPanel: () -> Unit,
onOpenQualityPanel: () -> Unit, onOpenQualityPanel: () -> Unit,
onOpenQueue: () -> Unit, onExpandPlaylist: () -> Unit,
onCollapsePlaylist: () -> Unit,
onSelectQueueItem: (String) -> Unit,
qualityButtonEnabled: Boolean,
audioButtonEnabled: Boolean,
subtitlesButtonEnabled: Boolean,
modifier: Modifier = Modifier modifier: Modifier = Modifier
) { ) {
Box( Box(
@@ -63,7 +94,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 +102,10 @@ internal fun TvPlayerControlsOverlay(
TvPlayerBottomSection( TvPlayerBottomSection(
uiState = uiState, uiState = uiState,
focusRequester = focusRequester, focusRequester = focusRequester,
isPlaylistExpanded = isPlaylistExpanded,
qualityButtonFocusRequester = qualityButtonFocusRequester,
audioButtonFocusRequester = audioButtonFocusRequester,
subtitlesButtonFocusRequester = subtitlesButtonFocusRequester,
onPlayPause = onPlayPause, onPlayPause = onPlayPause,
onSeek = onSeek, onSeek = onSeek,
onSeekRelative = onSeekRelative, onSeekRelative = onSeekRelative,
@@ -81,6 +115,12 @@ internal fun TvPlayerControlsOverlay(
onOpenAudioPanel = onOpenAudioPanel, onOpenAudioPanel = onOpenAudioPanel,
onOpenSubtitlesPanel = onOpenSubtitlesPanel, onOpenSubtitlesPanel = onOpenSubtitlesPanel,
onOpenQualityPanel = onOpenQualityPanel, onOpenQualityPanel = onOpenQualityPanel,
onExpandPlaylist = onExpandPlaylist,
onCollapsePlaylist = onCollapsePlaylist,
onSelectQueueItem = onSelectQueueItem,
qualityButtonEnabled = qualityButtonEnabled,
audioButtonEnabled = audioButtonEnabled,
subtitlesButtonEnabled = subtitlesButtonEnabled,
modifier = Modifier modifier = Modifier
.align(Alignment.BottomCenter) .align(Alignment.BottomCenter)
.fillMaxWidth() .fillMaxWidth()
@@ -93,7 +133,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 +157,6 @@ private fun TvPlayerTopBar(
) )
} }
} }
TvIconButton(
icon = Icons.AutoMirrored.Outlined.PlaylistPlay,
contentDescription = "Queue",
onClick = onOpenQueue
)
} }
} }
@@ -130,6 +164,10 @@ private fun TvPlayerTopBar(
private fun TvPlayerBottomSection( private fun TvPlayerBottomSection(
uiState: PlayerUiState, uiState: PlayerUiState,
focusRequester: FocusRequester, focusRequester: FocusRequester,
isPlaylistExpanded: Boolean,
qualityButtonFocusRequester: FocusRequester,
audioButtonFocusRequester: FocusRequester,
subtitlesButtonFocusRequester: FocusRequester,
onPlayPause: () -> Unit, onPlayPause: () -> Unit,
onSeek: (Long) -> Unit, onSeek: (Long) -> Unit,
onSeekRelative: (Long) -> Unit, onSeekRelative: (Long) -> Unit,
@@ -139,11 +177,38 @@ private fun TvPlayerBottomSection(
onOpenAudioPanel: () -> Unit, onOpenAudioPanel: () -> Unit,
onOpenSubtitlesPanel: () -> Unit, onOpenSubtitlesPanel: () -> Unit,
onOpenQualityPanel: () -> Unit, onOpenQualityPanel: () -> Unit,
onExpandPlaylist: () -> Unit,
onCollapsePlaylist: () -> Unit,
onSelectQueueItem: (String) -> Unit,
qualityButtonEnabled: Boolean,
audioButtonEnabled: Boolean,
subtitlesButtonEnabled: Boolean,
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 +254,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 +276,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,13 +315,57 @@ 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) enabled = qualityButtonEnabled,
modifier = expandPlaylistModifier
.focusRequester(qualityButtonFocusRequester)
.testTag(TvPlayerQualityButtonTag)
)
TvAudioSelectionButton(
onClick = onOpenAudioPanel,
enabled = audioButtonEnabled,
modifier = expandPlaylistModifier
.focusRequester(audioButtonFocusRequester)
.testTag(TvPlayerAudioButtonTag)
)
TvSubtitlesSelectionButton(
onClick = onOpenSubtitlesPanel,
enabled = subtitlesButtonEnabled,
modifier = expandPlaylistModifier
.focusRequester(subtitlesButtonFocusRequester)
.testTag(TvPlayerSubtitlesButtonTag)
)
} }
} }
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 {

View File

@@ -1,170 +1,240 @@
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
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.withFrameNanos
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember import androidx.compose.runtime.remember
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"
}
LaunchedEffect(uiState.queue, entryIndex) {
if (uiState.queue.isNotEmpty()) {
withFrameNanos { }
firstItemFocusRequester.requestFocus()
}
}
Box(
modifier = modifier.fillMaxSize(),
contentAlignment = Alignment.CenterEnd
) {
Surface( Surface(
modifier = Modifier modifier = modifier,
.fillMaxHeight() shape = RoundedCornerShape(24.dp),
.width(320.dp) color = scheme.surface.copy(alpha = 0.94f)
.clip(RoundedCornerShape(topStart = 20.dp, bottomStart = 20.dp)),
color = scheme.surface.copy(alpha = 0.97f)
) { ) {
Column( Column(
modifier = Modifier.padding(20.dp), modifier = Modifier.padding(horizontal = 22.dp, vertical = 20.dp),
verticalArrangement = Arrangement.spacedBy(12.dp) verticalArrangement = Arrangement.spacedBy(16.dp)
) {
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically
) { ) {
Column(verticalArrangement = Arrangement.spacedBy(4.dp)) {
Text( Text(
text = "Up next", text = "Playlist",
color = scheme.onSurface, color = scheme.onSurface,
style = MaterialTheme.typography.titleLarge, style = MaterialTheme.typography.titleLarge,
fontWeight = FontWeight.Bold 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(
text = "No items in queue", text = queueCountLabel,
color = scheme.onSurfaceVariant, color = scheme.onSurfaceVariant,
style = MaterialTheme.typography.bodyMedium 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 @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 {
Row( isFocused -> scheme.primary
modifier = Modifier isCurrent -> scheme.primary.copy(alpha = 0.55f)
.fillMaxWidth() else -> scheme.outlineVariant.copy(alpha = 0.35f)
.then(
if (isFocused) {
Modifier.border(2.dp, scheme.primary, RoundedCornerShape(12.dp))
} else {
Modifier
} }
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)) .clip(RoundedCornerShape(18.dp))
.background( .background(backgroundColor)
if (isFocused) scheme.primary.copy(alpha = 0.35f)
else if (isCurrent) scheme.primary.copy(alpha = 0.15f)
else scheme.surfaceVariant.copy(alpha = 0.8f)
)
.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 +243,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
@@ -71,7 +74,9 @@ internal fun TvPlayerSeekBar(
true true
} }
Key.Enter -> { Key.DirectionCenter,
Key.Enter,
Key.NumPadEnter -> {
togglePlayState() togglePlayState()
true true
} }

View File

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

View File

@@ -17,7 +17,7 @@ internal data class JellyfinAndroidTvProfileConfig(
val maxBitrate: Int = 100_000_000, val maxBitrate: Int = 100_000_000,
val isAc3Enabled: Boolean = true, val isAc3Enabled: Boolean = true,
val downMixAudio: Boolean = false, val downMixAudio: Boolean = false,
val assDirectPlay: Boolean = false, val assDirectPlay: Boolean = true,
val pgsDirectPlay: Boolean = true, val pgsDirectPlay: Boolean = true,
) )

View File

@@ -252,7 +252,7 @@ class JellyfinApiClient @Inject constructor(
result.content.items result.content.items
} }
suspend fun getNextEpisodes(episodeId: UUID, count: Int = 10): List<BaseItemDto> = withContext(Dispatchers.IO) { suspend fun getNextEpisodes(episodeId: UUID, count: Int): List<BaseItemDto> = withContext(Dispatchers.IO) {
if (!ensureConfigured()) { if (!ensureConfigured()) {
return@withContext emptyList() return@withContext emptyList()
} }

View File

@@ -34,9 +34,11 @@ dependencies {
implementation(libs.hilt) implementation(libs.hilt)
ksp(libs.hilt.compiler) ksp(libs.hilt.compiler)
implementation(libs.medi3.exoplayer) implementation(libs.medi3.exoplayer)
implementation(libs.medi3.exoplayer.hls)
implementation(libs.media3.datasource.okhttp) implementation(libs.media3.datasource.okhttp)
implementation(libs.datastore) implementation(libs.datastore)
implementation(libs.kotlinx.serialization.json) implementation(libs.kotlinx.serialization.json)
implementation(libs.jellyfin.core) implementation(libs.jellyfin.core)
implementation(libs.okhttp) implementation(libs.okhttp)
testImplementation(libs.junit)
} }

View File

@@ -45,7 +45,7 @@ class PlayerMediaRepository @Inject constructor(
Pair(mediaItem, resumePositionMs) Pair(mediaItem, resumePositionMs)
} }
suspend fun getNextUpMediaItems(episodeId: UUID, existingIds: Set<String>, count: Int = 5): List<MediaItem> = withContext(Dispatchers.IO) { suspend fun getNextUpMediaItems(episodeId: UUID, existingIds: Set<String>, count: Int = 9): List<MediaItem> = withContext(Dispatchers.IO) {
runCatching { runCatching {
val serverUrl = userSessionRepository.serverUrl.first() val serverUrl = userSessionRepository.serverUrl.first()
val episodes = jellyfinApiClient.getNextEpisodes(episodeId = episodeId, count = count) val episodes = jellyfinApiClient.getNextEpisodes(episodeId = episodeId, count = count)

View File

@@ -0,0 +1,71 @@
package hu.bbara.purefin.core.player.viewmodel
enum class ControlsAutoHideBlocker {
PLAYLIST,
TRACK_PANEL
}
internal sealed interface ControlsAutoHideCommand {
data object Cancel : ControlsAutoHideCommand
data class Schedule(val delayMs: Long) : ControlsAutoHideCommand
}
internal class ControlsAutoHidePolicy(
private val defaultDelayMs: Long
) {
private val blockers = mutableSetOf<ControlsAutoHideBlocker>()
private var isPlaying = false
var controlsVisible: Boolean = true
private set
var lastAutoHideDelayMs: Long = defaultDelayMs
private set
fun onPlaybackChanged(isPlaying: Boolean): ControlsAutoHideCommand {
this.isPlaying = isPlaying
return nextCommand()
}
fun setAutoHideDelay(delayMs: Long): ControlsAutoHideCommand {
lastAutoHideDelayMs = delayMs
return nextCommand()
}
fun showControls(delayMs: Long? = null): ControlsAutoHideCommand {
delayMs?.let { lastAutoHideDelayMs = it }
controlsVisible = true
return nextCommand()
}
fun toggleControlsVisibility(): ControlsAutoHideCommand {
controlsVisible = !controlsVisible
return nextCommand()
}
fun hideControls(): ControlsAutoHideCommand {
controlsVisible = false
return ControlsAutoHideCommand.Cancel
}
fun setBlocked(
blocker: ControlsAutoHideBlocker,
blocked: Boolean
): ControlsAutoHideCommand {
if (blocked) {
blockers += blocker
controlsVisible = true
} else {
blockers -= blocker
}
return nextCommand()
}
private fun nextCommand(): ControlsAutoHideCommand {
return if (!controlsVisible || !isPlaying || blockers.isNotEmpty()) {
ControlsAutoHideCommand.Cancel
} else {
ControlsAutoHideCommand.Schedule(lastAutoHideDelayMs)
}
}
}

View File

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

View File

@@ -0,0 +1,90 @@
package hu.bbara.purefin.core.player.viewmodel
import org.junit.Assert.assertEquals
import org.junit.Assert.assertTrue
import org.junit.Test
class ControlsAutoHidePolicyTest {
@Test
fun activeBlockerCancelsScheduledAutoHide() {
val policy = ControlsAutoHidePolicy(defaultDelayMs = 3_500L)
assertEquals(
ControlsAutoHideCommand.Schedule(3_500L),
policy.onPlaybackChanged(isPlaying = true)
)
assertEquals(
ControlsAutoHideCommand.Cancel,
policy.setBlocked(ControlsAutoHideBlocker.TRACK_PANEL, blocked = true)
)
assertTrue(policy.controlsVisible)
}
@Test
fun playlistBlockerPreventsAutoHideUntilCleared() {
val policy = ControlsAutoHidePolicy(defaultDelayMs = 3_500L)
policy.onPlaybackChanged(isPlaying = true)
policy.showControls(delayMs = 5_000L)
assertEquals(
ControlsAutoHideCommand.Cancel,
policy.setBlocked(ControlsAutoHideBlocker.PLAYLIST, blocked = true)
)
assertEquals(
ControlsAutoHideCommand.Schedule(5_000L),
policy.setBlocked(ControlsAutoHideBlocker.PLAYLIST, blocked = false)
)
}
@Test
fun trackPanelBlockerUsesRememberedDelayWhenRemoved() {
val policy = ControlsAutoHidePolicy(defaultDelayMs = 3_500L)
policy.onPlaybackChanged(isPlaying = true)
assertEquals(
ControlsAutoHideCommand.Cancel,
policy.setBlocked(ControlsAutoHideBlocker.TRACK_PANEL, blocked = true)
)
policy.showControls(delayMs = 5_000L)
assertEquals(
ControlsAutoHideCommand.Schedule(5_000L),
policy.setBlocked(ControlsAutoHideBlocker.TRACK_PANEL, blocked = false)
)
}
@Test
fun autoHideResumesOnlyAfterLastBlockerIsCleared() {
val policy = ControlsAutoHidePolicy(defaultDelayMs = 3_500L)
policy.onPlaybackChanged(isPlaying = true)
policy.showControls(delayMs = 5_000L)
policy.setBlocked(ControlsAutoHideBlocker.PLAYLIST, blocked = true)
policy.setBlocked(ControlsAutoHideBlocker.TRACK_PANEL, blocked = true)
assertEquals(
ControlsAutoHideCommand.Cancel,
policy.setBlocked(ControlsAutoHideBlocker.PLAYLIST, blocked = false)
)
assertEquals(
ControlsAutoHideCommand.Schedule(5_000L),
policy.setBlocked(ControlsAutoHideBlocker.TRACK_PANEL, blocked = false)
)
}
@Test
fun blockingStateMakesHiddenControlsVisibleAgain() {
val policy = ControlsAutoHidePolicy(defaultDelayMs = 3_500L)
policy.onPlaybackChanged(isPlaying = true)
policy.toggleControlsVisibility()
assertEquals(
ControlsAutoHideCommand.Cancel,
policy.setBlocked(ControlsAutoHideBlocker.PLAYLIST, blocked = true)
)
assertTrue(policy.controlsVisible)
}
}