diff --git a/app-tv/src/androidTest/java/hu/bbara/purefin/tv/home/ui/TvHomeContentTest.kt b/app-tv/src/androidTest/java/hu/bbara/purefin/tv/home/ui/TvHomeContentTest.kt index 7534cb88..e2413dad 100644 --- a/app-tv/src/androidTest/java/hu/bbara/purefin/tv/home/ui/TvHomeContentTest.kt +++ b/app-tv/src/androidTest/java/hu/bbara/purefin/tv/home/ui/TvHomeContentTest.kt @@ -1,13 +1,25 @@ package hu.bbara.purefin.tv.home.ui import androidx.activity.ComponentActivity +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.size +import androidx.compose.ui.Modifier +import androidx.compose.ui.test.ExperimentalTestApi +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.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.Movie import hu.bbara.purefin.feature.shared.home.ContinueWatchingItem import hu.bbara.purefin.feature.shared.home.LibraryItem 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 @@ -15,6 +27,7 @@ import org.junit.Rule import org.junit.Test import java.util.UUID +@OptIn(ExperimentalTestApi::class) class TvHomeContentTest { @get:Rule @@ -42,25 +55,120 @@ class TvHomeContentTest { composeRule.onNodeWithTag(TvHomeInitialFocusTag).assertIsFocused() } + @Test + fun tvHomeScreen_showsHeroForInitiallyFocusedItem() { + composeRule.setContent { + AppTheme { + TvHomeScreen( + libraries = sampleLibraries(), + libraryContent = sampleLibraryContent(), + continueWatching = sampleContinueWatching(), + nextUp = emptyList(), + serverUrl = "", + onMovieSelected = {}, + onSeriesSelected = {}, + onEpisodeSelected = { _, _, _ -> } + ) + } + } + + composeRule.waitForIdle() + + composeRule.onNodeWithTag(TvHomeInitialFocusTag).assertIsFocused() + composeRule.onNodeWithTag(TvHomeHeroTitleTag).assertTextEquals("Blade Runner 2049") + composeRule.onNodeWithTag(TvHomeHeroProgressLabelTag).assertTextEquals("42%") + } + + @Test + fun tvHomeScreen_updatesHeroWhenFocusMovesWithinRow() { + composeRule.setContent { + AppTheme { + TvHomeScreen( + libraries = sampleLibraries(), + libraryContent = sampleLibraryContent(), + continueWatching = sampleContinueWatchingRow(), + nextUp = emptyList(), + serverUrl = "", + onMovieSelected = {}, + onSeriesSelected = {}, + onEpisodeSelected = { _, _, _ -> } + ) + } + } + + composeRule.waitForIdle() + + composeRule.onNodeWithTag(TvHomeHeroTitleTag).assertTextEquals("Blade Runner 2049") + composeRule.onNodeWithTag(TvHomeInitialFocusTag) + .assertIsFocused() + .performKeyInput { + pressKey(Key.DirectionRight) + } + + composeRule.waitForIdle() + + composeRule.onNodeWithTag(TvHomeHeroTitleTag).assertTextEquals("Mad Max: Fury Road") + } + + @Test + fun tvHomeScreen_compactHeroKeepsFirstRowVisible() { + 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.onNodeWithTag(TvHomeHeroTitleTag).assertIsDisplayed() + composeRule.onNodeWithText("Continue Watching").assertIsDisplayed() + composeRule.onNodeWithTag(TvHomeInitialFocusTag) + .assertIsDisplayed() + .assertIsFocused() + } + private fun sampleContinueWatching(): List { return listOf( ContinueWatchingItem( type = BaseItemKind.MOVIE, - movie = Movie( - id = UUID.fromString("11111111-1111-1111-1111-111111111111"), - libraryId = UUID.fromString("22222222-2222-2222-2222-222222222222"), + movie = sampleMovie( + id = "11111111-1111-1111-1111-111111111111", title = "Blade Runner 2049", - progress = 42.0, - watched = false, - year = "2017", - rating = "16+", - runtime = "164m", - format = "4K", - synopsis = "Officer K uncovers a secret that sends him searching for Rick Deckard.", - imageUrlPrefix = "https://images.unsplash.com/photo-1500530855697-b586d89ba3ee", - audioTrack = "ENG 5.1", - subtitles = "ENG", - cast = emptyList() + progress = 42.0 + ) + ) + ) + } + + private fun sampleContinueWatchingRow(): List { + return listOf( + ContinueWatchingItem( + type = BaseItemKind.MOVIE, + movie = sampleMovie( + id = "11111111-1111-1111-1111-111111111111", + title = "Blade Runner 2049", + progress = 42.0 + ) + ), + ContinueWatchingItem( + type = BaseItemKind.MOVIE, + movie = sampleMovie( + id = "55555555-5555-5555-5555-555555555555", + title = "Mad Max: Fury Road", + progress = 8.0 ) ) ) @@ -85,24 +193,38 @@ class TvHomeContentTest { libraryId to listOf( PosterItem( type = BaseItemKind.MOVIE, - movie = Movie( - id = UUID.fromString("44444444-4444-4444-4444-444444444444"), - libraryId = libraryId, + movie = sampleMovie( + id = "44444444-4444-4444-4444-444444444444", title = "Arrival", progress = null, - watched = false, - year = "2016", - rating = "12+", - runtime = "116m", - format = "4K", - synopsis = "A linguist works to communicate with mysterious visitors.", - imageUrlPrefix = "https://images.unsplash.com/photo-1446776811953-b23d57bd21aa", - audioTrack = "ENG 5.1", - subtitles = "ENG", - cast = emptyList() + libraryId = libraryId ) ) ) ) } + + private fun sampleMovie( + id: String, + title: String, + progress: Double?, + libraryId: UUID = UUID.fromString("22222222-2222-2222-2222-222222222222") + ): Movie { + return Movie( + id = UUID.fromString(id), + libraryId = libraryId, + title = title, + progress = progress, + watched = false, + year = "2017", + rating = "16+", + runtime = "164m", + format = "4K", + synopsis = "Officer K uncovers a secret that sends him searching for Rick Deckard.", + imageUrlPrefix = "https://images.unsplash.com/photo-1500530855697-b586d89ba3ee", + audioTrack = "ENG 5.1", + subtitles = "ENG", + cast = emptyList() + ) + } } diff --git a/app-tv/src/androidTest/java/hu/bbara/purefin/tv/home/ui/TvHomeTopBarTest.kt b/app-tv/src/androidTest/java/hu/bbara/purefin/tv/home/ui/TvHomeTopBarTest.kt new file mode 100644 index 00000000..12d11375 --- /dev/null +++ b/app-tv/src/androidTest/java/hu/bbara/purefin/tv/home/ui/TvHomeTopBarTest.kt @@ -0,0 +1,61 @@ +package hu.bbara.purefin.tv.home.ui + +import androidx.activity.ComponentActivity +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.outlined.Home +import androidx.compose.material.icons.outlined.Search +import androidx.compose.ui.test.assertIsNotSelected +import androidx.compose.ui.test.assertIsSelected +import androidx.compose.ui.test.junit4.createAndroidComposeRule +import androidx.compose.ui.test.onNodeWithTag +import androidx.compose.ui.test.performClick +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableIntStateOf +import androidx.compose.runtime.setValue +import hu.bbara.purefin.ui.theme.AppTheme +import org.junit.Rule +import org.junit.Test + +class TvHomeTopBarTest { + + @get:Rule + val composeRule = createAndroidComposeRule() + + @Test + fun tvHomeTopBar_updatesSelectedTab() { + var selectedTabIndex by mutableIntStateOf(1) + + composeRule.setContent { + AppTheme { + TvHomeTopBar( + tabs = listOf( + TvHomeTabItem( + destination = TvHomeTabDestination.SEARCH, + label = "Search", + icon = Icons.Outlined.Search + ), + TvHomeTabItem( + destination = TvHomeTabDestination.HOME, + label = "Home", + icon = Icons.Outlined.Home + ) + ), + selectedTabIndex = selectedTabIndex, + onTabSelected = { index, _ -> + selectedTabIndex = index + } + ) + } + } + + composeRule.onNodeWithTag("${TvHomeTabTagPrefix}1").assertIsSelected() + composeRule.onNodeWithTag("${TvHomeTabTagPrefix}0") + .assertIsNotSelected() + .performClick() + + composeRule.waitForIdle() + + composeRule.onNodeWithTag("${TvHomeTabTagPrefix}0").assertIsSelected() + composeRule.onNodeWithTag("${TvHomeTabTagPrefix}1").assertIsNotSelected() + } +} diff --git a/app-tv/src/main/java/hu/bbara/purefin/common/ui/PosterCard.kt b/app-tv/src/main/java/hu/bbara/purefin/common/ui/PosterCard.kt index 62f607fb..9e3f4502 100644 --- a/app-tv/src/main/java/hu/bbara/purefin/common/ui/PosterCard.kt +++ b/app-tv/src/main/java/hu/bbara/purefin/common/ui/PosterCard.kt @@ -26,6 +26,7 @@ import androidx.compose.ui.graphics.graphicsLayer import androidx.compose.ui.layout.ContentScale import androidx.compose.ui.text.font.FontWeight 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.PurefinAsyncImage @@ -41,6 +42,10 @@ fun PosterCard( item: PosterItem, modifier: Modifier = Modifier, imageModifier: Modifier = Modifier, + posterWidth: Dp = 144.dp, + showSecondaryText: Boolean = false, + indicatorSize: Int = 28, + indicatorPadding: Dp = 8.dp, onFocusedItem: (FocusableItem) -> Unit = {}, onMovieSelected: (UUID) -> Unit, onSeriesSelected: (UUID) -> Unit, @@ -50,8 +55,6 @@ fun PosterCard( var isFocused by remember { mutableStateOf(false) } val scale by animateFloatAsState(targetValue = if (isFocused) 1.07f else 1.0f, label = "scale") - val posterWidth = 144.dp - fun openItem(posterItem: PosterItem) { when (posterItem.type) { BaseItemKind.MOVIE -> onMovieSelected(posterItem.id) @@ -73,7 +76,7 @@ fun PosterCard( transformOrigin = TransformOrigin(0.5f, 0f) } ) { - Box() { + Box { PurefinAsyncImage( model = item.imageUrl, contentDescription = null, @@ -99,9 +102,9 @@ fun PosterCard( BaseItemKind.MOVIE -> { val m = item.movie!! WatchStateIndicator( - size = 28, + size = indicatorSize, modifier = Modifier.align(Alignment.TopEnd) - .padding(8.dp), + .padding(indicatorPadding), watched = m.watched, started = (m.progress ?: 0.0) > 0 ) @@ -109,30 +112,45 @@ fun PosterCard( BaseItemKind.EPISODE -> { val ep = item.episode!! WatchStateIndicator( - size = 28, + size = indicatorSize, modifier = Modifier.align(Alignment.TopEnd) - .padding(8.dp), + .padding(indicatorPadding), watched = ep.watched, started = (ep.progress ?: 0.0) > 0 ) } BaseItemKind.SERIES -> UnwatchedEpisodeIndicator( - size = 28, + size = indicatorSize, modifier = Modifier.align(Alignment.TopEnd) - .padding(8.dp), + .padding(indicatorPadding), unwatchedCount = item.series!!.unwatchedEpisodeCount ) else -> {} } } - Text( - text = item.title, - color = scheme.onBackground, - fontSize = 13.sp, - fontWeight = FontWeight.Medium, - modifier = Modifier.padding(top = 8.dp, start = 4.dp, end = 4.dp, bottom = 8.dp), - maxLines = 1, - overflow = TextOverflow.Ellipsis - ) + Column( + modifier = Modifier.padding(top = 8.dp, start = 4.dp, end = 4.dp, bottom = 8.dp) + ) { + Text( + text = item.title, + color = scheme.onBackground, + fontSize = 13.sp, + fontWeight = FontWeight.Medium, + maxLines = 1, + overflow = TextOverflow.Ellipsis + ) + item.secondaryText + .takeIf { showSecondaryText } + ?.takeIf { it.isNotBlank() } + ?.let { text -> + Text( + text = text, + color = scheme.onSurfaceVariant, + fontSize = 11.sp, + maxLines = 1, + overflow = TextOverflow.Ellipsis + ) + } + } } } diff --git a/app-tv/src/main/java/hu/bbara/purefin/common/ui/components/MediaDetailShell.kt b/app-tv/src/main/java/hu/bbara/purefin/common/ui/components/MediaDetailShell.kt index 6685e07c..cacf6cd7 100644 --- a/app-tv/src/main/java/hu/bbara/purefin/common/ui/components/MediaDetailShell.kt +++ b/app-tv/src/main/java/hu/bbara/purefin/common/ui/components/MediaDetailShell.kt @@ -18,7 +18,6 @@ import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect import androidx.compose.ui.Modifier -import androidx.compose.ui.graphics.Brush import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp @@ -61,7 +60,7 @@ internal fun TvMediaDetailScaffold( heightFraction = heroHeightFraction, modifier = Modifier.fillMaxWidth() ) - MediaDetailHeroGradientOverlay() + MediaHeroScrimOverlay() Column( modifier = contentPadding .padding(top = 104.dp, bottom = 36.dp) @@ -131,36 +130,3 @@ internal fun MediaDetailPlaybackSection( ) } } - -@Composable -private fun MediaDetailHeroGradientOverlay() { - val background = MaterialTheme.colorScheme.background - - Box( - modifier = Modifier - .fillMaxSize() - .background( - Brush.horizontalGradient( - colors = listOf( - background, - background.copy(alpha = 0.95f), - background.copy(alpha = 0.7f), - background.copy(alpha = 0.15f) - ) - ) - ) - ) - Box( - modifier = Modifier - .fillMaxSize() - .background( - Brush.verticalGradient( - colors = listOf( - background.copy(alpha = 0.05f), - background.copy(alpha = 0.2f), - background - ) - ) - ) - ) -} diff --git a/app-tv/src/main/java/hu/bbara/purefin/common/ui/components/MediaHeroScrimOverlay.kt b/app-tv/src/main/java/hu/bbara/purefin/common/ui/components/MediaHeroScrimOverlay.kt new file mode 100644 index 00000000..d8dd296b --- /dev/null +++ b/app-tv/src/main/java/hu/bbara/purefin/common/ui/components/MediaHeroScrimOverlay.kt @@ -0,0 +1,42 @@ +package hu.bbara.purefin.common.ui.components + +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.material3.MaterialTheme +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Brush + +@Composable +internal fun MediaHeroScrimOverlay(modifier: Modifier = Modifier) { + val background = MaterialTheme.colorScheme.background + + Box( + modifier = modifier + .fillMaxSize() + .background( + Brush.horizontalGradient( + colors = listOf( + background, + background.copy(alpha = 0.95f), + background.copy(alpha = 0.7f), + background.copy(alpha = 0.15f) + ) + ) + ) + ) + Box( + modifier = modifier + .fillMaxSize() + .background( + Brush.verticalGradient( + colors = listOf( + background.copy(alpha = 0.05f), + background.copy(alpha = 0.2f), + background + ) + ) + ) + ) +} diff --git a/app-tv/src/main/java/hu/bbara/purefin/common/ui/components/MediaProgressBar.kt b/app-tv/src/main/java/hu/bbara/purefin/common/ui/components/MediaProgressBar.kt index 7e8d9f01..92f0840e 100644 --- a/app-tv/src/main/java/hu/bbara/purefin/common/ui/components/MediaProgressBar.kt +++ b/app-tv/src/main/java/hu/bbara/purefin/common/ui/components/MediaProgressBar.kt @@ -2,6 +2,7 @@ package hu.bbara.purefin.common.ui.components import androidx.compose.foundation.background import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.PaddingValues import androidx.compose.foundation.layout.fillMaxHeight import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.height @@ -12,6 +13,7 @@ import androidx.compose.runtime.Composable import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip import androidx.compose.ui.graphics.Color +import androidx.compose.ui.unit.Dp import androidx.compose.ui.unit.dp /** @@ -27,15 +29,18 @@ fun MediaProgressBar( progress: Float, foregroundColor: Color = MaterialTheme.colorScheme.onSurface, backgroundColor: Color = MaterialTheme.colorScheme.primary, + contentPadding: PaddingValues = PaddingValues(start = 8.dp, end = 8.dp, bottom = 8.dp), + barHeight: Dp = 4.dp, + cornerRadius: Dp = 24.dp, modifier: Modifier ) { - if (progress == 0f) return + if (progress <= 0f) return Box( modifier = modifier - .padding(bottom = 8.dp, start = 8.dp, end = 8.dp) - .clip(RoundedCornerShape(24.dp)) + .padding(contentPadding) + .clip(RoundedCornerShape(cornerRadius)) .fillMaxWidth() - .height(4.dp) + .height(barHeight) .background(backgroundColor.copy(alpha = 0.2f)) ) { Box( @@ -45,4 +50,4 @@ fun MediaProgressBar( .background(foregroundColor) ) } -} \ No newline at end of file +} diff --git a/app-tv/src/main/java/hu/bbara/purefin/tv/home/TvHomeScreen.kt b/app-tv/src/main/java/hu/bbara/purefin/tv/home/TvHomeScreen.kt index dadc0008..2369b989 100644 --- a/app-tv/src/main/java/hu/bbara/purefin/tv/home/TvHomeScreen.kt +++ b/app-tv/src/main/java/hu/bbara/purefin/tv/home/TvHomeScreen.kt @@ -1,21 +1,27 @@ package hu.bbara.purefin.tv.home -import android.annotation.SuppressLint +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.BoxWithConstraints +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.material3.MaterialTheme import androidx.compose.runtime.Composable -import androidx.compose.runtime.getValue -import androidx.compose.runtime.mutableStateOf -import androidx.compose.runtime.remember -import androidx.compose.runtime.setValue import androidx.compose.ui.Modifier +import androidx.compose.ui.unit.dp import hu.bbara.purefin.feature.shared.home.ContinueWatchingItem -import hu.bbara.purefin.feature.shared.home.FocusableItem 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.ui.TvFocusedItemHero import hu.bbara.purefin.tv.home.ui.TvHomeContent +import hu.bbara.purefin.tv.home.ui.rememberTvHomeHeroState import org.jellyfin.sdk.model.UUID -@SuppressLint("RememberInComposition") +private const val TvHomeHeroHeightFraction = 0.32f +private val TvHomeMinHeroHeight = 160.dp +private val TvHomeMaxHeroHeight = 200.dp + @Composable fun TvHomeScreen( libraries: List, @@ -28,20 +34,46 @@ fun TvHomeScreen( onEpisodeSelected: (UUID, UUID, UUID) -> Unit, modifier: Modifier = Modifier, ) { - var focusableItem by remember { mutableStateOf(null) } - - TvHomeContent( + val scheme = MaterialTheme.colorScheme + val heroState = rememberTvHomeHeroState( libraries = libraries, libraryContent = libraryContent, continueWatching = continueWatching, - nextUp = nextUp, - onMediaFocused = { item: FocusableItem -> - focusableItem = item - }, - onMovieSelected = onMovieSelected, - onSeriesSelected = onSeriesSelected, - onEpisodeSelected = onEpisodeSelected, - modifier = modifier + nextUp = nextUp ) + BoxWithConstraints( + modifier = modifier + .fillMaxSize() + .background(scheme.background) + ) { + val heroHeight = heroState.focusedHero?.let { + (maxHeight * TvHomeHeroHeightFraction) + .coerceIn(TvHomeMinHeroHeight, TvHomeMaxHeroHeight) + } + + Column( + modifier = Modifier.fillMaxSize() + ) { + heroState.focusedHero?.let { hero -> + TvFocusedItemHero( + item = hero, + height = heroHeight ?: TvHomeMinHeroHeight + ) + } + TvHomeContent( + libraries = libraries, + libraryContent = libraryContent, + continueWatching = continueWatching, + nextUp = nextUp, + onMediaFocused = heroState.onMediaFocused, + onMovieSelected = onMovieSelected, + onSeriesSelected = onSeriesSelected, + onEpisodeSelected = onEpisodeSelected, + modifier = Modifier + .weight(1f) + .fillMaxWidth() + ) + } + } } diff --git a/app-tv/src/main/java/hu/bbara/purefin/tv/home/ui/TvFocusedHeroModel.kt b/app-tv/src/main/java/hu/bbara/purefin/tv/home/ui/TvFocusedHeroModel.kt new file mode 100644 index 00000000..6860b22a --- /dev/null +++ b/app-tv/src/main/java/hu/bbara/purefin/tv/home/ui/TvFocusedHeroModel.kt @@ -0,0 +1,140 @@ +package hu.bbara.purefin.tv.home.ui + +import hu.bbara.purefin.core.data.image.JellyfinImageHelper +import hu.bbara.purefin.core.model.Episode +import hu.bbara.purefin.core.model.Movie +import hu.bbara.purefin.core.model.Series +import hu.bbara.purefin.feature.shared.home.ContinueWatchingItem +import hu.bbara.purefin.feature.shared.home.FocusableItem +import hu.bbara.purefin.feature.shared.home.NextUpItem +import hu.bbara.purefin.feature.shared.home.PosterItem +import org.jellyfin.sdk.model.UUID +import org.jellyfin.sdk.model.api.BaseItemKind +import org.jellyfin.sdk.model.api.ImageType +import kotlin.math.roundToInt + +internal data class TvFocusedHeroModel( + val id: UUID, + val backdropImageUrl: String, + val eyebrowText: String, + val title: String, + val metadata: List, + val watchedText: String?, + val progressFraction: Float?, + val progressLabel: String?, +) { + val metadataText: String? + get() = metadata.takeIf { it.isNotEmpty() }?.joinToString(" • ") +} + +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") + else -> unsupportedType(type) + } + + is NextUpItem -> episode.toTvFocusedHeroModel(sourceLabel = "Next up") + is PosterItem -> when (type) { + BaseItemKind.MOVIE -> movie!!.toTvFocusedHeroModel(sourceLabel = "Movie") + BaseItemKind.EPISODE -> episode!!.toTvFocusedHeroModel(sourceLabel = "Episode") + BaseItemKind.SERIES -> series!!.toTvFocusedHeroModel(sourceLabel = "Series") + else -> unsupportedType(type) + } + } +} + +internal fun backdropImageUrl(imageUrlPrefix: String?, fallbackImageUrl: String): String { + val backdropImageUrl = JellyfinImageHelper.finishImageUrl(imageUrlPrefix, ImageType.BACKDROP) + return backdropImageUrl.ifBlank { fallbackImageUrl } +} + +private fun Movie.toTvFocusedHeroModel(sourceLabel: String): TvFocusedHeroModel { + val progressFraction = progressFraction(progress) + 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) + return TvFocusedHeroModel( + id = id, + backdropImageUrl = backdropImageUrl( + imageUrlPrefix = imageUrlPrefix, + fallbackImageUrl = JellyfinImageHelper.finishImageUrl(imageUrlPrefix, ImageType.PRIMARY) + ), + eyebrowText = sourceLabel, + title = title, + metadata = listOf( + "Episode $index", + releaseDate, + runtime, + 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 { + val unwatchedText = if (unwatchedEpisodeCount > 0) { + "$unwatchedEpisodeCount unwatched" + } else { + null + } + + return TvFocusedHeroModel( + id = id, + backdropImageUrl = backdropImageUrl( + 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, + ) +} + +private fun progressFraction(progress: Double?): Float? { + val fraction = progress + ?.div(100.0) + ?.toFloat() + ?.coerceIn(0f, 1f) + + return fraction?.takeIf { it > 0f } +} + +private fun progressLabel(progressFraction: Float?): String? { + return progressFraction?.let { "${(it * 100).roundToInt()}%" } +} + +private fun List.compactMetadata(): List { + return map(String?::orEmpty) + .map(String::trim) + .filter(String::isNotBlank) +} + +private fun seasonLabel(seasonCount: Int): String { + return if (seasonCount == 1) "1 season" else "$seasonCount seasons" +} + +private fun unsupportedType(type: BaseItemKind): Nothing { + throw UnsupportedOperationException("Unsupported item type: $type") +} diff --git a/app-tv/src/main/java/hu/bbara/purefin/tv/home/ui/TvFocusedItemHero.kt b/app-tv/src/main/java/hu/bbara/purefin/tv/home/ui/TvFocusedItemHero.kt new file mode 100644 index 00000000..62df38fa --- /dev/null +++ b/app-tv/src/main/java/hu/bbara/purefin/tv/home/ui/TvFocusedItemHero.kt @@ -0,0 +1,193 @@ +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.MediaHeroScrimOverlay +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 +private val TvHomeHeroShape = RoundedCornerShape(bottomStart = 28.dp, bottomEnd = 28.dp) + +@Composable +internal fun TvFocusedItemHero( + item: TvFocusedHeroModel, + height: Dp, + modifier: Modifier = Modifier, +) { + val scheme = MaterialTheme.colorScheme + Box( + modifier = modifier + .fillMaxWidth() + .height(height) + .clip(TvHomeHeroShape) + .background(scheme.background) + ) { + Crossfade( + targetState = item.backdropImageUrl, + animationSpec = tween(durationMillis = TvHomeHeroAnimationMillis), + label = "tv-home-hero-background" + ) { imageUrl -> + PurefinAsyncImage( + model = imageUrl, + contentDescription = null, + modifier = Modifier.fillMaxSize(), + contentScale = ContentScale.Crop + ) + } + MediaHeroScrimOverlay() + Box( + modifier = Modifier + .fillMaxSize() + .background( + Brush.horizontalGradient( + colors = listOf( + scheme.background, + scheme.background.copy(alpha = 0.94f), + scheme.background.copy(alpha = 0.72f), + scheme.background.copy(alpha = 0.18f) + ) + ) + ) + ) + Box( + modifier = Modifier + .fillMaxSize() + .background( + Brush.verticalGradient( + colors = listOf( + scheme.background.copy(alpha = 0f), + scheme.background.copy(alpha = 0.22f), + scheme.background.copy(alpha = 0.92f) + ) + ) + ) + ) + Crossfade( + targetState = item, + animationSpec = tween(durationMillis = TvHomeHeroAnimationMillis), + label = "tv-home-hero-content" + ) { hero -> + Column( + verticalArrangement = Arrangement.Bottom, + modifier = Modifier + .fillMaxSize() + .padding(horizontal = 40.dp, vertical = 18.dp) + ) { + Column( + 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, + fontSize = 34.sp, + fontWeight = FontWeight.Bold, + lineHeight = 38.sp, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + modifier = Modifier.testTag(TvHomeHeroTitleTag) + ) + hero.metadataText?.let { metadataText -> + Text( + text = metadataText, + color = scheme.onSurfaceVariant, + fontSize = 14.sp, + lineHeight = 18.sp, + maxLines = 1, + 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) + ) + } + } + } + } + } + } + } +} diff --git a/app-tv/src/main/java/hu/bbara/purefin/tv/home/ui/TvHomeContent.kt b/app-tv/src/main/java/hu/bbara/purefin/tv/home/ui/TvHomeContent.kt index f8b8b5f2..f3e1e9ac 100644 --- a/app-tv/src/main/java/hu/bbara/purefin/tv/home/ui/TvHomeContent.kt +++ b/app-tv/src/main/java/hu/bbara/purefin/tv/home/ui/TvHomeContent.kt @@ -1,11 +1,13 @@ package hu.bbara.purefin.tv.home.ui import androidx.compose.foundation.background +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.material3.MaterialTheme import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue @@ -19,7 +21,6 @@ import androidx.compose.ui.focus.FocusRequester.Companion.FocusRequesterFactory. 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.graphics.Color import androidx.compose.ui.unit.dp import hu.bbara.purefin.feature.shared.home.ContinueWatchingItem import hu.bbara.purefin.feature.shared.home.FocusableItem @@ -40,30 +41,24 @@ fun TvHomeContent( onMovieSelected: (UUID) -> Unit, onSeriesSelected: (UUID) -> Unit, onEpisodeSelected: (UUID, UUID, UUID) -> Unit, + contentPadding: PaddingValues = PaddingValues(bottom = 32.dp), modifier: Modifier = Modifier, ) { - val visibleLibraries = remember(libraries, libraryContent) { - libraries.filter { libraryContent[it.id]?.isEmpty() != true } - } - val ( nextUpRef, continueWatchingRef ) = remember { FocusRequester.createRefs() } + val scheme = MaterialTheme.colorScheme + val itemRegistry = rememberTvHomeItemRegistry( + libraries = libraries, + libraryContent = libraryContent, + continueWatching = continueWatching, + 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 firstVisibleLibraryId = visibleLibraries.firstOrNull()?.id - val firstAvailableItemKey = remember( - continueWatching, - nextUp, - firstVisibleLibraryId, - libraryContent - ) { - when { - continueWatching.isNotEmpty() -> continueWatching.first().id - nextUp.isNotEmpty() -> nextUp.first().id - firstVisibleLibraryId != null -> libraryContent[firstVisibleLibraryId]?.firstOrNull()?.id - else -> null - } - } + val firstAvailableItemKey = itemRegistry.firstAvailableItemId var initialFocusApplied by remember { mutableStateOf(false) } LaunchedEffect(firstAvailableItemKey, initialFocusApplied) { @@ -77,7 +72,8 @@ fun TvHomeContent( LazyColumn( modifier = modifier .fillMaxSize() - .background(Color.Transparent) + .background(scheme.background), + contentPadding = contentPadding ) { item { Spacer(modifier = Modifier.height(8.dp)) @@ -97,7 +93,7 @@ fun TvHomeContent( ) } item { - Spacer(modifier = Modifier.height(16.dp)) + Spacer(modifier = Modifier.height(20.dp)) } item { TvNextUpSection( @@ -116,7 +112,7 @@ fun TvHomeContent( ) } item { - Spacer(modifier = Modifier.height(16.dp)) + Spacer(modifier = Modifier.height(20.dp)) } items( items = visibleLibraries, @@ -148,7 +144,7 @@ fun TvHomeContent( ?.let { down = it } } ) - Spacer(modifier = Modifier.height(8.dp)) + Spacer(modifier = Modifier.height(20.dp)) } } } diff --git a/app-tv/src/main/java/hu/bbara/purefin/tv/home/ui/TvHomeHeroState.kt b/app-tv/src/main/java/hu/bbara/purefin/tv/home/ui/TvHomeHeroState.kt new file mode 100644 index 00000000..21eec08c --- /dev/null +++ b/app-tv/src/main/java/hu/bbara/purefin/tv/home/ui/TvHomeHeroState.kt @@ -0,0 +1,111 @@ +package hu.bbara.purefin.tv.home.ui + +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import hu.bbara.purefin.feature.shared.home.ContinueWatchingItem +import hu.bbara.purefin.feature.shared.home.FocusableItem +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 org.jellyfin.sdk.model.UUID + +internal data class TvHomeItemRegistry( + val visibleLibraries: List, + private val libraryContent: Map>, + private val continueWatching: List, + private val nextUp: List, + val firstAvailableItemId: UUID?, +) { + val firstAvailableItem: FocusableItem? + get() = firstAvailableItemId?.let(::itemById) + + fun itemById(id: UUID): FocusableItem? { + return continueWatching.firstOrNull { it.id == id } + ?: nextUp.firstOrNull { it.id == id } + ?: visibleLibraries.asSequence() + .mapNotNull { library -> libraryContent[library.id] } + .flatten() + .firstOrNull { it.id == id } + } +} + +internal class TvHomeHeroState( + val focusedHero: TvFocusedHeroModel?, + val onMediaFocused: (FocusableItem) -> Unit, +) + +@Composable +internal fun rememberTvHomeItemRegistry( + libraries: List, + libraryContent: Map>, + continueWatching: List, + nextUp: List, +): TvHomeItemRegistry { + return remember(libraries, libraryContent, continueWatching, nextUp) { + createTvHomeItemRegistry( + libraries = libraries, + libraryContent = libraryContent, + continueWatching = continueWatching, + nextUp = nextUp + ) + } +} + +internal fun createTvHomeItemRegistry( + libraries: List, + libraryContent: Map>, + continueWatching: List, + nextUp: List, +): TvHomeItemRegistry { + val visibleLibraries = libraries.filter { libraryContent[it.id]?.isEmpty() != true } + val firstAvailableItemId = continueWatching.firstOrNull()?.id + ?: nextUp.firstOrNull()?.id + ?: visibleLibraries.firstOrNull()?.id?.let { libraryId -> + libraryContent[libraryId]?.firstOrNull()?.id + } + + return TvHomeItemRegistry( + visibleLibraries = visibleLibraries, + libraryContent = libraryContent, + continueWatching = continueWatching, + nextUp = nextUp, + firstAvailableItemId = firstAvailableItemId + ) +} + +@Composable +internal fun rememberTvHomeHeroState( + libraries: List, + libraryContent: Map>, + continueWatching: List, + nextUp: List, +): TvHomeHeroState { + val itemRegistry = rememberTvHomeItemRegistry( + libraries = libraries, + libraryContent = libraryContent, + continueWatching = continueWatching, + nextUp = nextUp + ) + var focusedItemId by remember { mutableStateOf(null) } + val focusedItem = remember(focusedItemId, itemRegistry) { + focusedItemId?.let(itemRegistry::itemById) + } + val focusedHero = remember(focusedItem, itemRegistry) { + (focusedItem ?: itemRegistry.firstAvailableItem)?.toTvFocusedHeroModel() + } + val onMediaFocused: (FocusableItem) -> Unit = remember { + { item -> + focusedItemId = item.id + } + } + + return remember(focusedHero, onMediaFocused) { + TvHomeHeroState( + focusedHero = focusedHero, + onMediaFocused = onMediaFocused + ) + } +} diff --git a/app-tv/src/main/java/hu/bbara/purefin/tv/home/ui/TvHomeMetaChip.kt b/app-tv/src/main/java/hu/bbara/purefin/tv/home/ui/TvHomeMetaChip.kt new file mode 100644 index 00000000..6a115126 --- /dev/null +++ b/app-tv/src/main/java/hu/bbara/purefin/tv/home/ui/TvHomeMetaChip.kt @@ -0,0 +1,46 @@ +package hu.bbara.purefin.tv.home.ui + +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.padding +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.text.font.FontWeight +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp + +private val TvHomeMetaChipShape = RoundedCornerShape(999.dp) + +@Composable +internal fun TvHomeMetaChip( + text: String, + modifier: Modifier = Modifier, + highlighted: Boolean = false, +) { + val scheme = MaterialTheme.colorScheme + Box( + modifier = modifier + .clip(TvHomeMetaChipShape) + .background( + if (highlighted) { + scheme.primary.copy(alpha = 0.16f) + } else { + scheme.surfaceContainerHigh.copy(alpha = 0.82f) + } + ) + .padding(horizontal = 12.dp, vertical = 7.dp), + contentAlignment = Alignment.Center + ) { + Text( + text = text, + color = if (highlighted) scheme.primary else scheme.onSurfaceVariant, + fontSize = 12.sp, + fontWeight = FontWeight.SemiBold + ) + } +} diff --git a/app-tv/src/main/java/hu/bbara/purefin/tv/home/ui/TvHomeSections.kt b/app-tv/src/main/java/hu/bbara/purefin/tv/home/ui/TvHomeSections.kt index a3c664bd..28978f7e 100644 --- a/app-tv/src/main/java/hu/bbara/purefin/tv/home/ui/TvHomeSections.kt +++ b/app-tv/src/main/java/hu/bbara/purefin/tv/home/ui/TvHomeSections.kt @@ -31,6 +31,7 @@ import androidx.compose.ui.draw.clip import androidx.compose.ui.focus.FocusRequester import androidx.compose.ui.focus.focusRequester import androidx.compose.ui.focus.onFocusChanged +import androidx.compose.ui.graphics.Brush import androidx.compose.ui.graphics.TransformOrigin import androidx.compose.ui.graphics.graphicsLayer import androidx.compose.ui.layout.ContentScale @@ -50,7 +51,13 @@ import hu.bbara.purefin.feature.shared.home.PosterItem import org.jellyfin.sdk.model.UUID import org.jellyfin.sdk.model.api.BaseItemKind import org.jellyfin.sdk.model.api.ImageType -import kotlin.math.nextUp + +private val TvHomeSectionsThumbShape = RoundedCornerShape(20.dp) +private val TvHomeSectionsPillShape = RoundedCornerShape(18.dp) +private val TvHomeSectionsHorizontalPadding = 32.dp +private val TvHomeSectionsRowSpacing = 18.dp +private val TvHomeLandscapeCardWidth = 248.dp +private val TvHomePosterCardWidth = 136.dp @Composable fun TvContinueWatchingSection( @@ -68,14 +75,30 @@ fun TvContinueWatchingSection( action = null ) LazyRow( - modifier = modifier - .fillMaxWidth(), - contentPadding = PaddingValues(horizontal = 16.dp), - horizontalArrangement = Arrangement.spacedBy(16.dp) + modifier = modifier.fillMaxWidth(), + contentPadding = PaddingValues(horizontal = TvHomeSectionsHorizontalPadding), + horizontalArrangement = Arrangement.spacedBy(TvHomeSectionsRowSpacing) ) { itemsIndexed(items = items, key = { _, item -> item.id }) { index, item -> - TvContinueWatchingCard( - item = item, + val progressFraction = (item.progress / 100.0).toFloat().coerceIn(0f, 1f) + TvHomeLandscapeCard( + title = item.primaryText, + supporting = item.secondaryText, + imageUrl = when (item.type) { + BaseItemKind.MOVIE -> JellyfinImageHelper.finishImageUrl( + prefixImageUrl = item.movie?.imageUrlPrefix, + imageType = ImageType.PRIMARY + ) + + BaseItemKind.EPISODE -> JellyfinImageHelper.finishImageUrl( + prefixImageUrl = item.episode?.imageUrlPrefix, + imageType = ImageType.PRIMARY + ) + + else -> null + }, + badgeText = if (progressFraction > 0f) "Resume" else null, + progress = progressFraction, imageModifier = Modifier .then( if (index == 0 && firstItemFocusRequester != null) { @@ -91,115 +114,18 @@ fun TvContinueWatchingSection( Modifier } ), - onFocusedItem = onFocusedItem, - onMovieSelected = onMovieSelected, - onEpisodeSelected = onEpisodeSelected - ) - } - } -} - -@Composable -fun TvContinueWatchingCard( - item: ContinueWatchingItem, - modifier: Modifier = Modifier, - imageModifier: Modifier = Modifier, - onFocusedItem: (FocusableItem) -> Unit = {}, - onMovieSelected: (UUID) -> Unit, - onEpisodeSelected: (UUID, UUID, UUID) -> Unit, -) { - val scheme = MaterialTheme.colorScheme - - var isFocused by remember { mutableStateOf(false) } - val scale by animateFloatAsState(targetValue = if (isFocused) 1.07f else 1.0f, label = "scale") - - val imageUrl = when (item.type) { - BaseItemKind.MOVIE -> JellyfinImageHelper.finishImageUrl( - prefixImageUrl = item.movie?.imageUrlPrefix, - imageType = ImageType.PRIMARY - ) - BaseItemKind.EPISODE -> JellyfinImageHelper.finishImageUrl( - prefixImageUrl = item.episode?.imageUrlPrefix, - imageType = ImageType.PRIMARY - ) - else -> null - } - - val cardWidth = 280.dp - - fun openItem(item: ContinueWatchingItem) { - when (item.type) { - BaseItemKind.MOVIE -> onMovieSelected(item.movie!!.id) - BaseItemKind.EPISODE -> { - val episode = item.episode!! - onEpisodeSelected(episode.seriesId, episode.seasonId, episode.id) - } - - else -> {} - } - } - - Column( - modifier = modifier - .width(cardWidth) - .wrapContentHeight() - .graphicsLayer { - scaleX = scale - scaleY = scale - transformOrigin = TransformOrigin(0.5f, 0f) - } - ) { - Box( - modifier = Modifier - .width(cardWidth) - .aspectRatio(16f / 9f) - .clip(RoundedCornerShape(16.dp)) - .border( - width = if (isFocused) 2.dp else 1.dp, - color = if (isFocused) scheme.primary else scheme.outlineVariant.copy(alpha = 0.3f), - shape = RoundedCornerShape(16.dp) - ) - .background(scheme.surfaceVariant) - ) { - PurefinAsyncImage( - model = imageUrl, - contentDescription = null, - modifier = imageModifier - .fillMaxSize() - .onFocusChanged { - isFocused = it.isFocused - if (it.isFocused) { - onFocusedItem(item) + onFocusedItem = { onFocusedItem(item) }, + onClick = { + when (item.type) { + BaseItemKind.MOVIE -> onMovieSelected(item.movie!!.id) + BaseItemKind.EPISODE -> { + val episode = item.episode!! + onEpisodeSelected(episode.seriesId, episode.seasonId, episode.id) } + + else -> Unit } - .clickable { - openItem(item) - }, - contentScale = ContentScale.Crop, - ) - MediaProgressBar( - progress = item.progress.toFloat().nextUp().div(100), - foregroundColor = scheme.onSurface, - backgroundColor = scheme.primary, - modifier = Modifier - .align(Alignment.BottomStart) - ) - } - Column(modifier = Modifier.padding(top = 12.dp)) { - Text( - text = item.primaryText, - color = scheme.onBackground, - fontSize = 16.sp, - fontWeight = FontWeight.SemiBold, - maxLines = 1, - overflow = TextOverflow.Ellipsis - ) - Text( - text = item.secondaryText, - color = scheme.onSurfaceVariant, - fontSize = 13.sp, - maxLines = 1, - overflow = TextOverflow.Ellipsis + } ) } } @@ -220,14 +146,16 @@ fun TvNextUpSection( action = null ) LazyRow( - modifier = modifier - .fillMaxWidth(), - contentPadding = PaddingValues(horizontal = 16.dp), - horizontalArrangement = Arrangement.spacedBy(16.dp) + modifier = modifier.fillMaxWidth(), + contentPadding = PaddingValues(horizontal = TvHomeSectionsHorizontalPadding), + horizontalArrangement = Arrangement.spacedBy(TvHomeSectionsRowSpacing) ) { itemsIndexed(items = items, key = { _, item -> item.id }) { index, item -> - TvNextUpCard( - item = item, + TvHomeLandscapeCard( + title = item.primaryText, + supporting = item.secondaryText, + imageUrl = item.imageUrl, + badgeText = "Next Up", imageModifier = Modifier .then( if (index == 0 && firstItemFocusRequester != null) { @@ -243,89 +171,11 @@ fun TvNextUpSection( Modifier } ), - onFocusedItem = onFocusedItem, - onEpisodeSelected = onEpisodeSelected - ) - } - } -} - -@Composable -fun TvNextUpCard( - item: NextUpItem, - modifier: Modifier = Modifier, - imageModifier: Modifier = Modifier, - onFocusedItem: (FocusableItem) -> Unit = {}, - onEpisodeSelected: (UUID, UUID, UUID) -> Unit, -) { - val scheme = MaterialTheme.colorScheme - - var isFocused by remember { mutableStateOf(false) } - val scale by animateFloatAsState(targetValue = if (isFocused) 1.07f else 1.0f, label = "scale") - - val imageUrl = item.imageUrl - - val cardWidth = 280.dp - - fun openItem(item: NextUpItem) { - val episode = item.episode - onEpisodeSelected(episode.seriesId, episode.seasonId, episode.id) - } - - Column( - modifier = modifier - .width(cardWidth) - .wrapContentHeight() - .graphicsLayer { - scaleX = scale - scaleY = scale - transformOrigin = TransformOrigin(0.5f, 0f) - } - ) { - Box( - modifier = Modifier - .width(cardWidth) - .aspectRatio(16f / 9f) - .clip(RoundedCornerShape(16.dp)) - .border( - width = if (isFocused) 2.dp else 1.dp, - color = if (isFocused) scheme.primary else scheme.outlineVariant.copy(alpha = 0.3f), - shape = RoundedCornerShape(16.dp) - ) - .background(scheme.surfaceVariant) - ) { - PurefinAsyncImage( - model = imageUrl, - contentDescription = null, - modifier = imageModifier - .fillMaxSize() - .onFocusChanged { - isFocused = it.isFocused - if (it.isFocused) { - onFocusedItem(item) - } - } - .clickable { - openItem(item) - }, - contentScale = ContentScale.Crop, - ) - } - Column(modifier = Modifier.padding(top = 12.dp)) { - Text( - text = item.primaryText, - color = scheme.onBackground, - fontSize = 16.sp, - fontWeight = FontWeight.SemiBold, - maxLines = 1, - overflow = TextOverflow.Ellipsis - ) - Text( - text = item.secondaryText, - color = scheme.onSurfaceVariant, - fontSize = 13.sp, - maxLines = 1, - overflow = TextOverflow.Ellipsis + onFocusedItem = { onFocusedItem(item) }, + onClick = { + val episode = item.episode + onEpisodeSelected(episode.seriesId, episode.seasonId, episode.id) + } ) } } @@ -349,14 +199,17 @@ fun TvLibraryPosterSection( action = action ) LazyRow( - modifier = modifier - .fillMaxWidth(), - contentPadding = PaddingValues(horizontal = 16.dp), - horizontalArrangement = Arrangement.spacedBy(16.dp) + modifier = modifier.fillMaxWidth(), + contentPadding = PaddingValues(horizontal = TvHomeSectionsHorizontalPadding), + horizontalArrangement = Arrangement.spacedBy(TvHomeSectionsRowSpacing) ) { itemsIndexed(items = items, key = { _, item -> item.id }) { index, item -> PosterCard( item = item, + posterWidth = TvHomePosterCardWidth, + showSecondaryText = true, + indicatorSize = 24, + indicatorPadding = 6.dp, imageModifier = Modifier .then( if (index == 0 && firstItemFocusRequester != null) { @@ -389,27 +242,150 @@ fun TvSectionHeader( onActionClick: () -> Unit = {} ) { val scheme = MaterialTheme.colorScheme - Row( modifier = modifier .fillMaxWidth() - .padding(horizontal = 16.dp, vertical = 12.dp), + .padding(horizontal = TvHomeSectionsHorizontalPadding, vertical = 14.dp), verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.SpaceBetween ) { Text( text = title, color = scheme.onBackground, - fontSize = 20.sp, - fontWeight = FontWeight.Bold + fontSize = 22.sp, + fontWeight = FontWeight.SemiBold ) if (action != null) { - Text( - text = action, - color = scheme.primary, - fontSize = 14.sp, - fontWeight = FontWeight.SemiBold, - modifier = Modifier.clickable { onActionClick() }) + Box( + modifier = Modifier + .clip(TvHomeSectionsPillShape) + .background(scheme.surfaceContainerHigh.copy(alpha = 0.82f)) + .border(1.dp, scheme.outlineVariant.copy(alpha = 0.6f), TvHomeSectionsPillShape) + .clickable { onActionClick() } + .padding(horizontal = 12.dp, vertical = 7.dp), + contentAlignment = Alignment.Center + ) { + Text( + text = action, + color = scheme.onSurfaceVariant, + fontSize = 12.sp, + fontWeight = FontWeight.SemiBold + ) + } + } + } +} + +@Composable +private fun TvHomeLandscapeCard( + title: String, + supporting: String, + imageUrl: String?, + modifier: Modifier = Modifier, + imageModifier: Modifier = Modifier, + badgeText: String? = null, + progress: Float? = null, + onFocusedItem: () -> Unit = {}, + onClick: () -> Unit, +) { + val scheme = MaterialTheme.colorScheme + var isFocused by remember { mutableStateOf(false) } + val scale by animateFloatAsState(targetValue = if (isFocused) 1.055f else 1f, label = "tv-home-landscape-scale") + val cardWidth = TvHomeLandscapeCardWidth + + Column( + modifier = modifier + .width(cardWidth) + .wrapContentHeight() + .graphicsLayer { + scaleX = scale + scaleY = scale + transformOrigin = TransformOrigin(0.5f, 0f) + } + ) { + Box( + modifier = Modifier + .width(cardWidth) + .aspectRatio(16f / 9f) + .clip(TvHomeSectionsThumbShape) + .border( + width = if (isFocused) 2.dp else 1.dp, + color = if (isFocused) scheme.primary else scheme.outlineVariant.copy(alpha = 0.6f), + shape = TvHomeSectionsThumbShape + ) + .background(scheme.surfaceContainer) + ) { + PurefinAsyncImage( + model = imageUrl, + contentDescription = null, + modifier = imageModifier + .fillMaxSize() + .onFocusChanged { + isFocused = it.isFocused + if (it.isFocused) { + onFocusedItem() + } + } + .clickable(onClick = onClick), + contentScale = ContentScale.Crop, + ) + Box( + modifier = Modifier + .fillMaxSize() + .background( + Brush.verticalGradient( + colors = listOf( + scheme.background.copy(alpha = 0.04f), + scheme.background.copy(alpha = 0.12f), + scheme.background.copy(alpha = 0.56f) + ) + ) + ) + ) + badgeText?.let { badge -> + TvHomeMetaChip( + text = badge, + modifier = Modifier + .align(Alignment.TopStart) + .padding(8.dp), + highlighted = isFocused + ) + } + if (progress != null && progress > 0f) { + MediaProgressBar( + progress = progress, + foregroundColor = scheme.primary, + backgroundColor = scheme.surfaceVariant, + contentPadding = PaddingValues(0.dp), + barHeight = 6.dp, + modifier = Modifier + .align(Alignment.BottomStart) + .padding(horizontal = 8.dp, vertical = 8.dp) + ) + } + } + Column( + verticalArrangement = Arrangement.spacedBy(4.dp), + modifier = Modifier.padding(top = 12.dp, start = 4.dp, end = 4.dp) + ) { + Text( + text = title, + color = scheme.onBackground, + fontSize = 15.sp, + fontWeight = FontWeight.SemiBold, + maxLines = 1, + overflow = TextOverflow.Ellipsis + ) + if (supporting.isNotBlank()) { + Text( + text = supporting, + color = scheme.onSurfaceVariant, + fontSize = 11.sp, + fontWeight = FontWeight.Medium, + maxLines = 1, + overflow = TextOverflow.Ellipsis + ) + } } } } diff --git a/app-tv/src/main/java/hu/bbara/purefin/tv/home/ui/TvHomeTopBar.kt b/app-tv/src/main/java/hu/bbara/purefin/tv/home/ui/TvHomeTopBar.kt index 2b19a556..9bcb7628 100644 --- a/app-tv/src/main/java/hu/bbara/purefin/tv/home/ui/TvHomeTopBar.kt +++ b/app-tv/src/main/java/hu/bbara/purefin/tv/home/ui/TvHomeTopBar.kt @@ -1,19 +1,44 @@ package hu.bbara.purefin.tv.home.ui +import androidx.compose.animation.animateColorAsState +import androidx.compose.animation.core.animateFloatAsState +import androidx.compose.foundation.background import androidx.compose.foundation.layout.Arrangement -import androidx.compose.foundation.layout.Row -import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.heightIn +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.lazy.LazyRow +import androidx.compose.foundation.lazy.itemsIndexed +import androidx.compose.foundation.lazy.rememberLazyListState +import androidx.compose.foundation.BorderStroke +import androidx.compose.material3.ButtonDefaults import androidx.compose.material3.Icon import androidx.compose.material3.MaterialTheme -import androidx.compose.material3.PrimaryScrollableTabRow -import androidx.compose.material3.Tab +import androidx.compose.material3.OutlinedButton import androidx.compose.material3.Text import androidx.compose.runtime.Composable -import androidx.compose.ui.Alignment +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.runtime.setValue import androidx.compose.ui.Modifier +import androidx.compose.ui.focus.onFocusChanged +import androidx.compose.ui.graphics.graphicsLayer +import androidx.compose.ui.platform.testTag +import androidx.compose.ui.semantics.selected +import androidx.compose.ui.semantics.semantics +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import kotlinx.coroutines.launch + +internal const val TvHomeTabTagPrefix = "tv-home-tab-" +private val TvHomeTopBarPillShape = RoundedCornerShape(18.dp) -@OptIn(ExperimentalMaterial3Api::class) @Composable fun TvHomeTopBar( tabs: List, @@ -23,29 +48,92 @@ fun TvHomeTopBar( ) { val scheme = MaterialTheme.colorScheme val safeSelectedTabIndex = selectedTabIndex.coerceIn(0, tabs.lastIndex.coerceAtLeast(0)) + val listState = rememberLazyListState() + val scope = rememberCoroutineScope() - PrimaryScrollableTabRow( - selectedTabIndex = safeSelectedTabIndex, - containerColor = scheme.surface, - contentColor = scheme.onSurface, + LaunchedEffect(safeSelectedTabIndex) { + if (tabs.isNotEmpty()) { + listState.animateScrollToItem(safeSelectedTabIndex) + } + } + + LazyRow( + state = listState, + modifier = modifier + .fillMaxWidth() + .background(scheme.background), + contentPadding = PaddingValues(horizontal = 24.dp, vertical = 14.dp), + horizontalArrangement = Arrangement.spacedBy(12.dp) ) { - tabs.forEachIndexed { index, tab -> - Tab( - selected = index == safeSelectedTabIndex, - onClick = { onTabSelected(index, tab) }, - text = { - Row( - horizontalArrangement = Arrangement.spacedBy(6.dp), - verticalAlignment = Alignment.CenterVertically - ) { - Icon( - imageVector = tab.icon, - contentDescription = null - ) - Text(text = tab.label) - } - } + itemsIndexed(items = tabs) { index, tab -> + var isFocused by remember { mutableStateOf(false) } + val isSelected = index == safeSelectedTabIndex + val containerColor by animateColorAsState( + targetValue = when { + isSelected -> scheme.surfaceContainerHigh + isFocused -> scheme.surfaceContainerHigh.copy(alpha = 0.92f) + else -> scheme.surfaceContainer.copy(alpha = 0.72f) + }, + label = "tv-home-tab-container" ) + val contentColor by animateColorAsState( + targetValue = if (isSelected || isFocused) { + scheme.onBackground + } else { + scheme.onSurfaceVariant + }, + label = "tv-home-tab-content" + ) + val borderColor by animateColorAsState( + targetValue = when { + isFocused -> scheme.primary + isSelected -> scheme.outline + else -> scheme.outlineVariant.copy(alpha = 0.6f) + }, + label = "tv-home-tab-border" + ) + val scale by animateFloatAsState( + targetValue = if (isFocused) 1.03f else 1f, + label = "tv-home-tab-scale" + ) + + OutlinedButton( + onClick = { onTabSelected(index, tab) }, + modifier = Modifier + .testTag("$TvHomeTabTagPrefix$index") + .semantics { selected = isSelected } + .heightIn(min = 46.dp) + .graphicsLayer { + scaleX = scale + scaleY = scale + } + .onFocusChanged { + isFocused = it.isFocused + if (it.isFocused) { + scope.launch { + listState.animateScrollToItem(index) + } + } + }, + shape = TvHomeTopBarPillShape, + border = BorderStroke(1.dp, borderColor), + colors = ButtonDefaults.outlinedButtonColors( + containerColor = containerColor, + contentColor = contentColor + ), + contentPadding = PaddingValues(horizontal = 18.dp, vertical = 12.dp) + ) { + Icon( + imageVector = tab.icon, + contentDescription = null, + modifier = Modifier.padding(end = 8.dp) + ) + Text( + text = tab.label, + fontSize = 15.sp, + fontWeight = if (isSelected) FontWeight.SemiBold else FontWeight.Medium + ) + } } } } diff --git a/app-tv/src/test/java/hu/bbara/purefin/tv/home/ui/TvFocusedItemHeroTest.kt b/app-tv/src/test/java/hu/bbara/purefin/tv/home/ui/TvFocusedItemHeroTest.kt new file mode 100644 index 00000000..29e16c29 --- /dev/null +++ b/app-tv/src/test/java/hu/bbara/purefin/tv/home/ui/TvFocusedItemHeroTest.kt @@ -0,0 +1,140 @@ +package hu.bbara.purefin.tv.home.ui + +import hu.bbara.purefin.core.data.image.JellyfinImageHelper +import hu.bbara.purefin.core.model.Episode +import hu.bbara.purefin.core.model.Movie +import hu.bbara.purefin.core.model.Series +import hu.bbara.purefin.feature.shared.home.ContinueWatchingItem +import hu.bbara.purefin.feature.shared.home.NextUpItem +import hu.bbara.purefin.feature.shared.home.PosterItem +import org.jellyfin.sdk.model.api.BaseItemKind +import org.jellyfin.sdk.model.api.ImageType +import org.junit.Assert.assertEquals +import org.junit.Assert.assertNull +import org.junit.Test +import java.util.UUID + +class TvFocusedItemHeroTest { + + @Test + fun movieItem_mapsProgressMetadataAndBackdrop() { + val movie = sampleMovie(progress = 42.0) + val item = ContinueWatchingItem( + type = BaseItemKind.MOVIE, + movie = movie + ) + + val hero = item.toTvFocusedHeroModel() + + assertEquals("Continue watching", hero.eyebrowText) + assertEquals("Blade Runner 2049", hero.title) + assertEquals("2017 • 16+ • 164m • 4K", hero.metadataText) + assertEquals(0.42f, hero.progressFraction!!, 0.0001f) + assertEquals("42%", hero.progressLabel) + assertNull(hero.watchedText) + assertEquals( + JellyfinImageHelper.finishImageUrl(movie.imageUrlPrefix, ImageType.BACKDROP), + hero.backdropImageUrl + ) + } + + @Test + fun backdropImageUrl_usesFallbackWhenBackdropPrefixIsMissing() { + assertEquals( + "https://example.com/fallback.jpg", + backdropImageUrl( + imageUrlPrefix = "", + fallbackImageUrl = "https://example.com/fallback.jpg" + ) + ) + } + + @Test + fun episodeItem_prefersWatchedStateOverProgress() { + val item = NextUpItem( + episode = sampleEpisode( + progress = 96.0, + watched = true + ) + ) + + val hero = item.toTvFocusedHeroModel() + + assertEquals("Next up", hero.eyebrowText) + assertEquals("The Very Pulse of the Machine", hero.title) + assertEquals("Episode 3 • 2022-05-20 • 16m • 12+ • HD", hero.metadataText) + assertEquals("Watched", hero.watchedText) + assertNull(hero.progressFraction) + assertNull(hero.progressLabel) + } + + @Test + fun seriesItem_usesSeasonAndUnwatchedMetadataWithoutProgress() { + val item = PosterItem( + type = BaseItemKind.SERIES, + series = sampleSeries() + ) + + val hero = item.toTvFocusedHeroModel() + + assertEquals("Series", hero.eyebrowText) + assertEquals("Love, Death & Robots", hero.title) + assertEquals("2019 • 3 seasons • 5 unwatched", hero.metadataText) + assertNull(hero.watchedText) + assertNull(hero.progressFraction) + assertNull(hero.progressLabel) + } + + private fun sampleMovie(progress: Double? = 42.0): Movie { + return Movie( + id = UUID.fromString("11111111-1111-1111-1111-111111111111"), + libraryId = UUID.fromString("22222222-2222-2222-2222-222222222222"), + title = "Blade Runner 2049", + progress = progress, + watched = false, + year = "2017", + rating = "16+", + runtime = "164m", + format = "4K", + synopsis = "Officer K uncovers a secret that sends him searching for Rick Deckard.", + imageUrlPrefix = "https://example.com/Items/11111111-1111-1111-1111-111111111111/Images/", + audioTrack = "ENG 5.1", + subtitles = "ENG", + cast = emptyList() + ) + } + + private fun sampleEpisode(progress: Double?, watched: Boolean): Episode { + return Episode( + id = UUID.fromString("33333333-3333-3333-3333-333333333333"), + seriesId = UUID.fromString("44444444-4444-4444-4444-444444444444"), + seasonId = UUID.fromString("55555555-5555-5555-5555-555555555555"), + index = 3, + title = "The Very Pulse of the Machine", + synopsis = "An astronaut faces a strange world alone.", + releaseDate = "2022-05-20", + rating = "12+", + runtime = "16m", + progress = progress, + watched = watched, + format = "HD", + imageUrlPrefix = "https://example.com/Items/33333333-3333-3333-3333-333333333333/Images/", + cast = emptyList() + ) + } + + private fun sampleSeries(): Series { + return Series( + id = UUID.fromString("66666666-6666-6666-6666-666666666666"), + libraryId = UUID.fromString("77777777-7777-7777-7777-777777777777"), + name = "Love, Death & Robots", + synopsis = "Animated sci-fi anthology.", + year = "2019", + imageUrlPrefix = "https://example.com/Items/66666666-6666-6666-6666-666666666666/Images/", + unwatchedEpisodeCount = 5, + seasonCount = 3, + seasons = emptyList(), + cast = emptyList() + ) + } +} diff --git a/app-tv/src/test/java/hu/bbara/purefin/tv/home/ui/TvHomeHeroStateTest.kt b/app-tv/src/test/java/hu/bbara/purefin/tv/home/ui/TvHomeHeroStateTest.kt new file mode 100644 index 00000000..086e89f2 --- /dev/null +++ b/app-tv/src/test/java/hu/bbara/purefin/tv/home/ui/TvHomeHeroStateTest.kt @@ -0,0 +1,174 @@ +package hu.bbara.purefin.tv.home.ui + +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 org.jellyfin.sdk.model.api.BaseItemKind +import org.jellyfin.sdk.model.api.CollectionType +import org.junit.Assert.assertEquals +import org.junit.Assert.assertNotNull +import org.junit.Assert.assertNull +import org.junit.Test +import java.util.UUID + +class TvHomeHeroStateTest { + + @Test + fun itemRegistry_prefersContinueWatchingForFirstAvailableItem() { + val continueWatching = continueWatchingItem( + id = "11111111-1111-1111-1111-111111111111", + title = "Blade Runner 2049" + ) + val nextUp = nextUpItem(id = "22222222-2222-2222-2222-222222222222") + val libraryId = UUID.fromString("33333333-3333-3333-3333-333333333333") + val registry = createTvHomeItemRegistry( + libraries = listOf(sampleLibrary(libraryId)), + libraryContent = mapOf(libraryId to listOf(posterItem(id = "44444444-4444-4444-4444-444444444444", libraryId = libraryId))), + continueWatching = listOf(continueWatching), + nextUp = listOf(nextUp) + ) + + assertEquals(continueWatching.id, registry.firstAvailableItemId) + assertEquals(continueWatching, registry.itemById(continueWatching.id)) + } + + @Test + fun itemRegistry_fallsBackToNextUpWhenContinueWatchingIsEmpty() { + val nextUp = nextUpItem(id = "22222222-2222-2222-2222-222222222222") + val registry = createTvHomeItemRegistry( + libraries = emptyList(), + libraryContent = emptyMap(), + continueWatching = emptyList(), + nextUp = listOf(nextUp) + ) + + assertEquals(nextUp.id, registry.firstAvailableItemId) + assertEquals(nextUp, registry.itemById(nextUp.id)) + } + + @Test + fun itemRegistry_fallsBackToFirstVisibleLibraryItemWhenRowsAreEmpty() { + val firstLibraryId = UUID.fromString("33333333-3333-3333-3333-333333333333") + val firstPoster = posterItem( + id = "44444444-4444-4444-4444-444444444444", + libraryId = firstLibraryId + ) + val registry = createTvHomeItemRegistry( + libraries = listOf( + sampleLibrary(firstLibraryId), + sampleLibrary( + id = UUID.fromString("55555555-5555-5555-5555-555555555555"), + isEmpty = true + ) + ), + libraryContent = mapOf(firstLibraryId to listOf(firstPoster)), + continueWatching = emptyList(), + nextUp = emptyList() + ) + + assertEquals(firstPoster.id, registry.firstAvailableItemId) + assertEquals(firstPoster, registry.itemById(firstPoster.id)) + } + + @Test + fun itemRegistry_returnsNullWhenNoItemsAreAvailable() { + val registry = createTvHomeItemRegistry( + libraries = emptyList(), + libraryContent = emptyMap(), + continueWatching = emptyList(), + nextUp = emptyList() + ) + + assertNull(registry.firstAvailableItemId) + assertNull(registry.firstAvailableItem) + assertNull(registry.itemById(UUID.fromString("99999999-9999-9999-9999-999999999999"))) + } + + @Test + fun itemRegistry_exposesFirstAvailableItem() { + val continueWatching = continueWatchingItem( + id = "11111111-1111-1111-1111-111111111111", + title = "Blade Runner 2049" + ) + val registry = createTvHomeItemRegistry( + libraries = emptyList(), + libraryContent = emptyMap(), + continueWatching = listOf(continueWatching), + nextUp = emptyList() + ) + + assertNotNull(registry.firstAvailableItem) + assertEquals(continueWatching, registry.firstAvailableItem) + } + + private fun sampleLibrary(id: UUID, isEmpty: Boolean = false): LibraryItem { + return LibraryItem( + id = id, + name = "Movies", + type = CollectionType.MOVIES, + posterUrl = "", + isEmpty = isEmpty + ) + } + + private fun continueWatchingItem(id: String, title: String): ContinueWatchingItem { + return ContinueWatchingItem( + type = BaseItemKind.MOVIE, + movie = sampleMovie(id = id, title = title) + ) + } + + private fun nextUpItem(id: String): NextUpItem { + return NextUpItem( + episode = Episode( + id = UUID.fromString(id), + seriesId = UUID.fromString("aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa"), + seasonId = UUID.fromString("bbbbbbbb-bbbb-bbbb-bbbb-bbbbbbbbbbbb"), + index = 1, + title = "Next Episode", + synopsis = "Episode synopsis", + releaseDate = "2024-01-01", + rating = "12+", + runtime = "48m", + progress = 10.0, + watched = false, + format = "HD", + imageUrlPrefix = "https://example.com/Items/$id/Images/", + cast = emptyList() + ) + ) + } + + private fun posterItem(id: String, libraryId: UUID): PosterItem { + return PosterItem( + type = BaseItemKind.MOVIE, + movie = sampleMovie(id = id, title = "Arrival", libraryId = libraryId) + ) + } + + private fun sampleMovie( + id: String, + title: String, + libraryId: UUID = UUID.fromString("cccccccc-cccc-cccc-cccc-cccccccccccc") + ): Movie { + return Movie( + id = UUID.fromString(id), + libraryId = libraryId, + title = title, + progress = 25.0, + watched = false, + year = "2017", + rating = "16+", + runtime = "164m", + format = "4K", + synopsis = "Movie synopsis", + imageUrlPrefix = "https://example.com/Items/$id/Images/", + audioTrack = "ENG 5.1", + subtitles = "ENG", + cast = emptyList() + ) + } +}