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

View File

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

View File

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

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.assertTextEquals
import androidx.compose.ui.test.junit4.createAndroidComposeRule
import androidx.compose.ui.test.onRoot
import androidx.compose.ui.test.onNodeWithText
import androidx.compose.ui.test.onNodeWithTag
import androidx.compose.ui.test.performKeyInput
import androidx.compose.ui.test.pressKey
import androidx.compose.ui.input.key.Key
import androidx.compose.ui.unit.dp
import hu.bbara.purefin.core.model.Episode
import hu.bbara.purefin.core.model.Movie
import hu.bbara.purefin.feature.shared.home.ContinueWatchingItem
import hu.bbara.purefin.feature.shared.home.LibraryItem
import hu.bbara.purefin.feature.shared.home.NextUpItem
import hu.bbara.purefin.feature.shared.home.PosterItem
import hu.bbara.purefin.tv.home.TvHomeScreen
import hu.bbara.purefin.ui.theme.AppTheme
import org.jellyfin.sdk.model.api.BaseItemKind
import org.jellyfin.sdk.model.api.CollectionType
import org.junit.Assert.assertEquals
import org.junit.Rule
import org.junit.Test
import java.util.UUID
@@ -140,6 +144,157 @@ class TvHomeContentTest {
.assertIsFocused()
}
@Test
fun tvHomeScreen_movesFocusedSectionToTopOfContent() {
composeRule.setContent {
AppTheme {
Box(
modifier = Modifier.size(width = 960.dp, height = 540.dp)
) {
TvHomeScreen(
libraries = sampleLibraries(),
libraryContent = sampleLibraryContent(),
continueWatching = sampleContinueWatching(),
nextUp = sampleNextUp(),
serverUrl = "",
onMovieSelected = {},
onSeriesSelected = {},
onEpisodeSelected = { _, _, _ -> }
)
}
}
}
composeRule.waitForIdle()
composeRule.onRoot().performKeyInput {
pressKey(Key.DirectionDown)
}
composeRule.waitForIdle()
composeRule.onNodeWithTag(TvHomeHeroTitleTag).assertTextEquals("The Train Job")
assertSectionRowAlignedToViewportTop(TvHomeNextUpRowTag)
composeRule.onRoot().performKeyInput {
pressKey(Key.DirectionDown)
}
composeRule.waitForIdle()
composeRule.onNodeWithTag(TvHomeHeroTitleTag).assertTextEquals("Arrival")
assertSectionRowAlignedToViewportTop(tvHomeLibraryRowTag(sampleLibraryId()))
}
@Test
fun tvHomeScreen_skipsMissingSectionAndAlignsLibraryToTop() {
composeRule.setContent {
AppTheme {
Box(
modifier = Modifier.size(width = 960.dp, height = 540.dp)
) {
TvHomeScreen(
libraries = sampleLibraries(),
libraryContent = sampleLibraryContent(),
continueWatching = sampleContinueWatching(),
nextUp = emptyList(),
serverUrl = "",
onMovieSelected = {},
onSeriesSelected = {},
onEpisodeSelected = { _, _, _ -> }
)
}
}
}
composeRule.waitForIdle()
composeRule.onRoot().performKeyInput {
pressKey(Key.DirectionDown)
}
composeRule.waitForIdle()
composeRule.onNodeWithTag(TvHomeHeroTitleTag).assertTextEquals("Arrival")
assertSectionRowAlignedToViewportTop(tvHomeLibraryRowTag(sampleLibraryId()))
}
@Test
fun tvHomeScreen_movesPreviousSectionToTopWhenNavigatingUp() {
composeRule.setContent {
AppTheme {
Box(
modifier = Modifier.size(width = 960.dp, height = 540.dp)
) {
TvHomeScreen(
libraries = sampleLibraries(),
libraryContent = sampleLibraryContent(),
continueWatching = sampleContinueWatching(),
nextUp = sampleNextUp(),
serverUrl = "",
onMovieSelected = {},
onSeriesSelected = {},
onEpisodeSelected = { _, _, _ -> }
)
}
}
}
composeRule.waitForIdle()
composeRule.onRoot().performKeyInput {
pressKey(Key.DirectionDown)
pressKey(Key.DirectionDown)
}
composeRule.waitForIdle()
composeRule.onRoot().performKeyInput {
pressKey(Key.DirectionUp)
}
composeRule.waitForIdle()
composeRule.onNodeWithTag(TvHomeHeroTitleTag).assertTextEquals("The Train Job")
assertSectionRowAlignedToViewportTop(TvHomeNextUpRowTag)
}
@Test
fun tvHomeScreen_horizontalMoveDoesNotChangeAlignedSectionPosition() {
composeRule.setContent {
AppTheme {
Box(
modifier = Modifier.size(width = 960.dp, height = 540.dp)
) {
TvHomeScreen(
libraries = sampleLibraries(),
libraryContent = sampleLibraryContent(),
continueWatching = sampleContinueWatching(),
nextUp = sampleNextUpRow(),
serverUrl = "",
onMovieSelected = {},
onSeriesSelected = {},
onEpisodeSelected = { _, _, _ -> }
)
}
}
}
composeRule.waitForIdle()
composeRule.onRoot().performKeyInput {
pressKey(Key.DirectionDown)
}
composeRule.waitForIdle()
assertSectionRowAlignedToViewportTop(TvHomeNextUpRowTag)
val alignedTopBefore = rowTop(TvHomeNextUpRowTag)
composeRule.onRoot().performKeyInput {
pressKey(Key.DirectionRight)
}
composeRule.waitForIdle()
composeRule.onNodeWithTag(TvHomeHeroTitleTag).assertTextEquals("Safe")
val alignedTopAfter = rowTop(TvHomeNextUpRowTag)
assertEquals(alignedTopBefore, alignedTopAfter, 2f)
}
private fun sampleContinueWatching(): List<ContinueWatchingItem> {
return listOf(
ContinueWatchingItem(
@@ -174,10 +329,39 @@ class TvHomeContentTest {
)
}
private fun sampleNextUp(): List<NextUpItem> {
return listOf(
NextUpItem(
episode = sampleEpisode(
id = "66666666-6666-6666-6666-666666666666",
title = "The Train Job"
)
)
)
}
private fun sampleNextUpRow(): List<NextUpItem> {
return listOf(
NextUpItem(
episode = sampleEpisode(
id = "66666666-6666-6666-6666-666666666666",
title = "The Train Job"
)
),
NextUpItem(
episode = sampleEpisode(
id = "77777777-7777-7777-7777-777777777777",
title = "Safe",
releaseDate = "2024-02-09"
)
)
)
}
private fun sampleLibraries(): List<LibraryItem> {
return listOf(
LibraryItem(
id = UUID.fromString("33333333-3333-3333-3333-333333333333"),
id = sampleLibraryId(),
name = "Movies",
type = CollectionType.MOVIES,
posterUrl = "",
@@ -187,7 +371,7 @@ class TvHomeContentTest {
}
private fun sampleLibraryContent(): Map<UUID, List<PosterItem>> {
val libraryId = UUID.fromString("33333333-3333-3333-3333-333333333333")
val libraryId = sampleLibraryId()
return mapOf(
libraryId to listOf(
@@ -204,6 +388,32 @@ class TvHomeContentTest {
)
}
private fun sampleEpisode(
id: String,
title: String,
releaseDate: String = "2002-09-20",
progress: Double? = 18.0,
seriesId: UUID = UUID.fromString("88888888-8888-8888-8888-888888888888"),
seasonId: UUID = UUID.fromString("99999999-9999-9999-9999-999999999999")
): Episode {
return Episode(
id = UUID.fromString(id),
seriesId = seriesId,
seasonId = seasonId,
index = 1,
title = title,
synopsis = "A crew member takes the shuttle for a spin and makes a mess.",
releaseDate = releaseDate,
rating = "16+",
runtime = "44m",
progress = progress,
watched = false,
format = "HD",
imageUrlPrefix = "https://images.unsplash.com/photo-1511497584788-876760111969",
cast = emptyList()
)
}
private fun sampleMovie(
id: String,
title: String,
@@ -227,4 +437,29 @@ class TvHomeContentTest {
cast = emptyList()
)
}
private fun sampleLibraryId(): UUID {
return UUID.fromString("33333333-3333-3333-3333-333333333333")
}
private fun assertSectionRowAlignedToViewportTop(sectionRowTag: String) {
val expectedRowTop = viewportTop() + with(composeRule.density) {
TvHomeFocusedItemTopOffset.toPx()
}
assertEquals(expectedRowTop, rowTop(sectionRowTag), 2f)
}
private fun viewportTop(): Float {
return composeRule.onNodeWithTag(TvHomeContentViewportTag, useUnmergedTree = true)
.fetchSemanticsNode()
.boundsInRoot
.top
}
private fun rowTop(sectionRowTag: String): Float {
return composeRule.onNodeWithTag(sectionRowTag, useUnmergedTree = true)
.fetchSemanticsNode()
.boundsInRoot
.top
}
}

View File

@@ -8,6 +8,7 @@ import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.remember
import androidx.compose.runtime.withFrameNanos
import androidx.compose.ui.Modifier
import androidx.compose.ui.focus.FocusRequester
import androidx.compose.ui.unit.dp
@@ -17,15 +18,14 @@ import hu.bbara.purefin.common.ui.PurefinWaitingScreen
import hu.bbara.purefin.common.ui.components.MediaDetailOverviewSection
import hu.bbara.purefin.common.ui.components.MediaDetailPlaybackSection
import hu.bbara.purefin.common.ui.components.MediaDetailSectionTitle
import hu.bbara.purefin.common.ui.components.TvMediaDetailBodyBox
import hu.bbara.purefin.common.ui.components.TvMediaDetailScaffold
import hu.bbara.purefin.core.data.image.JellyfinImageHelper
import hu.bbara.purefin.core.data.navigation.EpisodeDto
import hu.bbara.purefin.core.data.navigation.LocalNavigationBackStack
import hu.bbara.purefin.core.data.navigation.LocalNavigationManager
import hu.bbara.purefin.core.data.navigation.Route
import hu.bbara.purefin.common.ui.components.tvMediaDetailBackgroundImageUrl
import hu.bbara.purefin.core.data.navigation.EpisodeDto
import hu.bbara.purefin.core.model.Episode
import hu.bbara.purefin.feature.shared.content.episode.EpisodeScreenViewModel
import org.jellyfin.sdk.model.api.ImageType
@Composable
fun EpisodeScreen(
@@ -34,8 +34,6 @@ fun EpisodeScreen(
modifier: Modifier = Modifier
) {
val navigationManager = LocalNavigationManager.current
val backStack = LocalNavigationBackStack.current
val previousRoute = remember(backStack) { backStack.getOrNull(backStack.lastIndex - 1) }
LaunchedEffect(episode) {
viewModel.selectEpisode(
@@ -57,7 +55,6 @@ fun EpisodeScreen(
EpisodeScreenContent(
episode = selectedEpisode,
seriesTitle = seriesTitle.value,
onBack = viewModel::onBack,
onPlay = remember(selectedEpisode.id, navigationManager) {
{
navigationManager.navigate(
@@ -73,33 +70,35 @@ fun EpisodeScreen(
internal fun EpisodeScreenContent(
episode: Episode,
seriesTitle: String?,
onBack: () -> Unit,
onPlay: () -> Unit,
modifier: Modifier = Modifier,
) {
val playFocusRequester = remember { FocusRequester() }
LaunchedEffect(episode.id) {
withFrameNanos { }
playFocusRequester.requestFocus()
}
TvMediaDetailScaffold(
artworkImageUrl = JellyfinImageHelper.finishImageUrl(episode.imageUrlPrefix, ImageType.PRIMARY),
artworkWidth = 280.dp,
artworkAspectRatio = 16f / 9f,
resetScrollKey = episode.id,
modifier = modifier,
heroContent = {
EpisodeHeroSection(
episode = episode,
seriesTitle = seriesTitle,
onPlay = onPlay,
playFocusRequester = playFocusRequester,
modifier = Modifier.fillMaxWidth()
)
Spacer(modifier = Modifier.height(12.dp))
}
modifier = modifier
) {
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 {
Column(modifier = it.fillMaxWidth()) {
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.collectAsState
import androidx.compose.runtime.remember
import androidx.compose.runtime.withFrameNanos
import androidx.compose.ui.Modifier
import androidx.compose.ui.focus.FocusRequester
import androidx.compose.ui.unit.dp
@@ -17,12 +18,12 @@ import hu.bbara.purefin.common.ui.PurefinWaitingScreen
import hu.bbara.purefin.common.ui.components.MediaDetailOverviewSection
import hu.bbara.purefin.common.ui.components.MediaDetailPlaybackSection
import hu.bbara.purefin.common.ui.components.MediaDetailSectionTitle
import hu.bbara.purefin.common.ui.components.TvMediaDetailBodyBox
import hu.bbara.purefin.common.ui.components.TvMediaDetailScaffold
import hu.bbara.purefin.core.data.image.JellyfinImageHelper
import hu.bbara.purefin.common.ui.components.tvMediaDetailBackgroundImageUrl
import hu.bbara.purefin.core.data.navigation.MovieDto
import hu.bbara.purefin.core.model.Movie
import hu.bbara.purefin.feature.shared.content.movie.MovieScreenViewModel
import org.jellyfin.sdk.model.api.ImageType
@Composable
fun MovieScreen(
@@ -37,7 +38,6 @@ fun MovieScreen(
if (movieItem.value != null) {
MovieScreenContent(
movie = movieItem.value!!,
onBack = viewModel::onBack,
onPlay = viewModel::onPlay,
modifier = modifier
)
@@ -49,32 +49,34 @@ fun MovieScreen(
@Composable
internal fun MovieScreenContent(
movie: Movie,
onBack: () -> Unit,
onPlay: () -> Unit,
modifier: Modifier = Modifier,
) {
val playFocusRequester = remember { FocusRequester() }
LaunchedEffect(movie.id) {
withFrameNanos { }
playFocusRequester.requestFocus()
}
TvMediaDetailScaffold(
artworkImageUrl = JellyfinImageHelper.finishImageUrl(movie.imageUrlPrefix, ImageType.PRIMARY),
artworkWidth = 200.dp,
artworkAspectRatio = 2f / 3f,
resetScrollKey = movie.id,
modifier = modifier,
heroContent = {
MovieHeroSection(
movie = movie,
onPlay = onPlay,
playFocusRequester = playFocusRequester,
modifier = Modifier.fillMaxWidth()
)
Spacer(modifier = Modifier.height(12.dp))
}
modifier = modifier
) {
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 {
Column(modifier = it.fillMaxWidth()) {
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.LaunchedEffect
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.runtime.withFrameNanos
import androidx.compose.ui.Modifier
import androidx.compose.ui.focus.FocusRequester
import androidx.compose.ui.unit.dp
@@ -16,14 +19,14 @@ import androidx.hilt.navigation.compose.hiltViewModel
import hu.bbara.purefin.common.ui.PurefinWaitingScreen
import hu.bbara.purefin.common.ui.components.MediaDetailOverviewSection
import hu.bbara.purefin.common.ui.components.MediaDetailSectionTitle
import hu.bbara.purefin.common.ui.components.TvMediaDetailBodyBox
import hu.bbara.purefin.common.ui.components.TvMediaDetailScaffold
import hu.bbara.purefin.core.data.image.JellyfinImageHelper
import hu.bbara.purefin.common.ui.components.tvMediaDetailBackgroundImageUrl
import hu.bbara.purefin.core.data.navigation.SeriesDto
import hu.bbara.purefin.core.model.Season
import hu.bbara.purefin.core.model.Series
import hu.bbara.purefin.feature.shared.content.series.SeriesViewModel
import org.jellyfin.sdk.model.UUID
import org.jellyfin.sdk.model.api.ImageType
@Composable
fun SeriesScreen(
@@ -41,7 +44,6 @@ fun SeriesScreen(
if (seriesData != null && seriesData.seasons.isNotEmpty()) {
SeriesScreenContent(
series = seriesData,
onBack = viewModel::onBack,
onPlayEpisode = viewModel::onPlayEpisode,
modifier = modifier
)
@@ -53,27 +55,16 @@ fun SeriesScreen(
@Composable
internal fun SeriesScreenContent(
series: Series,
onBack: () -> Unit,
onPlayEpisode: (UUID) -> Unit,
modifier: Modifier = Modifier,
) {
fun getDefaultSeason(): Season {
for (season in series.seasons) {
val firstUnwatchedEpisode = season.episodes.firstOrNull { it.watched.not() }
if (firstUnwatchedEpisode != null) return season
}
return series.seasons.first()
}
val selectedSeason = remember(series.id) { mutableStateOf(getDefaultSeason()) }
val nextUpEpisode = remember(series.id) {
series.seasons.firstNotNullOfOrNull { season ->
season.episodes.firstOrNull { !it.watched }
} ?: series.seasons.firstOrNull()?.episodes?.firstOrNull()
}
var selectedSeason by remember(series.id) { mutableStateOf(series.defaultSeason()) }
val nextUpEpisode = remember(series.id) { series.nextUpEpisode() }
val playFocusRequester = remember { FocusRequester() }
val firstContentFocusRequester = remember { FocusRequester() }
LaunchedEffect(series.id, nextUpEpisode?.id) {
withFrameNanos { }
if (nextUpEpisode != null) {
playFocusRequester.requestFocus()
} else {
@@ -82,23 +73,25 @@ internal fun SeriesScreenContent(
}
TvMediaDetailScaffold(
artworkImageUrl = JellyfinImageHelper.finishImageUrl(series.imageUrlPrefix, ImageType.PRIMARY),
artworkWidth = 200.dp,
artworkAspectRatio = 2f / 3f,
resetScrollKey = series.id,
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))
}
modifier = modifier
) {
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 {
Column(modifier = it.fillMaxWidth()) {
Spacer(modifier = Modifier.height(16.dp))
@@ -112,17 +105,17 @@ internal fun SeriesScreenContent(
item {
SeasonTabs(
seasons = series.seasons,
selectedSeason = selectedSeason.value,
selectedSeason = selectedSeason,
firstItemFocusRequester = firstContentFocusRequester,
firstItemTestTag = SeriesFirstSeasonTabTag,
onSelect = { selectedSeason.value = it },
onSelect = { selectedSeason = it },
modifier = it
)
}
item {
Spacer(modifier = Modifier.height(20.dp))
EpisodeCarousel(
episodes = selectedSeason.value.episodes,
episodes = selectedSeason.episodes,
modifier = it
)
}
@@ -139,3 +132,16 @@ internal fun SeriesScreenContent(
}
}
}
private fun Series.defaultSeason(): Season {
for (season in seasons) {
if (season.episodes.any { !it.watched }) {
return season
}
}
return seasons.first()
}
private fun Series.nextUpEpisode() = seasons.firstNotNullOfOrNull { season ->
season.episodes.firstOrNull { !it.watched }
} ?: seasons.firstOrNull()?.episodes?.firstOrNull()

View File

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

View File

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

View File

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

View File

@@ -3,39 +3,29 @@ package hu.bbara.purefin.tv.home.ui
import androidx.compose.animation.Crossfade
import androidx.compose.animation.core.tween
import androidx.compose.foundation.background
import androidx.compose.foundation.border
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.layout.widthIn
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.Brush
import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.platform.testTag
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import hu.bbara.purefin.common.ui.components.MediaProgressBar
import hu.bbara.purefin.common.ui.components.PurefinAsyncImage
internal const val TvHomeHeroTitleTag = "tv-home-hero-title"
internal const val TvHomeHeroStatusTag = "tv-home-hero-status"
internal const val TvHomeHeroProgressLabelTag = "tv-home-hero-progress-label"
private const val TvHomeHeroAnimationMillis = 180
@@ -107,13 +97,6 @@ internal fun TvFocusedItemHero(
verticalArrangement = Arrangement.spacedBy(8.dp),
modifier = Modifier.widthIn(max = 720.dp)
) {
Text(
text = hero.eyebrowText.uppercase(),
color = scheme.primary,
fontSize = 10.sp,
letterSpacing = 1.2.sp,
fontWeight = FontWeight.Bold
)
Text(
text = hero.title,
color = scheme.onBackground,
@@ -134,54 +117,6 @@ internal fun TvFocusedItemHero(
overflow = TextOverflow.Ellipsis
)
}
if (hero.watchedText != null || hero.progressFraction != null) {
Row(
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(10.dp),
modifier = Modifier
.clip(RoundedCornerShape(22.dp))
.background(scheme.surfaceContainerHigh.copy(alpha = 0.92f))
.border(1.dp, scheme.outlineVariant.copy(alpha = 0.6f), RoundedCornerShape(22.dp))
.padding(horizontal = 12.dp, vertical = 10.dp)
) {
hero.watchedText?.let { watchedText ->
TvHomeMetaChip(
text = watchedText,
highlighted = true,
modifier = Modifier.testTag(TvHomeHeroStatusTag)
)
}
if (hero.progressFraction != null && hero.progressLabel != null) {
Column(
verticalArrangement = Arrangement.spacedBy(4.dp),
modifier = Modifier.width(188.dp)
) {
Text(
text = "Progress",
color = scheme.onSurfaceVariant.copy(alpha = 0.85f),
fontSize = 10.sp,
fontWeight = FontWeight.Medium
)
MediaProgressBar(
progress = hero.progressFraction,
foregroundColor = scheme.primary,
backgroundColor = scheme.surfaceVariant,
contentPadding = PaddingValues(0.dp),
barHeight = 6.dp,
modifier = Modifier
)
}
Text(
text = hero.progressLabel,
color = scheme.onBackground,
fontSize = 14.sp,
fontWeight = FontWeight.SemiBold,
textAlign = TextAlign.End,
modifier = Modifier.testTag(TvHomeHeroProgressLabelTag)
)
}
}
}
}
}
}

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

View File

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

View File

@@ -623,11 +623,20 @@ private fun TvPlayerStateCard(
verticalArrangement = Arrangement.spacedBy(16.dp)
) {
Text(
text = uiState.error ?: "Playback error",
text = uiState.error?.summary ?: "Playback error",
color = scheme.onBackground,
fontWeight = FontWeight.Bold,
style = MaterialTheme.typography.titleMedium
)
uiState.error?.detailText?.let { detail ->
Text(
text = detail,
color = scheme.onBackground.copy(alpha = 0.82f),
style = MaterialTheme.typography.bodySmall,
maxLines = 4,
overflow = TextOverflow.Ellipsis
)
}
Row(horizontalArrangement = Arrangement.spacedBy(12.dp)) {
Button(onClick = onRetry) { Text("Retry") }
Button(

View File

@@ -17,6 +17,7 @@ import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp
import hu.bbara.purefin.core.player.model.PlayerUiState
@@ -46,10 +47,19 @@ fun PlayerLoadingErrorEndCard(
verticalArrangement = Arrangement.spacedBy(12.dp)
) {
Text(
text = uiState.error ?: "Playback error",
text = uiState.error?.summary ?: "Playback error",
color = scheme.onBackground,
fontWeight = FontWeight.Bold
)
uiState.error?.detailText?.let { detail ->
Text(
text = detail,
color = scheme.onBackground.copy(alpha = 0.8f),
style = MaterialTheme.typography.bodySmall,
maxLines = 4,
overflow = TextOverflow.Ellipsis
)
}
Row(horizontalArrangement = Arrangement.spacedBy(12.dp)) {
Button(onClick = onRetry) {
Text("Retry")

View File

@@ -39,4 +39,5 @@ dependencies {
implementation(libs.kotlinx.serialization.json)
implementation(libs.jellyfin.core)
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.image.JellyfinImageHelper
import hu.bbara.purefin.core.data.session.UserSessionRepository
import hu.bbara.purefin.core.player.model.PlayerError
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.withContext
@@ -34,9 +35,34 @@ class PlayerMediaRepository @Inject constructor(
private val mediaRepository: MediaRepository,
private val downloadManager: DownloadManager
) {
companion object {
private const val TAG = "PlayerMediaRepo"
}
suspend fun getMediaItem(mediaId: UUID, forceTranscode: Boolean = false): Pair<MediaItem, Long?>? = withContext(Dispatchers.IO) {
buildOnlineMediaItem(mediaId, forceTranscode) ?: buildOfflineMediaItem(mediaId)
suspend fun getMediaItem(mediaId: UUID, forceTranscode: Boolean = false): PlayerMediaLoadResult = withContext(Dispatchers.IO) {
when (val onlineResult = buildOnlineMediaItem(mediaId, forceTranscode)) {
is PlayerMediaLoadResult.Success -> onlineResult
is PlayerMediaLoadResult.Failure -> {
when (val offlineResult = buildOfflineMediaItem(mediaId)) {
is OfflineLoadResult.Success -> {
Log.w(
TAG,
"Using offline fallback for $mediaId after online load failure (forceTranscode=$forceTranscode): ${onlineResult.error.detailText ?: onlineResult.error.summary}"
)
offlineResult.result
}
is OfflineLoadResult.Unavailable -> {
val finalError = onlineResult.error.withAdditionalTechnicalDetail(offlineResult.detail)
Log.w(
TAG,
"Unable to resolve media $mediaId (forceTranscode=$forceTranscode): ${finalError.detailText ?: finalError.summary}"
)
PlayerMediaLoadResult.Failure(finalError)
}
}
}
}
}
private fun calculateResumePosition(
@@ -88,17 +114,25 @@ class PlayerMediaRepository @Inject constructor(
)
}
}.getOrElse { error ->
Log.w("PlayerMediaRepo", "Unable to load next-up items for $episodeId", error)
Log.w(TAG, "Unable to load next-up items for $episodeId", error)
emptyList()
}
}
private suspend fun buildOnlineMediaItem(mediaId: UUID, forceTranscode: Boolean): Pair<MediaItem, Long?>? {
return runCatching {
private suspend fun buildOnlineMediaItem(mediaId: UUID, forceTranscode: Boolean): PlayerMediaLoadResult {
return try {
val playbackDecision = jellyfinApiClient.getPlaybackDecision(
mediaId = mediaId,
forceTranscode = forceTranscode
) ?: return null
) ?: run {
val detail = if (forceTranscode) {
"Jellyfin did not return a transcoded playback source."
} else {
"Jellyfin did not return a playable media source."
}
Log.w(TAG, "No playback decision for $mediaId (forceTranscode=$forceTranscode)")
return PlayerMediaLoadResult.Failure(PlayerError.loadFailure(technicalDetail = detail))
}
val selectedMediaSource = playbackDecision.mediaSource
val baseItem = jellyfinApiClient.getItemInfo(mediaId)
@@ -117,42 +151,50 @@ class PlayerMediaRepository @Inject constructor(
tag = playbackDecision.reportContext
)
mediaItem to resumePositionMs
}.getOrElse { error ->
Log.w("PlayerMediaRepo", "Falling back to offline playback for $mediaId", error)
null
PlayerMediaLoadResult.Success(mediaItem, resumePositionMs)
} catch (error: Exception) {
Log.w(TAG, "Online load failed for $mediaId (forceTranscode=$forceTranscode)", error)
PlayerMediaLoadResult.Failure(PlayerError.fromThrowable(error))
}
}
@OptIn(UnstableApi::class)
private suspend fun buildOfflineMediaItem(mediaId: UUID): Pair<MediaItem, Long?>? {
val download = downloadManager.downloadIndex.getDownload(mediaId.toString())
?.takeIf { it.state == Download.STATE_COMPLETED }
?: return null
private suspend fun buildOfflineMediaItem(mediaId: UUID): OfflineLoadResult {
return try {
val download = downloadManager.downloadIndex.getDownload(mediaId.toString())
?.takeIf { it.state == Download.STATE_COMPLETED }
?: return OfflineLoadResult.Unavailable("Offline fallback unavailable: no completed download.")
val serverUrl = userSessionRepository.serverUrl.first()
val movie = mediaRepository.movies.value[mediaId]
val episode = mediaRepository.episodes.value[mediaId]
val serverUrl = userSessionRepository.serverUrl.first()
val movie = mediaRepository.movies.value[mediaId]
val episode = mediaRepository.episodes.value[mediaId]
val title = movie?.title ?: episode?.title ?: String(download.request.data, Charsets.UTF_8).ifBlank {
"Offline media"
val title = movie?.title ?: episode?.title ?: String(download.request.data, Charsets.UTF_8).ifBlank {
"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? {
@@ -211,7 +253,7 @@ class PlayerMediaRepository @Inject constructor(
"$baseUrl/Videos/$mediaId/$mediaSourceId/Subtitles/${stream.index}/0/Stream.$format"
}
Log.d("PlayerMediaRepo", "External subtitle: ${stream.displayTitle} ($codec) -> $url")
Log.d(TAG, "External subtitle: ${stream.displayTitle} ($codec) -> $url")
MediaItem.SubtitleConfiguration.Builder(url.toUri())
.setMimeType(mimeType)
@@ -235,7 +277,7 @@ class PlayerMediaRepository @Inject constructor(
"sub", "microdvd" -> MimeTypes.APPLICATION_SUBRIP // sub often converted to srt by Jellyfin
"pgs", "pgssub" -> MimeTypes.APPLICATION_PGS
else -> {
Log.w("PlayerMediaRepo", "Unknown subtitle codec: $codec")
Log.w(TAG, "Unknown subtitle codec: $codec")
null
}
}
@@ -262,4 +304,9 @@ class PlayerMediaRepository @Inject constructor(
.setSubtitleConfigurations(subtitleConfigurations)
.build()
}
private sealed interface OfflineLoadResult {
data class Success(val result: PlayerMediaLoadResult.Success) : OfflineLoadResult
data class Unavailable(val detail: String) : OfflineLoadResult
}
}

View File

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

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 positionMs: Long = 0L,
val bufferedMs: Long = 0L,
val error: String? = null,
val error: PlayerError? = null,
val playbackSpeed: Float = 1f,
val chapters: List<TimedMarker> = emptyList(),
val ads: List<TimedMarker> = emptyList(),

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

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