Refactor TV media detail backdrop layout

This commit is contained in:
2026-04-06 19:00:24 +02:00
parent 32d50f750f
commit 9083a08f95
7 changed files with 77 additions and 185 deletions

View File

@@ -6,13 +6,9 @@ import androidx.compose.ui.test.assertCountEquals
import androidx.compose.ui.test.assertIsDisplayed import androidx.compose.ui.test.assertIsDisplayed
import androidx.compose.ui.test.assertIsFocused import androidx.compose.ui.test.assertIsFocused
import androidx.compose.ui.test.junit4.createAndroidComposeRule import androidx.compose.ui.test.junit4.createAndroidComposeRule
import androidx.compose.ui.test.performKeyInput
import androidx.compose.ui.test.pressKey
import androidx.compose.ui.test.onAllNodesWithText import androidx.compose.ui.test.onAllNodesWithText
import androidx.compose.ui.test.onNodeWithContentDescription
import androidx.compose.ui.test.onNodeWithTag import androidx.compose.ui.test.onNodeWithTag
import androidx.compose.ui.test.onNodeWithText import androidx.compose.ui.test.onNodeWithText
import androidx.compose.ui.input.key.Key
import hu.bbara.purefin.core.model.CastMember import hu.bbara.purefin.core.model.CastMember
import hu.bbara.purefin.core.model.Episode import hu.bbara.purefin.core.model.Episode
import hu.bbara.purefin.ui.theme.AppTheme import hu.bbara.purefin.ui.theme.AppTheme
@@ -27,13 +23,12 @@ class EpisodeScreenContentTest {
val composeRule = createAndroidComposeRule<ComponentActivity>() val composeRule = createAndroidComposeRule<ComponentActivity>()
@Test @Test
fun episodeScreenContent_showsSeriesContext_andMovesFromBackToPlayButton() { fun episodeScreenContent_showsSeriesContext_andFocusesPlayButton() {
composeRule.setContent { composeRule.setContent {
AppTheme { AppTheme {
EpisodeScreenContent( EpisodeScreenContent(
episode = sampleEpisode(progress = 63.0), episode = sampleEpisode(progress = 63.0),
seriesTitle = "Severance", seriesTitle = "Severance",
onBack = {},
onPlay = {} onPlay = {}
) )
} }
@@ -48,12 +43,6 @@ class EpisodeScreenContentTest {
composeRule.onAllNodesWithText("Playback").assertCountEquals(1) composeRule.onAllNodesWithText("Playback").assertCountEquals(1)
composeRule.onNodeWithTag(EpisodePlayButtonTag).assertIsDisplayed() composeRule.onNodeWithTag(EpisodePlayButtonTag).assertIsDisplayed()
composeRule.onNodeWithText("Series").assertIsDisplayed() composeRule.onNodeWithText("Series").assertIsDisplayed()
composeRule.onNodeWithContentDescription("Back")
.assertIsDisplayed()
.assertIsFocused()
.performKeyInput {
pressKey(Key.DirectionDown)
}
composeRule.onNodeWithTag(EpisodePlayButtonTag).assertIsFocused() composeRule.onNodeWithTag(EpisodePlayButtonTag).assertIsFocused()
} }
@@ -64,7 +53,6 @@ class EpisodeScreenContentTest {
EpisodeScreenContent( EpisodeScreenContent(
episode = sampleEpisode(progress = null), episode = sampleEpisode(progress = null),
seriesTitle = "Severance", seriesTitle = "Severance",
onBack = {},
onPlay = {} onPlay = {}
) )
} }

View File

@@ -6,13 +6,9 @@ import androidx.compose.ui.test.assertCountEquals
import androidx.compose.ui.test.assertIsDisplayed import androidx.compose.ui.test.assertIsDisplayed
import androidx.compose.ui.test.assertIsFocused import androidx.compose.ui.test.assertIsFocused
import androidx.compose.ui.test.junit4.createAndroidComposeRule import androidx.compose.ui.test.junit4.createAndroidComposeRule
import androidx.compose.ui.test.performKeyInput
import androidx.compose.ui.test.pressKey
import androidx.compose.ui.test.onAllNodesWithText import androidx.compose.ui.test.onAllNodesWithText
import androidx.compose.ui.test.onNodeWithContentDescription
import androidx.compose.ui.test.onNodeWithTag import androidx.compose.ui.test.onNodeWithTag
import androidx.compose.ui.test.onNodeWithText import androidx.compose.ui.test.onNodeWithText
import androidx.compose.ui.input.key.Key
import hu.bbara.purefin.core.model.CastMember import hu.bbara.purefin.core.model.CastMember
import hu.bbara.purefin.core.model.Movie import hu.bbara.purefin.core.model.Movie
import hu.bbara.purefin.ui.theme.AppTheme import hu.bbara.purefin.ui.theme.AppTheme
@@ -27,12 +23,11 @@ class MovieScreenContentTest {
val composeRule = createAndroidComposeRule<ComponentActivity>() val composeRule = createAndroidComposeRule<ComponentActivity>()
@Test @Test
fun movieScreenContent_focusesBack_thenMovesToPlayButton() { fun movieScreenContent_focusesPlayButton() {
composeRule.setContent { composeRule.setContent {
AppTheme { AppTheme {
MovieScreenContent( MovieScreenContent(
movie = sampleMovie(progress = 42.0), movie = sampleMovie(progress = 42.0),
onBack = {},
onPlay = {} onPlay = {}
) )
} }
@@ -43,14 +38,9 @@ class MovieScreenContentTest {
composeRule.onNodeWithText("Blade Runner 2049").assertIsDisplayed() composeRule.onNodeWithText("Blade Runner 2049").assertIsDisplayed()
composeRule.onNodeWithText("Overview").assertIsDisplayed() composeRule.onNodeWithText("Overview").assertIsDisplayed()
composeRule.onAllNodesWithText("Playback").assertCountEquals(1) composeRule.onAllNodesWithText("Playback").assertCountEquals(1)
composeRule.onNodeWithTag(MoviePlayButtonTag).assertIsDisplayed() composeRule.onNodeWithTag(MoviePlayButtonTag)
composeRule.onNodeWithContentDescription("Back")
.assertIsDisplayed() .assertIsDisplayed()
.assertIsFocused() .assertIsFocused()
.performKeyInput {
pressKey(Key.DirectionDown)
}
composeRule.onNodeWithTag(MoviePlayButtonTag).assertIsFocused()
} }
private fun sampleMovie(progress: Double?): Movie { private fun sampleMovie(progress: Double?): Movie {

View File

@@ -5,12 +5,8 @@ import androidx.compose.ui.test.ExperimentalTestApi
import androidx.compose.ui.test.assertIsDisplayed import androidx.compose.ui.test.assertIsDisplayed
import androidx.compose.ui.test.assertIsFocused import androidx.compose.ui.test.assertIsFocused
import androidx.compose.ui.test.junit4.createAndroidComposeRule import androidx.compose.ui.test.junit4.createAndroidComposeRule
import androidx.compose.ui.test.performKeyInput
import androidx.compose.ui.test.pressKey
import androidx.compose.ui.test.onNodeWithContentDescription
import androidx.compose.ui.test.onNodeWithTag import androidx.compose.ui.test.onNodeWithTag
import androidx.compose.ui.test.onNodeWithText import androidx.compose.ui.test.onNodeWithText
import androidx.compose.ui.input.key.Key
import hu.bbara.purefin.core.model.CastMember import hu.bbara.purefin.core.model.CastMember
import hu.bbara.purefin.core.model.Episode import hu.bbara.purefin.core.model.Episode
import hu.bbara.purefin.core.model.Season import hu.bbara.purefin.core.model.Season
@@ -27,12 +23,11 @@ class SeriesScreenContentTest {
val composeRule = createAndroidComposeRule<ComponentActivity>() val composeRule = createAndroidComposeRule<ComponentActivity>()
@Test @Test
fun seriesScreenContent_movesFromBackToPrimaryAction_whenNextUpExists() { fun seriesScreenContent_focusesPrimaryAction_whenNextUpExists() {
composeRule.setContent { composeRule.setContent {
AppTheme { AppTheme {
SeriesScreenContent( SeriesScreenContent(
series = sampleSeriesWithEpisodes(), series = sampleSeriesWithEpisodes(),
onBack = {},
onPlayEpisode = {} onPlayEpisode = {}
) )
} }
@@ -44,25 +39,18 @@ class SeriesScreenContentTest {
composeRule.onNodeWithText("Overview").assertIsDisplayed() composeRule.onNodeWithText("Overview").assertIsDisplayed()
composeRule.onNodeWithText("Continue Watching").assertIsDisplayed() composeRule.onNodeWithText("Continue Watching").assertIsDisplayed()
composeRule.onNodeWithTag(SeriesPlayButtonTag).assertIsDisplayed() composeRule.onNodeWithTag(SeriesPlayButtonTag).assertIsDisplayed()
.assertIsFocused()
composeRule.onNodeWithText("Season 1").assertIsDisplayed() composeRule.onNodeWithText("Season 1").assertIsDisplayed()
composeRule.onNodeWithText("Good News About Hell").assertIsDisplayed() composeRule.onNodeWithText("Good News About Hell").assertIsDisplayed()
composeRule.onNodeWithText("Episode 1 • 57m").assertIsDisplayed() composeRule.onNodeWithText("Episode 1 • 57m").assertIsDisplayed()
composeRule.onNodeWithContentDescription("Back")
.assertIsDisplayed()
.assertIsFocused()
.performKeyInput {
pressKey(Key.DirectionDown)
}
composeRule.onNodeWithTag(SeriesPlayButtonTag).assertIsFocused()
} }
@Test @Test
fun seriesScreenContent_movesFromBackToFirstSeason_whenNoPlayableEpisodeExists() { fun seriesScreenContent_focusesFirstSeason_whenNoPlayableEpisodeExists() {
composeRule.setContent { composeRule.setContent {
AppTheme { AppTheme {
SeriesScreenContent( SeriesScreenContent(
series = sampleSeriesWithoutEpisodes(), series = sampleSeriesWithoutEpisodes(),
onBack = {},
onPlayEpisode = {} onPlayEpisode = {}
) )
} }
@@ -72,14 +60,9 @@ class SeriesScreenContentTest {
composeRule.onNodeWithText("Overview").assertIsDisplayed() composeRule.onNodeWithText("Overview").assertIsDisplayed()
composeRule.onNodeWithText("Choose a season below to start watching.").assertIsDisplayed() composeRule.onNodeWithText("Choose a season below to start watching.").assertIsDisplayed()
composeRule.onNodeWithTag(SeriesFirstSeasonTabTag).assertIsDisplayed() composeRule.onNodeWithTag(SeriesFirstSeasonTabTag)
composeRule.onNodeWithContentDescription("Back")
.assertIsDisplayed() .assertIsDisplayed()
.assertIsFocused() .assertIsFocused()
.performKeyInput {
pressKey(Key.DirectionDown)
}
composeRule.onNodeWithTag(SeriesFirstSeasonTabTag).assertIsFocused()
} }
private fun sampleSeriesWithEpisodes(): Series { private fun sampleSeriesWithEpisodes(): Series {

View File

@@ -18,14 +18,12 @@ import hu.bbara.purefin.common.ui.components.MediaDetailOverviewSection
import hu.bbara.purefin.common.ui.components.MediaDetailPlaybackSection import hu.bbara.purefin.common.ui.components.MediaDetailPlaybackSection
import hu.bbara.purefin.common.ui.components.MediaDetailSectionTitle import hu.bbara.purefin.common.ui.components.MediaDetailSectionTitle
import hu.bbara.purefin.common.ui.components.TvMediaDetailScaffold import hu.bbara.purefin.common.ui.components.TvMediaDetailScaffold
import hu.bbara.purefin.core.data.image.JellyfinImageHelper
import hu.bbara.purefin.core.data.navigation.EpisodeDto
import hu.bbara.purefin.core.data.navigation.LocalNavigationBackStack
import hu.bbara.purefin.core.data.navigation.LocalNavigationManager import hu.bbara.purefin.core.data.navigation.LocalNavigationManager
import hu.bbara.purefin.core.data.navigation.Route import hu.bbara.purefin.core.data.navigation.Route
import hu.bbara.purefin.common.ui.components.tvMediaDetailBackgroundImageUrl
import hu.bbara.purefin.core.data.navigation.EpisodeDto
import hu.bbara.purefin.core.model.Episode import hu.bbara.purefin.core.model.Episode
import hu.bbara.purefin.feature.shared.content.episode.EpisodeScreenViewModel import hu.bbara.purefin.feature.shared.content.episode.EpisodeScreenViewModel
import org.jellyfin.sdk.model.api.ImageType
@Composable @Composable
fun EpisodeScreen( fun EpisodeScreen(
@@ -34,8 +32,6 @@ fun EpisodeScreen(
modifier: Modifier = Modifier modifier: Modifier = Modifier
) { ) {
val navigationManager = LocalNavigationManager.current val navigationManager = LocalNavigationManager.current
val backStack = LocalNavigationBackStack.current
val previousRoute = remember(backStack) { backStack.getOrNull(backStack.lastIndex - 1) }
LaunchedEffect(episode) { LaunchedEffect(episode) {
viewModel.selectEpisode( viewModel.selectEpisode(
@@ -57,7 +53,6 @@ fun EpisodeScreen(
EpisodeScreenContent( EpisodeScreenContent(
episode = selectedEpisode, episode = selectedEpisode,
seriesTitle = seriesTitle.value, seriesTitle = seriesTitle.value,
onBack = viewModel::onBack,
onPlay = remember(selectedEpisode.id, navigationManager) { onPlay = remember(selectedEpisode.id, navigationManager) {
{ {
navigationManager.navigate( navigationManager.navigate(
@@ -73,7 +68,6 @@ fun EpisodeScreen(
internal fun EpisodeScreenContent( internal fun EpisodeScreenContent(
episode: Episode, episode: Episode,
seriesTitle: String?, seriesTitle: String?,
onBack: () -> Unit,
onPlay: () -> Unit, onPlay: () -> Unit,
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
) { ) {
@@ -84,9 +78,7 @@ internal fun EpisodeScreenContent(
} }
TvMediaDetailScaffold( TvMediaDetailScaffold(
artworkImageUrl = JellyfinImageHelper.finishImageUrl(episode.imageUrlPrefix, ImageType.PRIMARY), backgroundImageUrl = tvMediaDetailBackgroundImageUrl(episode.imageUrlPrefix),
artworkWidth = 280.dp,
artworkAspectRatio = 16f / 9f,
resetScrollKey = episode.id, resetScrollKey = episode.id,
modifier = modifier, modifier = modifier,
heroContent = { heroContent = {

View File

@@ -18,11 +18,10 @@ import hu.bbara.purefin.common.ui.components.MediaDetailOverviewSection
import hu.bbara.purefin.common.ui.components.MediaDetailPlaybackSection import hu.bbara.purefin.common.ui.components.MediaDetailPlaybackSection
import hu.bbara.purefin.common.ui.components.MediaDetailSectionTitle import hu.bbara.purefin.common.ui.components.MediaDetailSectionTitle
import hu.bbara.purefin.common.ui.components.TvMediaDetailScaffold import hu.bbara.purefin.common.ui.components.TvMediaDetailScaffold
import hu.bbara.purefin.core.data.image.JellyfinImageHelper import hu.bbara.purefin.common.ui.components.tvMediaDetailBackgroundImageUrl
import hu.bbara.purefin.core.data.navigation.MovieDto import hu.bbara.purefin.core.data.navigation.MovieDto
import hu.bbara.purefin.core.model.Movie import hu.bbara.purefin.core.model.Movie
import hu.bbara.purefin.feature.shared.content.movie.MovieScreenViewModel import hu.bbara.purefin.feature.shared.content.movie.MovieScreenViewModel
import org.jellyfin.sdk.model.api.ImageType
@Composable @Composable
fun MovieScreen( fun MovieScreen(
@@ -37,7 +36,6 @@ fun MovieScreen(
if (movieItem.value != null) { if (movieItem.value != null) {
MovieScreenContent( MovieScreenContent(
movie = movieItem.value!!, movie = movieItem.value!!,
onBack = viewModel::onBack,
onPlay = viewModel::onPlay, onPlay = viewModel::onPlay,
modifier = modifier modifier = modifier
) )
@@ -49,7 +47,6 @@ fun MovieScreen(
@Composable @Composable
internal fun MovieScreenContent( internal fun MovieScreenContent(
movie: Movie, movie: Movie,
onBack: () -> Unit,
onPlay: () -> Unit, onPlay: () -> Unit,
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
) { ) {
@@ -60,9 +57,7 @@ internal fun MovieScreenContent(
} }
TvMediaDetailScaffold( TvMediaDetailScaffold(
artworkImageUrl = JellyfinImageHelper.finishImageUrl(movie.imageUrlPrefix, ImageType.PRIMARY), backgroundImageUrl = tvMediaDetailBackgroundImageUrl(movie.imageUrlPrefix),
artworkWidth = 200.dp,
artworkAspectRatio = 2f / 3f,
resetScrollKey = movie.id, resetScrollKey = movie.id,
modifier = modifier, modifier = modifier,
heroContent = { heroContent = {

View File

@@ -7,8 +7,10 @@ import androidx.compose.foundation.layout.height
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.collectAsState import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember import androidx.compose.runtime.remember
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.focus.FocusRequester import androidx.compose.ui.focus.FocusRequester
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
@@ -17,13 +19,12 @@ import hu.bbara.purefin.common.ui.PurefinWaitingScreen
import hu.bbara.purefin.common.ui.components.MediaDetailOverviewSection import hu.bbara.purefin.common.ui.components.MediaDetailOverviewSection
import hu.bbara.purefin.common.ui.components.MediaDetailSectionTitle import hu.bbara.purefin.common.ui.components.MediaDetailSectionTitle
import hu.bbara.purefin.common.ui.components.TvMediaDetailScaffold import hu.bbara.purefin.common.ui.components.TvMediaDetailScaffold
import hu.bbara.purefin.core.data.image.JellyfinImageHelper import hu.bbara.purefin.common.ui.components.tvMediaDetailBackgroundImageUrl
import hu.bbara.purefin.core.data.navigation.SeriesDto import hu.bbara.purefin.core.data.navigation.SeriesDto
import hu.bbara.purefin.core.model.Season import hu.bbara.purefin.core.model.Season
import hu.bbara.purefin.core.model.Series import hu.bbara.purefin.core.model.Series
import hu.bbara.purefin.feature.shared.content.series.SeriesViewModel import hu.bbara.purefin.feature.shared.content.series.SeriesViewModel
import org.jellyfin.sdk.model.UUID import org.jellyfin.sdk.model.UUID
import org.jellyfin.sdk.model.api.ImageType
@Composable @Composable
fun SeriesScreen( fun SeriesScreen(
@@ -41,7 +42,6 @@ fun SeriesScreen(
if (seriesData != null && seriesData.seasons.isNotEmpty()) { if (seriesData != null && seriesData.seasons.isNotEmpty()) {
SeriesScreenContent( SeriesScreenContent(
series = seriesData, series = seriesData,
onBack = viewModel::onBack,
onPlayEpisode = viewModel::onPlayEpisode, onPlayEpisode = viewModel::onPlayEpisode,
modifier = modifier modifier = modifier
) )
@@ -53,23 +53,11 @@ fun SeriesScreen(
@Composable @Composable
internal fun SeriesScreenContent( internal fun SeriesScreenContent(
series: Series, series: Series,
onBack: () -> Unit,
onPlayEpisode: (UUID) -> Unit, onPlayEpisode: (UUID) -> Unit,
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
) { ) {
fun getDefaultSeason(): Season { var selectedSeason by remember(series.id) { mutableStateOf(series.defaultSeason()) }
for (season in series.seasons) { val nextUpEpisode = remember(series.id) { series.nextUpEpisode() }
val firstUnwatchedEpisode = season.episodes.firstOrNull { it.watched.not() }
if (firstUnwatchedEpisode != null) return season
}
return series.seasons.first()
}
val selectedSeason = remember(series.id) { mutableStateOf(getDefaultSeason()) }
val nextUpEpisode = remember(series.id) {
series.seasons.firstNotNullOfOrNull { season ->
season.episodes.firstOrNull { !it.watched }
} ?: series.seasons.firstOrNull()?.episodes?.firstOrNull()
}
val playFocusRequester = remember { FocusRequester() } val playFocusRequester = remember { FocusRequester() }
val firstContentFocusRequester = remember { FocusRequester() } val firstContentFocusRequester = remember { FocusRequester() }
@@ -82,9 +70,7 @@ internal fun SeriesScreenContent(
} }
TvMediaDetailScaffold( TvMediaDetailScaffold(
artworkImageUrl = JellyfinImageHelper.finishImageUrl(series.imageUrlPrefix, ImageType.PRIMARY), backgroundImageUrl = tvMediaDetailBackgroundImageUrl(series.imageUrlPrefix),
artworkWidth = 200.dp,
artworkAspectRatio = 2f / 3f,
resetScrollKey = series.id, resetScrollKey = series.id,
modifier = modifier, modifier = modifier,
heroContent = { heroContent = {
@@ -112,17 +98,17 @@ internal fun SeriesScreenContent(
item { item {
SeasonTabs( SeasonTabs(
seasons = series.seasons, seasons = series.seasons,
selectedSeason = selectedSeason.value, selectedSeason = selectedSeason,
firstItemFocusRequester = firstContentFocusRequester, firstItemFocusRequester = firstContentFocusRequester,
firstItemTestTag = SeriesFirstSeasonTabTag, firstItemTestTag = SeriesFirstSeasonTabTag,
onSelect = { selectedSeason.value = it }, onSelect = { selectedSeason = it },
modifier = it modifier = it
) )
} }
item { item {
Spacer(modifier = Modifier.height(20.dp)) Spacer(modifier = Modifier.height(20.dp))
EpisodeCarousel( EpisodeCarousel(
episodes = selectedSeason.value.episodes, episodes = selectedSeason.episodes,
modifier = it modifier = it
) )
} }
@@ -139,3 +125,16 @@ internal fun SeriesScreenContent(
} }
} }
} }
private fun Series.defaultSeason(): Season {
for (season in seasons) {
if (season.episodes.any { !it.watched }) {
return season
}
}
return seasons.first()
}
private fun Series.nextUpEpisode() = seasons.firstNotNullOfOrNull { season ->
season.episodes.firstOrNull { !it.watched }
} ?: seasons.firstOrNull()?.episodes?.firstOrNull()

View File

@@ -2,18 +2,14 @@ package hu.bbara.purefin.common.ui.components
import androidx.compose.foundation.background import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.BoxWithConstraints
import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.ColumnScope import androidx.compose.foundation.layout.ColumnScope
import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.aspectRatio
import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.heightIn import androidx.compose.foundation.layout.heightIn
import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.layout.widthIn
import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.LazyListScope import androidx.compose.foundation.lazy.LazyListScope
import androidx.compose.foundation.lazy.rememberLazyListState import androidx.compose.foundation.lazy.rememberLazyListState
@@ -21,33 +17,24 @@ import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text import androidx.compose.material3.Text
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.LaunchedEffect
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.Brush import androidx.compose.ui.graphics.Brush
import androidx.compose.ui.graphics.graphicsLayer
import androidx.compose.ui.layout.ContentScale import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.platform.LocalConfiguration import androidx.compose.ui.platform.LocalConfiguration
import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp import androidx.compose.ui.unit.sp
import androidx.compose.foundation.shape.RoundedCornerShape
import hu.bbara.purefin.common.ui.MediaSynopsis import hu.bbara.purefin.common.ui.MediaSynopsis
import hu.bbara.purefin.core.data.image.JellyfinImageHelper
import org.jellyfin.sdk.model.api.ImageType
internal val MediaDetailHorizontalPadding = 48.dp internal val MediaDetailHorizontalPadding = 48.dp
private val MediaDetailHeaderTopPadding = 104.dp private val MediaDetailHeaderTopPadding = 104.dp
private val MediaDetailHeaderBottomPadding = 36.dp private val MediaDetailHeaderBottomPadding = 36.dp
private val MediaDetailCornerArtworkTopPadding = 40.dp
private val MediaDetailCornerArtworkShape = RoundedCornerShape(24.dp)
private val MediaDetailMinimumContentWidth = 280.dp
private val MediaDetailContentArtworkGap = 32.dp
@Composable @Composable
internal fun TvMediaDetailScaffold( internal fun TvMediaDetailScaffold(
artworkImageUrl: String, backgroundImageUrl: String,
artworkWidth: Dp,
artworkAspectRatio: Float,
resetScrollKey: Any, resetScrollKey: Any,
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
headerHeightFraction: Float = 0.48f, headerHeightFraction: Float = 0.48f,
@@ -62,103 +49,42 @@ internal fun TvMediaDetailScaffold(
listState.scrollToItem(0) listState.scrollToItem(0)
} }
Box( LazyColumn(
state = listState,
modifier = modifier modifier = modifier
.fillMaxSize() .fillMaxSize()
.background(scheme.background) .background(scheme.background)
) { ) {
LazyColumn( item {
state = listState, TvMediaDetailHeader(
modifier = Modifier.fillMaxSize() backgroundImageUrl = backgroundImageUrl,
) { headerHeightFraction = headerHeightFraction,
item { heroContent = heroContent
TvMediaDetailHeader( )
artworkImageUrl = artworkImageUrl,
artworkWidth = artworkWidth,
artworkAspectRatio = artworkAspectRatio,
headerHeightFraction = headerHeightFraction,
heroContent = heroContent
)
}
bodyContent(contentPadding)
} }
bodyContent(contentPadding)
} }
} }
@Composable @Composable
private fun TvMediaDetailHeader( private fun TvMediaDetailHeader(
artworkImageUrl: String, backgroundImageUrl: String,
artworkWidth: Dp,
artworkAspectRatio: Float,
headerHeightFraction: Float, headerHeightFraction: Float,
heroContent: @Composable ColumnScope.() -> Unit heroContent: @Composable ColumnScope.() -> Unit
) { ) {
val scheme = MaterialTheme.colorScheme
val screenHeight = LocalConfiguration.current.screenHeightDp.dp val screenHeight = LocalConfiguration.current.screenHeightDp.dp
val headerHeight = screenHeight * headerHeightFraction val headerHeight = screenHeight * headerHeightFraction
BoxWithConstraints( Box(
modifier = Modifier modifier = Modifier
.fillMaxWidth() .fillMaxWidth()
.heightIn(min = headerHeight) .heightIn(min = headerHeight)
) {
val contentMaxWidth = (
maxWidth -
(MediaDetailHorizontalPadding * 2) -
artworkWidth -
MediaDetailContentArtworkGap
).coerceAtLeast(MediaDetailMinimumContentWidth)
Box(modifier = Modifier.fillMaxWidth()) {
TvMediaDetailCornerArtwork(
artworkImageUrl = artworkImageUrl,
artworkWidth = artworkWidth,
artworkAspectRatio = artworkAspectRatio,
modifier = Modifier
.align(Alignment.TopEnd)
.padding(
top = MediaDetailCornerArtworkTopPadding,
end = MediaDetailHorizontalPadding
)
)
Column(
modifier = Modifier
.align(Alignment.TopStart)
.padding(
start = MediaDetailHorizontalPadding,
top = MediaDetailHeaderTopPadding,
end = MediaDetailHorizontalPadding,
bottom = MediaDetailHeaderBottomPadding
)
.widthIn(max = contentMaxWidth)
) {
heroContent()
}
}
}
}
@Composable
private fun TvMediaDetailCornerArtwork(
artworkImageUrl: String,
artworkWidth: Dp,
artworkAspectRatio: Float,
modifier: Modifier = Modifier
) {
val scheme = MaterialTheme.colorScheme
Box(
modifier = modifier
.width(artworkWidth)
.aspectRatio(artworkAspectRatio)
.clip(MediaDetailCornerArtworkShape)
.background(scheme.surfaceVariant.copy(alpha = 0.28f))
) { ) {
PurefinAsyncImage( PurefinAsyncImage(
model = artworkImageUrl, model = backgroundImageUrl,
contentDescription = null, contentDescription = null,
modifier = Modifier modifier = Modifier.fillMaxSize(),
.fillMaxSize()
.graphicsLayer { alpha = 0.52f },
contentScale = ContentScale.Crop contentScale = ContentScale.Crop
) )
Box( Box(
@@ -167,9 +93,10 @@ private fun TvMediaDetailCornerArtwork(
.background( .background(
Brush.horizontalGradient( Brush.horizontalGradient(
colorStops = arrayOf( colorStops = arrayOf(
0.0f to scheme.background.copy(alpha = 0.08f), 0.0f to scheme.background,
0.55f to scheme.background.copy(alpha = 0.2f), 0.28f to scheme.background.copy(alpha = 0.88f),
1.0f to scheme.background.copy(alpha = 0.56f) 0.62f to scheme.background.copy(alpha = 0.42f),
1.0f to scheme.background.copy(alpha = 0.12f)
) )
) )
) )
@@ -180,16 +107,34 @@ private fun TvMediaDetailCornerArtwork(
.background( .background(
Brush.verticalGradient( Brush.verticalGradient(
colorStops = arrayOf( colorStops = arrayOf(
0.0f to scheme.background.copy(alpha = 0.06f), 0.0f to scheme.background.copy(alpha = 0.08f),
0.65f to scheme.background.copy(alpha = 0.22f), 0.55f to scheme.background.copy(alpha = 0.16f),
1.0f to scheme.background.copy(alpha = 0.74f) 1.0f to scheme.background
) )
) )
) )
) )
Column(
modifier = Modifier
.fillMaxWidth()
.padding(
start = MediaDetailHorizontalPadding,
top = MediaDetailHeaderTopPadding,
end = MediaDetailHorizontalPadding,
bottom = MediaDetailHeaderBottomPadding
)
) {
heroContent()
}
} }
} }
internal fun tvMediaDetailBackgroundImageUrl(imageUrlPrefix: String?): String {
val primaryImageUrl = JellyfinImageHelper.finishImageUrl(imageUrlPrefix, ImageType.PRIMARY)
val backdropImageUrl = JellyfinImageHelper.finishImageUrl(imageUrlPrefix, ImageType.BACKDROP)
return backdropImageUrl.ifBlank { primaryImageUrl }
}
@Composable @Composable
internal fun MediaDetailSectionTitle( internal fun MediaDetailSectionTitle(
text: String, text: String,