refactor(tv): extract hero backdrop and simplify gradient drawing

Split the hero backdrop (image + gradient overlays) into TvHomeHeroBackdrop, rendered behind the home content. Replace nested Box/background gradient approach with drawWithContent for better composability. Clean up unused imports across TvHomeScreen, TvFocusedItemHero, and TvHomeContent.
This commit is contained in:
2026-06-20 18:52:09 +00:00
parent da89a8a661
commit a5d04998ac
3 changed files with 100 additions and 92 deletions

View File

@@ -1,6 +1,6 @@
package hu.bbara.purefin.ui.screen.home package hu.bbara.purefin.ui.screen.home
import androidx.compose.foundation.background import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.fillMaxWidth
@@ -10,11 +10,12 @@ import androidx.compose.runtime.Composable
import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember import androidx.compose.runtime.remember
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import hu.bbara.purefin.core.model.LibraryUiModel
import hu.bbara.purefin.core.model.MediaUiModel import hu.bbara.purefin.core.model.MediaUiModel
import hu.bbara.purefin.core.model.MovieUiModel import hu.bbara.purefin.core.model.MovieUiModel
import hu.bbara.purefin.core.model.LibraryUiModel
import hu.bbara.purefin.ui.screen.home.components.TvFocusedItemHero import hu.bbara.purefin.ui.screen.home.components.TvFocusedItemHero
import hu.bbara.purefin.ui.screen.home.components.TvHomeContent import hu.bbara.purefin.ui.screen.home.components.TvHomeContent
import hu.bbara.purefin.ui.screen.home.components.TvHomeHeroBackdrop
import java.util.UUID import java.util.UUID
@Composable @Composable
@@ -30,30 +31,33 @@ fun TvHomeScreen(
val focusedMediaUiModel = remember { mutableStateOf<MediaUiModel>(MovieUiModel.createPlaceholder()) } val focusedMediaUiModel = remember { mutableStateOf<MediaUiModel>(MovieUiModel.createPlaceholder()) }
Surface( Surface(
modifier = modifier modifier = modifier.fillMaxSize(),
.fillMaxSize() color = scheme.background
.background(scheme.background)
) { ) {
Box(modifier = Modifier.fillMaxSize()) {
Column( TvHomeHeroBackdrop(
modifier = Modifier.fillMaxSize() backdropImageUrl = focusedMediaUiModel.value.backdropImageUrl
) {
TvFocusedItemHero(
item = focusedMediaUiModel.value
)
TvHomeContent(
libraries = libraries,
libraryContent = libraryContent,
continueWatching = continueWatching,
nextUp = nextUp,
onMediaFocused = {
focusedMediaUiModel.value = it
},
onMediaSelected = onMediaSelected,
modifier = Modifier
.weight(1f)
.fillMaxWidth()
) )
Column(
modifier = Modifier.fillMaxSize()
) {
TvFocusedItemHero(
item = focusedMediaUiModel.value
)
TvHomeContent(
libraries = libraries,
libraryContent = libraryContent,
continueWatching = continueWatching,
nextUp = nextUp,
onMediaFocused = {
focusedMediaUiModel.value = it
},
onMediaSelected = onMediaSelected,
modifier = Modifier
.weight(1f)
.fillMaxWidth()
)
}
} }
} }
} }

View File

@@ -2,9 +2,7 @@ package hu.bbara.purefin.ui.screen.home.components
import androidx.compose.animation.Crossfade import androidx.compose.animation.Crossfade
import androidx.compose.animation.core.tween import androidx.compose.animation.core.tween
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxHeight import androidx.compose.foundation.layout.fillMaxHeight
@@ -16,6 +14,7 @@ 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.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.drawWithContent
import androidx.compose.ui.graphics.Brush import androidx.compose.ui.graphics.Brush
import androidx.compose.ui.layout.ContentScale import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.platform.testTag import androidx.compose.ui.platform.testTag
@@ -31,40 +30,34 @@ internal const val TvHomeHeroTitleTag = "tv-home-hero-title"
internal const val TvHomeHeroProgressLabelTag = "tv-home-hero-progress-label" internal const val TvHomeHeroProgressLabelTag = "tv-home-hero-progress-label"
private const val TvHomeHeroAnimationMillis = 180 private const val TvHomeHeroAnimationMillis = 180
// Half the screen for the billboard, half for the content rows. Tuned so both // The billboard backdrop covers the top portion of the home screen so the hero
// the hero block and the first focused row fit on a 540dp-tall (1080p xhdpi) TV. // text sits at the bottom of the backdrop and the content rows begin below it.
internal const val TvHomeHeroHeightFraction = 0.45f internal const val TvHomeHeroHeightFraction = 0.45f
internal const val TvHomeBackdropOffset = 0.20f
/**
* The home screen background image plus the darkening gradients that keep the
* hero text readable. Drawn behind the home content and sized to the top
* [TvHomeHeroHeightFraction] of the screen.
*/
@Composable @Composable
internal fun TvFocusedItemHero( internal fun TvHomeHeroBackdrop(
item: MediaUiModel, backdropImageUrl: String?,
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
heightFraction: Float = TvHomeHeroHeightFraction, heightFraction: Float = TvHomeHeroHeightFraction + TvHomeBackdropOffset,
) { ) {
val scheme = MaterialTheme.colorScheme val scheme = MaterialTheme.colorScheme
Box( Crossfade(
targetState = backdropImageUrl,
animationSpec = tween(durationMillis = TvHomeHeroAnimationMillis),
label = "tv-home-hero-backdrop",
modifier = modifier modifier = modifier
.fillMaxWidth() .fillMaxWidth()
.fillMaxHeight(heightFraction) .fillMaxHeight(heightFraction)
.background(scheme.background) .drawWithContent {
) { drawContent()
Crossfade( // Darken the left side so the text block stays readable over any backdrop.
targetState = item.backdropImageUrl, drawRect(
animationSpec = tween(durationMillis = TvHomeHeroAnimationMillis),
label = "tv-home-hero-background"
) { imageUrl ->
PurefinAsyncImage(
model = imageUrl,
contentDescription = null,
modifier = Modifier.fillMaxSize(),
contentScale = ContentScale.Crop
)
}
// Darken the left side so the text block stays readable over any backdrop.
Box(
modifier = Modifier
.fillMaxSize()
.background(
Brush.horizontalGradient( Brush.horizontalGradient(
colorStops = arrayOf( colorStops = arrayOf(
0.0f to scheme.background.copy(alpha = 0.86f), 0.0f to scheme.background.copy(alpha = 0.86f),
@@ -74,12 +67,8 @@ internal fun TvFocusedItemHero(
) )
) )
) )
) // Strong bottom fade so the title and metadata are legible.
// Strong bottom fade so the title and metadata are legible. drawRect(
Box(
modifier = Modifier
.fillMaxSize()
.background(
Brush.verticalGradient( Brush.verticalGradient(
colorStops = arrayOf( colorStops = arrayOf(
0.0f to scheme.background.copy(alpha = 0f), 0.0f to scheme.background.copy(alpha = 0f),
@@ -89,43 +78,62 @@ internal fun TvFocusedItemHero(
) )
) )
) )
}
) { imageUrl ->
PurefinAsyncImage(
model = imageUrl,
contentDescription = null,
modifier = Modifier.fillMaxSize(),
contentScale = ContentScale.Crop
) )
Crossfade( }
targetState = item, }
animationSpec = tween(durationMillis = TvHomeHeroAnimationMillis),
label = "tv-home-hero-content" @Composable
) { hero -> internal fun TvFocusedItemHero(
item: MediaUiModel,
modifier: Modifier = Modifier,
heightFraction: Float = TvHomeHeroHeightFraction,
) {
val scheme = MaterialTheme.colorScheme
Crossfade(
targetState = item,
animationSpec = tween(durationMillis = TvHomeHeroAnimationMillis),
label = "tv-home-hero-content",
modifier = modifier
.fillMaxWidth()
.fillMaxHeight(heightFraction)
) { hero ->
Column(
verticalArrangement = Arrangement.Bottom,
modifier = Modifier
.fillMaxSize()
.padding(horizontal = 40.dp, vertical = 16.dp)
) {
Column( Column(
verticalArrangement = Arrangement.Bottom, verticalArrangement = Arrangement.spacedBy(8.dp),
modifier = Modifier modifier = Modifier.widthIn(max = 720.dp)
.fillMaxSize()
.padding(horizontal = 40.dp, vertical = 16.dp)
) { ) {
Column( Text(
verticalArrangement = Arrangement.spacedBy(8.dp), text = hero.primaryText,
modifier = Modifier.widthIn(max = 720.dp) color = scheme.onBackground,
) { fontSize = 40.sp,
fontWeight = FontWeight.Bold,
lineHeight = 44.sp,
maxLines = 2,
overflow = TextOverflow.Ellipsis,
modifier = Modifier.testTag(TvHomeHeroTitleTag)
)
TvHomeHeroMetadataRow(item = hero)
if (hero.description.isNotBlank()) {
Text( Text(
text = hero.primaryText, text = hero.description,
color = scheme.onBackground, color = scheme.onSurfaceVariant,
fontSize = 40.sp, fontSize = 14.sp,
fontWeight = FontWeight.Bold, lineHeight = 18.sp,
lineHeight = 44.sp,
maxLines = 2, maxLines = 2,
overflow = TextOverflow.Ellipsis, overflow = TextOverflow.Ellipsis
modifier = Modifier.testTag(TvHomeHeroTitleTag)
) )
TvHomeHeroMetadataRow(item = hero)
if (hero.description.isNotBlank()) {
Text(
text = hero.description,
color = scheme.onSurfaceVariant,
fontSize = 14.sp,
lineHeight = 18.sp,
maxLines = 2,
overflow = TextOverflow.Ellipsis
)
}
} }
} }
} }

View File

@@ -2,7 +2,6 @@
package hu.bbara.purefin.ui.screen.home.components package hu.bbara.purefin.ui.screen.home.components
import androidx.compose.foundation.background
import androidx.compose.foundation.gestures.LocalBringIntoViewSpec import androidx.compose.foundation.gestures.LocalBringIntoViewSpec
import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.PaddingValues import androidx.compose.foundation.layout.PaddingValues
@@ -11,7 +10,6 @@ import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.height
import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.itemsIndexed import androidx.compose.foundation.lazy.itemsIndexed
import androidx.compose.material3.MaterialTheme
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.CompositionLocalProvider import androidx.compose.runtime.CompositionLocalProvider
import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.LaunchedEffect
@@ -42,7 +40,6 @@ fun TvHomeContent(
contentPadding: PaddingValues = PaddingValues(bottom = 32.dp), contentPadding: PaddingValues = PaddingValues(bottom = 32.dp),
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
) { ) {
val scheme = MaterialTheme.colorScheme
val topOffsetPx = with(LocalDensity.current) { TvHomeFocusedItemTopOffset.toPx() } val topOffsetPx = with(LocalDensity.current) { TvHomeFocusedItemTopOffset.toPx() }
val hasContinueWatching = continueWatching.isNotEmpty() val hasContinueWatching = continueWatching.isNotEmpty()
val hasNextUp = nextUp.isNotEmpty() val hasNextUp = nextUp.isNotEmpty()
@@ -65,7 +62,6 @@ fun TvHomeContent(
LazyColumn( LazyColumn(
modifier = modifier modifier = modifier
.fillMaxSize() .fillMaxSize()
.background(scheme.background)
.focusRequester(initialFocusRequester), .focusRequester(initialFocusRequester),
verticalArrangement = Arrangement.spacedBy(24.dp), verticalArrangement = Arrangement.spacedBy(24.dp),
contentPadding = contentPadding contentPadding = contentPadding