Stagger TV hero content reveal and consolidate motion durations

Billboard rotations now play one orchestrated moment: logo, metadata,
description, and buttons fade and rise in sequence, driven by a single
Animatable read inside graphicsLayer so the stagger never recomposes the
panel. Ad-hoc tween literals across hero, shelves, cards, and the ambient
tint move to FluxaDimensions.AnimDuration tokens; the hero title fallback
also picks up the display face.
This commit is contained in:
KhooLy 2026-07-02 00:51:01 +03:00
parent 327e869488
commit f4f233f59d
5 changed files with 43 additions and 15 deletions

View file

@ -77,7 +77,6 @@ object FluxaDimensions {
// ── Profile screens ───────────────────────────────────────────────────────
object Profile {
val avatarSize = 120.dp
val avatarPickerThumbSize = 80.dp
}
// ── Card text sizes ───────────────────────────────────────────────────────
@ -111,6 +110,10 @@ object FluxaDimensions {
const val heightAnim = 130
const val settingsExpand = 240
const val settingsExpandAlt = 260
const val heroReveal = 650
const val fadeIn = 200
const val fadeOut = 150
const val ambientColor = 700
}
// ── Focus indicator stroke ────────────────────────────────────────────────

View file

@ -33,7 +33,7 @@ fun rememberAmbientHeroColor(artworkUrl: String?): Color {
value = ThemeHelper.extractColors(context, artworkUrl)
}
val target = lerp(colors.darkMuted, FluxaColors.background, 0.35f)
return animateColorAsState(target, tween(700), label = "ambientHero").value
return animateColorAsState(target, tween(FluxaDimensions.AnimDuration.ambientColor), label = "ambientHero").value
}
object ThemeHelper {

View file

@ -18,8 +18,11 @@ import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.layout.widthIn
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.animation.core.Animatable
import androidx.compose.animation.core.animateFloatAsState
import androidx.compose.animation.core.tween
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.ui.graphics.graphicsLayer
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.remember
@ -284,6 +287,13 @@ private fun youtubeEmbedHtml(youtubeId: String): String {
""".trimIndent()
}
private fun Modifier.heroReveal(progress: () -> Float, step: Int): Modifier = graphicsLayer {
val start = step * 0.15f
val f = ((progress() - start) / 0.55f).coerceIn(0f, 1f)
alpha = f
translationY = (1f - f) * 22.dp.toPx()
}
@Composable
internal fun HomeHeroPanel(
movie: Meta,
@ -300,12 +310,16 @@ internal fun HomeHeroPanel(
) {
val buttonsAlpha by animateFloatAsState(
targetValue = if (showButtons) 1f else 0f,
animationSpec = tween(220),
animationSpec = tween(FluxaDimensions.AnimDuration.contentExpand),
label = "heroButtonsAlpha"
)
val logoCandidates = remember(movie.id, movie.logo, logoUrl) {
listOfNotNull(logoUrl, movie.logo).distinct()
}
val reveal = remember(movie.id) { Animatable(0f) }
LaunchedEffect(movie.id) {
reveal.animateTo(1f, tween(FluxaDimensions.AnimDuration.heroReveal))
}
androidx.compose.foundation.layout.Column(modifier = modifier.widthIn(max = 560.dp)) {
if (logoCandidates.isNotEmpty()) {
@ -314,7 +328,8 @@ internal fun HomeHeroPanel(
contentDescription = movie.name,
modifier = Modifier
.height(94.dp)
.widthIn(max = 360.dp),
.widthIn(max = 360.dp)
.heroReveal({ reveal.value }, 0),
contentScale = ContentScale.Fit
)
} else {
@ -323,20 +338,26 @@ internal fun HomeHeroPanel(
color = Color(0xFFFFD94B),
fontSize = 54.sp,
lineHeight = 56.sp,
fontFamily = FluxaDisplay,
fontWeight = FontWeight.Black,
style = TextStyle(
shadow = Shadow(
color = Color.Black.copy(alpha = 0.55f),
blurRadius = 20f
)
)
),
modifier = Modifier.heroReveal({ reveal.value }, 0)
)
}
Spacer(modifier = Modifier.height(18.dp))
HeroMetaLine(movie = movie, lang = lang)
Spacer(modifier = Modifier.height(10.dp))
HeroRatingLine(movie = movie)
Box(modifier = Modifier.heroReveal({ reveal.value }, 1)) {
androidx.compose.foundation.layout.Column {
HeroMetaLine(movie = movie, lang = lang)
Spacer(modifier = Modifier.height(10.dp))
HeroRatingLine(movie = movie)
}
}
Spacer(modifier = Modifier.height(18.dp))
if (!movie.description.isNullOrBlank()) {
@ -347,14 +368,18 @@ internal fun HomeHeroPanel(
lineHeight = 27.sp,
maxLines = 4,
overflow = TextOverflow.Ellipsis,
modifier = Modifier.widthIn(max = 500.dp)
modifier = Modifier
.widthIn(max = 500.dp)
.heroReveal({ reveal.value }, 2)
)
Spacer(modifier = Modifier.height(22.dp))
}
Row(
horizontalArrangement = Arrangement.spacedBy(14.dp),
modifier = Modifier.alpha(buttonsAlpha)
modifier = Modifier
.alpha(buttonsAlpha)
.heroReveal({ reveal.value }, 3)
) {
Button(
onClick = onPlayClick,

View file

@ -117,7 +117,7 @@ private fun WideContinueCard(
onFocus: () -> Unit
) {
var isFocused by remember { mutableStateOf(false) }
val scale by animateFloatAsState(if (isFocused) 1.035f else 1f, tween(180), label = "continueScale")
val scale by animateFloatAsState(if (isFocused) 1.035f else 1f, tween(FluxaDimensions.AnimDuration.scaleAlpha), label = "continueScale")
Row(
modifier = Modifier
@ -353,12 +353,12 @@ internal fun HomeShelfRow(
@Composable
private fun ShelfExpandedDescription(expandedMeta: Meta?, expandedOffsetX: Dp, rowWidth: Dp) {
val animatedOffsetX by animateDpAsState(expandedOffsetX, tween(220), label = "expandedInfoOffset")
val animatedOffsetX by animateDpAsState(expandedOffsetX, tween(FluxaDimensions.AnimDuration.contentExpand), label = "expandedInfoOffset")
Box(modifier = Modifier.fillMaxWidth().height(90.dp)) {
AnimatedVisibility(
visible = expandedMeta != null,
enter = fadeIn(tween(200)),
exit = fadeOut(tween(150))
enter = fadeIn(tween(FluxaDimensions.AnimDuration.fadeIn)),
exit = fadeOut(tween(FluxaDimensions.AnimDuration.fadeOut))
) {
val info = expandedMeta
if (info != null) {

View file

@ -77,7 +77,7 @@ internal fun TvMovieCard(
val radius = posterCornerRadius(profile?.safeCardCornerPreset ?: "soft")
val animationDuration = when {
profile?.safeAnimationsEnabled == false -> 0
else -> if (isShelfStyle) 220 else 240
else -> if (isShelfStyle) FluxaDimensions.AnimDuration.contentExpand else FluxaDimensions.AnimDuration.cardFocusScale
}
val hidePosterTitles = profile?.safePosterHideTitles == true || meta.hideTitle == true