diff --git a/app-tv/src/androidTest/java/hu/bbara/purefin/app/content/episode/EpisodeScreenContentTest.kt b/app-tv/src/androidTest/java/hu/bbara/purefin/app/content/episode/EpisodeScreenContentTest.kt new file mode 100644 index 0000000..42274f2 --- /dev/null +++ b/app-tv/src/androidTest/java/hu/bbara/purefin/app/content/episode/EpisodeScreenContentTest.kt @@ -0,0 +1,93 @@ +package hu.bbara.purefin.app.content.episode + +import androidx.activity.ComponentActivity +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.onAllNodesWithText +import androidx.compose.ui.test.onNodeWithTag +import androidx.compose.ui.test.onNodeWithText +import hu.bbara.purefin.core.data.navigation.Route +import hu.bbara.purefin.core.model.CastMember +import hu.bbara.purefin.core.model.Episode +import hu.bbara.purefin.ui.theme.AppTheme +import org.junit.Rule +import org.junit.Test +import java.util.UUID + +class EpisodeScreenContentTest { + + @get:Rule + val composeRule = createAndroidComposeRule() + + @Test + fun episodeScreenContent_showsSeriesContext_andFocusesPlayButton() { + composeRule.setContent { + AppTheme { + EpisodeScreenContent( + episode = sampleEpisode(progress = 63.0), + seriesTitle = "Severance", + topBarShortcut = episodeTopBarShortcut(Route.Home, onSeriesClick = {}), + onBack = {}, + onPlay = {} + ) + } + } + + composeRule.waitForIdle() + + composeRule.onNodeWithText("Severance").assertIsDisplayed() + composeRule.onNodeWithText("The You You Are").assertIsDisplayed() + composeRule.onNodeWithText("Episode 4").assertIsDisplayed() + composeRule.onNodeWithTag(EpisodePlayButtonTag).assertIsDisplayed().assertIsFocused() + composeRule.onNodeWithText("Series").assertIsDisplayed() + } + + @Test + fun episodeScreenContent_hidesShortcut_whenNoShortcutIsProvided() { + composeRule.setContent { + AppTheme { + EpisodeScreenContent( + episode = sampleEpisode(progress = null), + seriesTitle = "Severance", + topBarShortcut = episodeTopBarShortcut( + previousRoute = Route.PlayerRoute(mediaId = "episode-4"), + onSeriesClick = {} + ), + onBack = {}, + onPlay = {} + ) + } + } + + composeRule.waitForIdle() + + composeRule.onNodeWithTag(EpisodePlayButtonTag).assertIsDisplayed() + composeRule.onAllNodesWithText("Series").assertCountEquals(0) + } + + private fun sampleEpisode(progress: Double?): Episode { + val seriesId = UUID.fromString("11111111-1111-1111-1111-111111111111") + val seasonId = UUID.fromString("22222222-2222-2222-2222-222222222222") + + return Episode( + id = UUID.fromString("33333333-3333-3333-3333-333333333333"), + seriesId = seriesId, + seasonId = seasonId, + index = 4, + title = "The You You Are", + synopsis = "Mark is pulled deeper into Lumon's fractured world as the team chases a clue.", + releaseDate = "2025", + rating = "16+", + runtime = "49m", + progress = progress, + watched = false, + format = "4K", + heroImageUrl = "https://images.unsplash.com/photo-1500530855697-b586d89ba3ee", + cast = listOf( + CastMember("Adam Scott", "Mark Scout", null) + ) + ) + } +} diff --git a/app-tv/src/androidTest/java/hu/bbara/purefin/app/content/movie/MovieScreenContentTest.kt b/app-tv/src/androidTest/java/hu/bbara/purefin/app/content/movie/MovieScreenContentTest.kt new file mode 100644 index 0000000..6ac6856 --- /dev/null +++ b/app-tv/src/androidTest/java/hu/bbara/purefin/app/content/movie/MovieScreenContentTest.kt @@ -0,0 +1,62 @@ +package hu.bbara.purefin.app.content.movie + +import androidx.activity.ComponentActivity +import androidx.compose.ui.test.assertIsDisplayed +import androidx.compose.ui.test.assertIsFocused +import androidx.compose.ui.test.junit4.createAndroidComposeRule +import androidx.compose.ui.test.onNodeWithContentDescription +import androidx.compose.ui.test.onNodeWithTag +import androidx.compose.ui.test.onNodeWithText +import hu.bbara.purefin.core.model.CastMember +import hu.bbara.purefin.core.model.Movie +import hu.bbara.purefin.ui.theme.AppTheme +import org.junit.Rule +import org.junit.Test +import java.util.UUID + +class MovieScreenContentTest { + + @get:Rule + val composeRule = createAndroidComposeRule() + + @Test + fun movieScreenContent_showsTvHeader_andFocusesPlayButton() { + composeRule.setContent { + AppTheme { + MovieScreenContent( + movie = sampleMovie(progress = 42.0), + onBack = {}, + onPlay = {} + ) + } + } + + composeRule.waitForIdle() + + composeRule.onNodeWithText("Blade Runner 2049").assertIsDisplayed() + composeRule.onNodeWithText("Playback").assertIsDisplayed() + composeRule.onNodeWithTag(MoviePlayButtonTag).assertIsDisplayed().assertIsFocused() + composeRule.onNodeWithContentDescription("Back").assertIsDisplayed() + } + + private fun sampleMovie(progress: Double?): 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.", + heroImageUrl = "https://images.unsplash.com/photo-1500530855697-b586d89ba3ee", + audioTrack = "ENG 5.1", + subtitles = "ENG", + cast = listOf( + CastMember("Ryan Gosling", "K", null) + ) + ) + } +} diff --git a/app-tv/src/androidTest/java/hu/bbara/purefin/app/content/series/SeriesScreenContentTest.kt b/app-tv/src/androidTest/java/hu/bbara/purefin/app/content/series/SeriesScreenContentTest.kt new file mode 100644 index 0000000..2bf3390 --- /dev/null +++ b/app-tv/src/androidTest/java/hu/bbara/purefin/app/content/series/SeriesScreenContentTest.kt @@ -0,0 +1,151 @@ +package hu.bbara.purefin.app.content.series + +import androidx.activity.ComponentActivity +import androidx.compose.ui.test.assertIsDisplayed +import androidx.compose.ui.test.assertIsFocused +import androidx.compose.ui.test.junit4.createAndroidComposeRule +import androidx.compose.ui.test.onNodeWithTag +import androidx.compose.ui.test.onNodeWithText +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.Rule +import org.junit.Test +import java.util.UUID + +class SeriesScreenContentTest { + + @get:Rule + val composeRule = createAndroidComposeRule() + + @Test + fun seriesScreenContent_focusesPrimaryAction_whenNextUpExists() { + composeRule.setContent { + AppTheme { + SeriesScreenContent( + series = sampleSeriesWithEpisodes(), + onBack = {}, + onPlayEpisode = {} + ) + } + } + + composeRule.waitForIdle() + + composeRule.onNodeWithText("Severance").assertIsDisplayed() + composeRule.onNodeWithText("Up Next").assertIsDisplayed() + composeRule.onNodeWithTag(SeriesPlayButtonTag).assertIsDisplayed().assertIsFocused() + composeRule.onNodeWithText("Season 1").assertIsDisplayed() + composeRule.onNodeWithText("Good News About Hell").assertIsDisplayed() + } + + @Test + fun seriesScreenContent_focusesFirstSeason_whenNoPlayableEpisodeExists() { + composeRule.setContent { + AppTheme { + SeriesScreenContent( + series = sampleSeriesWithoutEpisodes(), + onBack = {}, + onPlayEpisode = {} + ) + } + } + + composeRule.waitForIdle() + + composeRule.onNodeWithText("Library Status").assertIsDisplayed() + composeRule.onNodeWithTag(SeriesFirstSeasonTabTag).assertIsDisplayed().assertIsFocused() + } + + private fun sampleSeriesWithEpisodes(): Series { + val seriesId = UUID.fromString("11111111-1111-1111-1111-111111111111") + val seasonId = UUID.fromString("22222222-2222-2222-2222-222222222222") + + return Series( + id = seriesId, + libraryId = UUID.fromString("33333333-3333-3333-3333-333333333333"), + name = "Severance", + synopsis = "Mark leads a team of office workers whose memories have been surgically divided.", + year = "2022", + heroImageUrl = "https://images.unsplash.com/photo-1500530855697-b586d89ba3ee", + unwatchedEpisodeCount = 3, + seasonCount = 1, + seasons = listOf( + Season( + id = seasonId, + seriesId = seriesId, + name = "Season 1", + index = 1, + unwatchedEpisodeCount = 3, + episodeCount = 2, + episodes = listOf( + Episode( + id = UUID.fromString("44444444-4444-4444-4444-444444444444"), + seriesId = seriesId, + seasonId = seasonId, + index = 1, + title = "Good News About Hell", + synopsis = "Mark is promoted after an unexpected tragedy.", + releaseDate = "2022", + rating = "16+", + runtime = "57m", + progress = 18.0, + watched = false, + format = "4K", + heroImageUrl = "https://images.unsplash.com/photo-1500530855697-b586d89ba3ee", + cast = emptyList() + ), + Episode( + id = UUID.fromString("55555555-5555-5555-5555-555555555555"), + seriesId = seriesId, + seasonId = seasonId, + index = 2, + title = "Half Loop", + synopsis = "Mark takes the team out for a sanctioned dinner.", + releaseDate = "2022", + rating = "16+", + runtime = "51m", + progress = null, + watched = false, + format = "4K", + heroImageUrl = "https://images.unsplash.com/photo-1500530855697-b586d89ba3ee", + cast = emptyList() + ) + ) + ) + ), + cast = listOf( + CastMember("Adam Scott", "Mark Scout", null) + ) + ) + } + + private fun sampleSeriesWithoutEpisodes(): Series { + val seriesId = UUID.fromString("66666666-6666-6666-6666-666666666666") + + return Series( + id = seriesId, + libraryId = UUID.fromString("77777777-7777-7777-7777-777777777777"), + name = "Foundation", + synopsis = "A band of exiles works to preserve knowledge through the fall of an empire.", + year = "2021", + heroImageUrl = "https://images.unsplash.com/photo-1500530855697-b586d89ba3ee", + unwatchedEpisodeCount = 0, + seasonCount = 1, + seasons = listOf( + Season( + id = UUID.fromString("88888888-8888-8888-8888-888888888888"), + seriesId = seriesId, + name = "Season 1", + index = 1, + unwatchedEpisodeCount = 0, + episodeCount = 0, + episodes = emptyList() + ) + ), + cast = emptyList() + ) + } +} diff --git a/app-tv/src/main/java/hu/bbara/purefin/app/content/episode/EpisodeComponents.kt b/app-tv/src/main/java/hu/bbara/purefin/app/content/episode/EpisodeComponents.kt index e931410..2fbf9b8 100644 --- a/app-tv/src/main/java/hu/bbara/purefin/app/content/episode/EpisodeComponents.kt +++ b/app-tv/src/main/java/hu/bbara/purefin/app/content/episode/EpisodeComponents.kt @@ -1,11 +1,35 @@ package hu.bbara.purefin.app.content.episode +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.ExperimentalLayoutApi +import androidx.compose.foundation.layout.FlowRow +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.sizeIn +import androidx.compose.foundation.layout.widthIn +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.ui.Modifier import androidx.compose.ui.focus.FocusRequester +import androidx.compose.ui.focus.focusRequester +import androidx.compose.ui.platform.testTag +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.sp +import hu.bbara.purefin.common.ui.MediaMetaChip +import hu.bbara.purefin.common.ui.MediaSynopsis import hu.bbara.purefin.common.ui.components.MediaDetailsTopBar import hu.bbara.purefin.common.ui.components.MediaDetailsTopBarShortcut +import hu.bbara.purefin.common.ui.components.MediaPlaybackSettings +import hu.bbara.purefin.common.ui.components.MediaResumeButton import hu.bbara.purefin.core.data.navigation.Route +import hu.bbara.purefin.core.model.Episode + +internal const val EpisodePlayButtonTag = "episode-play-button" internal sealed interface EpisodeTopBarShortcut { val label: String @@ -40,3 +64,117 @@ internal fun EpisodeTopBar( downFocusRequester = downFocusRequester ) } + +@OptIn(ExperimentalLayoutApi::class) +@Composable +internal fun EpisodeHeroSection( + episode: Episode, + seriesTitle: String?, + onPlay: () -> Unit, + playFocusRequester: FocusRequester, + modifier: Modifier = Modifier +) { + val scheme = MaterialTheme.colorScheme + val mutedStrong = scheme.onSurfaceVariant.copy(alpha = 0.82f) + + Column( + modifier = modifier + .fillMaxWidth() + .widthIn(max = 760.dp) + ) { + if (!seriesTitle.isNullOrBlank()) { + Text( + text = seriesTitle, + color = scheme.primary, + fontSize = 18.sp, + fontWeight = FontWeight.SemiBold, + maxLines = 1, + overflow = TextOverflow.Ellipsis + ) + Spacer(modifier = Modifier.height(10.dp)) + } + Text( + text = episode.title, + color = scheme.onBackground, + fontSize = 42.sp, + fontWeight = FontWeight.Bold, + lineHeight = 48.sp, + maxLines = 2, + overflow = TextOverflow.Ellipsis + ) + Spacer(modifier = Modifier.height(8.dp)) + Text( + text = "Episode ${episode.index}", + color = mutedStrong, + fontSize = 18.sp, + fontWeight = FontWeight.Medium + ) + Spacer(modifier = Modifier.height(18.dp)) + FlowRow( + horizontalArrangement = Arrangement.spacedBy(8.dp), + verticalArrangement = Arrangement.spacedBy(8.dp) + ) { + MediaMetaChip(text = episode.releaseDate) + MediaMetaChip(text = episode.rating) + MediaMetaChip(text = episode.runtime) + MediaMetaChip( + text = episode.format, + background = scheme.primary.copy(alpha = 0.2f), + border = scheme.primary.copy(alpha = 0.35f), + textColor = scheme.primary + ) + } + Spacer(modifier = Modifier.height(24.dp)) + MediaResumeButton( + text = episode.playButtonText(), + progress = episode.progress?.div(100)?.toFloat() ?: 0f, + onClick = onPlay, + modifier = Modifier + .sizeIn(minWidth = 216.dp, maxWidth = 240.dp) + .focusRequester(playFocusRequester) + .testTag(EpisodePlayButtonTag) + ) + } +} + +@Composable +internal fun EpisodeOverviewPanel( + episode: Episode, + modifier: Modifier = Modifier +) { + val scheme = MaterialTheme.colorScheme + val mutedStrong = scheme.onSurfaceVariant.copy(alpha = 0.85f) + + Column(modifier = modifier) { + MediaSynopsis( + synopsis = episode.synopsis, + title = "Overview", + titleColor = scheme.onSurface, + bodyColor = mutedStrong, + titleFontSize = 20.sp, + bodyFontSize = 16.sp, + bodyLineHeight = 24.sp, + titleSpacing = 10.dp, + collapsedLines = 5, + collapseInitially = false + ) + Spacer(modifier = Modifier.height(24.dp)) + Text( + text = "Playback", + color = scheme.onSurface, + fontSize = 20.sp, + fontWeight = FontWeight.Bold + ) + Spacer(modifier = Modifier.height(14.dp)) + MediaPlaybackSettings( + backgroundColor = scheme.surfaceContainerHigh, + foregroundColor = scheme.onSurface, + audioTrack = "ENG", + subtitles = "ENG" + ) + } +} + +private fun Episode.playButtonText(): String { + return if ((progress ?: 0.0) > 0.0 && !watched) "Resume" else "Play" +} diff --git a/app-tv/src/main/java/hu/bbara/purefin/app/content/episode/EpisodeScreen.kt b/app-tv/src/main/java/hu/bbara/purefin/app/content/episode/EpisodeScreen.kt index 2368fc8..2303fb8 100644 --- a/app-tv/src/main/java/hu/bbara/purefin/app/content/episode/EpisodeScreen.kt +++ b/app-tv/src/main/java/hu/bbara/purefin/app/content/episode/EpisodeScreen.kt @@ -1,21 +1,8 @@ package hu.bbara.purefin.app.content.episode -import androidx.compose.foundation.background -import androidx.compose.foundation.layout.Arrangement -import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column -import androidx.compose.foundation.layout.ExperimentalLayoutApi -import androidx.compose.foundation.layout.FlowRow -import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Spacer -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.sizeIn -import androidx.compose.foundation.lazy.LazyColumn -import androidx.compose.material3.MaterialTheme -import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.collectAsState @@ -23,18 +10,13 @@ import androidx.compose.runtime.remember import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.focus.FocusRequester -import androidx.compose.ui.focus.focusRequester -import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.unit.dp -import androidx.compose.ui.unit.sp import androidx.hilt.navigation.compose.hiltViewModel import hu.bbara.purefin.common.ui.MediaCastRow -import hu.bbara.purefin.common.ui.MediaMetaChip -import hu.bbara.purefin.common.ui.MediaSynopsis import hu.bbara.purefin.common.ui.PurefinWaitingScreen -import hu.bbara.purefin.common.ui.components.MediaHero -import hu.bbara.purefin.common.ui.components.MediaPlaybackSettings -import hu.bbara.purefin.common.ui.components.MediaResumeButton +import hu.bbara.purefin.common.ui.components.MediaDetailHeaderRow +import hu.bbara.purefin.common.ui.components.MediaDetailSectionTitle +import hu.bbara.purefin.common.ui.components.TvMediaDetailScaffold import hu.bbara.purefin.core.data.navigation.EpisodeDto import hu.bbara.purefin.core.data.navigation.LocalNavigationBackStack import hu.bbara.purefin.core.data.navigation.LocalNavigationManager @@ -61,6 +43,7 @@ fun EpisodeScreen( } val episode = viewModel.episode.collectAsState() + val seriesTitle = viewModel.seriesTitle.collectAsState() val selectedEpisode = episode.value if (selectedEpisode == null) { @@ -68,8 +51,9 @@ fun EpisodeScreen( return } - EpisodeScreenInternal( + EpisodeScreenContent( episode = selectedEpisode, + seriesTitle = seriesTitle.value, topBarShortcut = remember(previousRoute, viewModel) { episodeTopBarShortcut( previousRoute = previousRoute, @@ -88,122 +72,63 @@ fun EpisodeScreen( ) } -@OptIn(ExperimentalLayoutApi::class) @Composable -private fun EpisodeScreenInternal( +internal fun EpisodeScreenContent( episode: Episode, + seriesTitle: String?, topBarShortcut: EpisodeTopBarShortcut?, onBack: () -> Unit, onPlay: () -> Unit, modifier: Modifier = Modifier, ) { - val scheme = MaterialTheme.colorScheme - val hPad = Modifier.padding(horizontal = 16.dp) val playFocusRequester = remember { FocusRequester() } - LaunchedEffect(Unit) { + LaunchedEffect(episode.id) { playFocusRequester.requestFocus() } - Box( - modifier = modifier - .fillMaxSize() - .background(scheme.background) + TvMediaDetailScaffold( + heroImageUrl = episode.heroImageUrl, + modifier = modifier, + topBar = { + EpisodeTopBar( + onBack = onBack, + shortcut = topBarShortcut, + downFocusRequester = playFocusRequester, + modifier = Modifier.align(Alignment.TopStart) + ) + }, + heroContent = { + MediaDetailHeaderRow( + leftContent = { headerModifier -> + EpisodeHeroSection( + episode = episode, + seriesTitle = seriesTitle, + onPlay = onPlay, + playFocusRequester = playFocusRequester, + modifier = headerModifier + ) + }, + rightContent = { panelModifier -> + EpisodeOverviewPanel( + episode = episode, + modifier = panelModifier + ) + } + ) + Spacer(modifier = Modifier.height(12.dp)) + } ) { - LazyColumn( - modifier = Modifier.fillMaxSize() - ) { + if (episode.cast.isNotEmpty()) { item { - MediaHero( - imageUrl = episode.heroImageUrl, - backgroundColor = scheme.background, - heightFraction = 0.30f, - modifier = Modifier.fillMaxWidth() - ) - } - item { - Column(modifier = hPad) { - Text( - text = episode.title, - color = scheme.onBackground, - fontSize = 32.sp, - fontWeight = FontWeight.Bold, - lineHeight = 38.sp - ) - Spacer(modifier = Modifier.height(4.dp)) - Text( - text = "Episode ${episode.index}", - color = scheme.onBackground, - fontSize = 14.sp, - fontWeight = FontWeight.Medium - ) - Spacer(modifier = Modifier.height(16.dp)) - FlowRow( - horizontalArrangement = Arrangement.spacedBy(8.dp), - verticalArrangement = Arrangement.spacedBy(8.dp) - ) { - MediaMetaChip(text = episode.releaseDate) - MediaMetaChip(text = episode.rating) - MediaMetaChip(text = episode.runtime) - MediaMetaChip( - text = episode.format, - background = scheme.primary.copy(alpha = 0.2f), - border = scheme.primary.copy(alpha = 0.3f), - textColor = scheme.primary - ) - } - } - } - item { - Spacer(modifier = Modifier.height(24.dp)) - MediaSynopsis( - synopsis = episode.synopsis, - modifier = hPad - ) - } - item { - Spacer(modifier = Modifier.height(24.dp)) - Row(modifier = hPad) { - MediaResumeButton( - text = if (episode.progress == null) "Play" else "Resume", - progress = episode.progress?.div(100)?.toFloat() ?: 0f, - onClick = onPlay, - modifier = Modifier.sizeIn(maxWidth = 200.dp).focusRequester(playFocusRequester) - ) - } - } - item { - Spacer(modifier = Modifier.height(24.dp)) - MediaPlaybackSettings( - backgroundColor = scheme.surface, - foregroundColor = scheme.onSurface, - audioTrack = "ENG", - subtitles = "ENG", - modifier = hPad - ) - } - if (episode.cast.isNotEmpty()) { - item { - Column(modifier = hPad) { - Spacer(modifier = Modifier.height(24.dp)) - Text( - text = "Cast", - color = scheme.onBackground, - fontSize = 18.sp, - fontWeight = FontWeight.Bold - ) - Spacer(modifier = Modifier.height(12.dp)) - MediaCastRow(cast = episode.cast) - Spacer(modifier = Modifier.height(16.dp)) - } + Column(modifier = it) { + Spacer(modifier = Modifier.height(8.dp)) + MediaDetailSectionTitle(text = "Cast") + Spacer(modifier = Modifier.height(14.dp)) + MediaCastRow(cast = episode.cast) + Spacer(modifier = Modifier.height(24.dp)) } } } - EpisodeTopBar( - onBack = onBack, - shortcut = topBarShortcut, - downFocusRequester = playFocusRequester, - modifier = Modifier.align(Alignment.TopStart) - ) } } diff --git a/app-tv/src/main/java/hu/bbara/purefin/app/content/movie/MovieComponents.kt b/app-tv/src/main/java/hu/bbara/purefin/app/content/movie/MovieComponents.kt index 1f35c2a..f6dfc9a 100644 --- a/app-tv/src/main/java/hu/bbara/purefin/app/content/movie/MovieComponents.kt +++ b/app-tv/src/main/java/hu/bbara/purefin/app/content/movie/MovieComponents.kt @@ -1,9 +1,33 @@ package hu.bbara.purefin.app.content.movie +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.ExperimentalLayoutApi +import androidx.compose.foundation.layout.FlowRow +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.sizeIn +import androidx.compose.foundation.layout.widthIn +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.ui.Modifier import androidx.compose.ui.focus.FocusRequester +import androidx.compose.ui.focus.focusRequester +import androidx.compose.ui.platform.testTag +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.sp +import hu.bbara.purefin.common.ui.MediaMetaChip +import hu.bbara.purefin.common.ui.MediaSynopsis import hu.bbara.purefin.common.ui.components.MediaDetailsTopBar +import hu.bbara.purefin.common.ui.components.MediaPlaybackSettings +import hu.bbara.purefin.common.ui.components.MediaResumeButton +import hu.bbara.purefin.core.model.Movie + +internal const val MoviePlayButtonTag = "movie-play-button" @Composable internal fun MovieTopBar( @@ -17,3 +41,97 @@ internal fun MovieTopBar( downFocusRequester = downFocusRequester ) } + +@OptIn(ExperimentalLayoutApi::class) +@Composable +internal fun MovieHeroSection( + movie: Movie, + onPlay: () -> Unit, + playFocusRequester: FocusRequester, + modifier: Modifier = Modifier +) { + val scheme = MaterialTheme.colorScheme + + Column( + modifier = modifier + .fillMaxWidth() + .widthIn(max = 760.dp) + ) { + Text( + text = movie.title, + color = scheme.onBackground, + fontSize = 42.sp, + fontWeight = FontWeight.Bold, + lineHeight = 48.sp, + maxLines = 2, + overflow = TextOverflow.Ellipsis + ) + Spacer(modifier = Modifier.height(18.dp)) + FlowRow( + horizontalArrangement = Arrangement.spacedBy(8.dp), + verticalArrangement = Arrangement.spacedBy(8.dp) + ) { + MediaMetaChip(text = movie.year) + MediaMetaChip(text = movie.rating) + MediaMetaChip(text = movie.runtime) + MediaMetaChip( + text = movie.format, + background = scheme.primary.copy(alpha = 0.2f), + border = scheme.primary.copy(alpha = 0.35f), + textColor = scheme.primary + ) + } + Spacer(modifier = Modifier.height(24.dp)) + MediaResumeButton( + text = movie.playButtonText(), + progress = movie.progress?.div(100)?.toFloat() ?: 0f, + onClick = onPlay, + modifier = Modifier + .sizeIn(minWidth = 216.dp, maxWidth = 240.dp) + .focusRequester(playFocusRequester) + .testTag(MoviePlayButtonTag) + ) + } +} + +@Composable +internal fun MovieOverviewPanel( + movie: Movie, + modifier: Modifier = Modifier +) { + val scheme = MaterialTheme.colorScheme + val mutedStrong = scheme.onSurfaceVariant.copy(alpha = 0.85f) + + Column(modifier = modifier) { + MediaSynopsis( + synopsis = movie.synopsis, + title = "Overview", + titleColor = scheme.onSurface, + bodyColor = mutedStrong, + titleFontSize = 20.sp, + bodyFontSize = 16.sp, + bodyLineHeight = 24.sp, + titleSpacing = 10.dp, + collapsedLines = 5, + collapseInitially = false + ) + Spacer(modifier = Modifier.height(24.dp)) + Text( + text = "Playback", + color = scheme.onSurface, + fontSize = 20.sp, + fontWeight = FontWeight.Bold + ) + Spacer(modifier = Modifier.height(14.dp)) + MediaPlaybackSettings( + backgroundColor = scheme.surfaceContainerHigh, + foregroundColor = scheme.onSurface, + audioTrack = movie.audioTrack, + subtitles = movie.subtitles + ) + } +} + +private fun Movie.playButtonText(): String { + return if ((progress ?: 0.0) > 0.0 && !watched) "Resume" else "Play" +} diff --git a/app-tv/src/main/java/hu/bbara/purefin/app/content/movie/MovieScreen.kt b/app-tv/src/main/java/hu/bbara/purefin/app/content/movie/MovieScreen.kt index cdf942c..24d7b1e 100644 --- a/app-tv/src/main/java/hu/bbara/purefin/app/content/movie/MovieScreen.kt +++ b/app-tv/src/main/java/hu/bbara/purefin/app/content/movie/MovieScreen.kt @@ -1,21 +1,8 @@ package hu.bbara.purefin.app.content.movie -import androidx.compose.foundation.background -import androidx.compose.foundation.layout.Arrangement -import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column -import androidx.compose.foundation.layout.ExperimentalLayoutApi -import androidx.compose.foundation.layout.FlowRow -import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Spacer -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.sizeIn -import androidx.compose.foundation.lazy.LazyColumn -import androidx.compose.material3.MaterialTheme -import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.collectAsState @@ -23,18 +10,13 @@ import androidx.compose.runtime.remember import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.focus.FocusRequester -import androidx.compose.ui.focus.focusRequester -import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.unit.dp -import androidx.compose.ui.unit.sp import androidx.hilt.navigation.compose.hiltViewModel import hu.bbara.purefin.common.ui.MediaCastRow -import hu.bbara.purefin.common.ui.MediaMetaChip -import hu.bbara.purefin.common.ui.MediaSynopsis import hu.bbara.purefin.common.ui.PurefinWaitingScreen -import hu.bbara.purefin.common.ui.components.MediaHero -import hu.bbara.purefin.common.ui.components.MediaPlaybackSettings -import hu.bbara.purefin.common.ui.components.MediaResumeButton +import hu.bbara.purefin.common.ui.components.MediaDetailHeaderRow +import hu.bbara.purefin.common.ui.components.MediaDetailSectionTitle +import hu.bbara.purefin.common.ui.components.TvMediaDetailScaffold import hu.bbara.purefin.core.data.navigation.MovieDto import hu.bbara.purefin.core.model.Movie import hu.bbara.purefin.feature.shared.content.movie.MovieScreenViewModel @@ -50,7 +32,7 @@ fun MovieScreen( val movieItem = viewModel.movie.collectAsState() if (movieItem.value != null) { - MovieScreenInternal( + MovieScreenContent( movie = movieItem.value!!, onBack = viewModel::onBack, onPlay = viewModel::onPlay, @@ -61,113 +43,59 @@ fun MovieScreen( } } -@OptIn(ExperimentalLayoutApi::class) @Composable -private fun MovieScreenInternal( +internal fun MovieScreenContent( movie: Movie, onBack: () -> Unit, onPlay: () -> Unit, modifier: Modifier = Modifier, ) { - val scheme = MaterialTheme.colorScheme - val hPad = Modifier.padding(horizontal = 16.dp) val playFocusRequester = remember { FocusRequester() } - LaunchedEffect(Unit) { + LaunchedEffect(movie.id) { playFocusRequester.requestFocus() } - Box( - modifier = modifier - .fillMaxSize() - .background(scheme.background) + TvMediaDetailScaffold( + heroImageUrl = movie.heroImageUrl, + modifier = modifier, + topBar = { + MovieTopBar( + onBack = onBack, + downFocusRequester = playFocusRequester, + modifier = Modifier.align(Alignment.TopStart) + ) + }, + heroContent = { + MediaDetailHeaderRow( + leftContent = { headerModifier -> + MovieHeroSection( + movie = movie, + onPlay = onPlay, + playFocusRequester = playFocusRequester, + modifier = headerModifier + ) + }, + rightContent = { panelModifier -> + MovieOverviewPanel( + movie = movie, + modifier = panelModifier + ) + } + ) + Spacer(modifier = Modifier.height(12.dp)) + } ) { - LazyColumn( - modifier = Modifier.fillMaxSize() - ) { + if (movie.cast.isNotEmpty()) { item { - MediaHero( - imageUrl = movie.heroImageUrl, - backgroundColor = scheme.background, - heightFraction = 0.30f, - modifier = Modifier.fillMaxWidth() - ) - } - item { - Column(modifier = hPad) { - Text( - text = movie.title, - color = scheme.onBackground, - fontSize = 32.sp, - fontWeight = FontWeight.Bold, - lineHeight = 38.sp - ) - Spacer(modifier = Modifier.height(16.dp)) - FlowRow( - horizontalArrangement = Arrangement.spacedBy(8.dp), - verticalArrangement = Arrangement.spacedBy(8.dp) - ) { - MediaMetaChip(text = movie.year) - MediaMetaChip(text = movie.rating) - MediaMetaChip(text = movie.runtime) - MediaMetaChip( - text = movie.format, - background = scheme.primary.copy(alpha = 0.2f), - border = scheme.primary.copy(alpha = 0.3f), - textColor = scheme.primary - ) - } - } - } - item { - Spacer(modifier = Modifier.height(24.dp)) - MediaSynopsis( - synopsis = movie.synopsis, - modifier = hPad - ) - } - item { - Spacer(modifier = Modifier.height(24.dp)) - Row(modifier = hPad) { - MediaResumeButton( - text = if (movie.progress == null) "Play" else "Resume", - progress = movie.progress?.div(100)?.toFloat() ?: 0f, - onClick = onPlay, - modifier = Modifier.sizeIn(maxWidth = 200.dp).focusRequester(playFocusRequester) - ) - } - } - item { - Spacer(modifier = Modifier.height(24.dp)) - MediaPlaybackSettings( - backgroundColor = scheme.surface, - foregroundColor = scheme.onSurface, - audioTrack = movie.audioTrack, - subtitles = movie.subtitles, - modifier = hPad - ) - } - if (movie.cast.isNotEmpty()) { - item { - Column(modifier = hPad) { - Spacer(modifier = Modifier.height(24.dp)) - Text( - text = "Cast", - color = scheme.onBackground, - fontSize = 18.sp, - fontWeight = FontWeight.Bold - ) - Spacer(modifier = Modifier.height(12.dp)) - MediaCastRow(cast = movie.cast) - Spacer(modifier = Modifier.height(16.dp)) - } + Column(modifier = it) { + Spacer(modifier = Modifier.height(8.dp)) + MediaDetailSectionTitle(text = "Cast") + Spacer(modifier = Modifier.height(14.dp)) + MediaCastRow(cast = movie.cast) + Spacer(modifier = Modifier.height(24.dp)) } } } - MovieTopBar( - onBack = onBack, - downFocusRequester = playFocusRequester, - modifier = Modifier.align(Alignment.TopStart) - ) } } diff --git a/app-tv/src/main/java/hu/bbara/purefin/app/content/series/SeriesComponents.kt b/app-tv/src/main/java/hu/bbara/purefin/app/content/series/SeriesComponents.kt index efb4779..e54654b 100644 --- a/app-tv/src/main/java/hu/bbara/purefin/app/content/series/SeriesComponents.kt +++ b/app-tv/src/main/java/hu/bbara/purefin/app/content/series/SeriesComponents.kt @@ -18,7 +18,9 @@ import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.sizeIn import androidx.compose.foundation.layout.width +import androidx.compose.foundation.layout.widthIn import androidx.compose.foundation.lazy.LazyRow import androidx.compose.foundation.lazy.items import androidx.compose.foundation.lazy.rememberLazyListState @@ -39,11 +41,13 @@ import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip import androidx.compose.ui.focus.FocusRequester +import androidx.compose.ui.focus.focusProperties import androidx.compose.ui.focus.focusRequester import androidx.compose.ui.focus.onFocusChanged import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.graphicsLayer 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.TextOverflow import androidx.compose.ui.unit.dp @@ -51,8 +55,11 @@ import androidx.compose.ui.unit.sp import androidx.hilt.navigation.compose.hiltViewModel import hu.bbara.purefin.common.ui.MediaCastRow import hu.bbara.purefin.common.ui.MediaMetaChip +import hu.bbara.purefin.common.ui.MediaSynopsis import hu.bbara.purefin.common.ui.components.MediaDetailsTopBar +import hu.bbara.purefin.common.ui.components.MediaDetailSectionTitle import hu.bbara.purefin.common.ui.components.MediaProgressBar +import hu.bbara.purefin.common.ui.components.MediaResumeButton import hu.bbara.purefin.common.ui.components.PurefinAsyncImage import hu.bbara.purefin.common.ui.components.WatchStateIndicator import hu.bbara.purefin.core.model.CastMember @@ -61,6 +68,9 @@ import hu.bbara.purefin.core.model.Season import hu.bbara.purefin.core.model.Series import hu.bbara.purefin.feature.shared.content.series.SeriesViewModel +internal const val SeriesPlayButtonTag = "series-play-button" +internal const val SeriesFirstSeasonTabTag = "series-first-season-tab" + @Composable internal fun SeriesTopBar( onBack: () -> Unit, @@ -93,6 +103,7 @@ internal fun SeasonTabs( selectedSeason: Season?, modifier: Modifier = Modifier, firstItemFocusRequester: FocusRequester? = null, + firstItemTestTag: String? = null, onSelect: (Season) -> Unit ) { Row( @@ -106,11 +117,21 @@ internal fun SeasonTabs( name = season.name, isSelected = season == selectedSeason, onSelect = { onSelect(season) }, - modifier = if (index == 0 && firstItemFocusRequester != null) { - Modifier.focusRequester(firstItemFocusRequester) - } else { - Modifier - } + modifier = Modifier + .then( + if (index == 0 && firstItemFocusRequester != null) { + Modifier.focusRequester(firstItemFocusRequester) + } else { + Modifier + } + ) + .then( + if (index == 0 && firstItemTestTag != null) { + Modifier.testTag(firstItemTestTag) + } else { + Modifier + } + ) ) } } @@ -176,6 +197,111 @@ internal fun EpisodeCarousel(episodes: List, modifier: Modifier = Modif } } +@Composable +internal fun SeriesHeroSection( + series: Series, + nextUpEpisode: Episode?, + onPlayEpisode: (Episode) -> Unit, + playFocusRequester: FocusRequester, + firstContentFocusRequester: FocusRequester, + modifier: Modifier = Modifier +) { + val scheme = MaterialTheme.colorScheme + val mutedStrong = scheme.onSurfaceVariant.copy(alpha = 0.82f) + + Column( + modifier = modifier + .fillMaxWidth() + .widthIn(max = 760.dp) + ) { + Text( + text = series.name, + color = scheme.onBackground, + fontSize = 42.sp, + fontWeight = FontWeight.Bold, + lineHeight = 48.sp, + maxLines = 2, + overflow = TextOverflow.Ellipsis + ) + Spacer(modifier = Modifier.height(18.dp)) + SeriesMetaChips(series = series) + Spacer(modifier = Modifier.height(24.dp)) + if (nextUpEpisode != null) { + MediaResumeButton( + text = nextUpEpisode.playButtonText(), + progress = nextUpEpisode.progress?.div(100)?.toFloat() ?: 0f, + onClick = { onPlayEpisode(nextUpEpisode) }, + modifier = Modifier + .sizeIn(minWidth = 216.dp, maxWidth = 240.dp) + .focusRequester(playFocusRequester) + .focusProperties { down = firstContentFocusRequester } + .testTag(SeriesPlayButtonTag) + ) + } else { + Text( + text = "Choose a season below to start watching.", + color = mutedStrong, + fontSize = 16.sp, + fontWeight = FontWeight.Medium + ) + } + } +} + +@Composable +internal fun SeriesOverviewPanel( + series: Series, + nextUpEpisode: Episode?, + modifier: Modifier = Modifier +) { + val scheme = MaterialTheme.colorScheme + val mutedStrong = scheme.onSurfaceVariant.copy(alpha = 0.85f) + + Column(modifier = modifier) { + MediaSynopsis( + synopsis = series.synopsis, + title = "Overview", + titleColor = scheme.onSurface, + bodyColor = mutedStrong, + titleFontSize = 20.sp, + bodyFontSize = 16.sp, + bodyLineHeight = 24.sp, + titleSpacing = 10.dp, + collapsedLines = 5, + collapseInitially = false + ) + Spacer(modifier = Modifier.height(24.dp)) + MediaDetailSectionTitle( + text = if (nextUpEpisode != null) "Up Next" else "Library Status", + modifier = Modifier.fillMaxWidth() + ) + Spacer(modifier = Modifier.height(14.dp)) + Text( + text = if (nextUpEpisode != null) { + nextUpEpisode.title + } else { + "${series.seasonCount} seasons ready to browse" + }, + color = scheme.onSurface, + fontSize = 18.sp, + fontWeight = FontWeight.SemiBold, + maxLines = 2, + overflow = TextOverflow.Ellipsis + ) + Spacer(modifier = Modifier.height(6.dp)) + Text( + text = if (nextUpEpisode != null) { + "Episode ${nextUpEpisode.index} • ${nextUpEpisode.runtime}" + } else { + "${series.unwatchedEpisodeCount} unwatched episodes" + }, + color = mutedStrong, + fontSize = 14.sp, + fontWeight = FontWeight.Medium + ) + } +} + @Composable private fun EpisodeCard( viewModel: SeriesViewModel = hiltViewModel(), @@ -290,3 +416,7 @@ internal fun CastRow(cast: List, modifier: Modifier = Modifier) { roleSize = 10.sp ) } + +private fun Episode.playButtonText(): String { + return if ((progress ?: 0.0) > 0.0 && !watched) "Resume" else "Play" +} diff --git a/app-tv/src/main/java/hu/bbara/purefin/app/content/series/SeriesScreen.kt b/app-tv/src/main/java/hu/bbara/purefin/app/content/series/SeriesScreen.kt index 4fa7606..c4f409f 100644 --- a/app-tv/src/main/java/hu/bbara/purefin/app/content/series/SeriesScreen.kt +++ b/app-tv/src/main/java/hu/bbara/purefin/app/content/series/SeriesScreen.kt @@ -1,18 +1,8 @@ package hu.bbara.purefin.app.content.series -import androidx.compose.foundation.background -import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column -import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Spacer -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.sizeIn -import androidx.compose.foundation.lazy.LazyColumn -import androidx.compose.material3.MaterialTheme -import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.collectAsState @@ -21,16 +11,12 @@ import androidx.compose.runtime.remember import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.focus.FocusRequester -import androidx.compose.ui.focus.focusProperties -import androidx.compose.ui.focus.focusRequester -import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.unit.dp -import androidx.compose.ui.unit.sp import androidx.hilt.navigation.compose.hiltViewModel -import hu.bbara.purefin.common.ui.MediaSynopsis import hu.bbara.purefin.common.ui.PurefinWaitingScreen -import hu.bbara.purefin.common.ui.components.MediaHero -import hu.bbara.purefin.common.ui.components.MediaResumeButton +import hu.bbara.purefin.common.ui.components.MediaDetailHeaderRow +import hu.bbara.purefin.common.ui.components.MediaDetailSectionTitle +import hu.bbara.purefin.common.ui.components.TvMediaDetailScaffold import hu.bbara.purefin.core.data.navigation.SeriesDto import hu.bbara.purefin.core.model.Episode import hu.bbara.purefin.core.model.Season @@ -52,7 +38,7 @@ fun SeriesScreen( val seriesData = series.value if (seriesData != null && seriesData.seasons.isNotEmpty()) { - SeriesScreenInternal( + SeriesScreenContent( series = seriesData, onBack = viewModel::onBack, onPlayEpisode = viewModel::onPlayEpisode, @@ -64,16 +50,12 @@ fun SeriesScreen( } @Composable -private fun SeriesScreenInternal( +internal fun SeriesScreenContent( series: Series, onBack: () -> Unit, onPlayEpisode: (UUID) -> Unit, modifier: Modifier = Modifier, ) { - val scheme = MaterialTheme.colorScheme - val textMutedStrong = scheme.onSurfaceVariant.copy(alpha = 0.7f) - val hPad = Modifier.padding(horizontal = 16.dp) - fun getDefaultSeason(): Season { for (season in series.seasons) { val firstUnwatchedEpisode = season.episodes.firstOrNull { it.watched.not() } @@ -98,103 +80,67 @@ private fun SeriesScreenInternal( } } - Box( - modifier = modifier - .fillMaxSize() - .background(scheme.background) - ) { - LazyColumn( - modifier = Modifier.fillMaxSize() - ) { - item { - MediaHero( - imageUrl = series.heroImageUrl, - heightFraction = 0.30f, - backgroundColor = scheme.background, - modifier = Modifier.fillMaxWidth() - ) - } - item { - Column(modifier = hPad) { - Text( - text = series.name, - color = scheme.onBackground, - fontSize = 30.sp, - fontWeight = FontWeight.Bold, - lineHeight = 36.sp + TvMediaDetailScaffold( + heroImageUrl = series.heroImageUrl, + modifier = modifier, + topBar = { + SeriesTopBar( + onBack = onBack, + downFocusRequester = nextUpEpisode?.let { playFocusRequester } ?: firstContentFocusRequester, + modifier = Modifier.align(Alignment.TopStart) + ) + }, + heroContent = { + MediaDetailHeaderRow( + leftContent = { headerModifier -> + SeriesHeroSection( + series = series, + nextUpEpisode = nextUpEpisode, + onPlayEpisode = { onPlayEpisode(it.id) }, + playFocusRequester = playFocusRequester, + firstContentFocusRequester = firstContentFocusRequester, + modifier = headerModifier + ) + }, + rightContent = { panelModifier -> + SeriesOverviewPanel( + series = series, + nextUpEpisode = nextUpEpisode, + modifier = panelModifier ) - Spacer(modifier = Modifier.height(16.dp)) - SeriesMetaChips(series = series) } - } - if (nextUpEpisode != null) { - item { + ) + Spacer(modifier = Modifier.height(12.dp)) + } + ) { + item { + Spacer(modifier = Modifier.height(16.dp)) + SeasonTabs( + seasons = series.seasons, + selectedSeason = selectedSeason.value, + firstItemFocusRequester = firstContentFocusRequester, + firstItemTestTag = SeriesFirstSeasonTabTag, + onSelect = { selectedSeason.value = it }, + modifier = it + ) + } + item { + Spacer(modifier = Modifier.height(20.dp)) + EpisodeCarousel( + episodes = selectedSeason.value.episodes, + modifier = it + ) + } + if (series.cast.isNotEmpty()) { + item { + Column(modifier = it) { + Spacer(modifier = Modifier.height(20.dp)) + MediaDetailSectionTitle(text = "Cast") + Spacer(modifier = Modifier.height(14.dp)) + CastRow(cast = series.cast) Spacer(modifier = Modifier.height(24.dp)) - Row(modifier = hPad) { - MediaResumeButton( - text = nextUpEpisode.playButtonText(), - progress = nextUpEpisode.progress?.div(100)?.toFloat() ?: 0f, - onClick = { onPlayEpisode(nextUpEpisode.id) }, - modifier = Modifier - .sizeIn(maxWidth = 200.dp) - .focusRequester(playFocusRequester) - .focusProperties { down = firstContentFocusRequester } - ) - } - } - } - item { - Spacer(modifier = Modifier.height(24.dp)) - MediaSynopsis( - synopsis = series.synopsis, - bodyColor = textMutedStrong, - bodyFontSize = 13.sp, - bodyLineHeight = null, - titleSpacing = 8.dp, - modifier = hPad - ) - } - item { - Spacer(modifier = Modifier.height(24.dp)) - SeasonTabs( - seasons = series.seasons, - selectedSeason = selectedSeason.value, - firstItemFocusRequester = firstContentFocusRequester, - onSelect = { selectedSeason.value = it }, - modifier = hPad - ) - } - item { - EpisodeCarousel( - episodes = selectedSeason.value.episodes, - modifier = hPad - ) - } - if (series.cast.isNotEmpty()) { - item { - Column(modifier = hPad) { - Spacer(modifier = Modifier.height(16.dp)) - Text( - text = "Cast", - color = scheme.onBackground, - fontSize = 18.sp, - fontWeight = FontWeight.Bold - ) - Spacer(modifier = Modifier.height(12.dp)) - CastRow(cast = series.cast) - Spacer(modifier = Modifier.height(16.dp)) - } } } } - SeriesTopBar( - onBack = onBack, - downFocusRequester = nextUpEpisode?.let { playFocusRequester } ?: firstContentFocusRequester, - modifier = Modifier.align(Alignment.TopStart) - ) } } - -private fun Episode.playButtonText(): String { - return if ((progress ?: 0.0) > 0.0 && !watched) "Resume" else "Play" -} 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 new file mode 100644 index 0000000..6e91e83 --- /dev/null +++ b/app-tv/src/main/java/hu/bbara/purefin/common/ui/components/MediaDetailShell.kt @@ -0,0 +1,148 @@ +package hu.bbara.purefin.common.ui.components + +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.BoxScope +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.ColumnScope +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.LazyListScope +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.graphics.Brush +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp + +internal val MediaDetailHorizontalPadding = 48.dp +private val MediaDetailPanelShape = RoundedCornerShape(28.dp) + +@Composable +internal fun TvMediaDetailScaffold( + heroImageUrl: String, + modifier: Modifier = Modifier, + heroHeightFraction: Float = 0.48f, + topBar: @Composable BoxScope.() -> Unit, + heroContent: @Composable ColumnScope.() -> Unit, + bodyContent: LazyListScope.(Modifier) -> Unit = { _ -> } +) { + val scheme = MaterialTheme.colorScheme + val contentPadding = Modifier.padding(horizontal = MediaDetailHorizontalPadding) + + Box( + modifier = modifier + .fillMaxSize() + .background(scheme.background) + ) { + LazyColumn( + modifier = Modifier.fillMaxSize() + ) { + item { + Box(modifier = Modifier.fillMaxWidth()) { + MediaHero( + imageUrl = heroImageUrl, + backgroundColor = scheme.background, + heightFraction = heroHeightFraction, + modifier = Modifier.fillMaxWidth() + ) + MediaDetailHeroGradientOverlay() + Column( + modifier = contentPadding + .padding(top = 104.dp, bottom = 36.dp) + ) { + heroContent() + } + } + } + bodyContent(contentPadding) + } + topBar() + } +} + +@Composable +internal fun MediaDetailHeaderRow( + modifier: Modifier = Modifier, + leftWeight: Float = 1.1f, + rightWeight: Float = 0.9f, + verticalAlignment: Alignment.Vertical = Alignment.Bottom, + leftContent: @Composable (Modifier) -> Unit, + rightContent: @Composable ColumnScope.(Modifier) -> Unit +) { + val scheme = MaterialTheme.colorScheme + + Row( + modifier = modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.spacedBy(40.dp), + verticalAlignment = verticalAlignment + ) { + leftContent(Modifier.weight(leftWeight)) + Column( + modifier = Modifier + .weight(rightWeight) + .background( + color = scheme.surface.copy(alpha = 0.9f), + shape = MediaDetailPanelShape + ) + .padding(28.dp) + ) { + rightContent(Modifier.fillMaxWidth()) + } + } +} + +@Composable +internal fun MediaDetailSectionTitle( + text: String, + modifier: Modifier = Modifier +) { + Text( + text = text, + color = MaterialTheme.colorScheme.onBackground, + fontSize = 22.sp, + fontWeight = FontWeight.Bold, + modifier = modifier + ) +} + +@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 + ) + ) + ) + ) +}