feat(tv): make hero responsive to screen height and enrich metadata

This commit is contained in:
2026-06-19 17:04:55 +00:00
parent eb540a0fbb
commit 831abfebb4
2 changed files with 56 additions and 24 deletions

View File

@@ -10,7 +10,6 @@ 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 androidx.compose.ui.unit.dp
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.core.model.LibraryUiModel
@@ -40,8 +39,7 @@ fun TvHomeScreen(
modifier = Modifier.fillMaxSize() modifier = Modifier.fillMaxSize()
) { ) {
TvFocusedItemHero( TvFocusedItemHero(
item = focusedMediaUiModel.value, item = focusedMediaUiModel.value
height = 220.dp
) )
TvHomeContent( TvHomeContent(
libraries = libraries, libraries = libraries,

View File

@@ -6,9 +6,10 @@ 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.Box
import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxHeight
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.padding import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.widthIn import androidx.compose.foundation.layout.widthIn
import androidx.compose.material3.MaterialTheme import androidx.compose.material3.MaterialTheme
@@ -20,28 +21,31 @@ import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.platform.testTag import androidx.compose.ui.platform.testTag
import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.text.style.TextOverflow
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 hu.bbara.purefin.core.model.MediaUiModel import hu.bbara.purefin.core.model.MediaUiModel
import hu.bbara.purefin.ui.common.image.PurefinAsyncImage import hu.bbara.purefin.ui.common.image.PurefinAsyncImage
import kotlin.math.roundToInt
internal const val TvHomeHeroTitleTag = "tv-home-hero-title" 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 hero block and the first focused row fit on a 540dp-tall (1080p xhdpi) TV.
internal const val TvHomeHeroHeightFraction = 0.5f
@Composable @Composable
internal fun TvFocusedItemHero( internal fun TvFocusedItemHero(
item: MediaUiModel, item: MediaUiModel,
height: Dp,
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
heightFraction: Float = TvHomeHeroHeightFraction,
) { ) {
val scheme = MaterialTheme.colorScheme val scheme = MaterialTheme.colorScheme
Box( Box(
modifier = modifier modifier = modifier
.fillMaxWidth() .fillMaxWidth()
.height(height) .fillMaxHeight(heightFraction)
.background(scheme.background) .background(scheme.background)
) { ) {
Crossfade( Crossfade(
@@ -56,20 +60,22 @@ internal fun TvFocusedItemHero(
contentScale = ContentScale.Crop contentScale = ContentScale.Crop
) )
} }
// Darken the left side so the text block stays readable over any backdrop.
Box( Box(
modifier = Modifier modifier = Modifier
.fillMaxSize() .fillMaxSize()
.background( .background(
Brush.horizontalGradient( Brush.horizontalGradient(
colorStops = arrayOf( colorStops = arrayOf(
0.0f to scheme.background, 0.0f to scheme.background.copy(alpha = 0.86f),
0.28f to scheme.background.copy(alpha = 0.88f), 0.34f to scheme.background.copy(alpha = 0.62f),
0.62f to scheme.background.copy(alpha = 0.42f), 0.66f to scheme.background.copy(alpha = 0.22f),
1.0f to scheme.background.copy(alpha = 0.06f) 1.0f to scheme.background.copy(alpha = 0.04f)
) )
) )
) )
) )
// Strong bottom fade so the title and metadata are legible.
Box( Box(
modifier = Modifier modifier = Modifier
.fillMaxSize() .fillMaxSize()
@@ -77,7 +83,8 @@ internal fun TvFocusedItemHero(
Brush.verticalGradient( Brush.verticalGradient(
colorStops = arrayOf( colorStops = arrayOf(
0.0f to scheme.background.copy(alpha = 0f), 0.0f to scheme.background.copy(alpha = 0f),
0.56f to scheme.background.copy(alpha = 0.1f), 0.40f to scheme.background.copy(alpha = 0.10f),
0.72f to scheme.background.copy(alpha = 0.58f),
1.0f to scheme.background 1.0f to scheme.background
) )
) )
@@ -92,7 +99,7 @@ internal fun TvFocusedItemHero(
verticalArrangement = Arrangement.Bottom, verticalArrangement = Arrangement.Bottom,
modifier = Modifier modifier = Modifier
.fillMaxSize() .fillMaxSize()
.padding(horizontal = 40.dp, vertical = 18.dp) .padding(horizontal = 40.dp, vertical = 16.dp)
) { ) {
Column( Column(
verticalArrangement = Arrangement.spacedBy(8.dp), verticalArrangement = Arrangement.spacedBy(8.dp),
@@ -101,19 +108,21 @@ internal fun TvFocusedItemHero(
Text( Text(
text = hero.primaryText, text = hero.primaryText,
color = scheme.onBackground, color = scheme.onBackground,
fontSize = 34.sp, fontSize = 40.sp,
fontWeight = FontWeight.Bold, fontWeight = FontWeight.Bold,
lineHeight = 38.sp, lineHeight = 44.sp,
maxLines = 1, maxLines = 2,
overflow = TextOverflow.Ellipsis, overflow = TextOverflow.Ellipsis,
modifier = Modifier.testTag(TvHomeHeroTitleTag) modifier = Modifier.testTag(TvHomeHeroTitleTag)
) )
TvHomeHeroMetadataRow(item = hero)
if (hero.description.isNotBlank()) {
Text( Text(
text = hero.description, text = hero.description,
color = scheme.onSurfaceVariant, color = scheme.onSurfaceVariant,
fontSize = 14.sp, fontSize = 14.sp,
lineHeight = 18.sp, lineHeight = 18.sp,
maxLines = 1, maxLines = 2,
overflow = TextOverflow.Ellipsis overflow = TextOverflow.Ellipsis
) )
} }
@@ -121,3 +130,28 @@ internal fun TvFocusedItemHero(
} }
} }
} }
}
@Composable
private fun TvHomeHeroMetadataRow(item: MediaUiModel) {
val hasSecondary = item.secondaryText.isNotBlank()
val progress = item.progress
val hasProgress = progress != null && progress > 0f && progress < 1f
if (!hasSecondary && !hasProgress) return
Row(
horizontalArrangement = Arrangement.spacedBy(8.dp),
modifier = Modifier.fillMaxWidth()
) {
if (hasSecondary) {
TvHomeMetaChip(text = item.secondaryText)
}
if (hasProgress) {
val percent = ((progress ?: 0f) * 100f).roundToInt()
TvHomeMetaChip(
text = "Resume \u00B7 $percent%",
highlighted = true,
modifier = Modifier.testTag(TvHomeHeroProgressLabelTag)
)
}
}
}