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,11 +31,13 @@ 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()) {
TvHomeHeroBackdrop(
backdropImageUrl = focusedMediaUiModel.value.backdropImageUrl
)
Column( Column(
modifier = Modifier.fillMaxSize() modifier = Modifier.fillMaxSize()
) { ) {
@@ -57,3 +60,4 @@ fun TvHomeScreen(
} }
} }
} }
}

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(
targetState = item.backdropImageUrl,
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. // Darken the left side so the text block stays readable over any backdrop.
Box( drawRect(
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.
Box( drawRect(
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,11 +78,31 @@ internal fun TvFocusedItemHero(
) )
) )
) )
}
) { imageUrl ->
PurefinAsyncImage(
model = imageUrl,
contentDescription = null,
modifier = Modifier.fillMaxSize(),
contentScale = ContentScale.Crop
) )
}
}
@Composable
internal fun TvFocusedItemHero(
item: MediaUiModel,
modifier: Modifier = Modifier,
heightFraction: Float = TvHomeHeroHeightFraction,
) {
val scheme = MaterialTheme.colorScheme
Crossfade( Crossfade(
targetState = item, targetState = item,
animationSpec = tween(durationMillis = TvHomeHeroAnimationMillis), animationSpec = tween(durationMillis = TvHomeHeroAnimationMillis),
label = "tv-home-hero-content" label = "tv-home-hero-content",
modifier = modifier
.fillMaxWidth()
.fillMaxHeight(heightFraction)
) { hero -> ) { hero ->
Column( Column(
verticalArrangement = Arrangement.Bottom, verticalArrangement = Arrangement.Bottom,
@@ -130,7 +139,6 @@ internal fun TvFocusedItemHero(
} }
} }
} }
}
@Composable @Composable
private fun TvHomeHeroMetadataRow(item: MediaUiModel) { private fun TvHomeHeroMetadataRow(item: MediaUiModel) {

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