mirror of
https://github.com/bbara04/Purefin.git
synced 2026-07-24 03:36:51 +00:00
Compare commits
8 Commits
2937ee836b
...
b91a8a81e8
| Author | SHA1 | Date | |
|---|---|---|---|
| b91a8a81e8 | |||
| 9e8772467c | |||
| 60291dc78e | |||
| 0870e8aee4 | |||
| f0bb37d8ec | |||
| 34ae15d645 | |||
| 9083a08f95 | |||
| 32d50f750f |
@@ -6,16 +6,17 @@ import androidx.compose.ui.test.assertCountEquals
|
||||
import androidx.compose.ui.test.assertIsDisplayed
|
||||
import androidx.compose.ui.test.assertIsFocused
|
||||
import androidx.compose.ui.test.junit4.createAndroidComposeRule
|
||||
import androidx.compose.ui.test.performKeyInput
|
||||
import androidx.compose.ui.test.pressKey
|
||||
import androidx.compose.ui.test.onAllNodesWithText
|
||||
import androidx.compose.ui.test.onNodeWithContentDescription
|
||||
import androidx.compose.ui.test.onRoot
|
||||
import androidx.compose.ui.test.onNodeWithTag
|
||||
import androidx.compose.ui.test.onNodeWithText
|
||||
import androidx.compose.ui.test.performKeyInput
|
||||
import androidx.compose.ui.test.pressKey
|
||||
import androidx.compose.ui.input.key.Key
|
||||
import hu.bbara.purefin.core.model.CastMember
|
||||
import hu.bbara.purefin.core.model.Episode
|
||||
import hu.bbara.purefin.ui.theme.AppTheme
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Rule
|
||||
import org.junit.Test
|
||||
import java.util.UUID
|
||||
@@ -27,13 +28,12 @@ class EpisodeScreenContentTest {
|
||||
val composeRule = createAndroidComposeRule<ComponentActivity>()
|
||||
|
||||
@Test
|
||||
fun episodeScreenContent_showsSeriesContext_andMovesFromBackToPlayButton() {
|
||||
fun episodeScreenContent_showsSeriesContext_andFocusesPlayButton() {
|
||||
composeRule.setContent {
|
||||
AppTheme {
|
||||
EpisodeScreenContent(
|
||||
episode = sampleEpisode(progress = 63.0),
|
||||
seriesTitle = "Severance",
|
||||
onBack = {},
|
||||
onPlay = {}
|
||||
)
|
||||
}
|
||||
@@ -48,12 +48,6 @@ class EpisodeScreenContentTest {
|
||||
composeRule.onAllNodesWithText("Playback").assertCountEquals(1)
|
||||
composeRule.onNodeWithTag(EpisodePlayButtonTag).assertIsDisplayed()
|
||||
composeRule.onNodeWithText("Series").assertIsDisplayed()
|
||||
composeRule.onNodeWithContentDescription("Back")
|
||||
.assertIsDisplayed()
|
||||
.assertIsFocused()
|
||||
.performKeyInput {
|
||||
pressKey(Key.DirectionDown)
|
||||
}
|
||||
composeRule.onNodeWithTag(EpisodePlayButtonTag).assertIsFocused()
|
||||
}
|
||||
|
||||
@@ -64,7 +58,6 @@ class EpisodeScreenContentTest {
|
||||
EpisodeScreenContent(
|
||||
episode = sampleEpisode(progress = null),
|
||||
seriesTitle = "Severance",
|
||||
onBack = {},
|
||||
onPlay = {}
|
||||
)
|
||||
}
|
||||
@@ -78,6 +71,32 @@ class EpisodeScreenContentTest {
|
||||
composeRule.onAllNodesWithText("Series").assertCountEquals(0)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun episodeScreenContent_startsPlaybackOnFirstCenterPress() {
|
||||
var playCount = 0
|
||||
|
||||
composeRule.setContent {
|
||||
AppTheme {
|
||||
EpisodeScreenContent(
|
||||
episode = sampleEpisode(progress = 63.0),
|
||||
seriesTitle = "Severance",
|
||||
onPlay = { playCount++ }
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
composeRule.waitForIdle()
|
||||
|
||||
composeRule.onNodeWithTag(EpisodePlayButtonTag).assertIsFocused()
|
||||
composeRule.onRoot().performKeyInput {
|
||||
pressKey(Key.DirectionCenter)
|
||||
}
|
||||
|
||||
composeRule.waitForIdle()
|
||||
|
||||
assertEquals(1, playCount)
|
||||
}
|
||||
|
||||
private fun sampleEpisode(progress: Double?): Episode {
|
||||
val seriesId = UUID.fromString("11111111-1111-1111-1111-111111111111")
|
||||
val seasonId = UUID.fromString("22222222-2222-2222-2222-222222222222")
|
||||
|
||||
@@ -6,16 +6,17 @@ import androidx.compose.ui.test.assertCountEquals
|
||||
import androidx.compose.ui.test.assertIsDisplayed
|
||||
import androidx.compose.ui.test.assertIsFocused
|
||||
import androidx.compose.ui.test.junit4.createAndroidComposeRule
|
||||
import androidx.compose.ui.test.performKeyInput
|
||||
import androidx.compose.ui.test.pressKey
|
||||
import androidx.compose.ui.test.onAllNodesWithText
|
||||
import androidx.compose.ui.test.onNodeWithContentDescription
|
||||
import androidx.compose.ui.test.onRoot
|
||||
import androidx.compose.ui.test.onNodeWithTag
|
||||
import androidx.compose.ui.test.onNodeWithText
|
||||
import androidx.compose.ui.test.performKeyInput
|
||||
import androidx.compose.ui.test.pressKey
|
||||
import androidx.compose.ui.input.key.Key
|
||||
import hu.bbara.purefin.core.model.CastMember
|
||||
import hu.bbara.purefin.core.model.Movie
|
||||
import hu.bbara.purefin.ui.theme.AppTheme
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Rule
|
||||
import org.junit.Test
|
||||
import java.util.UUID
|
||||
@@ -27,12 +28,11 @@ class MovieScreenContentTest {
|
||||
val composeRule = createAndroidComposeRule<ComponentActivity>()
|
||||
|
||||
@Test
|
||||
fun movieScreenContent_focusesBack_thenMovesToPlayButton() {
|
||||
fun movieScreenContent_focusesPlayButton() {
|
||||
composeRule.setContent {
|
||||
AppTheme {
|
||||
MovieScreenContent(
|
||||
movie = sampleMovie(progress = 42.0),
|
||||
onBack = {},
|
||||
onPlay = {}
|
||||
)
|
||||
}
|
||||
@@ -43,14 +43,34 @@ class MovieScreenContentTest {
|
||||
composeRule.onNodeWithText("Blade Runner 2049").assertIsDisplayed()
|
||||
composeRule.onNodeWithText("Overview").assertIsDisplayed()
|
||||
composeRule.onAllNodesWithText("Playback").assertCountEquals(1)
|
||||
composeRule.onNodeWithTag(MoviePlayButtonTag).assertIsDisplayed()
|
||||
composeRule.onNodeWithContentDescription("Back")
|
||||
composeRule.onNodeWithTag(MoviePlayButtonTag)
|
||||
.assertIsDisplayed()
|
||||
.assertIsFocused()
|
||||
.performKeyInput {
|
||||
pressKey(Key.DirectionDown)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun movieScreenContent_startsPlaybackOnFirstCenterPress() {
|
||||
var playCount = 0
|
||||
|
||||
composeRule.setContent {
|
||||
AppTheme {
|
||||
MovieScreenContent(
|
||||
movie = sampleMovie(progress = 42.0),
|
||||
onPlay = { playCount++ }
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
composeRule.waitForIdle()
|
||||
|
||||
composeRule.onNodeWithTag(MoviePlayButtonTag).assertIsFocused()
|
||||
composeRule.onRoot().performKeyInput {
|
||||
pressKey(Key.DirectionCenter)
|
||||
}
|
||||
|
||||
composeRule.waitForIdle()
|
||||
|
||||
assertEquals(1, playCount)
|
||||
}
|
||||
|
||||
private fun sampleMovie(progress: Double?): Movie {
|
||||
|
||||
@@ -5,17 +5,18 @@ import androidx.compose.ui.test.ExperimentalTestApi
|
||||
import androidx.compose.ui.test.assertIsDisplayed
|
||||
import androidx.compose.ui.test.assertIsFocused
|
||||
import androidx.compose.ui.test.junit4.createAndroidComposeRule
|
||||
import androidx.compose.ui.test.performKeyInput
|
||||
import androidx.compose.ui.test.pressKey
|
||||
import androidx.compose.ui.test.onNodeWithContentDescription
|
||||
import androidx.compose.ui.test.onRoot
|
||||
import androidx.compose.ui.test.onNodeWithTag
|
||||
import androidx.compose.ui.test.onNodeWithText
|
||||
import androidx.compose.ui.test.performKeyInput
|
||||
import androidx.compose.ui.test.pressKey
|
||||
import androidx.compose.ui.input.key.Key
|
||||
import hu.bbara.purefin.core.model.CastMember
|
||||
import hu.bbara.purefin.core.model.Episode
|
||||
import hu.bbara.purefin.core.model.Season
|
||||
import hu.bbara.purefin.core.model.Series
|
||||
import hu.bbara.purefin.ui.theme.AppTheme
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Rule
|
||||
import org.junit.Test
|
||||
import java.util.UUID
|
||||
@@ -27,12 +28,11 @@ class SeriesScreenContentTest {
|
||||
val composeRule = createAndroidComposeRule<ComponentActivity>()
|
||||
|
||||
@Test
|
||||
fun seriesScreenContent_movesFromBackToPrimaryAction_whenNextUpExists() {
|
||||
fun seriesScreenContent_focusesPrimaryAction_whenNextUpExists() {
|
||||
composeRule.setContent {
|
||||
AppTheme {
|
||||
SeriesScreenContent(
|
||||
series = sampleSeriesWithEpisodes(),
|
||||
onBack = {},
|
||||
onPlayEpisode = {}
|
||||
)
|
||||
}
|
||||
@@ -44,25 +44,18 @@ class SeriesScreenContentTest {
|
||||
composeRule.onNodeWithText("Overview").assertIsDisplayed()
|
||||
composeRule.onNodeWithText("Continue Watching").assertIsDisplayed()
|
||||
composeRule.onNodeWithTag(SeriesPlayButtonTag).assertIsDisplayed()
|
||||
.assertIsFocused()
|
||||
composeRule.onNodeWithText("Season 1").assertIsDisplayed()
|
||||
composeRule.onNodeWithText("Good News About Hell").assertIsDisplayed()
|
||||
composeRule.onNodeWithText("Episode 1 • 57m").assertIsDisplayed()
|
||||
composeRule.onNodeWithContentDescription("Back")
|
||||
.assertIsDisplayed()
|
||||
.assertIsFocused()
|
||||
.performKeyInput {
|
||||
pressKey(Key.DirectionDown)
|
||||
}
|
||||
composeRule.onNodeWithTag(SeriesPlayButtonTag).assertIsFocused()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun seriesScreenContent_movesFromBackToFirstSeason_whenNoPlayableEpisodeExists() {
|
||||
fun seriesScreenContent_focusesFirstSeason_whenNoPlayableEpisodeExists() {
|
||||
composeRule.setContent {
|
||||
AppTheme {
|
||||
SeriesScreenContent(
|
||||
series = sampleSeriesWithoutEpisodes(),
|
||||
onBack = {},
|
||||
onPlayEpisode = {}
|
||||
)
|
||||
}
|
||||
@@ -72,14 +65,34 @@ class SeriesScreenContentTest {
|
||||
|
||||
composeRule.onNodeWithText("Overview").assertIsDisplayed()
|
||||
composeRule.onNodeWithText("Choose a season below to start watching.").assertIsDisplayed()
|
||||
composeRule.onNodeWithTag(SeriesFirstSeasonTabTag).assertIsDisplayed()
|
||||
composeRule.onNodeWithContentDescription("Back")
|
||||
composeRule.onNodeWithTag(SeriesFirstSeasonTabTag)
|
||||
.assertIsDisplayed()
|
||||
.assertIsFocused()
|
||||
.performKeyInput {
|
||||
pressKey(Key.DirectionDown)
|
||||
}
|
||||
composeRule.onNodeWithTag(SeriesFirstSeasonTabTag).assertIsFocused()
|
||||
|
||||
@Test
|
||||
fun seriesScreenContent_startsPlaybackOnFirstCenterPress() {
|
||||
var playCount = 0
|
||||
|
||||
composeRule.setContent {
|
||||
AppTheme {
|
||||
SeriesScreenContent(
|
||||
series = sampleSeriesWithEpisodes(),
|
||||
onPlayEpisode = { playCount++ }
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
composeRule.waitForIdle()
|
||||
|
||||
composeRule.onNodeWithTag(SeriesPlayButtonTag).assertIsFocused()
|
||||
composeRule.onRoot().performKeyInput {
|
||||
pressKey(Key.DirectionCenter)
|
||||
}
|
||||
|
||||
composeRule.waitForIdle()
|
||||
|
||||
assertEquals(1, playCount)
|
||||
}
|
||||
|
||||
private fun sampleSeriesWithEpisodes(): Series {
|
||||
|
||||
@@ -0,0 +1,49 @@
|
||||
package hu.bbara.purefin.common.ui.components
|
||||
|
||||
import androidx.test.ext.junit.runners.AndroidJUnit4
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Test
|
||||
import org.junit.runner.RunWith
|
||||
|
||||
@RunWith(AndroidJUnit4::class)
|
||||
class TvMediaDetailBringIntoViewSpecTest {
|
||||
|
||||
@Test
|
||||
fun returnsZero_whenFocusedItemIsAlreadyFullyVisible() {
|
||||
assertEquals(
|
||||
0f,
|
||||
TvMediaDetailBringIntoViewSpec.calculateScrollDistance(
|
||||
offset = 320f,
|
||||
size = 52f,
|
||||
containerSize = 1080f
|
||||
),
|
||||
0.0001f
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun scrollsOnlyEnoughToRevealFocusedItemBelowViewport() {
|
||||
assertEquals(
|
||||
36f,
|
||||
TvMediaDetailBringIntoViewSpec.calculateScrollDistance(
|
||||
offset = 1064f,
|
||||
size = 52f,
|
||||
containerSize = 1080f
|
||||
),
|
||||
0.0001f
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun scrollsBackJustToTopEdge_whenFocusedItemMovesAboveViewport() {
|
||||
assertEquals(
|
||||
-24f,
|
||||
TvMediaDetailBringIntoViewSpec.calculateScrollDistance(
|
||||
offset = -24f,
|
||||
size = 52f,
|
||||
containerSize = 1080f
|
||||
),
|
||||
0.0001f
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -9,20 +9,24 @@ import androidx.compose.ui.test.assertIsDisplayed
|
||||
import androidx.compose.ui.test.assertIsFocused
|
||||
import androidx.compose.ui.test.assertTextEquals
|
||||
import androidx.compose.ui.test.junit4.createAndroidComposeRule
|
||||
import androidx.compose.ui.test.onRoot
|
||||
import androidx.compose.ui.test.onNodeWithText
|
||||
import androidx.compose.ui.test.onNodeWithTag
|
||||
import androidx.compose.ui.test.performKeyInput
|
||||
import androidx.compose.ui.test.pressKey
|
||||
import androidx.compose.ui.input.key.Key
|
||||
import androidx.compose.ui.unit.dp
|
||||
import hu.bbara.purefin.core.model.Episode
|
||||
import hu.bbara.purefin.core.model.Movie
|
||||
import hu.bbara.purefin.feature.shared.home.ContinueWatchingItem
|
||||
import hu.bbara.purefin.feature.shared.home.LibraryItem
|
||||
import hu.bbara.purefin.feature.shared.home.NextUpItem
|
||||
import hu.bbara.purefin.feature.shared.home.PosterItem
|
||||
import hu.bbara.purefin.tv.home.TvHomeScreen
|
||||
import hu.bbara.purefin.ui.theme.AppTheme
|
||||
import org.jellyfin.sdk.model.api.BaseItemKind
|
||||
import org.jellyfin.sdk.model.api.CollectionType
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Rule
|
||||
import org.junit.Test
|
||||
import java.util.UUID
|
||||
@@ -140,6 +144,157 @@ class TvHomeContentTest {
|
||||
.assertIsFocused()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun tvHomeScreen_movesFocusedSectionToTopOfContent() {
|
||||
composeRule.setContent {
|
||||
AppTheme {
|
||||
Box(
|
||||
modifier = Modifier.size(width = 960.dp, height = 540.dp)
|
||||
) {
|
||||
TvHomeScreen(
|
||||
libraries = sampleLibraries(),
|
||||
libraryContent = sampleLibraryContent(),
|
||||
continueWatching = sampleContinueWatching(),
|
||||
nextUp = sampleNextUp(),
|
||||
serverUrl = "",
|
||||
onMovieSelected = {},
|
||||
onSeriesSelected = {},
|
||||
onEpisodeSelected = { _, _, _ -> }
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
composeRule.waitForIdle()
|
||||
|
||||
composeRule.onRoot().performKeyInput {
|
||||
pressKey(Key.DirectionDown)
|
||||
}
|
||||
composeRule.waitForIdle()
|
||||
|
||||
composeRule.onNodeWithTag(TvHomeHeroTitleTag).assertTextEquals("The Train Job")
|
||||
assertSectionRowAlignedToViewportTop(TvHomeNextUpRowTag)
|
||||
|
||||
composeRule.onRoot().performKeyInput {
|
||||
pressKey(Key.DirectionDown)
|
||||
}
|
||||
composeRule.waitForIdle()
|
||||
|
||||
composeRule.onNodeWithTag(TvHomeHeroTitleTag).assertTextEquals("Arrival")
|
||||
assertSectionRowAlignedToViewportTop(tvHomeLibraryRowTag(sampleLibraryId()))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun tvHomeScreen_skipsMissingSectionAndAlignsLibraryToTop() {
|
||||
composeRule.setContent {
|
||||
AppTheme {
|
||||
Box(
|
||||
modifier = Modifier.size(width = 960.dp, height = 540.dp)
|
||||
) {
|
||||
TvHomeScreen(
|
||||
libraries = sampleLibraries(),
|
||||
libraryContent = sampleLibraryContent(),
|
||||
continueWatching = sampleContinueWatching(),
|
||||
nextUp = emptyList(),
|
||||
serverUrl = "",
|
||||
onMovieSelected = {},
|
||||
onSeriesSelected = {},
|
||||
onEpisodeSelected = { _, _, _ -> }
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
composeRule.waitForIdle()
|
||||
|
||||
composeRule.onRoot().performKeyInput {
|
||||
pressKey(Key.DirectionDown)
|
||||
}
|
||||
composeRule.waitForIdle()
|
||||
|
||||
composeRule.onNodeWithTag(TvHomeHeroTitleTag).assertTextEquals("Arrival")
|
||||
assertSectionRowAlignedToViewportTop(tvHomeLibraryRowTag(sampleLibraryId()))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun tvHomeScreen_movesPreviousSectionToTopWhenNavigatingUp() {
|
||||
composeRule.setContent {
|
||||
AppTheme {
|
||||
Box(
|
||||
modifier = Modifier.size(width = 960.dp, height = 540.dp)
|
||||
) {
|
||||
TvHomeScreen(
|
||||
libraries = sampleLibraries(),
|
||||
libraryContent = sampleLibraryContent(),
|
||||
continueWatching = sampleContinueWatching(),
|
||||
nextUp = sampleNextUp(),
|
||||
serverUrl = "",
|
||||
onMovieSelected = {},
|
||||
onSeriesSelected = {},
|
||||
onEpisodeSelected = { _, _, _ -> }
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
composeRule.waitForIdle()
|
||||
|
||||
composeRule.onRoot().performKeyInput {
|
||||
pressKey(Key.DirectionDown)
|
||||
pressKey(Key.DirectionDown)
|
||||
}
|
||||
composeRule.waitForIdle()
|
||||
|
||||
composeRule.onRoot().performKeyInput {
|
||||
pressKey(Key.DirectionUp)
|
||||
}
|
||||
composeRule.waitForIdle()
|
||||
|
||||
composeRule.onNodeWithTag(TvHomeHeroTitleTag).assertTextEquals("The Train Job")
|
||||
assertSectionRowAlignedToViewportTop(TvHomeNextUpRowTag)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun tvHomeScreen_horizontalMoveDoesNotChangeAlignedSectionPosition() {
|
||||
composeRule.setContent {
|
||||
AppTheme {
|
||||
Box(
|
||||
modifier = Modifier.size(width = 960.dp, height = 540.dp)
|
||||
) {
|
||||
TvHomeScreen(
|
||||
libraries = sampleLibraries(),
|
||||
libraryContent = sampleLibraryContent(),
|
||||
continueWatching = sampleContinueWatching(),
|
||||
nextUp = sampleNextUpRow(),
|
||||
serverUrl = "",
|
||||
onMovieSelected = {},
|
||||
onSeriesSelected = {},
|
||||
onEpisodeSelected = { _, _, _ -> }
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
composeRule.waitForIdle()
|
||||
|
||||
composeRule.onRoot().performKeyInput {
|
||||
pressKey(Key.DirectionDown)
|
||||
}
|
||||
composeRule.waitForIdle()
|
||||
|
||||
assertSectionRowAlignedToViewportTop(TvHomeNextUpRowTag)
|
||||
val alignedTopBefore = rowTop(TvHomeNextUpRowTag)
|
||||
|
||||
composeRule.onRoot().performKeyInput {
|
||||
pressKey(Key.DirectionRight)
|
||||
}
|
||||
composeRule.waitForIdle()
|
||||
|
||||
composeRule.onNodeWithTag(TvHomeHeroTitleTag).assertTextEquals("Safe")
|
||||
val alignedTopAfter = rowTop(TvHomeNextUpRowTag)
|
||||
assertEquals(alignedTopBefore, alignedTopAfter, 2f)
|
||||
}
|
||||
|
||||
private fun sampleContinueWatching(): List<ContinueWatchingItem> {
|
||||
return listOf(
|
||||
ContinueWatchingItem(
|
||||
@@ -174,10 +329,39 @@ class TvHomeContentTest {
|
||||
)
|
||||
}
|
||||
|
||||
private fun sampleNextUp(): List<NextUpItem> {
|
||||
return listOf(
|
||||
NextUpItem(
|
||||
episode = sampleEpisode(
|
||||
id = "66666666-6666-6666-6666-666666666666",
|
||||
title = "The Train Job"
|
||||
)
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
private fun sampleNextUpRow(): List<NextUpItem> {
|
||||
return listOf(
|
||||
NextUpItem(
|
||||
episode = sampleEpisode(
|
||||
id = "66666666-6666-6666-6666-666666666666",
|
||||
title = "The Train Job"
|
||||
)
|
||||
),
|
||||
NextUpItem(
|
||||
episode = sampleEpisode(
|
||||
id = "77777777-7777-7777-7777-777777777777",
|
||||
title = "Safe",
|
||||
releaseDate = "2024-02-09"
|
||||
)
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
private fun sampleLibraries(): List<LibraryItem> {
|
||||
return listOf(
|
||||
LibraryItem(
|
||||
id = UUID.fromString("33333333-3333-3333-3333-333333333333"),
|
||||
id = sampleLibraryId(),
|
||||
name = "Movies",
|
||||
type = CollectionType.MOVIES,
|
||||
posterUrl = "",
|
||||
@@ -187,7 +371,7 @@ class TvHomeContentTest {
|
||||
}
|
||||
|
||||
private fun sampleLibraryContent(): Map<UUID, List<PosterItem>> {
|
||||
val libraryId = UUID.fromString("33333333-3333-3333-3333-333333333333")
|
||||
val libraryId = sampleLibraryId()
|
||||
|
||||
return mapOf(
|
||||
libraryId to listOf(
|
||||
@@ -204,6 +388,32 @@ class TvHomeContentTest {
|
||||
)
|
||||
}
|
||||
|
||||
private fun sampleEpisode(
|
||||
id: String,
|
||||
title: String,
|
||||
releaseDate: String = "2002-09-20",
|
||||
progress: Double? = 18.0,
|
||||
seriesId: UUID = UUID.fromString("88888888-8888-8888-8888-888888888888"),
|
||||
seasonId: UUID = UUID.fromString("99999999-9999-9999-9999-999999999999")
|
||||
): Episode {
|
||||
return Episode(
|
||||
id = UUID.fromString(id),
|
||||
seriesId = seriesId,
|
||||
seasonId = seasonId,
|
||||
index = 1,
|
||||
title = title,
|
||||
synopsis = "A crew member takes the shuttle for a spin and makes a mess.",
|
||||
releaseDate = releaseDate,
|
||||
rating = "16+",
|
||||
runtime = "44m",
|
||||
progress = progress,
|
||||
watched = false,
|
||||
format = "HD",
|
||||
imageUrlPrefix = "https://images.unsplash.com/photo-1511497584788-876760111969",
|
||||
cast = emptyList()
|
||||
)
|
||||
}
|
||||
|
||||
private fun sampleMovie(
|
||||
id: String,
|
||||
title: String,
|
||||
@@ -227,4 +437,29 @@ class TvHomeContentTest {
|
||||
cast = emptyList()
|
||||
)
|
||||
}
|
||||
|
||||
private fun sampleLibraryId(): UUID {
|
||||
return UUID.fromString("33333333-3333-3333-3333-333333333333")
|
||||
}
|
||||
|
||||
private fun assertSectionRowAlignedToViewportTop(sectionRowTag: String) {
|
||||
val expectedRowTop = viewportTop() + with(composeRule.density) {
|
||||
TvHomeFocusedItemTopOffset.toPx()
|
||||
}
|
||||
assertEquals(expectedRowTop, rowTop(sectionRowTag), 2f)
|
||||
}
|
||||
|
||||
private fun viewportTop(): Float {
|
||||
return composeRule.onNodeWithTag(TvHomeContentViewportTag, useUnmergedTree = true)
|
||||
.fetchSemanticsNode()
|
||||
.boundsInRoot
|
||||
.top
|
||||
}
|
||||
|
||||
private fun rowTop(sectionRowTag: String): Float {
|
||||
return composeRule.onNodeWithTag(sectionRowTag, useUnmergedTree = true)
|
||||
.fetchSemanticsNode()
|
||||
.boundsInRoot
|
||||
.top
|
||||
}
|
||||
}
|
||||
|
||||
@@ -8,6 +8,7 @@ import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.collectAsState
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.withFrameNanos
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.focus.FocusRequester
|
||||
import androidx.compose.ui.unit.dp
|
||||
@@ -17,15 +18,14 @@ import hu.bbara.purefin.common.ui.PurefinWaitingScreen
|
||||
import hu.bbara.purefin.common.ui.components.MediaDetailOverviewSection
|
||||
import hu.bbara.purefin.common.ui.components.MediaDetailPlaybackSection
|
||||
import hu.bbara.purefin.common.ui.components.MediaDetailSectionTitle
|
||||
import hu.bbara.purefin.common.ui.components.TvMediaDetailBodyBox
|
||||
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.LocalNavigationBackStack
|
||||
import hu.bbara.purefin.core.data.navigation.LocalNavigationManager
|
||||
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.feature.shared.content.episode.EpisodeScreenViewModel
|
||||
import org.jellyfin.sdk.model.api.ImageType
|
||||
|
||||
@Composable
|
||||
fun EpisodeScreen(
|
||||
@@ -34,8 +34,6 @@ fun EpisodeScreen(
|
||||
modifier: Modifier = Modifier
|
||||
) {
|
||||
val navigationManager = LocalNavigationManager.current
|
||||
val backStack = LocalNavigationBackStack.current
|
||||
val previousRoute = remember(backStack) { backStack.getOrNull(backStack.lastIndex - 1) }
|
||||
|
||||
LaunchedEffect(episode) {
|
||||
viewModel.selectEpisode(
|
||||
@@ -57,7 +55,6 @@ fun EpisodeScreen(
|
||||
EpisodeScreenContent(
|
||||
episode = selectedEpisode,
|
||||
seriesTitle = seriesTitle.value,
|
||||
onBack = viewModel::onBack,
|
||||
onPlay = remember(selectedEpisode.id, navigationManager) {
|
||||
{
|
||||
navigationManager.navigate(
|
||||
@@ -73,23 +70,25 @@ fun EpisodeScreen(
|
||||
internal fun EpisodeScreenContent(
|
||||
episode: Episode,
|
||||
seriesTitle: String?,
|
||||
onBack: () -> Unit,
|
||||
onPlay: () -> Unit,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
val playFocusRequester = remember { FocusRequester() }
|
||||
|
||||
LaunchedEffect(episode.id) {
|
||||
withFrameNanos { }
|
||||
playFocusRequester.requestFocus()
|
||||
}
|
||||
|
||||
TvMediaDetailScaffold(
|
||||
artworkImageUrl = JellyfinImageHelper.finishImageUrl(episode.imageUrlPrefix, ImageType.PRIMARY),
|
||||
artworkWidth = 280.dp,
|
||||
artworkAspectRatio = 16f / 9f,
|
||||
resetScrollKey = episode.id,
|
||||
modifier = modifier,
|
||||
heroContent = {
|
||||
modifier = modifier
|
||||
) {
|
||||
item {
|
||||
TvMediaDetailBodyBox(
|
||||
backgroundImageUrl = tvMediaDetailBackgroundImageUrl(episode.imageUrlPrefix),
|
||||
modifier = it
|
||||
) {
|
||||
EpisodeHeroSection(
|
||||
episode = episode,
|
||||
seriesTitle = seriesTitle,
|
||||
@@ -99,7 +98,7 @@ internal fun EpisodeScreenContent(
|
||||
)
|
||||
Spacer(modifier = Modifier.height(12.dp))
|
||||
}
|
||||
) {
|
||||
}
|
||||
item {
|
||||
Column(modifier = it.fillMaxWidth()) {
|
||||
Spacer(modifier = Modifier.height(16.dp))
|
||||
|
||||
@@ -8,6 +8,7 @@ import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.collectAsState
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.withFrameNanos
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.focus.FocusRequester
|
||||
import androidx.compose.ui.unit.dp
|
||||
@@ -17,12 +18,12 @@ import hu.bbara.purefin.common.ui.PurefinWaitingScreen
|
||||
import hu.bbara.purefin.common.ui.components.MediaDetailOverviewSection
|
||||
import hu.bbara.purefin.common.ui.components.MediaDetailPlaybackSection
|
||||
import hu.bbara.purefin.common.ui.components.MediaDetailSectionTitle
|
||||
import hu.bbara.purefin.common.ui.components.TvMediaDetailBodyBox
|
||||
import hu.bbara.purefin.common.ui.components.TvMediaDetailScaffold
|
||||
import hu.bbara.purefin.core.data.image.JellyfinImageHelper
|
||||
import hu.bbara.purefin.common.ui.components.tvMediaDetailBackgroundImageUrl
|
||||
import hu.bbara.purefin.core.data.navigation.MovieDto
|
||||
import hu.bbara.purefin.core.model.Movie
|
||||
import hu.bbara.purefin.feature.shared.content.movie.MovieScreenViewModel
|
||||
import org.jellyfin.sdk.model.api.ImageType
|
||||
|
||||
@Composable
|
||||
fun MovieScreen(
|
||||
@@ -37,7 +38,6 @@ fun MovieScreen(
|
||||
if (movieItem.value != null) {
|
||||
MovieScreenContent(
|
||||
movie = movieItem.value!!,
|
||||
onBack = viewModel::onBack,
|
||||
onPlay = viewModel::onPlay,
|
||||
modifier = modifier
|
||||
)
|
||||
@@ -49,23 +49,25 @@ fun MovieScreen(
|
||||
@Composable
|
||||
internal fun MovieScreenContent(
|
||||
movie: Movie,
|
||||
onBack: () -> Unit,
|
||||
onPlay: () -> Unit,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
val playFocusRequester = remember { FocusRequester() }
|
||||
|
||||
LaunchedEffect(movie.id) {
|
||||
withFrameNanos { }
|
||||
playFocusRequester.requestFocus()
|
||||
}
|
||||
|
||||
TvMediaDetailScaffold(
|
||||
artworkImageUrl = JellyfinImageHelper.finishImageUrl(movie.imageUrlPrefix, ImageType.PRIMARY),
|
||||
artworkWidth = 200.dp,
|
||||
artworkAspectRatio = 2f / 3f,
|
||||
resetScrollKey = movie.id,
|
||||
modifier = modifier,
|
||||
heroContent = {
|
||||
modifier = modifier
|
||||
) {
|
||||
item {
|
||||
TvMediaDetailBodyBox(
|
||||
backgroundImageUrl = tvMediaDetailBackgroundImageUrl(movie.imageUrlPrefix),
|
||||
modifier = it
|
||||
) {
|
||||
MovieHeroSection(
|
||||
movie = movie,
|
||||
onPlay = onPlay,
|
||||
@@ -74,7 +76,7 @@ internal fun MovieScreenContent(
|
||||
)
|
||||
Spacer(modifier = Modifier.height(12.dp))
|
||||
}
|
||||
) {
|
||||
}
|
||||
item {
|
||||
Column(modifier = it.fillMaxWidth()) {
|
||||
Spacer(modifier = Modifier.height(16.dp))
|
||||
|
||||
@@ -7,8 +7,11 @@ import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.collectAsState
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.runtime.withFrameNanos
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.focus.FocusRequester
|
||||
import androidx.compose.ui.unit.dp
|
||||
@@ -16,14 +19,14 @@ import androidx.hilt.navigation.compose.hiltViewModel
|
||||
import hu.bbara.purefin.common.ui.PurefinWaitingScreen
|
||||
import hu.bbara.purefin.common.ui.components.MediaDetailOverviewSection
|
||||
import hu.bbara.purefin.common.ui.components.MediaDetailSectionTitle
|
||||
import hu.bbara.purefin.common.ui.components.TvMediaDetailBodyBox
|
||||
import hu.bbara.purefin.common.ui.components.TvMediaDetailScaffold
|
||||
import hu.bbara.purefin.core.data.image.JellyfinImageHelper
|
||||
import hu.bbara.purefin.common.ui.components.tvMediaDetailBackgroundImageUrl
|
||||
import hu.bbara.purefin.core.data.navigation.SeriesDto
|
||||
import hu.bbara.purefin.core.model.Season
|
||||
import hu.bbara.purefin.core.model.Series
|
||||
import hu.bbara.purefin.feature.shared.content.series.SeriesViewModel
|
||||
import org.jellyfin.sdk.model.UUID
|
||||
import org.jellyfin.sdk.model.api.ImageType
|
||||
|
||||
@Composable
|
||||
fun SeriesScreen(
|
||||
@@ -41,7 +44,6 @@ fun SeriesScreen(
|
||||
if (seriesData != null && seriesData.seasons.isNotEmpty()) {
|
||||
SeriesScreenContent(
|
||||
series = seriesData,
|
||||
onBack = viewModel::onBack,
|
||||
onPlayEpisode = viewModel::onPlayEpisode,
|
||||
modifier = modifier
|
||||
)
|
||||
@@ -53,27 +55,16 @@ fun SeriesScreen(
|
||||
@Composable
|
||||
internal fun SeriesScreenContent(
|
||||
series: Series,
|
||||
onBack: () -> Unit,
|
||||
onPlayEpisode: (UUID) -> Unit,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
fun getDefaultSeason(): Season {
|
||||
for (season in series.seasons) {
|
||||
val firstUnwatchedEpisode = season.episodes.firstOrNull { it.watched.not() }
|
||||
if (firstUnwatchedEpisode != null) return season
|
||||
}
|
||||
return series.seasons.first()
|
||||
}
|
||||
val selectedSeason = remember(series.id) { mutableStateOf(getDefaultSeason()) }
|
||||
val nextUpEpisode = remember(series.id) {
|
||||
series.seasons.firstNotNullOfOrNull { season ->
|
||||
season.episodes.firstOrNull { !it.watched }
|
||||
} ?: series.seasons.firstOrNull()?.episodes?.firstOrNull()
|
||||
}
|
||||
var selectedSeason by remember(series.id) { mutableStateOf(series.defaultSeason()) }
|
||||
val nextUpEpisode = remember(series.id) { series.nextUpEpisode() }
|
||||
val playFocusRequester = remember { FocusRequester() }
|
||||
val firstContentFocusRequester = remember { FocusRequester() }
|
||||
|
||||
LaunchedEffect(series.id, nextUpEpisode?.id) {
|
||||
withFrameNanos { }
|
||||
if (nextUpEpisode != null) {
|
||||
playFocusRequester.requestFocus()
|
||||
} else {
|
||||
@@ -82,12 +73,14 @@ internal fun SeriesScreenContent(
|
||||
}
|
||||
|
||||
TvMediaDetailScaffold(
|
||||
artworkImageUrl = JellyfinImageHelper.finishImageUrl(series.imageUrlPrefix, ImageType.PRIMARY),
|
||||
artworkWidth = 200.dp,
|
||||
artworkAspectRatio = 2f / 3f,
|
||||
resetScrollKey = series.id,
|
||||
modifier = modifier,
|
||||
heroContent = {
|
||||
modifier = modifier
|
||||
) {
|
||||
item {
|
||||
TvMediaDetailBodyBox(
|
||||
backgroundImageUrl = tvMediaDetailBackgroundImageUrl(series.imageUrlPrefix),
|
||||
modifier = it
|
||||
) {
|
||||
SeriesHeroSection(
|
||||
series = series,
|
||||
nextUpEpisode = nextUpEpisode,
|
||||
@@ -98,7 +91,7 @@ internal fun SeriesScreenContent(
|
||||
)
|
||||
Spacer(modifier = Modifier.height(12.dp))
|
||||
}
|
||||
) {
|
||||
}
|
||||
item {
|
||||
Column(modifier = it.fillMaxWidth()) {
|
||||
Spacer(modifier = Modifier.height(16.dp))
|
||||
@@ -112,17 +105,17 @@ internal fun SeriesScreenContent(
|
||||
item {
|
||||
SeasonTabs(
|
||||
seasons = series.seasons,
|
||||
selectedSeason = selectedSeason.value,
|
||||
selectedSeason = selectedSeason,
|
||||
firstItemFocusRequester = firstContentFocusRequester,
|
||||
firstItemTestTag = SeriesFirstSeasonTabTag,
|
||||
onSelect = { selectedSeason.value = it },
|
||||
onSelect = { selectedSeason = it },
|
||||
modifier = it
|
||||
)
|
||||
}
|
||||
item {
|
||||
Spacer(modifier = Modifier.height(20.dp))
|
||||
EpisodeCarousel(
|
||||
episodes = selectedSeason.value.episodes,
|
||||
episodes = selectedSeason.episodes,
|
||||
modifier = it
|
||||
)
|
||||
}
|
||||
@@ -139,3 +132,16 @@ internal fun SeriesScreenContent(
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun Series.defaultSeason(): Season {
|
||||
for (season in seasons) {
|
||||
if (season.episodes.any { !it.watched }) {
|
||||
return season
|
||||
}
|
||||
}
|
||||
return seasons.first()
|
||||
}
|
||||
|
||||
private fun Series.nextUpEpisode() = seasons.firstNotNullOfOrNull { season ->
|
||||
season.episodes.firstOrNull { !it.watched }
|
||||
} ?: seasons.firstOrNull()?.episodes?.firstOrNull()
|
||||
|
||||
@@ -1,57 +1,66 @@
|
||||
@file:OptIn(androidx.compose.foundation.ExperimentalFoundationApi::class)
|
||||
|
||||
package hu.bbara.purefin.common.ui.components
|
||||
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.gestures.BringIntoViewSpec
|
||||
import androidx.compose.foundation.gestures.LocalBringIntoViewSpec
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.BoxWithConstraints
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.ColumnScope
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
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.height
|
||||
import androidx.compose.foundation.layout.heightIn
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.width
|
||||
import androidx.compose.foundation.layout.widthIn
|
||||
import androidx.compose.foundation.lazy.LazyColumn
|
||||
import androidx.compose.foundation.lazy.LazyListScope
|
||||
import androidx.compose.foundation.lazy.rememberLazyListState
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.CompositionLocalProvider
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.clip
|
||||
import androidx.compose.ui.graphics.Brush
|
||||
import androidx.compose.ui.graphics.graphicsLayer
|
||||
import androidx.compose.ui.layout.ContentScale
|
||||
import androidx.compose.ui.platform.LocalConfiguration
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.unit.Dp
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.sp
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import hu.bbara.purefin.common.ui.MediaSynopsis
|
||||
import hu.bbara.purefin.core.data.image.JellyfinImageHelper
|
||||
import org.jellyfin.sdk.model.api.ImageType
|
||||
|
||||
internal val MediaDetailHorizontalPadding = 48.dp
|
||||
private val MediaDetailHeaderTopPadding = 104.dp
|
||||
private val MediaDetailHeaderBottomPadding = 36.dp
|
||||
private val MediaDetailCornerArtworkTopPadding = 40.dp
|
||||
private val MediaDetailCornerArtworkShape = RoundedCornerShape(24.dp)
|
||||
private val MediaDetailMinimumContentWidth = 280.dp
|
||||
private val MediaDetailContentArtworkGap = 32.dp
|
||||
private const val MediaDetailBodyImageWidthFraction = 0.66f
|
||||
|
||||
internal val TvMediaDetailBringIntoViewSpec: BringIntoViewSpec =
|
||||
object : BringIntoViewSpec {
|
||||
override fun calculateScrollDistance(
|
||||
offset: Float,
|
||||
size: Float,
|
||||
containerSize: Float,
|
||||
): Float {
|
||||
val trailingEdge = offset + size
|
||||
|
||||
return when {
|
||||
offset < 0f -> offset
|
||||
trailingEdge > containerSize -> trailingEdge - containerSize
|
||||
else -> 0f
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
internal fun TvMediaDetailScaffold(
|
||||
artworkImageUrl: String,
|
||||
artworkWidth: Dp,
|
||||
artworkAspectRatio: Float,
|
||||
resetScrollKey: Any,
|
||||
modifier: Modifier = Modifier,
|
||||
headerHeightFraction: Float = 0.48f,
|
||||
heroContent: @Composable ColumnScope.() -> Unit,
|
||||
bodyContent: LazyListScope.(Modifier) -> Unit = { _ -> }
|
||||
) {
|
||||
val scheme = MaterialTheme.colorScheme
|
||||
@@ -62,132 +71,89 @@ internal fun TvMediaDetailScaffold(
|
||||
listState.scrollToItem(0)
|
||||
}
|
||||
|
||||
Box(
|
||||
CompositionLocalProvider(LocalBringIntoViewSpec provides TvMediaDetailBringIntoViewSpec) {
|
||||
LazyColumn(
|
||||
state = listState,
|
||||
modifier = modifier
|
||||
.fillMaxSize()
|
||||
.background(scheme.background)
|
||||
) {
|
||||
LazyColumn(
|
||||
state = listState,
|
||||
modifier = Modifier.fillMaxSize()
|
||||
) {
|
||||
item {
|
||||
TvMediaDetailHeader(
|
||||
artworkImageUrl = artworkImageUrl,
|
||||
artworkWidth = artworkWidth,
|
||||
artworkAspectRatio = artworkAspectRatio,
|
||||
headerHeightFraction = headerHeightFraction,
|
||||
heroContent = heroContent
|
||||
)
|
||||
}
|
||||
bodyContent(contentPadding)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun TvMediaDetailHeader(
|
||||
artworkImageUrl: String,
|
||||
artworkWidth: Dp,
|
||||
artworkAspectRatio: Float,
|
||||
headerHeightFraction: Float,
|
||||
heroContent: @Composable ColumnScope.() -> Unit
|
||||
internal fun TvMediaDetailBodyBox(
|
||||
backgroundImageUrl: String,
|
||||
modifier: Modifier = Modifier,
|
||||
heightFraction: Float = 0.48f,
|
||||
content: @Composable ColumnScope.() -> Unit
|
||||
) {
|
||||
val scheme = MaterialTheme.colorScheme
|
||||
val screenHeight = LocalConfiguration.current.screenHeightDp.dp
|
||||
val headerHeight = screenHeight * headerHeightFraction
|
||||
val bodyHeight = screenHeight * heightFraction
|
||||
|
||||
BoxWithConstraints(
|
||||
modifier = Modifier
|
||||
Box(
|
||||
modifier = modifier
|
||||
.fillMaxWidth()
|
||||
.heightIn(min = headerHeight)
|
||||
.heightIn(min = bodyHeight)
|
||||
) {
|
||||
val contentMaxWidth = (
|
||||
maxWidth -
|
||||
(MediaDetailHorizontalPadding * 2) -
|
||||
artworkWidth -
|
||||
MediaDetailContentArtworkGap
|
||||
).coerceAtLeast(MediaDetailMinimumContentWidth)
|
||||
|
||||
Box(modifier = Modifier.fillMaxWidth()) {
|
||||
TvMediaDetailCornerArtwork(
|
||||
artworkImageUrl = artworkImageUrl,
|
||||
artworkWidth = artworkWidth,
|
||||
artworkAspectRatio = artworkAspectRatio,
|
||||
Box(modifier = Modifier.matchParentSize()) {
|
||||
PurefinAsyncImage(
|
||||
model = backgroundImageUrl,
|
||||
contentDescription = null,
|
||||
modifier = Modifier
|
||||
.align(Alignment.TopEnd)
|
||||
.padding(
|
||||
top = MediaDetailCornerArtworkTopPadding,
|
||||
end = MediaDetailHorizontalPadding
|
||||
.fillMaxHeight()
|
||||
.fillMaxWidth(MediaDetailBodyImageWidthFraction)
|
||||
.align(Alignment.TopEnd),
|
||||
contentScale = ContentScale.Crop
|
||||
)
|
||||
}
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.matchParentSize()
|
||||
.background(
|
||||
Brush.horizontalGradient(
|
||||
colorStops = arrayOf(
|
||||
0.0f to scheme.background,
|
||||
0.28f to scheme.background.copy(alpha = 0.88f),
|
||||
0.62f to scheme.background.copy(alpha = 0.42f),
|
||||
1.0f to scheme.background.copy(alpha = 0.12f)
|
||||
)
|
||||
)
|
||||
)
|
||||
)
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.matchParentSize()
|
||||
.background(
|
||||
Brush.verticalGradient(
|
||||
colorStops = arrayOf(
|
||||
0.0f to scheme.background.copy(alpha = 0.08f),
|
||||
0.55f to scheme.background.copy(alpha = 0.16f),
|
||||
1.0f to scheme.background
|
||||
)
|
||||
)
|
||||
)
|
||||
)
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.align(Alignment.TopStart)
|
||||
.fillMaxWidth()
|
||||
.padding(
|
||||
start = MediaDetailHorizontalPadding,
|
||||
top = MediaDetailHeaderTopPadding,
|
||||
end = MediaDetailHorizontalPadding,
|
||||
bottom = MediaDetailHeaderBottomPadding
|
||||
)
|
||||
.widthIn(max = contentMaxWidth)
|
||||
) {
|
||||
heroContent()
|
||||
}
|
||||
content()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun TvMediaDetailCornerArtwork(
|
||||
artworkImageUrl: String,
|
||||
artworkWidth: Dp,
|
||||
artworkAspectRatio: Float,
|
||||
modifier: Modifier = Modifier
|
||||
) {
|
||||
val scheme = MaterialTheme.colorScheme
|
||||
|
||||
Box(
|
||||
modifier = modifier
|
||||
.width(artworkWidth)
|
||||
.aspectRatio(artworkAspectRatio)
|
||||
.clip(MediaDetailCornerArtworkShape)
|
||||
.background(scheme.surfaceVariant.copy(alpha = 0.28f))
|
||||
) {
|
||||
PurefinAsyncImage(
|
||||
model = artworkImageUrl,
|
||||
contentDescription = null,
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.graphicsLayer { alpha = 0.52f },
|
||||
contentScale = ContentScale.Crop
|
||||
)
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.background(
|
||||
Brush.horizontalGradient(
|
||||
colorStops = arrayOf(
|
||||
0.0f to scheme.background.copy(alpha = 0.08f),
|
||||
0.55f to scheme.background.copy(alpha = 0.2f),
|
||||
1.0f to scheme.background.copy(alpha = 0.56f)
|
||||
)
|
||||
)
|
||||
)
|
||||
)
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.background(
|
||||
Brush.verticalGradient(
|
||||
colorStops = arrayOf(
|
||||
0.0f to scheme.background.copy(alpha = 0.06f),
|
||||
0.65f to scheme.background.copy(alpha = 0.22f),
|
||||
1.0f to scheme.background.copy(alpha = 0.74f)
|
||||
)
|
||||
)
|
||||
)
|
||||
)
|
||||
}
|
||||
internal fun tvMediaDetailBackgroundImageUrl(imageUrlPrefix: String?): String {
|
||||
val primaryImageUrl = JellyfinImageHelper.finishImageUrl(imageUrlPrefix, ImageType.PRIMARY)
|
||||
val backdropImageUrl = JellyfinImageHelper.finishImageUrl(imageUrlPrefix, ImageType.BACKDROP)
|
||||
return backdropImageUrl.ifBlank { primaryImageUrl }
|
||||
}
|
||||
|
||||
@Composable
|
||||
|
||||
@@ -68,8 +68,8 @@ fun MediaResumeButton(
|
||||
.border(3.dp, focusBorderColor, focusShape)
|
||||
.clip(focusShape)
|
||||
.onFocusChanged { isFocused = it.isFocused || it.hasFocus }
|
||||
.focusable()
|
||||
.clickable(onClick = onClick)
|
||||
.focusable()
|
||||
) {
|
||||
// Bottom layer: inverted colors (visible for the remaining %)
|
||||
Box(
|
||||
|
||||
@@ -16,12 +16,8 @@ import kotlin.math.roundToInt
|
||||
internal data class TvFocusedHeroModel(
|
||||
val id: UUID,
|
||||
val backdropImageUrl: String,
|
||||
val eyebrowText: String,
|
||||
val title: String,
|
||||
val metadata: List<String>,
|
||||
val watchedText: String?,
|
||||
val progressFraction: Float?,
|
||||
val progressLabel: String?,
|
||||
) {
|
||||
val metadataText: String?
|
||||
get() = metadata.takeIf { it.isNotEmpty() }?.joinToString(" • ")
|
||||
@@ -30,16 +26,16 @@ internal data class TvFocusedHeroModel(
|
||||
internal fun FocusableItem.toTvFocusedHeroModel(): TvFocusedHeroModel {
|
||||
return when (this) {
|
||||
is ContinueWatchingItem -> when (type) {
|
||||
BaseItemKind.MOVIE -> movie!!.toTvFocusedHeroModel(sourceLabel = "Continue watching")
|
||||
BaseItemKind.EPISODE -> episode!!.toTvFocusedHeroModel(sourceLabel = "Continue watching")
|
||||
BaseItemKind.MOVIE -> movie!!.toTvFocusedHeroModel()
|
||||
BaseItemKind.EPISODE -> episode!!.toTvFocusedHeroModel()
|
||||
else -> unsupportedType(type)
|
||||
}
|
||||
|
||||
is NextUpItem -> episode.toTvFocusedHeroModel(sourceLabel = "Next up")
|
||||
is NextUpItem -> episode.toTvFocusedHeroModel()
|
||||
is PosterItem -> when (type) {
|
||||
BaseItemKind.MOVIE -> movie!!.toTvFocusedHeroModel(sourceLabel = "Movie")
|
||||
BaseItemKind.EPISODE -> episode!!.toTvFocusedHeroModel(sourceLabel = "Episode")
|
||||
BaseItemKind.SERIES -> series!!.toTvFocusedHeroModel(sourceLabel = "Series")
|
||||
BaseItemKind.MOVIE -> movie!!.toTvFocusedHeroModel()
|
||||
BaseItemKind.EPISODE -> episode!!.toTvFocusedHeroModel()
|
||||
BaseItemKind.SERIES -> series!!.toTvFocusedHeroModel()
|
||||
else -> unsupportedType(type)
|
||||
}
|
||||
}
|
||||
@@ -50,32 +46,25 @@ internal fun backdropImageUrl(imageUrlPrefix: String?, fallbackImageUrl: String)
|
||||
return backdropImageUrl.ifBlank { fallbackImageUrl }
|
||||
}
|
||||
|
||||
private fun Movie.toTvFocusedHeroModel(sourceLabel: String): TvFocusedHeroModel {
|
||||
val progressFraction = progressFraction(progress)
|
||||
private fun Movie.toTvFocusedHeroModel(): TvFocusedHeroModel {
|
||||
return TvFocusedHeroModel(
|
||||
id = id,
|
||||
backdropImageUrl = backdropImageUrl(
|
||||
imageUrlPrefix = imageUrlPrefix,
|
||||
fallbackImageUrl = JellyfinImageHelper.finishImageUrl(imageUrlPrefix, ImageType.PRIMARY)
|
||||
),
|
||||
eyebrowText = sourceLabel,
|
||||
title = title,
|
||||
metadata = listOf(year, rating, runtime, format).compactMetadata(),
|
||||
watchedText = "Watched".takeIf { watched },
|
||||
progressFraction = progressFraction.takeIf { !watched && it != null },
|
||||
progressLabel = progressLabel(progressFraction).takeIf { !watched && progressFraction != null },
|
||||
)
|
||||
}
|
||||
|
||||
private fun Episode.toTvFocusedHeroModel(sourceLabel: String): TvFocusedHeroModel {
|
||||
val progressFraction = progressFraction(progress)
|
||||
private fun Episode.toTvFocusedHeroModel(): TvFocusedHeroModel {
|
||||
return TvFocusedHeroModel(
|
||||
id = id,
|
||||
backdropImageUrl = backdropImageUrl(
|
||||
imageUrlPrefix = imageUrlPrefix,
|
||||
fallbackImageUrl = JellyfinImageHelper.finishImageUrl(imageUrlPrefix, ImageType.PRIMARY)
|
||||
),
|
||||
eyebrowText = sourceLabel,
|
||||
title = title,
|
||||
metadata = listOf(
|
||||
"Episode $index",
|
||||
@@ -84,13 +73,10 @@ private fun Episode.toTvFocusedHeroModel(sourceLabel: String): TvFocusedHeroMode
|
||||
rating,
|
||||
format
|
||||
).compactMetadata(),
|
||||
watchedText = "Watched".takeIf { watched },
|
||||
progressFraction = progressFraction.takeIf { !watched && it != null },
|
||||
progressLabel = progressLabel(progressFraction).takeIf { !watched && progressFraction != null },
|
||||
)
|
||||
}
|
||||
|
||||
private fun Series.toTvFocusedHeroModel(sourceLabel: String): TvFocusedHeroModel {
|
||||
private fun Series.toTvFocusedHeroModel(): TvFocusedHeroModel {
|
||||
val unwatchedText = if (unwatchedEpisodeCount > 0) {
|
||||
"$unwatchedEpisodeCount unwatched"
|
||||
} else {
|
||||
@@ -103,12 +89,8 @@ private fun Series.toTvFocusedHeroModel(sourceLabel: String): TvFocusedHeroModel
|
||||
imageUrlPrefix = imageUrlPrefix,
|
||||
fallbackImageUrl = JellyfinImageHelper.finishImageUrl(imageUrlPrefix, ImageType.PRIMARY)
|
||||
),
|
||||
eyebrowText = sourceLabel,
|
||||
title = name,
|
||||
metadata = listOf(year, seasonLabel(seasonCount), unwatchedText).compactMetadata(),
|
||||
watchedText = null,
|
||||
progressFraction = null,
|
||||
progressLabel = null,
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@@ -3,39 +3,29 @@ package hu.bbara.purefin.tv.home.ui
|
||||
import androidx.compose.animation.Crossfade
|
||||
import androidx.compose.animation.core.tween
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.border
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.PaddingValues
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.width
|
||||
import androidx.compose.foundation.layout.widthIn
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.clip
|
||||
import androidx.compose.ui.graphics.Brush
|
||||
import androidx.compose.ui.layout.ContentScale
|
||||
import androidx.compose.ui.platform.testTag
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.text.style.TextAlign
|
||||
import androidx.compose.ui.text.style.TextOverflow
|
||||
import androidx.compose.ui.unit.Dp
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.sp
|
||||
import hu.bbara.purefin.common.ui.components.MediaProgressBar
|
||||
import hu.bbara.purefin.common.ui.components.PurefinAsyncImage
|
||||
|
||||
internal const val TvHomeHeroTitleTag = "tv-home-hero-title"
|
||||
internal const val TvHomeHeroStatusTag = "tv-home-hero-status"
|
||||
internal const val TvHomeHeroProgressLabelTag = "tv-home-hero-progress-label"
|
||||
|
||||
private const val TvHomeHeroAnimationMillis = 180
|
||||
@@ -107,13 +97,6 @@ internal fun TvFocusedItemHero(
|
||||
verticalArrangement = Arrangement.spacedBy(8.dp),
|
||||
modifier = Modifier.widthIn(max = 720.dp)
|
||||
) {
|
||||
Text(
|
||||
text = hero.eyebrowText.uppercase(),
|
||||
color = scheme.primary,
|
||||
fontSize = 10.sp,
|
||||
letterSpacing = 1.2.sp,
|
||||
fontWeight = FontWeight.Bold
|
||||
)
|
||||
Text(
|
||||
text = hero.title,
|
||||
color = scheme.onBackground,
|
||||
@@ -134,54 +117,6 @@ internal fun TvFocusedItemHero(
|
||||
overflow = TextOverflow.Ellipsis
|
||||
)
|
||||
}
|
||||
if (hero.watchedText != null || hero.progressFraction != null) {
|
||||
Row(
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.spacedBy(10.dp),
|
||||
modifier = Modifier
|
||||
.clip(RoundedCornerShape(22.dp))
|
||||
.background(scheme.surfaceContainerHigh.copy(alpha = 0.92f))
|
||||
.border(1.dp, scheme.outlineVariant.copy(alpha = 0.6f), RoundedCornerShape(22.dp))
|
||||
.padding(horizontal = 12.dp, vertical = 10.dp)
|
||||
) {
|
||||
hero.watchedText?.let { watchedText ->
|
||||
TvHomeMetaChip(
|
||||
text = watchedText,
|
||||
highlighted = true,
|
||||
modifier = Modifier.testTag(TvHomeHeroStatusTag)
|
||||
)
|
||||
}
|
||||
if (hero.progressFraction != null && hero.progressLabel != null) {
|
||||
Column(
|
||||
verticalArrangement = Arrangement.spacedBy(4.dp),
|
||||
modifier = Modifier.width(188.dp)
|
||||
) {
|
||||
Text(
|
||||
text = "Progress",
|
||||
color = scheme.onSurfaceVariant.copy(alpha = 0.85f),
|
||||
fontSize = 10.sp,
|
||||
fontWeight = FontWeight.Medium
|
||||
)
|
||||
MediaProgressBar(
|
||||
progress = hero.progressFraction,
|
||||
foregroundColor = scheme.primary,
|
||||
backgroundColor = scheme.surfaceVariant,
|
||||
contentPadding = PaddingValues(0.dp),
|
||||
barHeight = 6.dp,
|
||||
modifier = Modifier
|
||||
)
|
||||
}
|
||||
Text(
|
||||
text = hero.progressLabel,
|
||||
color = scheme.onBackground,
|
||||
fontSize = 14.sp,
|
||||
fontWeight = FontWeight.SemiBold,
|
||||
textAlign = TextAlign.End,
|
||||
modifier = Modifier.testTag(TvHomeHeroProgressLabelTag)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,63 @@
|
||||
@file:OptIn(androidx.compose.foundation.ExperimentalFoundationApi::class)
|
||||
|
||||
package hu.bbara.purefin.tv.home.ui
|
||||
|
||||
import androidx.compose.foundation.gestures.BringIntoViewSpec
|
||||
import androidx.compose.ui.unit.dp
|
||||
import kotlin.math.abs
|
||||
|
||||
internal val TvHomeFocusedItemTopOffset = 56.dp
|
||||
internal val TvHomeBringIntoViewTrailingSpace = 120.dp
|
||||
|
||||
private const val TvHomeRowPivotParentFraction = 0.3f
|
||||
private const val TvHomeRowPivotChildFraction = 0f
|
||||
|
||||
internal fun tvHomeColumnBringIntoViewSpec(topOffsetPx: Float): BringIntoViewSpec {
|
||||
return object : BringIntoViewSpec {
|
||||
override fun calculateScrollDistance(
|
||||
offset: Float,
|
||||
size: Float,
|
||||
containerSize: Float,
|
||||
): Float {
|
||||
val leadingEdge = offset
|
||||
val trailingEdge = offset + size
|
||||
val childSize = abs(trailingEdge - leadingEdge)
|
||||
val childSmallerThanParent = childSize <= containerSize
|
||||
val spaceAvailableToShowItem = containerSize - topOffsetPx
|
||||
val targetForLeadingEdge =
|
||||
if (childSmallerThanParent && spaceAvailableToShowItem < childSize) {
|
||||
containerSize - childSize
|
||||
} else {
|
||||
topOffsetPx
|
||||
}
|
||||
|
||||
return leadingEdge - targetForLeadingEdge
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
internal val TvHomeRowBringIntoViewSpec: BringIntoViewSpec =
|
||||
object : BringIntoViewSpec {
|
||||
override fun calculateScrollDistance(
|
||||
offset: Float,
|
||||
size: Float,
|
||||
containerSize: Float,
|
||||
): Float {
|
||||
val leadingEdge = offset
|
||||
val trailingEdge = offset + size
|
||||
val childSize = abs(trailingEdge - leadingEdge)
|
||||
val childSmallerThanParent = childSize <= containerSize
|
||||
val initialTargetForLeadingEdge =
|
||||
TvHomeRowPivotParentFraction * containerSize -
|
||||
(TvHomeRowPivotChildFraction * childSize)
|
||||
val spaceAvailableToShowItem = containerSize - initialTargetForLeadingEdge
|
||||
val targetForLeadingEdge =
|
||||
if (childSmallerThanParent && spaceAvailableToShowItem < childSize) {
|
||||
containerSize - childSize
|
||||
} else {
|
||||
initialTargetForLeadingEdge
|
||||
}
|
||||
|
||||
return leadingEdge - targetForLeadingEdge
|
||||
}
|
||||
}
|
||||
@@ -1,14 +1,18 @@
|
||||
@file:OptIn(androidx.compose.foundation.ExperimentalFoundationApi::class)
|
||||
|
||||
package hu.bbara.purefin.tv.home.ui
|
||||
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.gestures.LocalBringIntoViewSpec
|
||||
import androidx.compose.foundation.layout.PaddingValues
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.lazy.LazyColumn
|
||||
import androidx.compose.foundation.lazy.items
|
||||
import androidx.compose.foundation.lazy.itemsIndexed
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.CompositionLocalProvider
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
@@ -17,10 +21,8 @@ import androidx.compose.runtime.setValue
|
||||
import androidx.compose.runtime.withFrameNanos
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.focus.FocusRequester
|
||||
import androidx.compose.ui.focus.FocusRequester.Companion.FocusRequesterFactory.component1
|
||||
import androidx.compose.ui.focus.FocusRequester.Companion.FocusRequesterFactory.component2
|
||||
import androidx.compose.ui.focus.focusProperties
|
||||
import androidx.compose.ui.focus.focusRequester
|
||||
import androidx.compose.ui.platform.LocalDensity
|
||||
import androidx.compose.ui.platform.testTag
|
||||
import androidx.compose.ui.unit.dp
|
||||
import hu.bbara.purefin.feature.shared.home.ContinueWatchingItem
|
||||
import hu.bbara.purefin.feature.shared.home.FocusableItem
|
||||
@@ -30,6 +32,7 @@ import hu.bbara.purefin.feature.shared.home.PosterItem
|
||||
import org.jellyfin.sdk.model.UUID
|
||||
|
||||
internal const val TvHomeInitialFocusTag = "tv-home-initial-focus-item"
|
||||
internal const val TvHomeContentViewportTag = "tv-home-content-viewport"
|
||||
|
||||
@Composable
|
||||
fun TvHomeContent(
|
||||
@@ -52,14 +55,18 @@ fun TvHomeContent(
|
||||
nextUp = nextUp
|
||||
)
|
||||
val visibleLibraries = itemRegistry.visibleLibraries
|
||||
val (nextUpRef, continueWatchingRef) = remember { FocusRequester.createRefs() }
|
||||
val libraryRefs = remember(visibleLibraries) {
|
||||
visibleLibraries.associate { it.id to FocusRequester() }
|
||||
}
|
||||
val initialFocusRequester = remember { FocusRequester() }
|
||||
val hasContinueWatching = continueWatching.isNotEmpty()
|
||||
val hasNextUp = nextUp.isNotEmpty()
|
||||
val hasLibraries = visibleLibraries.isNotEmpty()
|
||||
val hasVisibleContent = hasContinueWatching || hasNextUp || hasLibraries
|
||||
val firstVisibleLibraryId = visibleLibraries.firstOrNull()?.id
|
||||
val initialFocusRequester = remember { FocusRequester() }
|
||||
val firstAvailableItemKey = itemRegistry.firstAvailableItemId
|
||||
var initialFocusApplied by remember { mutableStateOf(false) }
|
||||
val topOffsetPx = with(LocalDensity.current) { TvHomeFocusedItemTopOffset.toPx() }
|
||||
val columnBringIntoViewSpec = remember(topOffsetPx) {
|
||||
tvHomeColumnBringIntoViewSpec(topOffsetPx = topOffsetPx)
|
||||
}
|
||||
|
||||
LaunchedEffect(firstAvailableItemKey, initialFocusApplied) {
|
||||
if (!initialFocusApplied && firstAvailableItemKey != null) {
|
||||
@@ -69,81 +76,90 @@ fun TvHomeContent(
|
||||
}
|
||||
}
|
||||
|
||||
CompositionLocalProvider(LocalBringIntoViewSpec provides columnBringIntoViewSpec) {
|
||||
LazyColumn(
|
||||
modifier = modifier
|
||||
.fillMaxSize()
|
||||
.background(scheme.background),
|
||||
.background(scheme.background)
|
||||
.testTag(TvHomeContentViewportTag),
|
||||
contentPadding = contentPadding
|
||||
) {
|
||||
item {
|
||||
Spacer(modifier = Modifier.height(8.dp))
|
||||
}
|
||||
if (hasContinueWatching) {
|
||||
item {
|
||||
TvContinueWatchingSection(
|
||||
items = continueWatching,
|
||||
onFocusedItem = onMediaFocused,
|
||||
onMovieSelected = onMovieSelected,
|
||||
onEpisodeSelected = onEpisodeSelected,
|
||||
firstItemFocusRequester = initialFocusRequester.takeIf { continueWatching.isNotEmpty() },
|
||||
firstItemTestTag = TvHomeInitialFocusTag.takeIf { continueWatching.isNotEmpty() },
|
||||
modifier = Modifier.focusRequester(continueWatchingRef)
|
||||
.focusProperties {
|
||||
down = nextUpRef
|
||||
}
|
||||
firstItemFocusRequester = initialFocusRequester,
|
||||
firstItemTestTag = TvHomeInitialFocusTag,
|
||||
rowTestTag = TvHomeContinueWatchingRowTag
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
if (hasContinueWatching && (hasNextUp || hasLibraries)) {
|
||||
item {
|
||||
Spacer(modifier = Modifier.height(20.dp))
|
||||
}
|
||||
}
|
||||
|
||||
if (hasNextUp) {
|
||||
item {
|
||||
TvNextUpSection(
|
||||
items = nextUp,
|
||||
onFocusedItem = onMediaFocused,
|
||||
onEpisodeSelected = onEpisodeSelected,
|
||||
firstItemFocusRequester = initialFocusRequester
|
||||
.takeIf { continueWatching.isEmpty() && nextUp.isNotEmpty() },
|
||||
firstItemTestTag = TvHomeInitialFocusTag
|
||||
.takeIf { continueWatching.isEmpty() && nextUp.isNotEmpty() },
|
||||
modifier = Modifier.focusRequester(nextUpRef)
|
||||
.focusProperties {
|
||||
up = continueWatchingRef
|
||||
libraryRefs.values.firstOrNull()?.let { down = it }
|
||||
}
|
||||
firstItemFocusRequester = initialFocusRequester.takeIf { !hasContinueWatching },
|
||||
firstItemTestTag = TvHomeInitialFocusTag.takeIf { !hasContinueWatching },
|
||||
rowTestTag = TvHomeNextUpRowTag
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
if (hasLibraries && (hasContinueWatching || hasNextUp)) {
|
||||
item {
|
||||
Spacer(modifier = Modifier.height(20.dp))
|
||||
}
|
||||
items(
|
||||
}
|
||||
|
||||
itemsIndexed(
|
||||
items = visibleLibraries,
|
||||
key = { it.id }
|
||||
) { item ->
|
||||
val ref = libraryRefs[item.id]
|
||||
key = { _, library -> library.id }
|
||||
) { index, library ->
|
||||
TvLibraryPosterSection(
|
||||
title = item.name,
|
||||
items = libraryContent[item.id] ?: emptyList(),
|
||||
title = library.name,
|
||||
items = libraryContent[library.id].orEmpty(),
|
||||
onFocusedItem = onMediaFocused,
|
||||
onMovieSelected = onMovieSelected,
|
||||
onSeriesSelected = onSeriesSelected,
|
||||
onEpisodeSelected = onEpisodeSelected,
|
||||
firstItemFocusRequester = initialFocusRequester.takeIf {
|
||||
continueWatching.isEmpty() &&
|
||||
nextUp.isEmpty() &&
|
||||
item.id == firstVisibleLibraryId
|
||||
!hasContinueWatching &&
|
||||
!hasNextUp &&
|
||||
library.id == firstVisibleLibraryId
|
||||
},
|
||||
firstItemTestTag = TvHomeInitialFocusTag.takeIf {
|
||||
continueWatching.isEmpty() &&
|
||||
nextUp.isEmpty() &&
|
||||
item.id == firstVisibleLibraryId
|
||||
!hasContinueWatching &&
|
||||
!hasNextUp &&
|
||||
library.id == firstVisibleLibraryId
|
||||
},
|
||||
modifier = (if (ref != null) Modifier.focusRequester(ref) else Modifier)
|
||||
.focusProperties {
|
||||
up = nextUpRef
|
||||
libraryRefs.values.dropWhile { it != ref }.drop(1).firstOrNull()
|
||||
?.let { down = it }
|
||||
}
|
||||
rowTestTag = tvHomeLibraryRowTag(library.id),
|
||||
onMovieSelected = onMovieSelected,
|
||||
onSeriesSelected = onSeriesSelected,
|
||||
onEpisodeSelected = onEpisodeSelected
|
||||
)
|
||||
|
||||
if (index < visibleLibraries.lastIndex) {
|
||||
Spacer(modifier = Modifier.height(20.dp))
|
||||
}
|
||||
}
|
||||
|
||||
if (hasVisibleContent) {
|
||||
item {
|
||||
Spacer(modifier = Modifier.height(TvHomeBringIntoViewTrailingSpace))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,9 +1,11 @@
|
||||
package hu.bbara.purefin.tv.home.ui
|
||||
|
||||
import androidx.compose.animation.core.animateFloatAsState
|
||||
import androidx.compose.foundation.ExperimentalFoundationApi
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.border
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.gestures.LocalBringIntoViewSpec
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Column
|
||||
@@ -15,12 +17,14 @@ import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.width
|
||||
import androidx.compose.foundation.layout.wrapContentHeight
|
||||
import androidx.compose.foundation.lazy.LazyListScope
|
||||
import androidx.compose.foundation.lazy.LazyRow
|
||||
import androidx.compose.foundation.lazy.itemsIndexed
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.CompositionLocalProvider
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
@@ -57,6 +61,12 @@ private val TvHomeSectionsHorizontalPadding = 32.dp
|
||||
private val TvHomeSectionsRowSpacing = 18.dp
|
||||
private val TvHomeLandscapeCardWidth = 248.dp
|
||||
private val TvHomePosterCardWidth = 136.dp
|
||||
internal const val TvHomeSectionRowTagPrefix = "tv-home-section-row-"
|
||||
internal const val TvHomeContinueWatchingRowTag = "${TvHomeSectionRowTagPrefix}continue-watching"
|
||||
internal const val TvHomeNextUpRowTag = "${TvHomeSectionRowTagPrefix}next-up"
|
||||
|
||||
internal fun tvHomeLibraryRowTag(libraryId: UUID): String =
|
||||
"${TvHomeSectionRowTagPrefix}library-$libraryId"
|
||||
|
||||
@Composable
|
||||
fun TvContinueWatchingSection(
|
||||
@@ -66,16 +76,16 @@ fun TvContinueWatchingSection(
|
||||
onEpisodeSelected: (UUID, UUID, UUID) -> Unit,
|
||||
firstItemFocusRequester: FocusRequester? = null,
|
||||
firstItemTestTag: String? = null,
|
||||
rowTestTag: String? = null,
|
||||
modifier: Modifier = Modifier
|
||||
) {
|
||||
if (items.isEmpty()) return
|
||||
TvSectionHeader(
|
||||
title = "Continue Watching",
|
||||
)
|
||||
LazyRow(
|
||||
modifier = modifier.fillMaxWidth(),
|
||||
contentPadding = PaddingValues(horizontal = TvHomeSectionsHorizontalPadding),
|
||||
horizontalArrangement = Arrangement.spacedBy(TvHomeSectionsRowSpacing)
|
||||
TvHomeSectionRow(
|
||||
modifier = modifier,
|
||||
rowTestTag = rowTestTag
|
||||
) {
|
||||
itemsIndexed(items = items, key = { _, item -> item.id }) { index, item ->
|
||||
val progressFraction = (item.progress / 100.0).toFloat().coerceIn(0f, 1f)
|
||||
@@ -135,16 +145,16 @@ fun TvNextUpSection(
|
||||
onEpisodeSelected: (UUID, UUID, UUID) -> Unit,
|
||||
firstItemFocusRequester: FocusRequester? = null,
|
||||
firstItemTestTag: String? = null,
|
||||
rowTestTag: String? = null,
|
||||
modifier: Modifier = Modifier
|
||||
) {
|
||||
if (items.isEmpty()) return
|
||||
TvSectionHeader(
|
||||
title = "Next Up",
|
||||
)
|
||||
LazyRow(
|
||||
modifier = modifier.fillMaxWidth(),
|
||||
contentPadding = PaddingValues(horizontal = TvHomeSectionsHorizontalPadding),
|
||||
horizontalArrangement = Arrangement.spacedBy(TvHomeSectionsRowSpacing)
|
||||
TvHomeSectionRow(
|
||||
modifier = modifier,
|
||||
rowTestTag = rowTestTag
|
||||
) {
|
||||
itemsIndexed(items = items, key = { _, item -> item.id }) { index, item ->
|
||||
TvHomeLandscapeCard(
|
||||
@@ -183,6 +193,7 @@ fun TvLibraryPosterSection(
|
||||
onFocusedItem: (FocusableItem) -> Unit = {},
|
||||
firstItemFocusRequester: FocusRequester? = null,
|
||||
firstItemTestTag: String? = null,
|
||||
rowTestTag: String? = null,
|
||||
modifier: Modifier = Modifier,
|
||||
onMovieSelected: (UUID) -> Unit,
|
||||
onSeriesSelected: (UUID) -> Unit,
|
||||
@@ -191,10 +202,9 @@ fun TvLibraryPosterSection(
|
||||
TvSectionHeader(
|
||||
title = title,
|
||||
)
|
||||
LazyRow(
|
||||
modifier = modifier.fillMaxWidth(),
|
||||
contentPadding = PaddingValues(horizontal = TvHomeSectionsHorizontalPadding),
|
||||
horizontalArrangement = Arrangement.spacedBy(TvHomeSectionsRowSpacing)
|
||||
TvHomeSectionRow(
|
||||
modifier = modifier,
|
||||
rowTestTag = rowTestTag
|
||||
) {
|
||||
itemsIndexed(items = items, key = { _, item -> item.id }) { index, item ->
|
||||
PosterCard(
|
||||
@@ -227,6 +237,31 @@ fun TvLibraryPosterSection(
|
||||
}
|
||||
}
|
||||
|
||||
@OptIn(ExperimentalFoundationApi::class)
|
||||
@Composable
|
||||
private fun TvHomeSectionRow(
|
||||
modifier: Modifier = Modifier,
|
||||
rowTestTag: String? = null,
|
||||
content: LazyListScope.() -> Unit,
|
||||
) {
|
||||
CompositionLocalProvider(LocalBringIntoViewSpec provides TvHomeRowBringIntoViewSpec) {
|
||||
LazyRow(
|
||||
modifier = modifier
|
||||
.fillMaxWidth()
|
||||
.then(
|
||||
if (rowTestTag != null) {
|
||||
Modifier.testTag(rowTestTag)
|
||||
} else {
|
||||
Modifier
|
||||
}
|
||||
),
|
||||
contentPadding = PaddingValues(horizontal = TvHomeSectionsHorizontalPadding),
|
||||
horizontalArrangement = Arrangement.spacedBy(TvHomeSectionsRowSpacing),
|
||||
content = content
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun TvSectionHeader(
|
||||
title: String,
|
||||
|
||||
@@ -623,11 +623,20 @@ private fun TvPlayerStateCard(
|
||||
verticalArrangement = Arrangement.spacedBy(16.dp)
|
||||
) {
|
||||
Text(
|
||||
text = uiState.error ?: "Playback error",
|
||||
text = uiState.error?.summary ?: "Playback error",
|
||||
color = scheme.onBackground,
|
||||
fontWeight = FontWeight.Bold,
|
||||
style = MaterialTheme.typography.titleMedium
|
||||
)
|
||||
uiState.error?.detailText?.let { detail ->
|
||||
Text(
|
||||
text = detail,
|
||||
color = scheme.onBackground.copy(alpha = 0.82f),
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
maxLines = 4,
|
||||
overflow = TextOverflow.Ellipsis
|
||||
)
|
||||
}
|
||||
Row(horizontalArrangement = Arrangement.spacedBy(12.dp)) {
|
||||
Button(onClick = onRetry) { Text("Retry") }
|
||||
Button(
|
||||
|
||||
@@ -17,6 +17,7 @@ import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.clip
|
||||
import androidx.compose.ui.text.style.TextOverflow
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.unit.dp
|
||||
import hu.bbara.purefin.core.player.model.PlayerUiState
|
||||
@@ -46,10 +47,19 @@ fun PlayerLoadingErrorEndCard(
|
||||
verticalArrangement = Arrangement.spacedBy(12.dp)
|
||||
) {
|
||||
Text(
|
||||
text = uiState.error ?: "Playback error",
|
||||
text = uiState.error?.summary ?: "Playback error",
|
||||
color = scheme.onBackground,
|
||||
fontWeight = FontWeight.Bold
|
||||
)
|
||||
uiState.error?.detailText?.let { detail ->
|
||||
Text(
|
||||
text = detail,
|
||||
color = scheme.onBackground.copy(alpha = 0.8f),
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
maxLines = 4,
|
||||
overflow = TextOverflow.Ellipsis
|
||||
)
|
||||
}
|
||||
Row(horizontalArrangement = Arrangement.spacedBy(12.dp)) {
|
||||
Button(onClick = onRetry) {
|
||||
Text("Retry")
|
||||
|
||||
@@ -39,4 +39,5 @@ dependencies {
|
||||
implementation(libs.kotlinx.serialization.json)
|
||||
implementation(libs.jellyfin.core)
|
||||
implementation(libs.okhttp)
|
||||
testImplementation(libs.junit)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,15 @@
|
||||
package hu.bbara.purefin.core.player.data
|
||||
|
||||
import androidx.media3.common.MediaItem
|
||||
import hu.bbara.purefin.core.player.model.PlayerError
|
||||
|
||||
sealed interface PlayerMediaLoadResult {
|
||||
data class Success(
|
||||
val mediaItem: MediaItem,
|
||||
val resumePositionMs: Long?
|
||||
) : PlayerMediaLoadResult
|
||||
|
||||
data class Failure(
|
||||
val error: PlayerError
|
||||
) : PlayerMediaLoadResult
|
||||
}
|
||||
@@ -16,6 +16,7 @@ import hu.bbara.purefin.core.data.client.JellyfinApiClient
|
||||
import hu.bbara.purefin.core.data.client.PlaybackReportContext
|
||||
import hu.bbara.purefin.core.data.image.JellyfinImageHelper
|
||||
import hu.bbara.purefin.core.data.session.UserSessionRepository
|
||||
import hu.bbara.purefin.core.player.model.PlayerError
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.flow.first
|
||||
import kotlinx.coroutines.withContext
|
||||
@@ -34,9 +35,34 @@ class PlayerMediaRepository @Inject constructor(
|
||||
private val mediaRepository: MediaRepository,
|
||||
private val downloadManager: DownloadManager
|
||||
) {
|
||||
companion object {
|
||||
private const val TAG = "PlayerMediaRepo"
|
||||
}
|
||||
|
||||
suspend fun getMediaItem(mediaId: UUID, forceTranscode: Boolean = false): Pair<MediaItem, Long?>? = withContext(Dispatchers.IO) {
|
||||
buildOnlineMediaItem(mediaId, forceTranscode) ?: buildOfflineMediaItem(mediaId)
|
||||
suspend fun getMediaItem(mediaId: UUID, forceTranscode: Boolean = false): PlayerMediaLoadResult = withContext(Dispatchers.IO) {
|
||||
when (val onlineResult = buildOnlineMediaItem(mediaId, forceTranscode)) {
|
||||
is PlayerMediaLoadResult.Success -> onlineResult
|
||||
is PlayerMediaLoadResult.Failure -> {
|
||||
when (val offlineResult = buildOfflineMediaItem(mediaId)) {
|
||||
is OfflineLoadResult.Success -> {
|
||||
Log.w(
|
||||
TAG,
|
||||
"Using offline fallback for $mediaId after online load failure (forceTranscode=$forceTranscode): ${onlineResult.error.detailText ?: onlineResult.error.summary}"
|
||||
)
|
||||
offlineResult.result
|
||||
}
|
||||
|
||||
is OfflineLoadResult.Unavailable -> {
|
||||
val finalError = onlineResult.error.withAdditionalTechnicalDetail(offlineResult.detail)
|
||||
Log.w(
|
||||
TAG,
|
||||
"Unable to resolve media $mediaId (forceTranscode=$forceTranscode): ${finalError.detailText ?: finalError.summary}"
|
||||
)
|
||||
PlayerMediaLoadResult.Failure(finalError)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun calculateResumePosition(
|
||||
@@ -88,17 +114,25 @@ class PlayerMediaRepository @Inject constructor(
|
||||
)
|
||||
}
|
||||
}.getOrElse { error ->
|
||||
Log.w("PlayerMediaRepo", "Unable to load next-up items for $episodeId", error)
|
||||
Log.w(TAG, "Unable to load next-up items for $episodeId", error)
|
||||
emptyList()
|
||||
}
|
||||
}
|
||||
|
||||
private suspend fun buildOnlineMediaItem(mediaId: UUID, forceTranscode: Boolean): Pair<MediaItem, Long?>? {
|
||||
return runCatching {
|
||||
private suspend fun buildOnlineMediaItem(mediaId: UUID, forceTranscode: Boolean): PlayerMediaLoadResult {
|
||||
return try {
|
||||
val playbackDecision = jellyfinApiClient.getPlaybackDecision(
|
||||
mediaId = mediaId,
|
||||
forceTranscode = forceTranscode
|
||||
) ?: return null
|
||||
) ?: run {
|
||||
val detail = if (forceTranscode) {
|
||||
"Jellyfin did not return a transcoded playback source."
|
||||
} else {
|
||||
"Jellyfin did not return a playable media source."
|
||||
}
|
||||
Log.w(TAG, "No playback decision for $mediaId (forceTranscode=$forceTranscode)")
|
||||
return PlayerMediaLoadResult.Failure(PlayerError.loadFailure(technicalDetail = detail))
|
||||
}
|
||||
val selectedMediaSource = playbackDecision.mediaSource
|
||||
val baseItem = jellyfinApiClient.getItemInfo(mediaId)
|
||||
|
||||
@@ -117,18 +151,19 @@ class PlayerMediaRepository @Inject constructor(
|
||||
tag = playbackDecision.reportContext
|
||||
)
|
||||
|
||||
mediaItem to resumePositionMs
|
||||
}.getOrElse { error ->
|
||||
Log.w("PlayerMediaRepo", "Falling back to offline playback for $mediaId", error)
|
||||
null
|
||||
PlayerMediaLoadResult.Success(mediaItem, resumePositionMs)
|
||||
} catch (error: Exception) {
|
||||
Log.w(TAG, "Online load failed for $mediaId (forceTranscode=$forceTranscode)", error)
|
||||
PlayerMediaLoadResult.Failure(PlayerError.fromThrowable(error))
|
||||
}
|
||||
}
|
||||
|
||||
@OptIn(UnstableApi::class)
|
||||
private suspend fun buildOfflineMediaItem(mediaId: UUID): Pair<MediaItem, Long?>? {
|
||||
private suspend fun buildOfflineMediaItem(mediaId: UUID): OfflineLoadResult {
|
||||
return try {
|
||||
val download = downloadManager.downloadIndex.getDownload(mediaId.toString())
|
||||
?.takeIf { it.state == Download.STATE_COMPLETED }
|
||||
?: return null
|
||||
?: return OfflineLoadResult.Unavailable("Offline fallback unavailable: no completed download.")
|
||||
|
||||
val serverUrl = userSessionRepository.serverUrl.first()
|
||||
val movie = mediaRepository.movies.value[mediaId]
|
||||
@@ -152,7 +187,14 @@ class PlayerMediaRepository @Inject constructor(
|
||||
subtitle = subtitle,
|
||||
artworkUrl = artworkUrl,
|
||||
)
|
||||
return mediaItem to resumePositionMs
|
||||
OfflineLoadResult.Success(PlayerMediaLoadResult.Success(mediaItem, resumePositionMs))
|
||||
} catch (error: Exception) {
|
||||
Log.w(TAG, "Offline fallback failed for $mediaId", error)
|
||||
val technicalDetail = PlayerError.fromThrowable(error).detailText ?: error.javaClass.simpleName
|
||||
OfflineLoadResult.Unavailable(
|
||||
"Offline fallback failed: $technicalDetail"
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
private fun resumePositionFor(movie: hu.bbara.purefin.core.model.Movie?, episode: hu.bbara.purefin.core.model.Episode?): Long? {
|
||||
@@ -211,7 +253,7 @@ class PlayerMediaRepository @Inject constructor(
|
||||
"$baseUrl/Videos/$mediaId/$mediaSourceId/Subtitles/${stream.index}/0/Stream.$format"
|
||||
}
|
||||
|
||||
Log.d("PlayerMediaRepo", "External subtitle: ${stream.displayTitle} ($codec) -> $url")
|
||||
Log.d(TAG, "External subtitle: ${stream.displayTitle} ($codec) -> $url")
|
||||
|
||||
MediaItem.SubtitleConfiguration.Builder(url.toUri())
|
||||
.setMimeType(mimeType)
|
||||
@@ -235,7 +277,7 @@ class PlayerMediaRepository @Inject constructor(
|
||||
"sub", "microdvd" -> MimeTypes.APPLICATION_SUBRIP // sub often converted to srt by Jellyfin
|
||||
"pgs", "pgssub" -> MimeTypes.APPLICATION_PGS
|
||||
else -> {
|
||||
Log.w("PlayerMediaRepo", "Unknown subtitle codec: $codec")
|
||||
Log.w(TAG, "Unknown subtitle codec: $codec")
|
||||
null
|
||||
}
|
||||
}
|
||||
@@ -262,4 +304,9 @@ class PlayerMediaRepository @Inject constructor(
|
||||
.setSubtitleConfigurations(subtitleConfigurations)
|
||||
.build()
|
||||
}
|
||||
|
||||
private sealed interface OfflineLoadResult {
|
||||
data class Success(val result: PlayerMediaLoadResult.Success) : OfflineLoadResult
|
||||
data class Unavailable(val detail: String) : OfflineLoadResult
|
||||
}
|
||||
}
|
||||
|
||||
@@ -10,6 +10,7 @@ import androidx.media3.common.Tracks
|
||||
import androidx.media3.common.util.UnstableApi
|
||||
import dagger.hilt.android.scopes.ViewModelScoped
|
||||
import hu.bbara.purefin.core.data.client.PlaybackReportContext
|
||||
import hu.bbara.purefin.core.player.model.PlayerError
|
||||
import hu.bbara.purefin.core.player.model.QueueItemUi
|
||||
import hu.bbara.purefin.core.player.model.TrackOption
|
||||
import hu.bbara.purefin.core.player.model.TrackType
|
||||
@@ -74,8 +75,7 @@ class PlayerManager @Inject constructor(
|
||||
state.copy(
|
||||
isBuffering = buffering,
|
||||
isEnded = ended,
|
||||
error = if (playbackState == Player.STATE_IDLE) state.error else null,
|
||||
errorCode = if (playbackState == Player.STATE_IDLE) state.errorCode else null
|
||||
error = if (playbackState == Player.STATE_IDLE) state.error else null
|
||||
)
|
||||
}
|
||||
if (ended) player.pause()
|
||||
@@ -84,8 +84,7 @@ class PlayerManager @Inject constructor(
|
||||
override fun onPlayerError(error: PlaybackException) {
|
||||
_playbackState.update {
|
||||
it.copy(
|
||||
error = error.errorCodeName ?: error.localizedMessage ?: "Playback error",
|
||||
errorCode = error.errorCode
|
||||
error = PlayerError.fromPlaybackException(error)
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -135,7 +134,7 @@ class PlayerManager @Inject constructor(
|
||||
_progress.value = PlaybackProgressSnapshot()
|
||||
refreshMetadata(mediaItem)
|
||||
refreshQueue()
|
||||
_playbackState.update { it.copy(isEnded = false, error = null, errorCode = null) }
|
||||
_playbackState.update { it.copy(isEnded = false, error = null) }
|
||||
}
|
||||
|
||||
fun replaceCurrentMediaItem(mediaItem: MediaItem, mediaContext: MediaContext? = null, startPositionMs: Long? = null) {
|
||||
@@ -155,7 +154,7 @@ class PlayerManager @Inject constructor(
|
||||
player.playWhenReady = true
|
||||
refreshMetadata(mediaItem)
|
||||
refreshQueue()
|
||||
_playbackState.update { it.copy(isEnded = false, error = null, errorCode = null) }
|
||||
_playbackState.update { it.copy(isEnded = false, error = null) }
|
||||
}
|
||||
|
||||
fun addToQueue(mediaItem: MediaItem) {
|
||||
@@ -259,7 +258,7 @@ class PlayerManager @Inject constructor(
|
||||
}
|
||||
|
||||
fun clearError() {
|
||||
_playbackState.update { it.copy(error = null, errorCode = null) }
|
||||
_playbackState.update { it.copy(error = null) }
|
||||
}
|
||||
|
||||
fun snapshotProgress(): PlaybackProgressSnapshot {
|
||||
@@ -386,8 +385,7 @@ data class PlaybackStateSnapshot(
|
||||
val isPlaying: Boolean = false,
|
||||
val isBuffering: Boolean = false,
|
||||
val isEnded: Boolean = false,
|
||||
val error: String? = null,
|
||||
val errorCode: Int? = null
|
||||
val error: PlayerError? = null
|
||||
)
|
||||
|
||||
data class PlaybackProgressSnapshot(
|
||||
|
||||
@@ -0,0 +1,110 @@
|
||||
package hu.bbara.purefin.core.player.model
|
||||
|
||||
import androidx.media3.common.PlaybackException
|
||||
|
||||
enum class PlayerErrorSource {
|
||||
LOAD,
|
||||
PLAYBACK
|
||||
}
|
||||
|
||||
data class PlayerError(
|
||||
val summary: String,
|
||||
val technicalDetail: String? = null,
|
||||
val source: PlayerErrorSource,
|
||||
val errorCode: Int? = null,
|
||||
val errorCodeName: String? = null,
|
||||
val retryable: Boolean = false
|
||||
) {
|
||||
val detailText: String?
|
||||
get() = technicalDetail?.takeIf { detail ->
|
||||
detail.isNotBlank() && !detail.equals(summary, ignoreCase = true)
|
||||
}
|
||||
|
||||
fun withAdditionalTechnicalDetail(additionalDetail: String?): PlayerError {
|
||||
val normalizedAdditionalDetail = additionalDetail?.trim()?.takeIf { it.isNotEmpty() } ?: return this
|
||||
val mergedDetail = linkedSetOf<String>().apply {
|
||||
technicalDetail?.trim()?.takeIf { it.isNotEmpty() }?.let(::add)
|
||||
add(normalizedAdditionalDetail)
|
||||
}.joinToString(" | ")
|
||||
|
||||
return copy(technicalDetail = mergedDetail)
|
||||
}
|
||||
|
||||
companion object {
|
||||
fun loadFailure(
|
||||
summary: String = "Unable to load media",
|
||||
technicalDetail: String? = null,
|
||||
retryable: Boolean = true
|
||||
): PlayerError {
|
||||
return PlayerError(
|
||||
summary = summary,
|
||||
technicalDetail = technicalDetail,
|
||||
source = PlayerErrorSource.LOAD,
|
||||
retryable = retryable
|
||||
)
|
||||
}
|
||||
|
||||
fun invalidMediaId(mediaId: String): PlayerError {
|
||||
return PlayerError(
|
||||
summary = "Invalid media id",
|
||||
technicalDetail = "The requested media id is not a valid UUID: $mediaId",
|
||||
source = PlayerErrorSource.LOAD,
|
||||
retryable = false
|
||||
)
|
||||
}
|
||||
|
||||
fun fromPlaybackException(error: PlaybackException): PlayerError {
|
||||
return playbackFailure(
|
||||
errorCode = error.errorCode,
|
||||
errorCodeName = error.errorCodeName,
|
||||
technicalDetail = error.localizedMessage,
|
||||
cause = error.cause
|
||||
)
|
||||
}
|
||||
|
||||
fun playbackFailure(
|
||||
errorCode: Int? = null,
|
||||
errorCodeName: String? = null,
|
||||
technicalDetail: String? = null,
|
||||
cause: Throwable? = null,
|
||||
retryable: Boolean = true
|
||||
): PlayerError {
|
||||
val mergedTechnicalDetail = linkedSetOf<String>().apply {
|
||||
errorCodeName?.takeIf { it.isNotBlank() }?.let(::add)
|
||||
technicalDetail?.takeIf { it.isNotBlank() }?.let(::add)
|
||||
cause?.toTechnicalDetail()?.let(::add)
|
||||
}.joinToString(" | ").ifBlank { null }
|
||||
|
||||
return PlayerError(
|
||||
summary = "Playback error",
|
||||
technicalDetail = mergedTechnicalDetail,
|
||||
source = PlayerErrorSource.PLAYBACK,
|
||||
errorCode = errorCode,
|
||||
errorCodeName = errorCodeName,
|
||||
retryable = retryable
|
||||
)
|
||||
}
|
||||
|
||||
fun fromThrowable(
|
||||
throwable: Throwable,
|
||||
summary: String = "Unable to load media",
|
||||
retryable: Boolean = true
|
||||
): PlayerError {
|
||||
return loadFailure(
|
||||
summary = summary,
|
||||
technicalDetail = throwable.toTechnicalDetail(),
|
||||
retryable = retryable
|
||||
)
|
||||
}
|
||||
|
||||
internal fun Throwable.toTechnicalDetail(): String {
|
||||
val typeName = this::class.simpleName ?: javaClass.simpleName.ifBlank { javaClass.name }
|
||||
val message = message?.trim().takeIf { !it.isNullOrEmpty() }
|
||||
return if (message != null) {
|
||||
"$typeName: $message"
|
||||
} else {
|
||||
typeName
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -10,7 +10,7 @@ data class PlayerUiState(
|
||||
val durationMs: Long = 0L,
|
||||
val positionMs: Long = 0L,
|
||||
val bufferedMs: Long = 0L,
|
||||
val error: String? = null,
|
||||
val error: PlayerError? = null,
|
||||
val playbackSpeed: Float = 1f,
|
||||
val chapters: List<TimedMarker> = emptyList(),
|
||||
val ads: List<TimedMarker> = emptyList(),
|
||||
|
||||
@@ -0,0 +1,50 @@
|
||||
package hu.bbara.purefin.core.player.viewmodel
|
||||
|
||||
import androidx.media3.common.PlaybackException
|
||||
import hu.bbara.purefin.core.data.client.PlaybackReportContext
|
||||
import hu.bbara.purefin.core.player.model.PlayerError
|
||||
import hu.bbara.purefin.core.player.model.PlayerErrorSource
|
||||
import org.jellyfin.sdk.model.api.PlayMethod
|
||||
|
||||
internal object PlaybackRetryPolicy {
|
||||
private val retryableErrorCodes = setOf(
|
||||
PlaybackException.ERROR_CODE_DECODER_INIT_FAILED,
|
||||
PlaybackException.ERROR_CODE_DECODER_QUERY_FAILED,
|
||||
PlaybackException.ERROR_CODE_PARSING_CONTAINER_MALFORMED,
|
||||
PlaybackException.ERROR_CODE_PARSING_CONTAINER_UNSUPPORTED
|
||||
)
|
||||
|
||||
fun shouldRetryWithTranscoding(
|
||||
error: PlayerError,
|
||||
playbackReportContext: PlaybackReportContext
|
||||
): Boolean {
|
||||
if (error.source != PlayerErrorSource.PLAYBACK) {
|
||||
return false
|
||||
}
|
||||
if (!playbackReportContext.canRetryWithTranscoding) {
|
||||
return false
|
||||
}
|
||||
if (playbackReportContext.playMethod == PlayMethod.TRANSCODE) {
|
||||
return false
|
||||
}
|
||||
|
||||
val errorCode = error.errorCode
|
||||
if (errorCode != null && errorCode in retryableErrorCodes) {
|
||||
return true
|
||||
}
|
||||
|
||||
val detail = buildString {
|
||||
append(error.summary)
|
||||
error.errorCodeName?.let {
|
||||
append(' ')
|
||||
append(it)
|
||||
}
|
||||
error.detailText?.let {
|
||||
append(' ')
|
||||
append(it)
|
||||
}
|
||||
}.lowercase()
|
||||
|
||||
return "decoder" in detail || "codec" in detail || "unsupported" in detail
|
||||
}
|
||||
}
|
||||
@@ -1,20 +1,20 @@
|
||||
package hu.bbara.purefin.core.player.viewmodel
|
||||
|
||||
import androidx.media3.common.PlaybackException
|
||||
import androidx.lifecycle.SavedStateHandle
|
||||
import androidx.lifecycle.ViewModel
|
||||
import androidx.lifecycle.viewModelScope
|
||||
import dagger.hilt.android.lifecycle.HiltViewModel
|
||||
import hu.bbara.purefin.core.data.MediaRepository
|
||||
import hu.bbara.purefin.core.data.client.PlaybackReportContext
|
||||
import hu.bbara.purefin.core.player.data.PlayerMediaLoadResult
|
||||
import hu.bbara.purefin.core.player.data.PlayerMediaRepository
|
||||
import hu.bbara.purefin.core.player.manager.MediaContext
|
||||
import hu.bbara.purefin.core.player.manager.PlaybackStateSnapshot
|
||||
import hu.bbara.purefin.core.player.manager.PlayerManager
|
||||
import hu.bbara.purefin.core.player.manager.ProgressManager
|
||||
import hu.bbara.purefin.core.player.model.PlayerError
|
||||
import hu.bbara.purefin.core.player.model.PlayerErrorSource
|
||||
import hu.bbara.purefin.core.player.model.PlayerUiState
|
||||
import hu.bbara.purefin.core.player.model.TrackOption
|
||||
import org.jellyfin.sdk.model.api.PlayMethod
|
||||
import kotlinx.coroutines.Job
|
||||
import kotlinx.coroutines.delay
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
@@ -46,9 +46,10 @@ class PlayerViewModel @Inject constructor(
|
||||
|
||||
private var autoHideJob: Job? = null
|
||||
private var lastNextUpMediaId: String? = null
|
||||
private var dataErrorMessage: String? = null
|
||||
private var loadError: PlayerError? = null
|
||||
private var activeMediaId: String? = null
|
||||
private var transcodingRetryMediaId: String? = null
|
||||
private var lastLoadRequest: PendingLoadRequest? = null
|
||||
|
||||
init {
|
||||
progressManager.bind(
|
||||
@@ -72,7 +73,7 @@ class PlayerViewModel @Inject constructor(
|
||||
isPlaying = state.isPlaying,
|
||||
isBuffering = state.isBuffering,
|
||||
isEnded = state.isEnded,
|
||||
error = state.error ?: dataErrorMessage
|
||||
error = state.error ?: loadError
|
||||
)
|
||||
}
|
||||
if (state.isEnded) {
|
||||
@@ -156,16 +157,26 @@ class PlayerViewModel @Inject constructor(
|
||||
startPositionMsOverride: Long?,
|
||||
replaceCurrent: Boolean
|
||||
) {
|
||||
lastLoadRequest = PendingLoadRequest(
|
||||
id = id,
|
||||
forceTranscode = forceTranscode,
|
||||
startPositionMsOverride = startPositionMsOverride,
|
||||
replaceCurrent = replaceCurrent
|
||||
)
|
||||
val uuid = id.toUuidOrNull()
|
||||
if (uuid == null) {
|
||||
dataErrorMessage = "Invalid media id"
|
||||
_uiState.update { it.copy(error = dataErrorMessage) }
|
||||
loadError = PlayerError.invalidMediaId(id)
|
||||
_uiState.update { it.copy(isBuffering = false, error = loadError) }
|
||||
return
|
||||
}
|
||||
loadError = null
|
||||
_uiState.update { it.copy(isBuffering = true, error = null) }
|
||||
viewModelScope.launch {
|
||||
val result = playerMediaRepository.getMediaItem(uuid, forceTranscode = forceTranscode)
|
||||
if (result != null) {
|
||||
val (mediaItem, resumePositionMs) = result
|
||||
when (result) {
|
||||
is PlayerMediaLoadResult.Success -> {
|
||||
val mediaItem = result.mediaItem
|
||||
val resumePositionMs = result.resumePositionMs
|
||||
|
||||
// Determine preference key: movies use their own ID, episodes use series ID
|
||||
val preferenceKey = mediaRepository.episodes.value[uuid]?.seriesId?.toString() ?: id
|
||||
@@ -178,13 +189,16 @@ class PlayerViewModel @Inject constructor(
|
||||
playerManager.play(mediaItem, mediaContext, startPositionMs)
|
||||
}
|
||||
|
||||
if (dataErrorMessage != null) {
|
||||
dataErrorMessage = null
|
||||
if (loadError != null) {
|
||||
loadError = null
|
||||
_uiState.update { it.copy(error = null) }
|
||||
}
|
||||
} else {
|
||||
dataErrorMessage = "Unable to load media"
|
||||
_uiState.update { it.copy(error = dataErrorMessage) }
|
||||
}
|
||||
|
||||
is PlayerMediaLoadResult.Failure -> {
|
||||
loadError = result.error
|
||||
_uiState.update { it.copy(isBuffering = false, error = loadError) }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -192,11 +206,10 @@ class PlayerViewModel @Inject constructor(
|
||||
private fun maybeRetryWithTranscoding(state: PlaybackStateSnapshot): Boolean {
|
||||
val currentMediaId = playerManager.metadata.value.mediaId ?: return false
|
||||
val playbackReportContext = playerManager.metadata.value.playbackReportContext ?: return false
|
||||
val errorCode = state.errorCode ?: return false
|
||||
val error = state.error ?: return false
|
||||
|
||||
if (currentMediaId == transcodingRetryMediaId) return false
|
||||
if (!playbackReportContext.canRetryWithTranscoding) return false
|
||||
if (!isRetryablePlaybackError(errorCode, state.error, playbackReportContext)) return false
|
||||
if (!PlaybackRetryPolicy.shouldRetryWithTranscoding(error, playbackReportContext)) return false
|
||||
|
||||
transcodingRetryMediaId = currentMediaId
|
||||
loadMediaById(
|
||||
@@ -208,29 +221,6 @@ class PlayerViewModel @Inject constructor(
|
||||
return true
|
||||
}
|
||||
|
||||
private fun isRetryablePlaybackError(
|
||||
errorCode: Int,
|
||||
errorMessage: String?,
|
||||
playbackReportContext: PlaybackReportContext
|
||||
): Boolean {
|
||||
if (playbackReportContext.playMethod == PlayMethod.TRANSCODE) {
|
||||
return false
|
||||
}
|
||||
|
||||
if (errorCode in setOf(
|
||||
PlaybackException.ERROR_CODE_DECODER_INIT_FAILED,
|
||||
PlaybackException.ERROR_CODE_DECODER_QUERY_FAILED,
|
||||
PlaybackException.ERROR_CODE_PARSING_CONTAINER_MALFORMED,
|
||||
PlaybackException.ERROR_CODE_PARSING_CONTAINER_UNSUPPORTED
|
||||
)
|
||||
) {
|
||||
return true
|
||||
}
|
||||
|
||||
val message = errorMessage?.lowercase().orEmpty()
|
||||
return "decoder" in message || "codec" in message || "unsupported" in message
|
||||
}
|
||||
|
||||
private fun loadNextUp(currentMediaId: String) {
|
||||
val uuid = currentMediaId.toUuidOrNull() ?: return
|
||||
viewModelScope.launch {
|
||||
@@ -304,6 +294,11 @@ class PlayerViewModel @Inject constructor(
|
||||
}
|
||||
|
||||
fun retry() {
|
||||
val error = uiState.value.error
|
||||
if (error?.source == PlayerErrorSource.LOAD) {
|
||||
retryLoad()
|
||||
return
|
||||
}
|
||||
playerManager.retry()
|
||||
}
|
||||
|
||||
@@ -313,7 +308,7 @@ class PlayerViewModel @Inject constructor(
|
||||
}
|
||||
|
||||
fun clearError() {
|
||||
dataErrorMessage = null
|
||||
loadError = null
|
||||
playerManager.clearError()
|
||||
_uiState.update { it.copy(error = null) }
|
||||
}
|
||||
@@ -326,5 +321,22 @@ class PlayerViewModel @Inject constructor(
|
||||
playerManager.release()
|
||||
}
|
||||
|
||||
private fun String.toUuidOrNull(): UUID? = runCatching { UUID.fromString(this) }.getOrNull()
|
||||
private fun retryLoad() {
|
||||
val request = lastLoadRequest ?: return
|
||||
loadMediaById(
|
||||
id = request.id,
|
||||
forceTranscode = request.forceTranscode,
|
||||
startPositionMsOverride = request.startPositionMsOverride,
|
||||
replaceCurrent = request.replaceCurrent
|
||||
)
|
||||
}
|
||||
|
||||
private fun String.toUuidOrNull(): UUID? = runCatching { UUID.fromString(this) }.getOrNull()
|
||||
|
||||
private data class PendingLoadRequest(
|
||||
val id: String,
|
||||
val forceTranscode: Boolean,
|
||||
val startPositionMsOverride: Long?,
|
||||
val replaceCurrent: Boolean
|
||||
)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,48 @@
|
||||
package hu.bbara.purefin.core.player.model
|
||||
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertFalse
|
||||
import org.junit.Assert.assertNotNull
|
||||
import org.junit.Assert.assertTrue
|
||||
import org.junit.Test
|
||||
|
||||
class PlayerErrorTest {
|
||||
@Test
|
||||
fun `playbackFailure preserves code and detail`() {
|
||||
val playerError = PlayerError.playbackFailure(
|
||||
errorCode = 4001,
|
||||
errorCodeName = "ERROR_CODE_DECODER_INIT_FAILED",
|
||||
technicalDetail = "Decoder init failed on test device"
|
||||
)
|
||||
|
||||
assertEquals("Playback error", playerError.summary)
|
||||
assertEquals(PlayerErrorSource.PLAYBACK, playerError.source)
|
||||
assertEquals(4001, playerError.errorCode)
|
||||
assertEquals("ERROR_CODE_DECODER_INIT_FAILED", playerError.errorCodeName)
|
||||
assertNotNull(playerError.detailText)
|
||||
assertTrue(playerError.detailText!!.contains("ERROR_CODE_DECODER_INIT_FAILED"))
|
||||
assertTrue(playerError.detailText!!.contains("Decoder init failed on test device"))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `withAdditionalTechnicalDetail appends distinct detail`() {
|
||||
val playerError = PlayerError.loadFailure(technicalDetail = "IllegalStateException: boom")
|
||||
|
||||
val mergedError = playerError.withAdditionalTechnicalDetail(
|
||||
"Offline fallback unavailable: no completed download."
|
||||
)
|
||||
|
||||
assertTrue(mergedError.detailText!!.contains("IllegalStateException: boom"))
|
||||
assertTrue(mergedError.detailText!!.contains("Offline fallback unavailable: no completed download."))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `invalidMediaId is a non retryable load error`() {
|
||||
val playerError = PlayerError.invalidMediaId("abc")
|
||||
|
||||
assertEquals("Invalid media id", playerError.summary)
|
||||
assertEquals(PlayerErrorSource.LOAD, playerError.source)
|
||||
assertFalse(playerError.retryable)
|
||||
assertTrue(playerError.detailText!!.contains("abc"))
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,77 @@
|
||||
package hu.bbara.purefin.core.player.viewmodel
|
||||
|
||||
import androidx.media3.common.PlaybackException
|
||||
import hu.bbara.purefin.core.data.client.PlaybackReportContext
|
||||
import hu.bbara.purefin.core.player.model.PlayerError
|
||||
import hu.bbara.purefin.core.player.model.PlayerErrorSource
|
||||
import org.jellyfin.sdk.model.api.PlayMethod
|
||||
import org.junit.Assert.assertFalse
|
||||
import org.junit.Assert.assertTrue
|
||||
import org.junit.Test
|
||||
|
||||
class PlaybackRetryPolicyTest {
|
||||
@Test
|
||||
fun `decoder failures are retried when transcoding is available`() {
|
||||
val error = PlayerError(
|
||||
summary = "Playback error",
|
||||
technicalDetail = "Decoder init failed",
|
||||
source = PlayerErrorSource.PLAYBACK,
|
||||
errorCode = PlaybackException.ERROR_CODE_DECODER_INIT_FAILED,
|
||||
errorCodeName = "ERROR_CODE_DECODER_INIT_FAILED",
|
||||
retryable = true
|
||||
)
|
||||
|
||||
val playbackReportContext = playbackReportContext(playMethod = PlayMethod.DIRECT_PLAY)
|
||||
|
||||
assertTrue(PlaybackRetryPolicy.shouldRetryWithTranscoding(error, playbackReportContext))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `transcoded playback is not retried again`() {
|
||||
val error = PlayerError(
|
||||
summary = "Playback error",
|
||||
technicalDetail = "Unsupported container",
|
||||
source = PlayerErrorSource.PLAYBACK,
|
||||
errorCodeName = "ERROR_CODE_PARSING_CONTAINER_UNSUPPORTED",
|
||||
retryable = true
|
||||
)
|
||||
|
||||
val playbackReportContext = playbackReportContext(playMethod = PlayMethod.TRANSCODE)
|
||||
|
||||
assertFalse(PlaybackRetryPolicy.shouldRetryWithTranscoding(error, playbackReportContext))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `load errors are never retried as transcoding fallbacks`() {
|
||||
val error = PlayerError.loadFailure(technicalDetail = "No playable source was returned.")
|
||||
|
||||
val playbackReportContext = playbackReportContext(playMethod = PlayMethod.DIRECT_PLAY)
|
||||
|
||||
assertFalse(PlaybackRetryPolicy.shouldRetryWithTranscoding(error, playbackReportContext))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `unsupported codec detail is retryable without an error code`() {
|
||||
val error = PlayerError(
|
||||
summary = "Playback error",
|
||||
technicalDetail = "Codec unsupported by this device",
|
||||
source = PlayerErrorSource.PLAYBACK
|
||||
)
|
||||
|
||||
val playbackReportContext = playbackReportContext(playMethod = PlayMethod.DIRECT_STREAM)
|
||||
|
||||
assertTrue(PlaybackRetryPolicy.shouldRetryWithTranscoding(error, playbackReportContext))
|
||||
}
|
||||
|
||||
private fun playbackReportContext(playMethod: PlayMethod): PlaybackReportContext {
|
||||
return PlaybackReportContext(
|
||||
playMethod = playMethod,
|
||||
mediaSourceId = "source-id",
|
||||
audioStreamIndex = 0,
|
||||
subtitleStreamIndex = null,
|
||||
liveStreamId = null,
|
||||
playSessionId = "session-id",
|
||||
canRetryWithTranscoding = true
|
||||
)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user