Compare commits

...

8 Commits

Author SHA1 Message Date
b91a8a81e8 Fix TV detail playback single-press behavior 2026-04-06 20:38:05 +02:00
9e8772467c Improve player error reporting 2026-04-06 20:20:44 +02:00
60291dc78e Keep TV detail hero visible on initial focus 2026-04-06 19:54:01 +02:00
0870e8aee4 Remove status and progress overlays from TV hero section
Removes eyebrow text, watched status chips, and progress bars from the
focused hero component on the TV home screen. The underlying model and
UI are simplified to display only the title and basic metadata.
2026-04-06 19:38:27 +02:00
f0bb37d8ec refactor: adjust image layout in TvMediaDetailScaffold for improved responsiveness 2026-04-06 19:28:55 +02:00
34ae15d645 Move TV detail hero content into body 2026-04-06 19:12:52 +02:00
9083a08f95 Refactor TV media detail backdrop layout 2026-04-06 19:00:24 +02:00
32d50f750f Use BringIntoViewSpec for TV home focus alignment 2026-04-06 18:38:20 +02:00
27 changed files with 1244 additions and 527 deletions

View File

@@ -6,16 +6,17 @@ import androidx.compose.ui.test.assertCountEquals
import androidx.compose.ui.test.assertIsDisplayed import androidx.compose.ui.test.assertIsDisplayed
import androidx.compose.ui.test.assertIsFocused import androidx.compose.ui.test.assertIsFocused
import androidx.compose.ui.test.junit4.createAndroidComposeRule 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.onAllNodesWithText
import androidx.compose.ui.test.onNodeWithContentDescription import androidx.compose.ui.test.onRoot
import androidx.compose.ui.test.onNodeWithTag import androidx.compose.ui.test.onNodeWithTag
import androidx.compose.ui.test.onNodeWithText 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 androidx.compose.ui.input.key.Key
import hu.bbara.purefin.core.model.CastMember import hu.bbara.purefin.core.model.CastMember
import hu.bbara.purefin.core.model.Episode import hu.bbara.purefin.core.model.Episode
import hu.bbara.purefin.ui.theme.AppTheme import hu.bbara.purefin.ui.theme.AppTheme
import org.junit.Assert.assertEquals
import org.junit.Rule import org.junit.Rule
import org.junit.Test import org.junit.Test
import java.util.UUID import java.util.UUID
@@ -27,13 +28,12 @@ class EpisodeScreenContentTest {
val composeRule = createAndroidComposeRule<ComponentActivity>() val composeRule = createAndroidComposeRule<ComponentActivity>()
@Test @Test
fun episodeScreenContent_showsSeriesContext_andMovesFromBackToPlayButton() { fun episodeScreenContent_showsSeriesContext_andFocusesPlayButton() {
composeRule.setContent { composeRule.setContent {
AppTheme { AppTheme {
EpisodeScreenContent( EpisodeScreenContent(
episode = sampleEpisode(progress = 63.0), episode = sampleEpisode(progress = 63.0),
seriesTitle = "Severance", seriesTitle = "Severance",
onBack = {},
onPlay = {} onPlay = {}
) )
} }
@@ -48,12 +48,6 @@ class EpisodeScreenContentTest {
composeRule.onAllNodesWithText("Playback").assertCountEquals(1) composeRule.onAllNodesWithText("Playback").assertCountEquals(1)
composeRule.onNodeWithTag(EpisodePlayButtonTag).assertIsDisplayed() composeRule.onNodeWithTag(EpisodePlayButtonTag).assertIsDisplayed()
composeRule.onNodeWithText("Series").assertIsDisplayed() composeRule.onNodeWithText("Series").assertIsDisplayed()
composeRule.onNodeWithContentDescription("Back")
.assertIsDisplayed()
.assertIsFocused()
.performKeyInput {
pressKey(Key.DirectionDown)
}
composeRule.onNodeWithTag(EpisodePlayButtonTag).assertIsFocused() composeRule.onNodeWithTag(EpisodePlayButtonTag).assertIsFocused()
} }
@@ -64,7 +58,6 @@ class EpisodeScreenContentTest {
EpisodeScreenContent( EpisodeScreenContent(
episode = sampleEpisode(progress = null), episode = sampleEpisode(progress = null),
seriesTitle = "Severance", seriesTitle = "Severance",
onBack = {},
onPlay = {} onPlay = {}
) )
} }
@@ -78,6 +71,32 @@ class EpisodeScreenContentTest {
composeRule.onAllNodesWithText("Series").assertCountEquals(0) 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 { private fun sampleEpisode(progress: Double?): Episode {
val seriesId = UUID.fromString("11111111-1111-1111-1111-111111111111") val seriesId = UUID.fromString("11111111-1111-1111-1111-111111111111")
val seasonId = UUID.fromString("22222222-2222-2222-2222-222222222222") val seasonId = UUID.fromString("22222222-2222-2222-2222-222222222222")

View File

@@ -6,16 +6,17 @@ import androidx.compose.ui.test.assertCountEquals
import androidx.compose.ui.test.assertIsDisplayed import androidx.compose.ui.test.assertIsDisplayed
import androidx.compose.ui.test.assertIsFocused import androidx.compose.ui.test.assertIsFocused
import androidx.compose.ui.test.junit4.createAndroidComposeRule 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.onAllNodesWithText
import androidx.compose.ui.test.onNodeWithContentDescription import androidx.compose.ui.test.onRoot
import androidx.compose.ui.test.onNodeWithTag import androidx.compose.ui.test.onNodeWithTag
import androidx.compose.ui.test.onNodeWithText 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 androidx.compose.ui.input.key.Key
import hu.bbara.purefin.core.model.CastMember import hu.bbara.purefin.core.model.CastMember
import hu.bbara.purefin.core.model.Movie import hu.bbara.purefin.core.model.Movie
import hu.bbara.purefin.ui.theme.AppTheme import hu.bbara.purefin.ui.theme.AppTheme
import org.junit.Assert.assertEquals
import org.junit.Rule import org.junit.Rule
import org.junit.Test import org.junit.Test
import java.util.UUID import java.util.UUID
@@ -27,12 +28,11 @@ class MovieScreenContentTest {
val composeRule = createAndroidComposeRule<ComponentActivity>() val composeRule = createAndroidComposeRule<ComponentActivity>()
@Test @Test
fun movieScreenContent_focusesBack_thenMovesToPlayButton() { fun movieScreenContent_focusesPlayButton() {
composeRule.setContent { composeRule.setContent {
AppTheme { AppTheme {
MovieScreenContent( MovieScreenContent(
movie = sampleMovie(progress = 42.0), movie = sampleMovie(progress = 42.0),
onBack = {},
onPlay = {} onPlay = {}
) )
} }
@@ -43,14 +43,34 @@ class MovieScreenContentTest {
composeRule.onNodeWithText("Blade Runner 2049").assertIsDisplayed() composeRule.onNodeWithText("Blade Runner 2049").assertIsDisplayed()
composeRule.onNodeWithText("Overview").assertIsDisplayed() composeRule.onNodeWithText("Overview").assertIsDisplayed()
composeRule.onAllNodesWithText("Playback").assertCountEquals(1) composeRule.onAllNodesWithText("Playback").assertCountEquals(1)
composeRule.onNodeWithTag(MoviePlayButtonTag).assertIsDisplayed() composeRule.onNodeWithTag(MoviePlayButtonTag)
composeRule.onNodeWithContentDescription("Back")
.assertIsDisplayed() .assertIsDisplayed()
.assertIsFocused() .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.onNodeWithTag(MoviePlayButtonTag).assertIsFocused()
composeRule.onRoot().performKeyInput {
pressKey(Key.DirectionCenter)
}
composeRule.waitForIdle()
assertEquals(1, playCount)
} }
private fun sampleMovie(progress: Double?): Movie { private fun sampleMovie(progress: Double?): Movie {

View File

@@ -5,17 +5,18 @@ import androidx.compose.ui.test.ExperimentalTestApi
import androidx.compose.ui.test.assertIsDisplayed import androidx.compose.ui.test.assertIsDisplayed
import androidx.compose.ui.test.assertIsFocused import androidx.compose.ui.test.assertIsFocused
import androidx.compose.ui.test.junit4.createAndroidComposeRule import androidx.compose.ui.test.junit4.createAndroidComposeRule
import androidx.compose.ui.test.performKeyInput import androidx.compose.ui.test.onRoot
import androidx.compose.ui.test.pressKey
import androidx.compose.ui.test.onNodeWithContentDescription
import androidx.compose.ui.test.onNodeWithTag import androidx.compose.ui.test.onNodeWithTag
import androidx.compose.ui.test.onNodeWithText 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 androidx.compose.ui.input.key.Key
import hu.bbara.purefin.core.model.CastMember import hu.bbara.purefin.core.model.CastMember
import hu.bbara.purefin.core.model.Episode import hu.bbara.purefin.core.model.Episode
import hu.bbara.purefin.core.model.Season import hu.bbara.purefin.core.model.Season
import hu.bbara.purefin.core.model.Series import hu.bbara.purefin.core.model.Series
import hu.bbara.purefin.ui.theme.AppTheme import hu.bbara.purefin.ui.theme.AppTheme
import org.junit.Assert.assertEquals
import org.junit.Rule import org.junit.Rule
import org.junit.Test import org.junit.Test
import java.util.UUID import java.util.UUID
@@ -27,12 +28,11 @@ class SeriesScreenContentTest {
val composeRule = createAndroidComposeRule<ComponentActivity>() val composeRule = createAndroidComposeRule<ComponentActivity>()
@Test @Test
fun seriesScreenContent_movesFromBackToPrimaryAction_whenNextUpExists() { fun seriesScreenContent_focusesPrimaryAction_whenNextUpExists() {
composeRule.setContent { composeRule.setContent {
AppTheme { AppTheme {
SeriesScreenContent( SeriesScreenContent(
series = sampleSeriesWithEpisodes(), series = sampleSeriesWithEpisodes(),
onBack = {},
onPlayEpisode = {} onPlayEpisode = {}
) )
} }
@@ -44,25 +44,18 @@ class SeriesScreenContentTest {
composeRule.onNodeWithText("Overview").assertIsDisplayed() composeRule.onNodeWithText("Overview").assertIsDisplayed()
composeRule.onNodeWithText("Continue Watching").assertIsDisplayed() composeRule.onNodeWithText("Continue Watching").assertIsDisplayed()
composeRule.onNodeWithTag(SeriesPlayButtonTag).assertIsDisplayed() composeRule.onNodeWithTag(SeriesPlayButtonTag).assertIsDisplayed()
.assertIsFocused()
composeRule.onNodeWithText("Season 1").assertIsDisplayed() composeRule.onNodeWithText("Season 1").assertIsDisplayed()
composeRule.onNodeWithText("Good News About Hell").assertIsDisplayed() composeRule.onNodeWithText("Good News About Hell").assertIsDisplayed()
composeRule.onNodeWithText("Episode 1 • 57m").assertIsDisplayed() composeRule.onNodeWithText("Episode 1 • 57m").assertIsDisplayed()
composeRule.onNodeWithContentDescription("Back")
.assertIsDisplayed()
.assertIsFocused()
.performKeyInput {
pressKey(Key.DirectionDown)
}
composeRule.onNodeWithTag(SeriesPlayButtonTag).assertIsFocused()
} }
@Test @Test
fun seriesScreenContent_movesFromBackToFirstSeason_whenNoPlayableEpisodeExists() { fun seriesScreenContent_focusesFirstSeason_whenNoPlayableEpisodeExists() {
composeRule.setContent { composeRule.setContent {
AppTheme { AppTheme {
SeriesScreenContent( SeriesScreenContent(
series = sampleSeriesWithoutEpisodes(), series = sampleSeriesWithoutEpisodes(),
onBack = {},
onPlayEpisode = {} onPlayEpisode = {}
) )
} }
@@ -72,14 +65,34 @@ class SeriesScreenContentTest {
composeRule.onNodeWithText("Overview").assertIsDisplayed() composeRule.onNodeWithText("Overview").assertIsDisplayed()
composeRule.onNodeWithText("Choose a season below to start watching.").assertIsDisplayed() composeRule.onNodeWithText("Choose a season below to start watching.").assertIsDisplayed()
composeRule.onNodeWithTag(SeriesFirstSeasonTabTag).assertIsDisplayed() composeRule.onNodeWithTag(SeriesFirstSeasonTabTag)
composeRule.onNodeWithContentDescription("Back")
.assertIsDisplayed() .assertIsDisplayed()
.assertIsFocused() .assertIsFocused()
.performKeyInput { }
pressKey(Key.DirectionDown)
@Test
fun seriesScreenContent_startsPlaybackOnFirstCenterPress() {
var playCount = 0
composeRule.setContent {
AppTheme {
SeriesScreenContent(
series = sampleSeriesWithEpisodes(),
onPlayEpisode = { playCount++ }
)
} }
composeRule.onNodeWithTag(SeriesFirstSeasonTabTag).assertIsFocused() }
composeRule.waitForIdle()
composeRule.onNodeWithTag(SeriesPlayButtonTag).assertIsFocused()
composeRule.onRoot().performKeyInput {
pressKey(Key.DirectionCenter)
}
composeRule.waitForIdle()
assertEquals(1, playCount)
} }
private fun sampleSeriesWithEpisodes(): Series { private fun sampleSeriesWithEpisodes(): Series {

View File

@@ -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
)
}
}

View File

@@ -9,20 +9,24 @@ import androidx.compose.ui.test.assertIsDisplayed
import androidx.compose.ui.test.assertIsFocused import androidx.compose.ui.test.assertIsFocused
import androidx.compose.ui.test.assertTextEquals import androidx.compose.ui.test.assertTextEquals
import androidx.compose.ui.test.junit4.createAndroidComposeRule import androidx.compose.ui.test.junit4.createAndroidComposeRule
import androidx.compose.ui.test.onRoot
import androidx.compose.ui.test.onNodeWithText import androidx.compose.ui.test.onNodeWithText
import androidx.compose.ui.test.onNodeWithTag import androidx.compose.ui.test.onNodeWithTag
import androidx.compose.ui.test.performKeyInput import androidx.compose.ui.test.performKeyInput
import androidx.compose.ui.test.pressKey import androidx.compose.ui.test.pressKey
import androidx.compose.ui.input.key.Key import androidx.compose.ui.input.key.Key
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import hu.bbara.purefin.core.model.Episode
import hu.bbara.purefin.core.model.Movie import hu.bbara.purefin.core.model.Movie
import hu.bbara.purefin.feature.shared.home.ContinueWatchingItem import hu.bbara.purefin.feature.shared.home.ContinueWatchingItem
import hu.bbara.purefin.feature.shared.home.LibraryItem 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.feature.shared.home.PosterItem
import hu.bbara.purefin.tv.home.TvHomeScreen import hu.bbara.purefin.tv.home.TvHomeScreen
import hu.bbara.purefin.ui.theme.AppTheme import hu.bbara.purefin.ui.theme.AppTheme
import org.jellyfin.sdk.model.api.BaseItemKind import org.jellyfin.sdk.model.api.BaseItemKind
import org.jellyfin.sdk.model.api.CollectionType import org.jellyfin.sdk.model.api.CollectionType
import org.junit.Assert.assertEquals
import org.junit.Rule import org.junit.Rule
import org.junit.Test import org.junit.Test
import java.util.UUID import java.util.UUID
@@ -140,6 +144,157 @@ class TvHomeContentTest {
.assertIsFocused() .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> { private fun sampleContinueWatching(): List<ContinueWatchingItem> {
return listOf( return listOf(
ContinueWatchingItem( 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> { private fun sampleLibraries(): List<LibraryItem> {
return listOf( return listOf(
LibraryItem( LibraryItem(
id = UUID.fromString("33333333-3333-3333-3333-333333333333"), id = sampleLibraryId(),
name = "Movies", name = "Movies",
type = CollectionType.MOVIES, type = CollectionType.MOVIES,
posterUrl = "", posterUrl = "",
@@ -187,7 +371,7 @@ class TvHomeContentTest {
} }
private fun sampleLibraryContent(): Map<UUID, List<PosterItem>> { private fun sampleLibraryContent(): Map<UUID, List<PosterItem>> {
val libraryId = UUID.fromString("33333333-3333-3333-3333-333333333333") val libraryId = sampleLibraryId()
return mapOf( return mapOf(
libraryId to listOf( 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( private fun sampleMovie(
id: String, id: String,
title: String, title: String,
@@ -227,4 +437,29 @@ class TvHomeContentTest {
cast = emptyList() 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
}
} }

View File

@@ -8,6 +8,7 @@ import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.collectAsState import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.remember import androidx.compose.runtime.remember
import androidx.compose.runtime.withFrameNanos
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.focus.FocusRequester import androidx.compose.ui.focus.FocusRequester
import androidx.compose.ui.unit.dp 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.MediaDetailOverviewSection
import hu.bbara.purefin.common.ui.components.MediaDetailPlaybackSection import hu.bbara.purefin.common.ui.components.MediaDetailPlaybackSection
import hu.bbara.purefin.common.ui.components.MediaDetailSectionTitle import hu.bbara.purefin.common.ui.components.MediaDetailSectionTitle
import hu.bbara.purefin.common.ui.components.TvMediaDetailBodyBox
import hu.bbara.purefin.common.ui.components.TvMediaDetailScaffold import hu.bbara.purefin.common.ui.components.TvMediaDetailScaffold
import hu.bbara.purefin.core.data.image.JellyfinImageHelper
import hu.bbara.purefin.core.data.navigation.EpisodeDto
import hu.bbara.purefin.core.data.navigation.LocalNavigationBackStack
import hu.bbara.purefin.core.data.navigation.LocalNavigationManager import hu.bbara.purefin.core.data.navigation.LocalNavigationManager
import hu.bbara.purefin.core.data.navigation.Route import hu.bbara.purefin.core.data.navigation.Route
import hu.bbara.purefin.common.ui.components.tvMediaDetailBackgroundImageUrl
import hu.bbara.purefin.core.data.navigation.EpisodeDto
import hu.bbara.purefin.core.model.Episode import hu.bbara.purefin.core.model.Episode
import hu.bbara.purefin.feature.shared.content.episode.EpisodeScreenViewModel import hu.bbara.purefin.feature.shared.content.episode.EpisodeScreenViewModel
import org.jellyfin.sdk.model.api.ImageType
@Composable @Composable
fun EpisodeScreen( fun EpisodeScreen(
@@ -34,8 +34,6 @@ fun EpisodeScreen(
modifier: Modifier = Modifier modifier: Modifier = Modifier
) { ) {
val navigationManager = LocalNavigationManager.current val navigationManager = LocalNavigationManager.current
val backStack = LocalNavigationBackStack.current
val previousRoute = remember(backStack) { backStack.getOrNull(backStack.lastIndex - 1) }
LaunchedEffect(episode) { LaunchedEffect(episode) {
viewModel.selectEpisode( viewModel.selectEpisode(
@@ -57,7 +55,6 @@ fun EpisodeScreen(
EpisodeScreenContent( EpisodeScreenContent(
episode = selectedEpisode, episode = selectedEpisode,
seriesTitle = seriesTitle.value, seriesTitle = seriesTitle.value,
onBack = viewModel::onBack,
onPlay = remember(selectedEpisode.id, navigationManager) { onPlay = remember(selectedEpisode.id, navigationManager) {
{ {
navigationManager.navigate( navigationManager.navigate(
@@ -73,33 +70,35 @@ fun EpisodeScreen(
internal fun EpisodeScreenContent( internal fun EpisodeScreenContent(
episode: Episode, episode: Episode,
seriesTitle: String?, seriesTitle: String?,
onBack: () -> Unit,
onPlay: () -> Unit, onPlay: () -> Unit,
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
) { ) {
val playFocusRequester = remember { FocusRequester() } val playFocusRequester = remember { FocusRequester() }
LaunchedEffect(episode.id) { LaunchedEffect(episode.id) {
withFrameNanos { }
playFocusRequester.requestFocus() playFocusRequester.requestFocus()
} }
TvMediaDetailScaffold( TvMediaDetailScaffold(
artworkImageUrl = JellyfinImageHelper.finishImageUrl(episode.imageUrlPrefix, ImageType.PRIMARY),
artworkWidth = 280.dp,
artworkAspectRatio = 16f / 9f,
resetScrollKey = episode.id, resetScrollKey = episode.id,
modifier = modifier, modifier = modifier
heroContent = {
EpisodeHeroSection(
episode = episode,
seriesTitle = seriesTitle,
onPlay = onPlay,
playFocusRequester = playFocusRequester,
modifier = Modifier.fillMaxWidth()
)
Spacer(modifier = Modifier.height(12.dp))
}
) { ) {
item {
TvMediaDetailBodyBox(
backgroundImageUrl = tvMediaDetailBackgroundImageUrl(episode.imageUrlPrefix),
modifier = it
) {
EpisodeHeroSection(
episode = episode,
seriesTitle = seriesTitle,
onPlay = onPlay,
playFocusRequester = playFocusRequester,
modifier = Modifier.fillMaxWidth()
)
Spacer(modifier = Modifier.height(12.dp))
}
}
item { item {
Column(modifier = it.fillMaxWidth()) { Column(modifier = it.fillMaxWidth()) {
Spacer(modifier = Modifier.height(16.dp)) Spacer(modifier = Modifier.height(16.dp))

View File

@@ -8,6 +8,7 @@ import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.collectAsState import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.remember import androidx.compose.runtime.remember
import androidx.compose.runtime.withFrameNanos
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.focus.FocusRequester import androidx.compose.ui.focus.FocusRequester
import androidx.compose.ui.unit.dp 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.MediaDetailOverviewSection
import hu.bbara.purefin.common.ui.components.MediaDetailPlaybackSection import hu.bbara.purefin.common.ui.components.MediaDetailPlaybackSection
import hu.bbara.purefin.common.ui.components.MediaDetailSectionTitle import hu.bbara.purefin.common.ui.components.MediaDetailSectionTitle
import hu.bbara.purefin.common.ui.components.TvMediaDetailBodyBox
import hu.bbara.purefin.common.ui.components.TvMediaDetailScaffold 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.data.navigation.MovieDto
import hu.bbara.purefin.core.model.Movie import hu.bbara.purefin.core.model.Movie
import hu.bbara.purefin.feature.shared.content.movie.MovieScreenViewModel import hu.bbara.purefin.feature.shared.content.movie.MovieScreenViewModel
import org.jellyfin.sdk.model.api.ImageType
@Composable @Composable
fun MovieScreen( fun MovieScreen(
@@ -37,7 +38,6 @@ fun MovieScreen(
if (movieItem.value != null) { if (movieItem.value != null) {
MovieScreenContent( MovieScreenContent(
movie = movieItem.value!!, movie = movieItem.value!!,
onBack = viewModel::onBack,
onPlay = viewModel::onPlay, onPlay = viewModel::onPlay,
modifier = modifier modifier = modifier
) )
@@ -49,32 +49,34 @@ fun MovieScreen(
@Composable @Composable
internal fun MovieScreenContent( internal fun MovieScreenContent(
movie: Movie, movie: Movie,
onBack: () -> Unit,
onPlay: () -> Unit, onPlay: () -> Unit,
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
) { ) {
val playFocusRequester = remember { FocusRequester() } val playFocusRequester = remember { FocusRequester() }
LaunchedEffect(movie.id) { LaunchedEffect(movie.id) {
withFrameNanos { }
playFocusRequester.requestFocus() playFocusRequester.requestFocus()
} }
TvMediaDetailScaffold( TvMediaDetailScaffold(
artworkImageUrl = JellyfinImageHelper.finishImageUrl(movie.imageUrlPrefix, ImageType.PRIMARY),
artworkWidth = 200.dp,
artworkAspectRatio = 2f / 3f,
resetScrollKey = movie.id, resetScrollKey = movie.id,
modifier = modifier, modifier = modifier
heroContent = {
MovieHeroSection(
movie = movie,
onPlay = onPlay,
playFocusRequester = playFocusRequester,
modifier = Modifier.fillMaxWidth()
)
Spacer(modifier = Modifier.height(12.dp))
}
) { ) {
item {
TvMediaDetailBodyBox(
backgroundImageUrl = tvMediaDetailBackgroundImageUrl(movie.imageUrlPrefix),
modifier = it
) {
MovieHeroSection(
movie = movie,
onPlay = onPlay,
playFocusRequester = playFocusRequester,
modifier = Modifier.fillMaxWidth()
)
Spacer(modifier = Modifier.height(12.dp))
}
}
item { item {
Column(modifier = it.fillMaxWidth()) { Column(modifier = it.fillMaxWidth()) {
Spacer(modifier = Modifier.height(16.dp)) Spacer(modifier = Modifier.height(16.dp))

View File

@@ -7,8 +7,11 @@ import androidx.compose.foundation.layout.height
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.collectAsState import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.runtime.withFrameNanos
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.focus.FocusRequester import androidx.compose.ui.focus.FocusRequester
import androidx.compose.ui.unit.dp 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.PurefinWaitingScreen
import hu.bbara.purefin.common.ui.components.MediaDetailOverviewSection import hu.bbara.purefin.common.ui.components.MediaDetailOverviewSection
import hu.bbara.purefin.common.ui.components.MediaDetailSectionTitle import hu.bbara.purefin.common.ui.components.MediaDetailSectionTitle
import hu.bbara.purefin.common.ui.components.TvMediaDetailBodyBox
import hu.bbara.purefin.common.ui.components.TvMediaDetailScaffold 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.data.navigation.SeriesDto
import hu.bbara.purefin.core.model.Season import hu.bbara.purefin.core.model.Season
import hu.bbara.purefin.core.model.Series import hu.bbara.purefin.core.model.Series
import hu.bbara.purefin.feature.shared.content.series.SeriesViewModel import hu.bbara.purefin.feature.shared.content.series.SeriesViewModel
import org.jellyfin.sdk.model.UUID import org.jellyfin.sdk.model.UUID
import org.jellyfin.sdk.model.api.ImageType
@Composable @Composable
fun SeriesScreen( fun SeriesScreen(
@@ -41,7 +44,6 @@ fun SeriesScreen(
if (seriesData != null && seriesData.seasons.isNotEmpty()) { if (seriesData != null && seriesData.seasons.isNotEmpty()) {
SeriesScreenContent( SeriesScreenContent(
series = seriesData, series = seriesData,
onBack = viewModel::onBack,
onPlayEpisode = viewModel::onPlayEpisode, onPlayEpisode = viewModel::onPlayEpisode,
modifier = modifier modifier = modifier
) )
@@ -53,27 +55,16 @@ fun SeriesScreen(
@Composable @Composable
internal fun SeriesScreenContent( internal fun SeriesScreenContent(
series: Series, series: Series,
onBack: () -> Unit,
onPlayEpisode: (UUID) -> Unit, onPlayEpisode: (UUID) -> Unit,
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
) { ) {
fun getDefaultSeason(): Season { var selectedSeason by remember(series.id) { mutableStateOf(series.defaultSeason()) }
for (season in series.seasons) { val nextUpEpisode = remember(series.id) { series.nextUpEpisode() }
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()
}
val playFocusRequester = remember { FocusRequester() } val playFocusRequester = remember { FocusRequester() }
val firstContentFocusRequester = remember { FocusRequester() } val firstContentFocusRequester = remember { FocusRequester() }
LaunchedEffect(series.id, nextUpEpisode?.id) { LaunchedEffect(series.id, nextUpEpisode?.id) {
withFrameNanos { }
if (nextUpEpisode != null) { if (nextUpEpisode != null) {
playFocusRequester.requestFocus() playFocusRequester.requestFocus()
} else { } else {
@@ -82,23 +73,25 @@ internal fun SeriesScreenContent(
} }
TvMediaDetailScaffold( TvMediaDetailScaffold(
artworkImageUrl = JellyfinImageHelper.finishImageUrl(series.imageUrlPrefix, ImageType.PRIMARY),
artworkWidth = 200.dp,
artworkAspectRatio = 2f / 3f,
resetScrollKey = series.id, resetScrollKey = series.id,
modifier = modifier, modifier = modifier
heroContent = {
SeriesHeroSection(
series = series,
nextUpEpisode = nextUpEpisode,
onPlayEpisode = { onPlayEpisode(it.id) },
playFocusRequester = playFocusRequester,
firstContentFocusRequester = firstContentFocusRequester,
modifier = Modifier.fillMaxWidth()
)
Spacer(modifier = Modifier.height(12.dp))
}
) { ) {
item {
TvMediaDetailBodyBox(
backgroundImageUrl = tvMediaDetailBackgroundImageUrl(series.imageUrlPrefix),
modifier = it
) {
SeriesHeroSection(
series = series,
nextUpEpisode = nextUpEpisode,
onPlayEpisode = { onPlayEpisode(it.id) },
playFocusRequester = playFocusRequester,
firstContentFocusRequester = firstContentFocusRequester,
modifier = Modifier.fillMaxWidth()
)
Spacer(modifier = Modifier.height(12.dp))
}
}
item { item {
Column(modifier = it.fillMaxWidth()) { Column(modifier = it.fillMaxWidth()) {
Spacer(modifier = Modifier.height(16.dp)) Spacer(modifier = Modifier.height(16.dp))
@@ -112,17 +105,17 @@ internal fun SeriesScreenContent(
item { item {
SeasonTabs( SeasonTabs(
seasons = series.seasons, seasons = series.seasons,
selectedSeason = selectedSeason.value, selectedSeason = selectedSeason,
firstItemFocusRequester = firstContentFocusRequester, firstItemFocusRequester = firstContentFocusRequester,
firstItemTestTag = SeriesFirstSeasonTabTag, firstItemTestTag = SeriesFirstSeasonTabTag,
onSelect = { selectedSeason.value = it }, onSelect = { selectedSeason = it },
modifier = it modifier = it
) )
} }
item { item {
Spacer(modifier = Modifier.height(20.dp)) Spacer(modifier = Modifier.height(20.dp))
EpisodeCarousel( EpisodeCarousel(
episodes = selectedSeason.value.episodes, episodes = selectedSeason.episodes,
modifier = it 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()

View File

@@ -1,57 +1,66 @@
@file:OptIn(androidx.compose.foundation.ExperimentalFoundationApi::class)
package hu.bbara.purefin.common.ui.components package hu.bbara.purefin.common.ui.components
import androidx.compose.foundation.background 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.Box
import androidx.compose.foundation.layout.BoxWithConstraints
import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.ColumnScope import androidx.compose.foundation.layout.ColumnScope
import androidx.compose.foundation.layout.Spacer 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.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.heightIn import androidx.compose.foundation.layout.heightIn
import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.layout.widthIn
import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.LazyListScope import androidx.compose.foundation.lazy.LazyListScope
import androidx.compose.foundation.lazy.rememberLazyListState import androidx.compose.foundation.lazy.rememberLazyListState
import androidx.compose.material3.MaterialTheme import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text import androidx.compose.material3.Text
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.CompositionLocalProvider
import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.LaunchedEffect
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.Brush import androidx.compose.ui.graphics.Brush
import androidx.compose.ui.graphics.graphicsLayer
import androidx.compose.ui.layout.ContentScale import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.platform.LocalConfiguration import androidx.compose.ui.platform.LocalConfiguration
import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp import androidx.compose.ui.unit.sp
import androidx.compose.foundation.shape.RoundedCornerShape
import hu.bbara.purefin.common.ui.MediaSynopsis 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 internal val MediaDetailHorizontalPadding = 48.dp
private val MediaDetailHeaderTopPadding = 104.dp private val MediaDetailHeaderTopPadding = 104.dp
private val MediaDetailHeaderBottomPadding = 36.dp private val MediaDetailHeaderBottomPadding = 36.dp
private val MediaDetailCornerArtworkTopPadding = 40.dp private const val MediaDetailBodyImageWidthFraction = 0.66f
private val MediaDetailCornerArtworkShape = RoundedCornerShape(24.dp)
private val MediaDetailMinimumContentWidth = 280.dp internal val TvMediaDetailBringIntoViewSpec: BringIntoViewSpec =
private val MediaDetailContentArtworkGap = 32.dp 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 @Composable
internal fun TvMediaDetailScaffold( internal fun TvMediaDetailScaffold(
artworkImageUrl: String,
artworkWidth: Dp,
artworkAspectRatio: Float,
resetScrollKey: Any, resetScrollKey: Any,
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
headerHeightFraction: Float = 0.48f,
heroContent: @Composable ColumnScope.() -> Unit,
bodyContent: LazyListScope.(Modifier) -> Unit = { _ -> } bodyContent: LazyListScope.(Modifier) -> Unit = { _ -> }
) { ) {
val scheme = MaterialTheme.colorScheme val scheme = MaterialTheme.colorScheme
@@ -62,132 +71,89 @@ internal fun TvMediaDetailScaffold(
listState.scrollToItem(0) listState.scrollToItem(0)
} }
Box( CompositionLocalProvider(LocalBringIntoViewSpec provides TvMediaDetailBringIntoViewSpec) {
modifier = modifier
.fillMaxSize()
.background(scheme.background)
) {
LazyColumn( LazyColumn(
state = listState, state = listState,
modifier = Modifier.fillMaxSize() modifier = modifier
.fillMaxSize()
.background(scheme.background)
) { ) {
item {
TvMediaDetailHeader(
artworkImageUrl = artworkImageUrl,
artworkWidth = artworkWidth,
artworkAspectRatio = artworkAspectRatio,
headerHeightFraction = headerHeightFraction,
heroContent = heroContent
)
}
bodyContent(contentPadding) bodyContent(contentPadding)
} }
} }
} }
@Composable @Composable
private fun TvMediaDetailHeader( internal fun TvMediaDetailBodyBox(
artworkImageUrl: String, backgroundImageUrl: String,
artworkWidth: Dp, modifier: Modifier = Modifier,
artworkAspectRatio: Float, heightFraction: Float = 0.48f,
headerHeightFraction: Float, content: @Composable ColumnScope.() -> Unit
heroContent: @Composable ColumnScope.() -> Unit
) { ) {
val scheme = MaterialTheme.colorScheme
val screenHeight = LocalConfiguration.current.screenHeightDp.dp val screenHeight = LocalConfiguration.current.screenHeightDp.dp
val headerHeight = screenHeight * headerHeightFraction val bodyHeight = screenHeight * heightFraction
BoxWithConstraints( Box(
modifier = Modifier modifier = modifier
.fillMaxWidth() .fillMaxWidth()
.heightIn(min = headerHeight) .heightIn(min = bodyHeight)
) { ) {
val contentMaxWidth = ( Box(modifier = Modifier.matchParentSize()) {
maxWidth - PurefinAsyncImage(
(MediaDetailHorizontalPadding * 2) - model = backgroundImageUrl,
artworkWidth - contentDescription = null,
MediaDetailContentArtworkGap
).coerceAtLeast(MediaDetailMinimumContentWidth)
Box(modifier = Modifier.fillMaxWidth()) {
TvMediaDetailCornerArtwork(
artworkImageUrl = artworkImageUrl,
artworkWidth = artworkWidth,
artworkAspectRatio = artworkAspectRatio,
modifier = Modifier modifier = Modifier
.align(Alignment.TopEnd) .fillMaxHeight()
.padding( .fillMaxWidth(MediaDetailBodyImageWidthFraction)
top = MediaDetailCornerArtworkTopPadding, .align(Alignment.TopEnd),
end = MediaDetailHorizontalPadding contentScale = ContentScale.Crop
)
) )
Column( }
modifier = Modifier Box(
.align(Alignment.TopStart) modifier = Modifier
.padding( .matchParentSize()
start = MediaDetailHorizontalPadding, .background(
top = MediaDetailHeaderTopPadding, Brush.horizontalGradient(
end = MediaDetailHorizontalPadding, colorStops = arrayOf(
bottom = MediaDetailHeaderBottomPadding 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)
)
) )
.widthIn(max = contentMaxWidth) )
) { )
heroContent() 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
.fillMaxWidth()
.padding(
top = MediaDetailHeaderTopPadding,
bottom = MediaDetailHeaderBottomPadding
)
) {
content()
} }
} }
} }
@Composable internal fun tvMediaDetailBackgroundImageUrl(imageUrlPrefix: String?): String {
private fun TvMediaDetailCornerArtwork( val primaryImageUrl = JellyfinImageHelper.finishImageUrl(imageUrlPrefix, ImageType.PRIMARY)
artworkImageUrl: String, val backdropImageUrl = JellyfinImageHelper.finishImageUrl(imageUrlPrefix, ImageType.BACKDROP)
artworkWidth: Dp, return backdropImageUrl.ifBlank { primaryImageUrl }
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)
)
)
)
)
}
} }
@Composable @Composable

View File

@@ -68,8 +68,8 @@ fun MediaResumeButton(
.border(3.dp, focusBorderColor, focusShape) .border(3.dp, focusBorderColor, focusShape)
.clip(focusShape) .clip(focusShape)
.onFocusChanged { isFocused = it.isFocused || it.hasFocus } .onFocusChanged { isFocused = it.isFocused || it.hasFocus }
.focusable()
.clickable(onClick = onClick) .clickable(onClick = onClick)
.focusable()
) { ) {
// Bottom layer: inverted colors (visible for the remaining %) // Bottom layer: inverted colors (visible for the remaining %)
Box( Box(

View File

@@ -16,12 +16,8 @@ import kotlin.math.roundToInt
internal data class TvFocusedHeroModel( internal data class TvFocusedHeroModel(
val id: UUID, val id: UUID,
val backdropImageUrl: String, val backdropImageUrl: String,
val eyebrowText: String,
val title: String, val title: String,
val metadata: List<String>, val metadata: List<String>,
val watchedText: String?,
val progressFraction: Float?,
val progressLabel: String?,
) { ) {
val metadataText: String? val metadataText: String?
get() = metadata.takeIf { it.isNotEmpty() }?.joinToString("") get() = metadata.takeIf { it.isNotEmpty() }?.joinToString("")
@@ -30,16 +26,16 @@ internal data class TvFocusedHeroModel(
internal fun FocusableItem.toTvFocusedHeroModel(): TvFocusedHeroModel { internal fun FocusableItem.toTvFocusedHeroModel(): TvFocusedHeroModel {
return when (this) { return when (this) {
is ContinueWatchingItem -> when (type) { is ContinueWatchingItem -> when (type) {
BaseItemKind.MOVIE -> movie!!.toTvFocusedHeroModel(sourceLabel = "Continue watching") BaseItemKind.MOVIE -> movie!!.toTvFocusedHeroModel()
BaseItemKind.EPISODE -> episode!!.toTvFocusedHeroModel(sourceLabel = "Continue watching") BaseItemKind.EPISODE -> episode!!.toTvFocusedHeroModel()
else -> unsupportedType(type) else -> unsupportedType(type)
} }
is NextUpItem -> episode.toTvFocusedHeroModel(sourceLabel = "Next up") is NextUpItem -> episode.toTvFocusedHeroModel()
is PosterItem -> when (type) { is PosterItem -> when (type) {
BaseItemKind.MOVIE -> movie!!.toTvFocusedHeroModel(sourceLabel = "Movie") BaseItemKind.MOVIE -> movie!!.toTvFocusedHeroModel()
BaseItemKind.EPISODE -> episode!!.toTvFocusedHeroModel(sourceLabel = "Episode") BaseItemKind.EPISODE -> episode!!.toTvFocusedHeroModel()
BaseItemKind.SERIES -> series!!.toTvFocusedHeroModel(sourceLabel = "Series") BaseItemKind.SERIES -> series!!.toTvFocusedHeroModel()
else -> unsupportedType(type) else -> unsupportedType(type)
} }
} }
@@ -50,32 +46,25 @@ internal fun backdropImageUrl(imageUrlPrefix: String?, fallbackImageUrl: String)
return backdropImageUrl.ifBlank { fallbackImageUrl } return backdropImageUrl.ifBlank { fallbackImageUrl }
} }
private fun Movie.toTvFocusedHeroModel(sourceLabel: String): TvFocusedHeroModel { private fun Movie.toTvFocusedHeroModel(): TvFocusedHeroModel {
val progressFraction = progressFraction(progress)
return TvFocusedHeroModel( return TvFocusedHeroModel(
id = id, id = id,
backdropImageUrl = backdropImageUrl( backdropImageUrl = backdropImageUrl(
imageUrlPrefix = imageUrlPrefix, imageUrlPrefix = imageUrlPrefix,
fallbackImageUrl = JellyfinImageHelper.finishImageUrl(imageUrlPrefix, ImageType.PRIMARY) fallbackImageUrl = JellyfinImageHelper.finishImageUrl(imageUrlPrefix, ImageType.PRIMARY)
), ),
eyebrowText = sourceLabel,
title = title, title = title,
metadata = listOf(year, rating, runtime, format).compactMetadata(), 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 { private fun Episode.toTvFocusedHeroModel(): TvFocusedHeroModel {
val progressFraction = progressFraction(progress)
return TvFocusedHeroModel( return TvFocusedHeroModel(
id = id, id = id,
backdropImageUrl = backdropImageUrl( backdropImageUrl = backdropImageUrl(
imageUrlPrefix = imageUrlPrefix, imageUrlPrefix = imageUrlPrefix,
fallbackImageUrl = JellyfinImageHelper.finishImageUrl(imageUrlPrefix, ImageType.PRIMARY) fallbackImageUrl = JellyfinImageHelper.finishImageUrl(imageUrlPrefix, ImageType.PRIMARY)
), ),
eyebrowText = sourceLabel,
title = title, title = title,
metadata = listOf( metadata = listOf(
"Episode $index", "Episode $index",
@@ -84,13 +73,10 @@ private fun Episode.toTvFocusedHeroModel(sourceLabel: String): TvFocusedHeroMode
rating, rating,
format format
).compactMetadata(), ).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) { val unwatchedText = if (unwatchedEpisodeCount > 0) {
"$unwatchedEpisodeCount unwatched" "$unwatchedEpisodeCount unwatched"
} else { } else {
@@ -103,12 +89,8 @@ private fun Series.toTvFocusedHeroModel(sourceLabel: String): TvFocusedHeroModel
imageUrlPrefix = imageUrlPrefix, imageUrlPrefix = imageUrlPrefix,
fallbackImageUrl = JellyfinImageHelper.finishImageUrl(imageUrlPrefix, ImageType.PRIMARY) fallbackImageUrl = JellyfinImageHelper.finishImageUrl(imageUrlPrefix, ImageType.PRIMARY)
), ),
eyebrowText = sourceLabel,
title = name, title = name,
metadata = listOf(year, seasonLabel(seasonCount), unwatchedText).compactMetadata(), metadata = listOf(year, seasonLabel(seasonCount), unwatchedText).compactMetadata(),
watchedText = null,
progressFraction = null,
progressLabel = null,
) )
} }

View File

@@ -3,39 +3,29 @@ package hu.bbara.purefin.tv.home.ui
import androidx.compose.animation.Crossfade import androidx.compose.animation.Crossfade
import androidx.compose.animation.core.tween import androidx.compose.animation.core.tween
import androidx.compose.foundation.background import androidx.compose.foundation.background
import androidx.compose.foundation.border
import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.layout.widthIn import androidx.compose.foundation.layout.widthIn
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.MaterialTheme import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text import androidx.compose.material3.Text
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.Brush import androidx.compose.ui.graphics.Brush
import androidx.compose.ui.layout.ContentScale import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.platform.testTag import androidx.compose.ui.platform.testTag
import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.Dp import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp import androidx.compose.ui.unit.sp
import hu.bbara.purefin.common.ui.components.MediaProgressBar
import hu.bbara.purefin.common.ui.components.PurefinAsyncImage import hu.bbara.purefin.common.ui.components.PurefinAsyncImage
internal const val TvHomeHeroTitleTag = "tv-home-hero-title" 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" internal const val TvHomeHeroProgressLabelTag = "tv-home-hero-progress-label"
private const val TvHomeHeroAnimationMillis = 180 private const val TvHomeHeroAnimationMillis = 180
@@ -107,13 +97,6 @@ internal fun TvFocusedItemHero(
verticalArrangement = Arrangement.spacedBy(8.dp), verticalArrangement = Arrangement.spacedBy(8.dp),
modifier = Modifier.widthIn(max = 720.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(
text = hero.title, text = hero.title,
color = scheme.onBackground, color = scheme.onBackground,
@@ -134,54 +117,6 @@ internal fun TvFocusedItemHero(
overflow = TextOverflow.Ellipsis 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)
)
}
}
}
} }
} }
} }

View File

@@ -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
}
}

View File

@@ -1,14 +1,18 @@
@file:OptIn(androidx.compose.foundation.ExperimentalFoundationApi::class)
package hu.bbara.purefin.tv.home.ui package hu.bbara.purefin.tv.home.ui
import androidx.compose.foundation.background import androidx.compose.foundation.background
import androidx.compose.foundation.gestures.LocalBringIntoViewSpec
import androidx.compose.foundation.layout.PaddingValues import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.height
import androidx.compose.foundation.lazy.LazyColumn 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.material3.MaterialTheme
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.CompositionLocalProvider
import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.mutableStateOf
@@ -17,10 +21,8 @@ import androidx.compose.runtime.setValue
import androidx.compose.runtime.withFrameNanos import androidx.compose.runtime.withFrameNanos
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.focus.FocusRequester import androidx.compose.ui.focus.FocusRequester
import androidx.compose.ui.focus.FocusRequester.Companion.FocusRequesterFactory.component1 import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.focus.FocusRequester.Companion.FocusRequesterFactory.component2 import androidx.compose.ui.platform.testTag
import androidx.compose.ui.focus.focusProperties
import androidx.compose.ui.focus.focusRequester
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import hu.bbara.purefin.feature.shared.home.ContinueWatchingItem import hu.bbara.purefin.feature.shared.home.ContinueWatchingItem
import hu.bbara.purefin.feature.shared.home.FocusableItem 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 import org.jellyfin.sdk.model.UUID
internal const val TvHomeInitialFocusTag = "tv-home-initial-focus-item" internal const val TvHomeInitialFocusTag = "tv-home-initial-focus-item"
internal const val TvHomeContentViewportTag = "tv-home-content-viewport"
@Composable @Composable
fun TvHomeContent( fun TvHomeContent(
@@ -52,14 +55,18 @@ fun TvHomeContent(
nextUp = nextUp nextUp = nextUp
) )
val visibleLibraries = itemRegistry.visibleLibraries val visibleLibraries = itemRegistry.visibleLibraries
val (nextUpRef, continueWatchingRef) = remember { FocusRequester.createRefs() } val hasContinueWatching = continueWatching.isNotEmpty()
val libraryRefs = remember(visibleLibraries) { val hasNextUp = nextUp.isNotEmpty()
visibleLibraries.associate { it.id to FocusRequester() } val hasLibraries = visibleLibraries.isNotEmpty()
} val hasVisibleContent = hasContinueWatching || hasNextUp || hasLibraries
val initialFocusRequester = remember { FocusRequester() }
val firstVisibleLibraryId = visibleLibraries.firstOrNull()?.id val firstVisibleLibraryId = visibleLibraries.firstOrNull()?.id
val initialFocusRequester = remember { FocusRequester() }
val firstAvailableItemKey = itemRegistry.firstAvailableItemId val firstAvailableItemKey = itemRegistry.firstAvailableItemId
var initialFocusApplied by remember { mutableStateOf(false) } var initialFocusApplied by remember { mutableStateOf(false) }
val topOffsetPx = with(LocalDensity.current) { TvHomeFocusedItemTopOffset.toPx() }
val columnBringIntoViewSpec = remember(topOffsetPx) {
tvHomeColumnBringIntoViewSpec(topOffsetPx = topOffsetPx)
}
LaunchedEffect(firstAvailableItemKey, initialFocusApplied) { LaunchedEffect(firstAvailableItemKey, initialFocusApplied) {
if (!initialFocusApplied && firstAvailableItemKey != null) { if (!initialFocusApplied && firstAvailableItemKey != null) {
@@ -69,81 +76,90 @@ fun TvHomeContent(
} }
} }
LazyColumn( CompositionLocalProvider(LocalBringIntoViewSpec provides columnBringIntoViewSpec) {
modifier = modifier LazyColumn(
.fillMaxSize() modifier = modifier
.background(scheme.background), .fillMaxSize()
contentPadding = contentPadding .background(scheme.background)
) { .testTag(TvHomeContentViewportTag),
item { contentPadding = contentPadding
Spacer(modifier = Modifier.height(8.dp)) ) {
} item {
item { Spacer(modifier = Modifier.height(8.dp))
TvContinueWatchingSection( }
items = continueWatching, if (hasContinueWatching) {
onFocusedItem = onMediaFocused, item {
onMovieSelected = onMovieSelected, TvContinueWatchingSection(
onEpisodeSelected = onEpisodeSelected, items = continueWatching,
firstItemFocusRequester = initialFocusRequester.takeIf { continueWatching.isNotEmpty() }, onFocusedItem = onMediaFocused,
firstItemTestTag = TvHomeInitialFocusTag.takeIf { continueWatching.isNotEmpty() }, onMovieSelected = onMovieSelected,
modifier = Modifier.focusRequester(continueWatchingRef) onEpisodeSelected = onEpisodeSelected,
.focusProperties { firstItemFocusRequester = initialFocusRequester,
down = nextUpRef firstItemTestTag = TvHomeInitialFocusTag,
} rowTestTag = TvHomeContinueWatchingRowTag
) )
} }
item { }
Spacer(modifier = Modifier.height(20.dp))
} if (hasContinueWatching && (hasNextUp || hasLibraries)) {
item { item {
TvNextUpSection( Spacer(modifier = Modifier.height(20.dp))
items = nextUp, }
onFocusedItem = onMediaFocused, }
onEpisodeSelected = onEpisodeSelected,
firstItemFocusRequester = initialFocusRequester if (hasNextUp) {
.takeIf { continueWatching.isEmpty() && nextUp.isNotEmpty() }, item {
firstItemTestTag = TvHomeInitialFocusTag TvNextUpSection(
.takeIf { continueWatching.isEmpty() && nextUp.isNotEmpty() }, items = nextUp,
modifier = Modifier.focusRequester(nextUpRef) onFocusedItem = onMediaFocused,
.focusProperties { onEpisodeSelected = onEpisodeSelected,
up = continueWatchingRef firstItemFocusRequester = initialFocusRequester.takeIf { !hasContinueWatching },
libraryRefs.values.firstOrNull()?.let { down = it } firstItemTestTag = TvHomeInitialFocusTag.takeIf { !hasContinueWatching },
} rowTestTag = TvHomeNextUpRowTag
) )
} }
item { }
Spacer(modifier = Modifier.height(20.dp))
} if (hasLibraries && (hasContinueWatching || hasNextUp)) {
items( item {
items = visibleLibraries, Spacer(modifier = Modifier.height(20.dp))
key = { it.id } }
) { item -> }
val ref = libraryRefs[item.id]
TvLibraryPosterSection( itemsIndexed(
title = item.name, items = visibleLibraries,
items = libraryContent[item.id] ?: emptyList(), key = { _, library -> library.id }
onFocusedItem = onMediaFocused, ) { index, library ->
onMovieSelected = onMovieSelected, TvLibraryPosterSection(
onSeriesSelected = onSeriesSelected, title = library.name,
onEpisodeSelected = onEpisodeSelected, items = libraryContent[library.id].orEmpty(),
firstItemFocusRequester = initialFocusRequester.takeIf { onFocusedItem = onMediaFocused,
continueWatching.isEmpty() && firstItemFocusRequester = initialFocusRequester.takeIf {
nextUp.isEmpty() && !hasContinueWatching &&
item.id == firstVisibleLibraryId !hasNextUp &&
}, library.id == firstVisibleLibraryId
firstItemTestTag = TvHomeInitialFocusTag.takeIf { },
continueWatching.isEmpty() && firstItemTestTag = TvHomeInitialFocusTag.takeIf {
nextUp.isEmpty() && !hasContinueWatching &&
item.id == firstVisibleLibraryId !hasNextUp &&
}, library.id == firstVisibleLibraryId
modifier = (if (ref != null) Modifier.focusRequester(ref) else Modifier) },
.focusProperties { rowTestTag = tvHomeLibraryRowTag(library.id),
up = nextUpRef onMovieSelected = onMovieSelected,
libraryRefs.values.dropWhile { it != ref }.drop(1).firstOrNull() onSeriesSelected = onSeriesSelected,
?.let { down = it } onEpisodeSelected = onEpisodeSelected
} )
)
Spacer(modifier = Modifier.height(20.dp)) if (index < visibleLibraries.lastIndex) {
Spacer(modifier = Modifier.height(20.dp))
}
}
if (hasVisibleContent) {
item {
Spacer(modifier = Modifier.height(TvHomeBringIntoViewTrailingSpace))
}
}
} }
} }
} }

View File

@@ -1,9 +1,11 @@
package hu.bbara.purefin.tv.home.ui package hu.bbara.purefin.tv.home.ui
import androidx.compose.animation.core.animateFloatAsState import androidx.compose.animation.core.animateFloatAsState
import androidx.compose.foundation.ExperimentalFoundationApi
import androidx.compose.foundation.background import androidx.compose.foundation.background
import androidx.compose.foundation.border import androidx.compose.foundation.border
import androidx.compose.foundation.clickable import androidx.compose.foundation.clickable
import androidx.compose.foundation.gestures.LocalBringIntoViewSpec
import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Column
@@ -15,12 +17,14 @@ import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.width import androidx.compose.foundation.layout.width
import androidx.compose.foundation.layout.wrapContentHeight import androidx.compose.foundation.layout.wrapContentHeight
import androidx.compose.foundation.lazy.LazyListScope
import androidx.compose.foundation.lazy.LazyRow import androidx.compose.foundation.lazy.LazyRow
import androidx.compose.foundation.lazy.itemsIndexed import androidx.compose.foundation.lazy.itemsIndexed
import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.MaterialTheme import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text import androidx.compose.material3.Text
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.CompositionLocalProvider
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember import androidx.compose.runtime.remember
@@ -57,6 +61,12 @@ private val TvHomeSectionsHorizontalPadding = 32.dp
private val TvHomeSectionsRowSpacing = 18.dp private val TvHomeSectionsRowSpacing = 18.dp
private val TvHomeLandscapeCardWidth = 248.dp private val TvHomeLandscapeCardWidth = 248.dp
private val TvHomePosterCardWidth = 136.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 @Composable
fun TvContinueWatchingSection( fun TvContinueWatchingSection(
@@ -66,16 +76,16 @@ fun TvContinueWatchingSection(
onEpisodeSelected: (UUID, UUID, UUID) -> Unit, onEpisodeSelected: (UUID, UUID, UUID) -> Unit,
firstItemFocusRequester: FocusRequester? = null, firstItemFocusRequester: FocusRequester? = null,
firstItemTestTag: String? = null, firstItemTestTag: String? = null,
rowTestTag: String? = null,
modifier: Modifier = Modifier modifier: Modifier = Modifier
) { ) {
if (items.isEmpty()) return if (items.isEmpty()) return
TvSectionHeader( TvSectionHeader(
title = "Continue Watching", title = "Continue Watching",
) )
LazyRow( TvHomeSectionRow(
modifier = modifier.fillMaxWidth(), modifier = modifier,
contentPadding = PaddingValues(horizontal = TvHomeSectionsHorizontalPadding), rowTestTag = rowTestTag
horizontalArrangement = Arrangement.spacedBy(TvHomeSectionsRowSpacing)
) { ) {
itemsIndexed(items = items, key = { _, item -> item.id }) { index, item -> itemsIndexed(items = items, key = { _, item -> item.id }) { index, item ->
val progressFraction = (item.progress / 100.0).toFloat().coerceIn(0f, 1f) val progressFraction = (item.progress / 100.0).toFloat().coerceIn(0f, 1f)
@@ -135,16 +145,16 @@ fun TvNextUpSection(
onEpisodeSelected: (UUID, UUID, UUID) -> Unit, onEpisodeSelected: (UUID, UUID, UUID) -> Unit,
firstItemFocusRequester: FocusRequester? = null, firstItemFocusRequester: FocusRequester? = null,
firstItemTestTag: String? = null, firstItemTestTag: String? = null,
rowTestTag: String? = null,
modifier: Modifier = Modifier modifier: Modifier = Modifier
) { ) {
if (items.isEmpty()) return if (items.isEmpty()) return
TvSectionHeader( TvSectionHeader(
title = "Next Up", title = "Next Up",
) )
LazyRow( TvHomeSectionRow(
modifier = modifier.fillMaxWidth(), modifier = modifier,
contentPadding = PaddingValues(horizontal = TvHomeSectionsHorizontalPadding), rowTestTag = rowTestTag
horizontalArrangement = Arrangement.spacedBy(TvHomeSectionsRowSpacing)
) { ) {
itemsIndexed(items = items, key = { _, item -> item.id }) { index, item -> itemsIndexed(items = items, key = { _, item -> item.id }) { index, item ->
TvHomeLandscapeCard( TvHomeLandscapeCard(
@@ -183,6 +193,7 @@ fun TvLibraryPosterSection(
onFocusedItem: (FocusableItem) -> Unit = {}, onFocusedItem: (FocusableItem) -> Unit = {},
firstItemFocusRequester: FocusRequester? = null, firstItemFocusRequester: FocusRequester? = null,
firstItemTestTag: String? = null, firstItemTestTag: String? = null,
rowTestTag: String? = null,
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
onMovieSelected: (UUID) -> Unit, onMovieSelected: (UUID) -> Unit,
onSeriesSelected: (UUID) -> Unit, onSeriesSelected: (UUID) -> Unit,
@@ -191,10 +202,9 @@ fun TvLibraryPosterSection(
TvSectionHeader( TvSectionHeader(
title = title, title = title,
) )
LazyRow( TvHomeSectionRow(
modifier = modifier.fillMaxWidth(), modifier = modifier,
contentPadding = PaddingValues(horizontal = TvHomeSectionsHorizontalPadding), rowTestTag = rowTestTag
horizontalArrangement = Arrangement.spacedBy(TvHomeSectionsRowSpacing)
) { ) {
itemsIndexed(items = items, key = { _, item -> item.id }) { index, item -> itemsIndexed(items = items, key = { _, item -> item.id }) { index, item ->
PosterCard( 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 @Composable
fun TvSectionHeader( fun TvSectionHeader(
title: String, title: String,

View File

@@ -623,11 +623,20 @@ private fun TvPlayerStateCard(
verticalArrangement = Arrangement.spacedBy(16.dp) verticalArrangement = Arrangement.spacedBy(16.dp)
) { ) {
Text( Text(
text = uiState.error ?: "Playback error", text = uiState.error?.summary ?: "Playback error",
color = scheme.onBackground, color = scheme.onBackground,
fontWeight = FontWeight.Bold, fontWeight = FontWeight.Bold,
style = MaterialTheme.typography.titleMedium 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)) { Row(horizontalArrangement = Arrangement.spacedBy(12.dp)) {
Button(onClick = onRetry) { Text("Retry") } Button(onClick = onRetry) { Text("Retry") }
Button( Button(

View File

@@ -17,6 +17,7 @@ import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip import androidx.compose.ui.draw.clip
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import hu.bbara.purefin.core.player.model.PlayerUiState import hu.bbara.purefin.core.player.model.PlayerUiState
@@ -46,10 +47,19 @@ fun PlayerLoadingErrorEndCard(
verticalArrangement = Arrangement.spacedBy(12.dp) verticalArrangement = Arrangement.spacedBy(12.dp)
) { ) {
Text( Text(
text = uiState.error ?: "Playback error", text = uiState.error?.summary ?: "Playback error",
color = scheme.onBackground, color = scheme.onBackground,
fontWeight = FontWeight.Bold 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)) { Row(horizontalArrangement = Arrangement.spacedBy(12.dp)) {
Button(onClick = onRetry) { Button(onClick = onRetry) {
Text("Retry") Text("Retry")

View File

@@ -39,4 +39,5 @@ dependencies {
implementation(libs.kotlinx.serialization.json) implementation(libs.kotlinx.serialization.json)
implementation(libs.jellyfin.core) implementation(libs.jellyfin.core)
implementation(libs.okhttp) implementation(libs.okhttp)
testImplementation(libs.junit)
} }

View File

@@ -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
}

View File

@@ -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.client.PlaybackReportContext
import hu.bbara.purefin.core.data.image.JellyfinImageHelper import hu.bbara.purefin.core.data.image.JellyfinImageHelper
import hu.bbara.purefin.core.data.session.UserSessionRepository import hu.bbara.purefin.core.data.session.UserSessionRepository
import hu.bbara.purefin.core.player.model.PlayerError
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.flow.first import kotlinx.coroutines.flow.first
import kotlinx.coroutines.withContext import kotlinx.coroutines.withContext
@@ -34,9 +35,34 @@ class PlayerMediaRepository @Inject constructor(
private val mediaRepository: MediaRepository, private val mediaRepository: MediaRepository,
private val downloadManager: DownloadManager 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) { suspend fun getMediaItem(mediaId: UUID, forceTranscode: Boolean = false): PlayerMediaLoadResult = withContext(Dispatchers.IO) {
buildOnlineMediaItem(mediaId, forceTranscode) ?: buildOfflineMediaItem(mediaId) 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( private fun calculateResumePosition(
@@ -88,17 +114,25 @@ class PlayerMediaRepository @Inject constructor(
) )
} }
}.getOrElse { error -> }.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() emptyList()
} }
} }
private suspend fun buildOnlineMediaItem(mediaId: UUID, forceTranscode: Boolean): Pair<MediaItem, Long?>? { private suspend fun buildOnlineMediaItem(mediaId: UUID, forceTranscode: Boolean): PlayerMediaLoadResult {
return runCatching { return try {
val playbackDecision = jellyfinApiClient.getPlaybackDecision( val playbackDecision = jellyfinApiClient.getPlaybackDecision(
mediaId = mediaId, mediaId = mediaId,
forceTranscode = forceTranscode 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 selectedMediaSource = playbackDecision.mediaSource
val baseItem = jellyfinApiClient.getItemInfo(mediaId) val baseItem = jellyfinApiClient.getItemInfo(mediaId)
@@ -117,42 +151,50 @@ class PlayerMediaRepository @Inject constructor(
tag = playbackDecision.reportContext tag = playbackDecision.reportContext
) )
mediaItem to resumePositionMs PlayerMediaLoadResult.Success(mediaItem, resumePositionMs)
}.getOrElse { error -> } catch (error: Exception) {
Log.w("PlayerMediaRepo", "Falling back to offline playback for $mediaId", error) Log.w(TAG, "Online load failed for $mediaId (forceTranscode=$forceTranscode)", error)
null PlayerMediaLoadResult.Failure(PlayerError.fromThrowable(error))
} }
} }
@OptIn(UnstableApi::class) @OptIn(UnstableApi::class)
private suspend fun buildOfflineMediaItem(mediaId: UUID): Pair<MediaItem, Long?>? { private suspend fun buildOfflineMediaItem(mediaId: UUID): OfflineLoadResult {
val download = downloadManager.downloadIndex.getDownload(mediaId.toString()) return try {
?.takeIf { it.state == Download.STATE_COMPLETED } val download = downloadManager.downloadIndex.getDownload(mediaId.toString())
?: return null ?.takeIf { it.state == Download.STATE_COMPLETED }
?: return OfflineLoadResult.Unavailable("Offline fallback unavailable: no completed download.")
val serverUrl = userSessionRepository.serverUrl.first() val serverUrl = userSessionRepository.serverUrl.first()
val movie = mediaRepository.movies.value[mediaId] val movie = mediaRepository.movies.value[mediaId]
val episode = mediaRepository.episodes.value[mediaId] val episode = mediaRepository.episodes.value[mediaId]
val title = movie?.title ?: episode?.title ?: String(download.request.data, Charsets.UTF_8).ifBlank { val title = movie?.title ?: episode?.title ?: String(download.request.data, Charsets.UTF_8).ifBlank {
"Offline media" "Offline media"
}
val subtitle = episode?.let { episodeSubtitle(null, it.index) }
val artworkUrl = when {
movie != null -> JellyfinImageHelper.finishImageUrl(movie.imageUrlPrefix, ImageType.PRIMARY)
episode != null -> JellyfinImageHelper.finishImageUrl(episode.imageUrlPrefix, ImageType.PRIMARY)
else -> JellyfinImageHelper.toImageUrl(serverUrl, mediaId, ImageType.PRIMARY)
}
val resumePositionMs = resumePositionFor(movie, episode)
val mediaItem = createMediaItem(
mediaId = mediaId.toString(),
playbackUrl = download.request.uri.toString(),
title = title,
subtitle = subtitle,
artworkUrl = artworkUrl,
)
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"
)
} }
val subtitle = episode?.let { episodeSubtitle(null, it.index) }
val artworkUrl = when {
movie != null -> JellyfinImageHelper.finishImageUrl(movie.imageUrlPrefix, ImageType.PRIMARY)
episode != null -> JellyfinImageHelper.finishImageUrl(episode.imageUrlPrefix, ImageType.PRIMARY)
else -> JellyfinImageHelper.toImageUrl(serverUrl, mediaId, ImageType.PRIMARY)
}
val resumePositionMs = resumePositionFor(movie, episode)
val mediaItem = createMediaItem(
mediaId = mediaId.toString(),
playbackUrl = download.request.uri.toString(),
title = title,
subtitle = subtitle,
artworkUrl = artworkUrl,
)
return mediaItem to resumePositionMs
} }
private fun resumePositionFor(movie: hu.bbara.purefin.core.model.Movie?, episode: hu.bbara.purefin.core.model.Episode?): Long? { 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" "$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()) MediaItem.SubtitleConfiguration.Builder(url.toUri())
.setMimeType(mimeType) .setMimeType(mimeType)
@@ -235,7 +277,7 @@ class PlayerMediaRepository @Inject constructor(
"sub", "microdvd" -> MimeTypes.APPLICATION_SUBRIP // sub often converted to srt by Jellyfin "sub", "microdvd" -> MimeTypes.APPLICATION_SUBRIP // sub often converted to srt by Jellyfin
"pgs", "pgssub" -> MimeTypes.APPLICATION_PGS "pgs", "pgssub" -> MimeTypes.APPLICATION_PGS
else -> { else -> {
Log.w("PlayerMediaRepo", "Unknown subtitle codec: $codec") Log.w(TAG, "Unknown subtitle codec: $codec")
null null
} }
} }
@@ -262,4 +304,9 @@ class PlayerMediaRepository @Inject constructor(
.setSubtitleConfigurations(subtitleConfigurations) .setSubtitleConfigurations(subtitleConfigurations)
.build() .build()
} }
private sealed interface OfflineLoadResult {
data class Success(val result: PlayerMediaLoadResult.Success) : OfflineLoadResult
data class Unavailable(val detail: String) : OfflineLoadResult
}
} }

View File

@@ -10,6 +10,7 @@ import androidx.media3.common.Tracks
import androidx.media3.common.util.UnstableApi import androidx.media3.common.util.UnstableApi
import dagger.hilt.android.scopes.ViewModelScoped import dagger.hilt.android.scopes.ViewModelScoped
import hu.bbara.purefin.core.data.client.PlaybackReportContext 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.QueueItemUi
import hu.bbara.purefin.core.player.model.TrackOption import hu.bbara.purefin.core.player.model.TrackOption
import hu.bbara.purefin.core.player.model.TrackType import hu.bbara.purefin.core.player.model.TrackType
@@ -74,8 +75,7 @@ class PlayerManager @Inject constructor(
state.copy( state.copy(
isBuffering = buffering, isBuffering = buffering,
isEnded = ended, isEnded = ended,
error = if (playbackState == Player.STATE_IDLE) state.error else null, error = if (playbackState == Player.STATE_IDLE) state.error else null
errorCode = if (playbackState == Player.STATE_IDLE) state.errorCode else null
) )
} }
if (ended) player.pause() if (ended) player.pause()
@@ -84,8 +84,7 @@ class PlayerManager @Inject constructor(
override fun onPlayerError(error: PlaybackException) { override fun onPlayerError(error: PlaybackException) {
_playbackState.update { _playbackState.update {
it.copy( it.copy(
error = error.errorCodeName ?: error.localizedMessage ?: "Playback error", error = PlayerError.fromPlaybackException(error)
errorCode = error.errorCode
) )
} }
} }
@@ -135,7 +134,7 @@ class PlayerManager @Inject constructor(
_progress.value = PlaybackProgressSnapshot() _progress.value = PlaybackProgressSnapshot()
refreshMetadata(mediaItem) refreshMetadata(mediaItem)
refreshQueue() 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) { fun replaceCurrentMediaItem(mediaItem: MediaItem, mediaContext: MediaContext? = null, startPositionMs: Long? = null) {
@@ -155,7 +154,7 @@ class PlayerManager @Inject constructor(
player.playWhenReady = true player.playWhenReady = true
refreshMetadata(mediaItem) refreshMetadata(mediaItem)
refreshQueue() refreshQueue()
_playbackState.update { it.copy(isEnded = false, error = null, errorCode = null) } _playbackState.update { it.copy(isEnded = false, error = null) }
} }
fun addToQueue(mediaItem: MediaItem) { fun addToQueue(mediaItem: MediaItem) {
@@ -259,7 +258,7 @@ class PlayerManager @Inject constructor(
} }
fun clearError() { fun clearError() {
_playbackState.update { it.copy(error = null, errorCode = null) } _playbackState.update { it.copy(error = null) }
} }
fun snapshotProgress(): PlaybackProgressSnapshot { fun snapshotProgress(): PlaybackProgressSnapshot {
@@ -386,8 +385,7 @@ data class PlaybackStateSnapshot(
val isPlaying: Boolean = false, val isPlaying: Boolean = false,
val isBuffering: Boolean = false, val isBuffering: Boolean = false,
val isEnded: Boolean = false, val isEnded: Boolean = false,
val error: String? = null, val error: PlayerError? = null
val errorCode: Int? = null
) )
data class PlaybackProgressSnapshot( data class PlaybackProgressSnapshot(

View File

@@ -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
}
}
}
}

View File

@@ -10,7 +10,7 @@ data class PlayerUiState(
val durationMs: Long = 0L, val durationMs: Long = 0L,
val positionMs: Long = 0L, val positionMs: Long = 0L,
val bufferedMs: Long = 0L, val bufferedMs: Long = 0L,
val error: String? = null, val error: PlayerError? = null,
val playbackSpeed: Float = 1f, val playbackSpeed: Float = 1f,
val chapters: List<TimedMarker> = emptyList(), val chapters: List<TimedMarker> = emptyList(),
val ads: List<TimedMarker> = emptyList(), val ads: List<TimedMarker> = emptyList(),

View File

@@ -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
}
}

View File

@@ -1,20 +1,20 @@
package hu.bbara.purefin.core.player.viewmodel package hu.bbara.purefin.core.player.viewmodel
import androidx.media3.common.PlaybackException
import androidx.lifecycle.SavedStateHandle import androidx.lifecycle.SavedStateHandle
import androidx.lifecycle.ViewModel import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope import androidx.lifecycle.viewModelScope
import dagger.hilt.android.lifecycle.HiltViewModel import dagger.hilt.android.lifecycle.HiltViewModel
import hu.bbara.purefin.core.data.MediaRepository 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.data.PlayerMediaRepository
import hu.bbara.purefin.core.player.manager.MediaContext import hu.bbara.purefin.core.player.manager.MediaContext
import hu.bbara.purefin.core.player.manager.PlaybackStateSnapshot import hu.bbara.purefin.core.player.manager.PlaybackStateSnapshot
import hu.bbara.purefin.core.player.manager.PlayerManager import hu.bbara.purefin.core.player.manager.PlayerManager
import hu.bbara.purefin.core.player.manager.ProgressManager 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.PlayerUiState
import hu.bbara.purefin.core.player.model.TrackOption import hu.bbara.purefin.core.player.model.TrackOption
import org.jellyfin.sdk.model.api.PlayMethod
import kotlinx.coroutines.Job import kotlinx.coroutines.Job
import kotlinx.coroutines.delay import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.MutableStateFlow
@@ -46,9 +46,10 @@ class PlayerViewModel @Inject constructor(
private var autoHideJob: Job? = null private var autoHideJob: Job? = null
private var lastNextUpMediaId: String? = null private var lastNextUpMediaId: String? = null
private var dataErrorMessage: String? = null private var loadError: PlayerError? = null
private var activeMediaId: String? = null private var activeMediaId: String? = null
private var transcodingRetryMediaId: String? = null private var transcodingRetryMediaId: String? = null
private var lastLoadRequest: PendingLoadRequest? = null
init { init {
progressManager.bind( progressManager.bind(
@@ -72,7 +73,7 @@ class PlayerViewModel @Inject constructor(
isPlaying = state.isPlaying, isPlaying = state.isPlaying,
isBuffering = state.isBuffering, isBuffering = state.isBuffering,
isEnded = state.isEnded, isEnded = state.isEnded,
error = state.error ?: dataErrorMessage error = state.error ?: loadError
) )
} }
if (state.isEnded) { if (state.isEnded) {
@@ -156,35 +157,48 @@ class PlayerViewModel @Inject constructor(
startPositionMsOverride: Long?, startPositionMsOverride: Long?,
replaceCurrent: Boolean replaceCurrent: Boolean
) { ) {
lastLoadRequest = PendingLoadRequest(
id = id,
forceTranscode = forceTranscode,
startPositionMsOverride = startPositionMsOverride,
replaceCurrent = replaceCurrent
)
val uuid = id.toUuidOrNull() val uuid = id.toUuidOrNull()
if (uuid == null) { if (uuid == null) {
dataErrorMessage = "Invalid media id" loadError = PlayerError.invalidMediaId(id)
_uiState.update { it.copy(error = dataErrorMessage) } _uiState.update { it.copy(isBuffering = false, error = loadError) }
return return
} }
loadError = null
_uiState.update { it.copy(isBuffering = true, error = null) }
viewModelScope.launch { viewModelScope.launch {
val result = playerMediaRepository.getMediaItem(uuid, forceTranscode = forceTranscode) val result = playerMediaRepository.getMediaItem(uuid, forceTranscode = forceTranscode)
if (result != null) { when (result) {
val (mediaItem, resumePositionMs) = result is PlayerMediaLoadResult.Success -> {
val mediaItem = result.mediaItem
val resumePositionMs = result.resumePositionMs
// Determine preference key: movies use their own ID, episodes use series ID // Determine preference key: movies use their own ID, episodes use series ID
val preferenceKey = mediaRepository.episodes.value[uuid]?.seriesId?.toString() ?: id val preferenceKey = mediaRepository.episodes.value[uuid]?.seriesId?.toString() ?: id
val mediaContext = MediaContext(mediaId = id, preferenceKey = preferenceKey) val mediaContext = MediaContext(mediaId = id, preferenceKey = preferenceKey)
val startPositionMs = startPositionMsOverride ?: resumePositionMs val startPositionMs = startPositionMsOverride ?: resumePositionMs
if (replaceCurrent) { if (replaceCurrent) {
playerManager.replaceCurrentMediaItem(mediaItem, mediaContext, startPositionMs) playerManager.replaceCurrentMediaItem(mediaItem, mediaContext, startPositionMs)
} else { } else {
playerManager.play(mediaItem, mediaContext, startPositionMs) playerManager.play(mediaItem, mediaContext, startPositionMs)
}
if (loadError != null) {
loadError = null
_uiState.update { it.copy(error = null) }
}
} }
if (dataErrorMessage != null) { is PlayerMediaLoadResult.Failure -> {
dataErrorMessage = null loadError = result.error
_uiState.update { it.copy(error = null) } _uiState.update { it.copy(isBuffering = false, error = loadError) }
} }
} else {
dataErrorMessage = "Unable to load media"
_uiState.update { it.copy(error = dataErrorMessage) }
} }
} }
} }
@@ -192,11 +206,10 @@ class PlayerViewModel @Inject constructor(
private fun maybeRetryWithTranscoding(state: PlaybackStateSnapshot): Boolean { private fun maybeRetryWithTranscoding(state: PlaybackStateSnapshot): Boolean {
val currentMediaId = playerManager.metadata.value.mediaId ?: return false val currentMediaId = playerManager.metadata.value.mediaId ?: return false
val playbackReportContext = playerManager.metadata.value.playbackReportContext ?: 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 (currentMediaId == transcodingRetryMediaId) return false
if (!playbackReportContext.canRetryWithTranscoding) return false if (!PlaybackRetryPolicy.shouldRetryWithTranscoding(error, playbackReportContext)) return false
if (!isRetryablePlaybackError(errorCode, state.error, playbackReportContext)) return false
transcodingRetryMediaId = currentMediaId transcodingRetryMediaId = currentMediaId
loadMediaById( loadMediaById(
@@ -208,29 +221,6 @@ class PlayerViewModel @Inject constructor(
return true 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) { private fun loadNextUp(currentMediaId: String) {
val uuid = currentMediaId.toUuidOrNull() ?: return val uuid = currentMediaId.toUuidOrNull() ?: return
viewModelScope.launch { viewModelScope.launch {
@@ -304,6 +294,11 @@ class PlayerViewModel @Inject constructor(
} }
fun retry() { fun retry() {
val error = uiState.value.error
if (error?.source == PlayerErrorSource.LOAD) {
retryLoad()
return
}
playerManager.retry() playerManager.retry()
} }
@@ -313,7 +308,7 @@ class PlayerViewModel @Inject constructor(
} }
fun clearError() { fun clearError() {
dataErrorMessage = null loadError = null
playerManager.clearError() playerManager.clearError()
_uiState.update { it.copy(error = null) } _uiState.update { it.copy(error = null) }
} }
@@ -326,5 +321,22 @@ class PlayerViewModel @Inject constructor(
playerManager.release() playerManager.release()
} }
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 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
)
} }

View File

@@ -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"))
}
}

View File

@@ -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
)
}
}