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)
}
}
}

View file

@ -6,6 +6,7 @@ import android.os.StrictMode
import androidx.activity.ComponentActivity
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.offset
import androidx.compose.foundation.layout.requiredSize
import androidx.compose.foundation.layout.size
import androidx.compose.runtime.CompositionLocalProvider
@ -19,6 +20,7 @@ import androidx.compose.ui.input.key.Key
import androidx.compose.ui.platform.testTag
import androidx.compose.ui.test.junit4.createAndroidComposeRule
import androidx.compose.ui.test.onNodeWithTag
import androidx.compose.ui.test.onNodeWithText
import androidx.compose.ui.test.onRoot
import androidx.compose.ui.test.performKeyInput
import androidx.compose.ui.test.pressKey
@ -114,6 +116,91 @@ class MotionSurfaceTest {
} finally { directory.deleteRecursively() }
}
@Test fun shelvesJoiningAnActiveLoadShareTheSameBreathingPhase() {
rule.mainClock.autoAdvance = false
val second = mutableStateOf(false)
rule.setContent { WolfskinTheme {
Box(Modifier.requiredSize(960.dp, 540.dp).background(WolfskinTokens.Color.bg)) {
Box(Modifier.size(200.dp, 140.dp).skeletonPulse().background(WolfskinTokens.Color.surface2))
if (second.value) Box(Modifier.offset(y = 200.dp).size(200.dp, 140.dp).skeletonPulse().background(WolfskinTokens.Color.surface2))
}
} }
rule.mainClock.advanceTimeBy(600)
update { second.value = true }
val directory = kotlin.io.path.createTempDirectory("wolfskin-shared-pulse-").toFile()
try {
var previous: Int? = null
repeat(2) { sample ->
val file = File(directory, "$sample.png")
rule.onRoot().captureRoboImage(file.absolutePath)
val bitmap = BitmapFactory.decodeFile(file.absolutePath)
try {
val first = bitmap.getPixel(100, 100)
assertEquals("Late shelves must join the existing phase", first, bitmap.getPixel(100, 500))
if (previous != null) assertNotEquals("The shared clock must still breathe", previous, first)
previous = first
} finally { bitmap.recycle() }
rule.mainClock.advanceTimeBy(450)
}
} finally { directory.deleteRecursively() }
}
@Test fun captionExitKeepsTranslationFixedAtRestAndDuringInterruptedEntry() {
rule.mainClock.autoAdvance = false
val title = mutableStateOf("Settled caption")
val visible = mutableStateOf(true)
rule.setContent { WolfskinTheme { Caption(title.value, "Facts", visible.value, Modifier.offset(40.dp, 80.dp)) } }
rule.mainClock.advanceTimeBy(400)
fun top() = rule.onNodeWithText(title.value).fetchSemanticsNode().boundsInRoot.top
val settled = top()
update { visible.value = false }
rule.mainClock.advanceTimeBy(48)
assertEquals("Exit cannot sink a settled caption", settled, top(), 0.01f)
update { title.value = "Interrupted caption"; visible.value = true }
rule.mainClock.advanceTimeBy(112)
val interrupted = top()
assertTrue("Entry must still be partway through its rise", interrupted > settled)
update { visible.value = false }
val exitTop = top()
assertTrue("Exit must retain the unfinished rise, not snap to its baseline", exitTop > settled)
rule.mainClock.advanceTimeBy(48)
assertEquals("An interrupted entry must fade exactly where it stopped", exitTop, top(), 0.01f)
}
@Test fun lateRatingsGenresAndOverviewFadeInsideTheirExistingSlots() {
rule.mainClock.autoAdvance = false
val series = TitleItem("series", "A series", "tv", held = true)
val state = TitleState(TitlePageData(series, connection = TitleConnection.SignedIn), viewer) {}
val shell = shell()
rule.setContent { WolfskinTheme { TitleSurface(state, shell, Modifier.requiredSize(960.dp, 540.dp)) } }
rule.mainClock.advanceTimeBy(500)
val actions = rule.onNodeWithTag("title-actions").fetchSemanticsNode().boundsInRoot
update { state.update(state.data.copy(item = series.copy(ratings = listOf(LibraryRating("rt", 88.0)), genres = listOf("Drama"), overview = "Late overview"))) }
rule.mainClock.advanceTimeBy(16)
val regions = listOf("88%", "Drama", "Late overview").map { rule.onNodeWithText(it).fetchSemanticsNode().boundsInRoot }
val directory = kotlin.io.path.createTempDirectory("wolfskin-late-fade-").toFile()
fun brightness(name: String): List<Long> {
val file = File(directory, name)
rule.onRoot().captureRoboImage(file.absolutePath)
val bitmap = BitmapFactory.decodeFile(file.absolutePath)
return try { regions.map { bounds ->
var sum = 0L
for (y in bounds.top.toInt() until bounds.bottom.toInt()) for (x in bounds.left.toInt() until bounds.right.toInt()) {
val pixel = bitmap.getPixel(x, y)
sum += android.graphics.Color.red(pixel) + android.graphics.Color.green(pixel) + android.graphics.Color.blue(pixel)
}
sum
} } finally { bitmap.recycle() }
}
try {
val entering = brightness("entering.png")
rule.mainClock.advanceTimeBy(240)
brightness("settled.png").zip(entering).forEach { (settled, early) -> assertTrue("Late text must fade rather than pop", settled > early) }
assertEquals(regions, listOf("88%", "Drama", "Late overview").map { rule.onNodeWithText(it).fetchSemanticsNode().boundsInRoot })
assertEquals(actions, rule.onNodeWithTag("title-actions").fetchSemanticsNode().boundsInRoot)
} finally { directory.deleteRecursively() }
}
@Test fun animatingSkeletonDoesNotPerformMainThreadDiskOrNetworkWork() {
val loading = mutableStateOf(false)
rule.setContent { WolfskinTheme { Box(Modifier.size(200.dp, 140.dp).skeletonPulse(loading.value).background(WolfskinTokens.Color.surface2)) } }
@ -128,6 +215,11 @@ class MotionSurfaceTest {
finally { rule.runOnIdle { StrictMode.setThreadPolicy(requireNotNull(previous)) } }
}
private fun update(block: () -> Unit) {
rule.runOnIdle { block(); androidx.compose.runtime.snapshots.Snapshot.sendApplyNotifications() }
rule.mainClock.advanceTimeByFrame()
}
private fun shell() = ShellState(listOf(viewer), viewer.key).apply { pickerVisible = false; pickerLaunch = false; enterDestination("title/series") }
private fun bounds() = listOf("title-overview", "title-actions", "title-shelf:episodes").map { rule.onNodeWithTag(it).fetchSemanticsNode().boundsInRoot }
private fun pixel(file: File): Int {