mirror of
https://github.com/bbara04/Purefin.git
synced 2026-07-24 03:36:51 +00:00
Compare commits
9 Commits
b41a9c24a2
...
7c30e12af7
| Author | SHA1 | Date | |
|---|---|---|---|
| 7c30e12af7 | |||
| 53f7cb58f3 | |||
| 313a971684 | |||
| 512f7bbb5b | |||
| 58980f4ac7 | |||
| d007c80af7 | |||
| 54e79d8bee | |||
| 4ef78b8ec6 | |||
| d253a6c8d0 |
@@ -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++ }
|
||||||
|
|||||||
@@ -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++ }
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -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++ }
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -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"
|
||||||
|
)
|
||||||
@@ -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,
|
||||||
@@ -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,
|
||||||
@@ -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,
|
||||||
@@ -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,
|
||||||
@@ -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
|
||||||
) {
|
) {
|
||||||
@@ -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
|
||||||
)
|
)
|
||||||
@@ -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)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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()
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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(
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -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
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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)
|
||||||
) {
|
) {
|
||||||
|
|||||||
@@ -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,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|||||||
@@ -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()
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -0,0 +1,71 @@
|
|||||||
|
package hu.bbara.purefin.core.player.viewmodel
|
||||||
|
|
||||||
|
enum class ControlsAutoHideBlocker {
|
||||||
|
PLAYLIST,
|
||||||
|
TRACK_PANEL
|
||||||
|
}
|
||||||
|
|
||||||
|
internal sealed interface ControlsAutoHideCommand {
|
||||||
|
data object Cancel : ControlsAutoHideCommand
|
||||||
|
data class Schedule(val delayMs: Long) : ControlsAutoHideCommand
|
||||||
|
}
|
||||||
|
|
||||||
|
internal class ControlsAutoHidePolicy(
|
||||||
|
private val defaultDelayMs: Long
|
||||||
|
) {
|
||||||
|
private val blockers = mutableSetOf<ControlsAutoHideBlocker>()
|
||||||
|
private var isPlaying = false
|
||||||
|
|
||||||
|
var controlsVisible: Boolean = true
|
||||||
|
private set
|
||||||
|
|
||||||
|
var lastAutoHideDelayMs: Long = defaultDelayMs
|
||||||
|
private set
|
||||||
|
|
||||||
|
fun onPlaybackChanged(isPlaying: Boolean): ControlsAutoHideCommand {
|
||||||
|
this.isPlaying = isPlaying
|
||||||
|
return nextCommand()
|
||||||
|
}
|
||||||
|
|
||||||
|
fun setAutoHideDelay(delayMs: Long): ControlsAutoHideCommand {
|
||||||
|
lastAutoHideDelayMs = delayMs
|
||||||
|
return nextCommand()
|
||||||
|
}
|
||||||
|
|
||||||
|
fun showControls(delayMs: Long? = null): ControlsAutoHideCommand {
|
||||||
|
delayMs?.let { lastAutoHideDelayMs = it }
|
||||||
|
controlsVisible = true
|
||||||
|
return nextCommand()
|
||||||
|
}
|
||||||
|
|
||||||
|
fun toggleControlsVisibility(): ControlsAutoHideCommand {
|
||||||
|
controlsVisible = !controlsVisible
|
||||||
|
return nextCommand()
|
||||||
|
}
|
||||||
|
|
||||||
|
fun hideControls(): ControlsAutoHideCommand {
|
||||||
|
controlsVisible = false
|
||||||
|
return ControlsAutoHideCommand.Cancel
|
||||||
|
}
|
||||||
|
|
||||||
|
fun setBlocked(
|
||||||
|
blocker: ControlsAutoHideBlocker,
|
||||||
|
blocked: Boolean
|
||||||
|
): ControlsAutoHideCommand {
|
||||||
|
if (blocked) {
|
||||||
|
blockers += blocker
|
||||||
|
controlsVisible = true
|
||||||
|
} else {
|
||||||
|
blockers -= blocker
|
||||||
|
}
|
||||||
|
return nextCommand()
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun nextCommand(): ControlsAutoHideCommand {
|
||||||
|
return if (!controlsVisible || !isPlaying || blockers.isNotEmpty()) {
|
||||||
|
ControlsAutoHideCommand.Cancel
|
||||||
|
} else {
|
||||||
|
ControlsAutoHideCommand.Schedule(lastAutoHideDelayMs)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -43,6 +43,7 @@ class PlayerViewModel @Inject constructor(
|
|||||||
private val _controlsVisible = MutableStateFlow(true)
|
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
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,90 @@
|
|||||||
|
package hu.bbara.purefin.core.player.viewmodel
|
||||||
|
|
||||||
|
import org.junit.Assert.assertEquals
|
||||||
|
import org.junit.Assert.assertTrue
|
||||||
|
import org.junit.Test
|
||||||
|
|
||||||
|
class ControlsAutoHidePolicyTest {
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun activeBlockerCancelsScheduledAutoHide() {
|
||||||
|
val policy = ControlsAutoHidePolicy(defaultDelayMs = 3_500L)
|
||||||
|
|
||||||
|
assertEquals(
|
||||||
|
ControlsAutoHideCommand.Schedule(3_500L),
|
||||||
|
policy.onPlaybackChanged(isPlaying = true)
|
||||||
|
)
|
||||||
|
assertEquals(
|
||||||
|
ControlsAutoHideCommand.Cancel,
|
||||||
|
policy.setBlocked(ControlsAutoHideBlocker.TRACK_PANEL, blocked = true)
|
||||||
|
)
|
||||||
|
assertTrue(policy.controlsVisible)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun playlistBlockerPreventsAutoHideUntilCleared() {
|
||||||
|
val policy = ControlsAutoHidePolicy(defaultDelayMs = 3_500L)
|
||||||
|
|
||||||
|
policy.onPlaybackChanged(isPlaying = true)
|
||||||
|
policy.showControls(delayMs = 5_000L)
|
||||||
|
|
||||||
|
assertEquals(
|
||||||
|
ControlsAutoHideCommand.Cancel,
|
||||||
|
policy.setBlocked(ControlsAutoHideBlocker.PLAYLIST, blocked = true)
|
||||||
|
)
|
||||||
|
assertEquals(
|
||||||
|
ControlsAutoHideCommand.Schedule(5_000L),
|
||||||
|
policy.setBlocked(ControlsAutoHideBlocker.PLAYLIST, blocked = false)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun trackPanelBlockerUsesRememberedDelayWhenRemoved() {
|
||||||
|
val policy = ControlsAutoHidePolicy(defaultDelayMs = 3_500L)
|
||||||
|
|
||||||
|
policy.onPlaybackChanged(isPlaying = true)
|
||||||
|
assertEquals(
|
||||||
|
ControlsAutoHideCommand.Cancel,
|
||||||
|
policy.setBlocked(ControlsAutoHideBlocker.TRACK_PANEL, blocked = true)
|
||||||
|
)
|
||||||
|
policy.showControls(delayMs = 5_000L)
|
||||||
|
|
||||||
|
assertEquals(
|
||||||
|
ControlsAutoHideCommand.Schedule(5_000L),
|
||||||
|
policy.setBlocked(ControlsAutoHideBlocker.TRACK_PANEL, blocked = false)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun autoHideResumesOnlyAfterLastBlockerIsCleared() {
|
||||||
|
val policy = ControlsAutoHidePolicy(defaultDelayMs = 3_500L)
|
||||||
|
|
||||||
|
policy.onPlaybackChanged(isPlaying = true)
|
||||||
|
policy.showControls(delayMs = 5_000L)
|
||||||
|
policy.setBlocked(ControlsAutoHideBlocker.PLAYLIST, blocked = true)
|
||||||
|
policy.setBlocked(ControlsAutoHideBlocker.TRACK_PANEL, blocked = true)
|
||||||
|
|
||||||
|
assertEquals(
|
||||||
|
ControlsAutoHideCommand.Cancel,
|
||||||
|
policy.setBlocked(ControlsAutoHideBlocker.PLAYLIST, blocked = false)
|
||||||
|
)
|
||||||
|
assertEquals(
|
||||||
|
ControlsAutoHideCommand.Schedule(5_000L),
|
||||||
|
policy.setBlocked(ControlsAutoHideBlocker.TRACK_PANEL, blocked = false)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun blockingStateMakesHiddenControlsVisibleAgain() {
|
||||||
|
val policy = ControlsAutoHidePolicy(defaultDelayMs = 3_500L)
|
||||||
|
|
||||||
|
policy.onPlaybackChanged(isPlaying = true)
|
||||||
|
policy.toggleControlsVisibility()
|
||||||
|
|
||||||
|
assertEquals(
|
||||||
|
ControlsAutoHideCommand.Cancel,
|
||||||
|
policy.setBlocked(ControlsAutoHideBlocker.PLAYLIST, blocked = true)
|
||||||
|
)
|
||||||
|
assertTrue(policy.controlsVisible)
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user