Refactor media detail screens with a unified TV scaffold

Introduces `TvMediaDetailScaffold` to standardize the layout of Movie, Series, and Episode detail screens. This refactor implements a consistent two-column header design featuring a hero section on the left and an overview panel on the right, supported by a new horizontal and vertical gradient overlay system.

Key changes:
- Created `TvMediaDetailScaffold`, `MediaDetailHeaderRow`, and `MediaDetailSectionTitle` common components.
- Extracted screen-specific hero and overview logic into internal components for `MovieScreen`, `SeriesScreen`, and `EpisodeScreen`.
- Standardized typography, spacing, and focus management across all detail views.
- Added comprehensive UI tests for Movie, Series, and Episode content screens.
- Improved the "Up Next" and "Library Status" visibility on the Series detail page.
This commit is contained in:
2026-03-29 18:11:05 +02:00
parent 55e87d9d04
commit c163a714d8
10 changed files with 995 additions and 356 deletions

View File

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

View File

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

View File

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

View File

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

View File

@@ -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)
) {
LazyColumn(
modifier = Modifier.fillMaxSize()
) {
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))
}
}
}
}
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))
}
) {
if (episode.cast.isNotEmpty()) {
item {
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))
}
}
}
}
}

View File

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

View File

@@ -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)
) {
LazyColumn(
modifier = Modifier.fillMaxSize()
) {
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))
}
}
}
}
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))
}
) {
if (movie.cast.isNotEmpty()) {
item {
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))
}
}
}
}
}

View File

@@ -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,12 +117,22 @@ internal fun SeasonTabs(
name = season.name,
isSelected = season == selectedSeason,
onSelect = { onSelect(season) },
modifier = if (index == 0 && firstItemFocusRequester != null) {
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<Episode>, 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<CastMember>, modifier: Modifier = Modifier) {
roleSize = 10.sp
)
}
private fun Episode.playButtonText(): String {
return if ((progress ?: 0.0) > 0.0 && !watched) "Resume" else "Play"
}

View File

@@ -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
)
Spacer(modifier = Modifier.height(16.dp))
SeriesMetaChips(series = series)
}
}
if (nextUpEpisode != null) {
item {
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))
}
}
}
}
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(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))
}
}
}
}
private fun Episode.playButtonText(): String {
return if ((progress ?: 0.0) > 0.0 && !watched) "Resume" else "Play"
}

View File

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