Refine late metadata fades and synchronize loading motion
All checks were successful
review/code-claude PASS on 16684454, delta fdff85c8 reviewed
review/design-claude PASS on 16684454, delta fdff85c8 reviewed

This commit is contained in:
justinzeus 2026-11-07 02:16:46 +01:00
parent 1668445418
commit fdff85c8a1
7 changed files with 149 additions and 15 deletions

View file

@ -5,6 +5,7 @@ import androidx.compose.animation.core.tween
import androidx.compose.foundation.layout.Column
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.mutableFloatStateOf
import androidx.compose.runtime.remember
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.graphicsLayer
@ -22,13 +23,16 @@ fun Caption(title: String, facts: String, visible: Boolean, modifier: Modifier =
titleStyle: TextStyle = WolfskinTokens.Type.button, factsStyle: TextStyle = WolfskinTokens.Type.caption,
titleOverflow: TextOverflow = TextOverflow.Ellipsis) {
val alpha = remember(title, facts) { Animatable(0f) }
val entryProgress = remember(title, facts) { mutableFloatStateOf(0f) }
val reduced = reducedMotion()
val rise = with(androidx.compose.ui.platform.LocalDensity.current) { WolfskinTokens.Motion.captionRise.toPx() }
LaunchedEffect(alpha, visible, reduced) {
if (visible && !reduced) kotlinx.coroutines.delay(WolfskinTokens.Motion.captionDelay.toLong())
alpha.animateTo(if (visible) 1f else 0f, tween(motionDuration(if (visible) WolfskinTokens.Motion.focusMove else WolfskinTokens.Motion.captionOut, reduced), easing = WolfskinTokens.Motion.easeOut))
alpha.animateTo(if (visible) 1f else 0f, tween(motionDuration(if (visible) WolfskinTokens.Motion.focusMove else WolfskinTokens.Motion.captionOut, reduced), easing = WolfskinTokens.Motion.easeOut)) {
if (visible) entryProgress.floatValue = value
}
}
Column(modifier.graphicsLayer { this.alpha = alpha.value; translationY = if (reduced) 0f else rise * (1f - alpha.value) }) {
Column(modifier.graphicsLayer { this.alpha = alpha.value; translationY = if (reduced) 0f else rise * (1f - entryProgress.floatValue) }) {
Text(title, color = WolfskinTokens.Color.fg, style = titleStyle, modifier = Modifier.exactLineBox(titleStyle), maxLines = 1, overflow = titleOverflow)
CaptionFacts(facts, accentFirstFact, accentLastFact, factsStyle)
}

View file

@ -8,6 +8,16 @@ import androidx.compose.animation.core.rememberInfiniteTransition
import androidx.compose.animation.core.tween
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.CompositionLocalProvider
import androidx.compose.runtime.DisposableEffect
import androidx.compose.runtime.SideEffect
import androidx.compose.runtime.Stable
import androidx.compose.runtime.State
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableFloatStateOf
import androidx.compose.runtime.mutableIntStateOf
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.setValue
import androidx.compose.runtime.compositionLocalOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
@ -28,14 +38,40 @@ fun reducedMotion(): Boolean = LocalReducedMotion.current ||
/** Short fades remain available when movement and continuous animation are disabled. */
fun motionDuration(duration: Int, reduced: Boolean): Int = if (reduced) minOf(duration, M.reducedFade) else duration
@Stable
private class SkeletonPulseClock {
var subscribers by mutableIntStateOf(0)
val idle: State<Float> = mutableFloatStateOf(M.skeletonLowAlpha)
var alpha by mutableStateOf(idle)
}
private val LocalSkeletonPulseClock = compositionLocalOf<SkeletonPulseClock> {
error("Skeleton loading requires WolfskinTheme")
}
/** Share phase across shelves, including late arrivals; stop the clock when no shelf needs it. */
@Composable
internal fun SkeletonPulseScope(content: @Composable () -> Unit) {
val clock = remember { SkeletonPulseClock() }
val reduced = reducedMotion()
val alpha = if (clock.subscribers > 0 && !reduced) {
rememberInfiniteTransition(label = "skeleton-screen").animateFloat(M.skeletonLowAlpha, 1f,
infiniteRepeatable(tween(M.skeletonCycle / 2, easing = M.breatheEase), RepeatMode.Reverse), label = "breathe")
} else clock.idle
SideEffect { clock.alpha = alpha }
CompositionLocalProvider(LocalSkeletonPulseClock provides clock, content = content)
}
/** One alpha layer owns all shapes in a shelf; individual placeholders never animate. */
@Composable
fun Modifier.skeletonPulse(loading: Boolean = true): Modifier {
if (!loading || reducedMotion()) return this
val clock = rememberInfiniteTransition(label = "skeleton-shelf")
val alpha = clock.animateFloat(M.skeletonLowAlpha, 1f,
infiniteRepeatable(tween(M.skeletonCycle / 2, easing = M.breatheEase), RepeatMode.Reverse), label = "breathe")
return graphicsLayer { this.alpha = alpha.value }
val clock = LocalSkeletonPulseClock.current
DisposableEffect(clock) {
clock.subscribers++
onDispose { clock.subscribers-- }
}
return graphicsLayer { alpha = clock.alpha.value }
}
/** Content enters on its row layer, never one animation per card or a full-screen transition. */

View file

@ -44,7 +44,7 @@ fun TitleRatings(ratings: List<LibraryRating>, modifier: Modifier = Modifier, st
val numberStyle = remember(textStyle) { textStyle.copy(fontFeatureSettings = "tnum", fontWeight = FontWeight.Bold) }
val gap = if (plate) with(density) { (style.fontSize * P.plateGapEm).toDp() } else D.ratingGap
val markGap = if (plate) with(density) { (style.fontSize * P.markGapEm).toDp() } else D.ratingMarkGap
Row(modifier, horizontalArrangement = Arrangement.spacedBy(gap), verticalAlignment = Alignment.CenterVertically) {
Row(if (plate) modifier else modifier.lateLineFade(ratings), horizontalArrangement = Arrangement.spacedBy(gap), verticalAlignment = Alignment.CenterVertically) {
ratings.forEach { rating ->
Row(horizontalArrangement = Arrangement.spacedBy(markGap), verticalAlignment = Alignment.CenterVertically) {
val resource = when (rating.source) {

View file

@ -57,7 +57,7 @@ fun TitleHeader(state: TitleState, active: Boolean, modifier: Modifier = Modifie
if (item.kind == "movie" && item.runtimeSeconds > 0) Text(item.runtime, color = C.muted, style = TitleTextStyles.body, modifier = Modifier.exactLineBox(TitleTextStyles.body))
if (item.kind == "tv" && item.seasons > 0) Text("${item.seasons} season${if (item.seasons == 1) "" else "s"}", color = C.muted, style = TitleTextStyles.body, modifier = Modifier.exactLineBox(TitleTextStyles.body))
if (state.data.connection != TitleConnection.SignedOut) TitleRatings(item.ratings)
if (item.genres.isNotEmpty()) Text(item.genres.take(3).joinToString(", "), color = C.muted, style = TitleTextStyles.body, modifier = Modifier.exactLineBox(TitleTextStyles.body))
if (item.genres.isNotEmpty()) Text(item.genres.take(3).joinToString(", "), color = C.muted, style = TitleTextStyles.body, modifier = Modifier.exactLineBox(TitleTextStyles.body).lateLineFade(item.genres))
}
Box(Modifier.fillMaxWidth().height(if (item.kind == "tv" || state.data.requestedStage != null) WolfskinTokens.Motion.readinessSlot else WolfskinTokens.Shell.zero).testTag("title-readiness-slot")) {
state.data.requestedStage?.let { stage ->
@ -69,7 +69,7 @@ fun TitleHeader(state: TitleState, active: Boolean, modifier: Modifier = Modifie
}
}
}
Text((episode ?: item).overview, color = C.muted, style = TitleTextStyles.body, maxLines = 3, overflow = TextOverflow.Ellipsis, modifier = Modifier.exactLineBox(TitleTextStyles.body, bottom = D.overviewGap).testTag("title-overview"))
Text((episode ?: item).overview, color = C.muted, style = TitleTextStyles.body, maxLines = 3, overflow = TextOverflow.Ellipsis, modifier = Modifier.exactLineBox(TitleTextStyles.body, bottom = D.overviewGap).lateLineFade((episode ?: item).overview).testTag("title-overview"))
if (!item.held && state.data.connection != TitleConnection.SignedIn) {
Text(if (state.data.connection == TitleConnection.SignedOut) "Requests need this TV to be signed in to Conductarr." else "Requests need Conductarr, and it can’t be reached right now.",
color = C.warn, style = TitleTextStyles.body, modifier = Modifier.exactLineBox(TitleTextStyles.body, bottom = D.overviewGap))

View file

@ -38,6 +38,8 @@ fun WolfskinTheme(content: @Composable () -> Unit) {
val reduced = reducedMotion()
androidx.compose.runtime.SideEffect { NetworkArtTransition.reduced = reduced }
androidx.compose.material3.MaterialTheme(colorScheme = WolfskinColors.darkSchemeMaterial) {
MaterialTheme(colorScheme = WolfskinColors.darkScheme, content = content)
MaterialTheme(colorScheme = WolfskinColors.darkScheme) {
SkeletonPulseScope(content)
}
}
}