Implement approved TV loading and bounded motion
All checks were successful
review/code-claude Code review passed on 16684454, no blocking finding
review/design-claude Design review pass: motion numbers equal direction A; 6 non-blocking notes

This commit is contained in:
justinzeus 2026-11-07 00:57:13 +01:00
parent fd48426fa7
commit 1668445418
86 changed files with 2026 additions and 121 deletions

View file

@ -32,6 +32,7 @@ def render_tokens(tokens: dict) -> tuple[str, str]:
"// Generated by generate_tokens.py; edit the JSON sources instead.",
"package org.justinvisser.wolfskin.design",
"",
"import androidx.compose.animation.core.CubicBezierEasing",
"import androidx.compose.ui.graphics.Color as ComposeColor",
"import androidx.compose.ui.text.TextStyle",
"import androidx.compose.ui.text.font.FontWeight",
@ -71,6 +72,10 @@ def render_tokens(tokens: dict) -> tuple[str, str]:
elif kind == "duration":
lines.append(f" const val {name} = {int(value.removesuffix('ms'))}")
css.append(f" --{group_name}-{name}: {value};")
elif kind == "cubicBezier":
points = ", ".join(f"{number(point)}f" for point in value)
lines.append(f" val {name} = CubicBezierEasing({points})")
css.append(f" --{group_name}-{name}: cubic-bezier({', '.join(map(str, value))});")
elif kind == "transition" and value.get("type") == "spring":
lines.append(f" const val {name}DampingRatio = {number(value['dampingRatio'])}f")
lines.append(f" const val {name}Stiffness = {number(value['stiffness'])}f")

View file

@ -1,7 +1,6 @@
package org.justinvisser.wolfskin.design
import androidx.compose.animation.core.Animatable
import androidx.compose.animation.core.CubicBezierEasing
import androidx.compose.animation.core.tween
import androidx.compose.foundation.layout.Column
import androidx.compose.runtime.Composable
@ -17,16 +16,19 @@ import androidx.compose.ui.draw.clipToBounds
import androidx.compose.ui.layout.Layout
import androidx.compose.ui.unit.constrainHeight
/** Captions fade without moving the reserved band under the focused poster. */
/** Captions rise inside the reserved band without changing measured geometry. */
@Composable
fun Caption(title: String, facts: String, visible: Boolean, modifier: Modifier = Modifier, accentLastFact: Boolean = false, accentFirstFact: Boolean = false,
titleStyle: TextStyle = WolfskinTokens.Type.button, factsStyle: TextStyle = WolfskinTokens.Type.caption,
titleOverflow: TextOverflow = TextOverflow.Ellipsis) {
val alpha = remember(title, facts) { Animatable(0f) }
LaunchedEffect(alpha, visible) {
alpha.animateTo(if (visible) 1f else 0f, tween(WolfskinTokens.Motion.fast, easing = CubicBezierEasing(.25f, .1f, .25f, 1f)))
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))
}
Column(modifier.graphicsLayer { this.alpha = alpha.value }) {
Column(modifier.graphicsLayer { this.alpha = alpha.value; translationY = if (reduced) 0f else rise * (1f - alpha.value) }) {
Text(title, color = WolfskinTokens.Color.fg, style = titleStyle, modifier = Modifier.exactLineBox(titleStyle), maxLines = 1, overflow = titleOverflow)
CaptionFacts(facts, accentFirstFact, accentLastFact, factsStyle)
}

View file

@ -56,12 +56,17 @@ fun DiscoverArtwork(path: String?, modifier: Modifier, poster: Boolean = false,
if (upright) WolfskinTokens.Image.heroPosterRequestWidth else WolfskinTokens.Image.heroRequestWidth,
if (upright) WolfskinTokens.Image.heroPosterRequestHeight else WolfskinTokens.Image.heroRequestHeight, upright)
}
if (painter != null) Image(painter, null, modifier, contentScale = ContentScale.Crop, colorFilter = filter)
val heroReady = LocalHeroArtReady.current
if (painter != null) {
androidx.compose.runtime.SideEffect { heroReady?.invoke() }
Image(painter, null, modifier, contentScale = ContentScale.Crop, colorFilter = filter)
}
else AsyncImage(
model = request,
imageLoader = WolfskinImages.loader(context),
contentDescription = null, modifier = modifier, contentScale = ContentScale.Crop, colorFilter = filter,
onError = { onError() },
onSuccess = { heroReady?.invoke() },
)
}
@ -70,7 +75,11 @@ fun DiscoverArtwork(path: String?, modifier: Modifier, poster: Boolean = false,
fun DiscoverBackdrop(title: DiscoverTitle, detail: Boolean = false, modifier: Modifier = Modifier) {
val failed = remember(title.poster, title.backdrop) { androidx.compose.runtime.mutableStateOf(false) }
Box(modifier.size(WolfskinTokens.Shell.stageWidth, WolfskinTokens.Shell.stageHeight).clipToBounds().background(C.surface3)) {
if (failed.value || (title.poster == null && title.backdrop == null)) HomeAmbient(Modifier.fillMaxSize().testTag("discover-missing-art"))
if (failed.value || (title.poster == null && title.backdrop == null)) {
val heroReady = LocalHeroArtReady.current
androidx.compose.runtime.SideEffect { heroReady?.invoke() }
HomeAmbient(Modifier.fillMaxSize().testTag("discover-missing-art"))
}
else if (title.posterOnly) {
DiscoverArtwork(title.poster, Modifier.offset(-D.ambientInset, -D.ambientInset)
.wrapContentSize(Alignment.TopStart, unbounded = true)

View file

@ -32,7 +32,7 @@ import androidx.compose.runtime.LaunchedEffect
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.graphics.graphicsLayer
import androidx.compose.ui.unit.IntOffset
import androidx.compose.ui.platform.testTag
import androidx.compose.ui.text.style.TextOverflow
import androidx.tv.material3.Text
@ -73,8 +73,8 @@ fun DiscoverScreen(state: DiscoverState, modifier: Modifier = Modifier) {
return
}
if (state.snapshot.status == DiscoverStatus.Loading) {
val loading = remember(state) { LibraryGridState("Discover", emptyList(), catalogue = true).apply { status = LibraryStatus.Loading } }
LibraryScreen(shell, loading, modifier, offlineArt = LocalDiscoverArt.current, registerFocus = false)
LaunchedEffect(state) { if (!shell.navFocused) shell.focusNav(shell.currentTab) }
DiscoverLoading(modifier)
return
}
val density = LocalDensity.current
@ -104,7 +104,9 @@ fun DiscoverScreen(state: DiscoverState, modifier: Modifier = Modifier) {
}
}
Box(modifier.fillMaxSize().testTag("discover-screen")) {
if (state.hasHero && state.service == null && list.firstVisibleItemIndex == 0) state.hero?.let { DiscoverBackdrop(it, modifier = Modifier.graphicsLayer { translationY = -list.firstVisibleItemScrollOffset.toFloat() }) }
if (state.hasHero && state.service == null && list.firstVisibleItemIndex == 0) state.hero?.let { title ->
HeroArtCrossfade(title.key, title, L.row_phero, Modifier.offset { IntOffset(0, -list.firstVisibleItemScrollOffset) }) { DiscoverBackdrop(it) }
}
LazyColumn(state = list, contentPadding = PaddingValues(bottom = (WolfskinTokens.Shell.stageHeight - L.pinnedLine - heights.last()).coerceAtLeast(D.frameBottomMin)), modifier = Modifier.fillMaxSize()) {
item(key = "discover-header", contentType = if (state.hasHero && state.service == null) "hero" else "header") {
if (state.hasHero && state.service == null) DiscoverHero(state) else LibraryHeader(state.shell.person, state.service?.name ?: "Discover", if (state.service != null) "General interest" else "")
@ -132,22 +134,23 @@ fun DiscoverScreen(state: DiscoverState, modifier: Modifier = Modifier) {
@Composable
private fun DiscoverHero(state: DiscoverState) {
val title = state.hero ?: return
Column(Modifier.fillMaxWidth().height(L.row_phero).padding(start = L.safeSide, bottom = L.heroCopyBottom), verticalArrangement = Arrangement.Bottom) {
HeroCopyHandoff(title.key, title) { copy ->
Column(Modifier.fillMaxWidth().height(L.row_phero).padding(start = L.safeSide, bottom = L.heroCopyBottom), verticalArrangement = Arrangement.Bottom) {
Row(horizontalArrangement = Arrangement.spacedBy(WolfskinTokens.Card.posterGap), verticalAlignment = Alignment.CenterVertically) {
Glyph("seerr", C.accentText, Modifier.size(L.buttonIcon))
Text(if (state.snapshot.status == DiscoverStatus.Ready) "For you" else "Trending", color = C.accentText, style = DiscoverType.label)
}
Spacer(Modifier.height(D.heroKickerGap))
Text(title.title, Modifier.width(L.heroCopyWidth).testTag("discover-hero-title-box").discoverLineBox(DiscoverType.hero), color = C.fg, style = DiscoverType.hero, maxLines = 2, overflow = TextOverflow.Ellipsis)
Text(copy.title, Modifier.width(L.heroCopyWidth).testTag("discover-hero-title-box").discoverLineBox(DiscoverType.hero), color = C.fg, style = DiscoverType.hero, maxLines = 2, overflow = TextOverflow.Ellipsis)
Spacer(Modifier.height(D.heroTitleGap))
Row(Modifier.height(D.heroFactsHeight), horizontalArrangement = Arrangement.spacedBy(D.heroFactsGap), verticalAlignment = Alignment.CenterVertically) {
Text("${title.typeWord}${title.year?.let { " · $it" }.orEmpty()}", color = C.muted, style = DiscoverType.body)
if (title.genres.isNotEmpty()) Text(title.genres.take(2).joinToString(", "), color = C.muted, style = DiscoverType.body)
if (state.isSignedIn) DiscoverRatings(title.ratings)
if (state.shell.person.key in title.heldBy) Text("In your library", color = C.accentText, style = DiscoverType.body)
Text("${copy.typeWord}${copy.year?.let { " · $it" }.orEmpty()}", color = C.muted, style = DiscoverType.body)
if (copy.genres.isNotEmpty()) Text(copy.genres.take(2).joinToString(", "), color = C.muted, style = DiscoverType.body)
if (state.isSignedIn) DiscoverRatings(copy.ratings)
if (state.shell.person.key in copy.heldBy) Text("In your library", color = C.accentText, style = DiscoverType.body)
}
Spacer(Modifier.height(D.heroMetaGap))
Text(title.overview, Modifier.width(L.heroOverviewMax), color = C.muted, style = DiscoverType.body, maxLines = 2, overflow = TextOverflow.Ellipsis)
Text(copy.overview, Modifier.width(L.heroOverviewMax), color = C.muted, style = DiscoverType.body, maxLines = 2, overflow = TextOverflow.Ellipsis)
Spacer(Modifier.height(D.heroOverviewGap))
Row(verticalAlignment = Alignment.CenterVertically) {
DiscoverButton("Details", state.contentFocused && state.focusedRow == 0, "about") { state.openTitle(title) }
@ -158,6 +161,7 @@ private fun DiscoverHero(state: DiscoverState) {
}
}
}
}
}
@Composable

View file

@ -55,7 +55,7 @@ fun DiscoverShelf(state: DiscoverState, lane: DiscoverLane, row: Int) {
LazyRow(state = cards, userScrollEnabled = false,
contentPadding = PaddingValues(start = L.safeSide, end = WolfskinTokens.Shell.stageWidth - L.safeSide - (K.posterW * slots + K.posterGap * (slots - 1)), top = K.posterGap, bottom = K.posterGap),
horizontalArrangement = Arrangement.spacedBy(K.posterGap),
modifier = Modifier.offset(y = D.shelfHeadingBand - K.posterGap).height(height + K.posterGap * 2)) {
modifier = Modifier.offset(y = D.shelfHeadingBand - K.posterGap).height(height + K.posterGap * 2).skeletonPulse(lane.pending).rowReveal(lane.id, !lane.pending)) {
if (services) itemsIndexed(state.snapshot.services, key = { _, service -> "service:${service.id}" }, contentType = { _, _ -> "entry" }) { index, service ->
Box(Modifier.size(K.entryW, K.entryH).focusScale(active && index == column, WolfskinTokens.Focus.scaleCard)
.focusRing(active && index == column, WolfskinTokens.Radius.card, WolfskinTokens.Focus.ringCardRing, FocusLift.Card)
@ -66,7 +66,7 @@ fun DiscoverShelf(state: DiscoverState, lane: DiscoverLane, row: Int) {
}
} else if (lane.pending) {
items(slots, key = { "pending:$it" }) {
Box(Modifier.size(K.posterW, K.posterH).background(C.surface3, RoundedCornerShape(WolfskinTokens.Radius.card)))
Box(Modifier.size(K.posterW, K.posterH).background(C.surface2, RoundedCornerShape(WolfskinTokens.Radius.card)))
}
} else {
itemsIndexed(lane.titles, key = { _, item -> item.key }, contentType = { _, _ -> "poster" }) { index, item ->

View file

@ -156,7 +156,8 @@ class DiscoverState(
}
private fun moveRow(direction: Int) {
val target = focusedRow + direction
var target = focusedRow + direction
while (target in firstShelf until rowCount && lanes[target - firstShelf].pending) target += direction
if (target < if (hasHero) 0 else 1) { shell.focusNav(shell.currentTab); return }
if (target >= rowCount) return
val key = when (target) { 0 -> "phero"; 1 -> "dchips"; else -> lanes[target - firstShelf].id }

View file

@ -1,6 +1,5 @@
package org.justinvisser.wolfskin.design
import androidx.compose.animation.core.CubicBezierEasing
import androidx.compose.animation.core.animateFloatAsState
import androidx.compose.animation.core.tween
import androidx.compose.runtime.Composable
@ -26,9 +25,10 @@ import androidx.compose.ui.unit.Dp
/** Only transforms animate: focus never changes measured geometry. */
@Composable
fun Modifier.focusScale(focused: Boolean, scale: Float): Modifier {
val reduced = reducedMotion()
val animated by animateFloatAsState(
if (focused) scale else 1f,
tween(WolfskinTokens.Motion.fast, easing = CubicBezierEasing(.25f, .1f, .25f, 1f)),
tween(if (reduced) 0 else WolfskinTokens.Motion.focusMove, easing = WolfskinTokens.Motion.easeOut),
label = "focus-scale",
)
return graphicsLayer { scaleX = animated; scaleY = animated }
@ -40,8 +40,9 @@ enum class FocusLift { None, Card, Button }
/** Every focus ring straddles the surface edge above content, without a background gap. */
@Composable
fun Modifier.focusRing(focused: Boolean, radius: Dp, width: Dp, lift: FocusLift = FocusLift.None): Modifier {
val reduced = reducedMotion()
val ringAlpha by animateFloatAsState(if (focused) 1f else 0f,
tween(WolfskinTokens.Motion.fast, easing = CubicBezierEasing(.25f, .1f, .25f, 1f)), label = "focus-ring")
tween(motionDuration(WolfskinTokens.Motion.focusMove, reduced), easing = WolfskinTokens.Motion.easeOut), label = "focus-ring")
val shadow = if (lift == FocusLift.None) this else {
val shape = remember(radius) { RoundedCornerShape(radius) }
val density = LocalDensity.current

View file

@ -1,7 +1,6 @@
// SPDX-License-Identifier: GPL-2.0-only
package org.justinvisser.wolfskin.design
import androidx.compose.animation.core.CubicBezierEasing
import androidx.compose.animation.core.animate
import androidx.compose.animation.core.tween
import androidx.compose.foundation.lazy.LazyListState
@ -11,8 +10,8 @@ internal suspend fun animateFrameScroll(list: LazyListState, distance: Float) {
if (distance == 0f) return
list.scroll {
var previous = 0f
animate(0f, distance, animationSpec = tween(WolfskinTokens.Motion.scroll,
easing = CubicBezierEasing(.33f, 1f, .68f, 1f))) { value, _ ->
animate(0f, distance, animationSpec = tween(if (NetworkArtTransition.reduced) 0 else WolfskinTokens.Motion.scroll,
easing = WolfskinTokens.Motion.easeOut)) { value, _ ->
scrollBy(value - previous)
previous = value
}

View file

@ -0,0 +1,100 @@
package org.justinvisser.wolfskin.design
import androidx.compose.animation.core.Animatable
import androidx.compose.animation.core.tween
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.requiredSize
import androidx.compose.foundation.layout.wrapContentSize
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.graphicsLayer
import androidx.compose.ui.unit.Dp
import androidx.compose.ui.platform.testTag
import org.justinvisser.wolfskin.design.WolfskinTokens.Motion as M
import kotlinx.coroutines.launch
import androidx.compose.runtime.CompositionLocalProvider
import androidx.compose.runtime.compositionLocalOf
import androidx.compose.runtime.rememberUpdatedState
import androidx.compose.runtime.snapshotFlow
import kotlinx.coroutines.flow.first
/** Artwork success or its final fallback releases a pending hero hand-off. */
internal val LocalHeroArtReady = compositionLocalOf<(() -> Unit)?> { null }
/** Only the upper hero region blends; the lower backdrop changes without an animated screen layer. */
@Composable
fun <T> HeroArtCrossfade(identity: Any, target: T, height: Dp, modifier: Modifier = Modifier, content: @Composable (T) -> Unit) {
var displayed by remember { mutableStateOf(target) }
var displayedIdentity by remember { mutableStateOf(identity) }
var outgoing by remember { mutableStateOf<T?>(null) }
val alpha = remember { Animatable(0f) }
val reduced = reducedMotion()
var readyIdentity by remember { mutableStateOf(identity) }
val latestIdentity by rememberUpdatedState(identity)
val onReady = remember(identity) { { if (latestIdentity == identity) readyIdentity = identity } }
LaunchedEffect(identity, reduced) {
if (displayedIdentity != identity) {
outgoing = displayed
alpha.snapTo(1f)
snapshotFlow { readyIdentity == identity }.first { it }
displayed = target
displayedIdentity = identity
}
if (outgoing != null) {
alpha.animateTo(0f, tween(motionDuration(M.heroFade, reduced), easing = M.easeInOut))
outgoing = null
}
}
Box(modifier) {
CompositionLocalProvider(LocalHeroArtReady provides onReady) { content(target) }
outgoing?.let { old ->
Box(Modifier.requiredSize(WolfskinTokens.Shell.stageWidth, height).graphicsLayer { this.alpha = alpha.value; clip = true }.testTag("hero-outgoing")) {
Box(Modifier.wrapContentSize(Alignment.TopStart, unbounded = true).requiredSize(WolfskinTokens.Shell.stageWidth, WolfskinTokens.Shell.stageHeight)) { content(old) }
}
}
}
}
/** One bounded copy layer fades out, hands off, then rises; controls keep their logical owner. */
@Composable
fun <T> HeroCopyHandoff(identity: Any, target: T, modifier: Modifier = Modifier, content: @Composable (T) -> Unit) {
val reduced = reducedMotion()
val alpha = remember { Animatable(0f) }
val progress = remember { Animatable(0f) }
var displayed by remember { mutableStateOf(target) }
var previous by remember { mutableStateOf(identity) }
var handingOff by remember { mutableStateOf(false) }
var entered by remember { mutableStateOf(false) }
val rise = with(androidx.compose.ui.platform.LocalDensity.current) { (if (entered) M.copyRise else M.copyEntryRise).toPx() }
LaunchedEffect(identity, reduced) {
if (previous != identity || handingOff) {
handingOff = true
alpha.animateTo(0f, tween(motionDuration(M.heroCopyOut, reduced), easing = M.ease))
displayed = target
previous = identity
progress.snapTo(0f)
if (!reduced) kotlinx.coroutines.delay((M.heroCopyDelay - M.heroCopyOut).toLong())
kotlinx.coroutines.coroutineScope {
launch { alpha.animateTo(1f, tween(motionDuration(M.heroCopyIn, reduced), easing = M.easeOut)) }
launch { progress.animateTo(1f, tween(motionDuration(M.heroCopyIn, reduced), easing = M.easeOut)) }
}
handingOff = false
} else if (!entered) {
kotlinx.coroutines.coroutineScope {
launch { alpha.animateTo(1f, tween(motionDuration(M.rowEnter, reduced), easing = M.easeOut)) }
launch { progress.animateTo(1f, tween(motionDuration(M.rowEnter, reduced), easing = M.easeOut)) }
}
}
entered = true
displayed = target
}
Box(modifier.graphicsLayer { this.alpha = alpha.value; translationY = if (reduced) 0f else rise * (1f - progress.value) }) {
content(if (handingOff || previous != identity) displayed else target)
}
}

View file

@ -23,24 +23,27 @@ fun HomeHero(row: HomeRow, focused: Boolean, column: Int, ratings: List<HomeRati
if (!row.skeleton) {
val title = row.cards.first()
val artModifier = Modifier.wrapContentSize(Alignment.TopStart, unbounded = true).requiredSize(WolfskinTokens.Shell.stageWidth, WolfskinTokens.Shell.stageHeight)
TitleArtwork(title.title, title.type, title.poster, WolfskinTokens.Shell.stageWidth, WolfskinTokens.Shell.stageHeight, artModifier, backdrop = title.backdrop, posterOnly = title.posterOnly, landscape = true, hero = true)
HeroArtCrossfade(title.id, title, L.row_hero, artModifier) { art ->
TitleArtwork(art.title, art.type, art.poster, WolfskinTokens.Shell.stageWidth, WolfskinTokens.Shell.stageHeight, artModifier, backdrop = art.backdrop, posterOnly = art.posterOnly, landscape = true, hero = true)
}
Box(artModifier.background(Brush.verticalGradient(0f to C.bg.copy(alpha = 0f), .52f to C.bg.copy(alpha = 0f), .74f to C.bg.copy(alpha = .6f), 1f to C.bg)))
Box(artModifier.background(Brush.horizontalGradient(0f to C.bg.copy(alpha = .92f), .42f to C.bg.copy(alpha = .55f), .70f to C.bg.copy(alpha = 0f))))
Column(Modifier.align(Alignment.BottomStart).padding(start = L.safeSide, bottom = L.heroCopyBottom).width(L.heroCopyWidth)) {
HeroCopyHandoff(title.id, title, Modifier.align(Alignment.BottomStart).padding(start = L.safeSide, bottom = L.heroCopyBottom).width(L.heroCopyWidth)) { copy ->
Column {
Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(H.kickerGap)) {
Glyph(if (row.heroKind == "pick") "seerr" else "playback", C.accentText, Modifier.size(L.buttonIcon * (28f / 30f)))
Text(when (row.heroKind) { "resume" -> "Continue watching"; "next" -> "Up next"; "new" -> "New in your library"; else -> "For you" }, modifier = Modifier.exactLineBox(T.label), color = C.accentText, style = T.label)
}
Spacer(Modifier.height(H.kickerBottom))
Text(title.displayTitle, modifier = Modifier.exactLineBox(T.displayHero), color = C.fg, style = T.displayHero, maxLines = 2, overflow = TextOverflow.Ellipsis)
Text(copy.displayTitle, modifier = Modifier.exactLineBox(T.displayHero), color = C.fg, style = T.displayHero, maxLines = 2, overflow = TextOverflow.Ellipsis)
Spacer(Modifier.height(H.titleBottom))
Row(Modifier.heightIn(min = H.factsHeight), verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(H.factsGap)) {
Text(if (title.isEpisode) title.subtitle else title.year?.toString().orEmpty(), modifier = Modifier.exactLineBox(T.body), color = C.muted, style = T.body, maxLines = 1)
if (row.heroKind == "resume" || title.runtimeSeconds != null) Text(if (row.heroKind == "resume") title.remaining else title.runtime, modifier = Modifier.exactLineBox(T.body), color = C.muted, style = T.body, maxLines = 1)
Text(if (copy.isEpisode) copy.subtitle else copy.year?.toString().orEmpty(), modifier = Modifier.exactLineBox(T.body), color = C.muted, style = T.body, maxLines = 1)
if (row.heroKind == "resume" || copy.runtimeSeconds != null) Text(if (row.heroKind == "resume") copy.remaining else copy.runtime, modifier = Modifier.exactLineBox(T.body), color = C.muted, style = T.body, maxLines = 1)
HomeRatingsRow(ratings)
}
Spacer(Modifier.height(H.factsBottom))
Text(title.overview, color = C.muted, style = T.body, maxLines = 2, overflow = TextOverflow.Ellipsis, modifier = Modifier.width(L.heroOverviewMax).exactLineBox(T.body))
Text(copy.overview, color = C.muted, style = T.body, maxLines = 2, overflow = TextOverflow.Ellipsis, modifier = Modifier.width(L.heroOverviewMax).exactLineBox(T.body))
Spacer(Modifier.height(H.overviewBottom))
Row(horizontalArrangement = Arrangement.spacedBy(L.buttonGap)) {
val detailsOnly = row.heroKind == "pick" || !row.watchReady
@ -49,14 +52,15 @@ fun HomeHero(row: HomeRow, focused: Boolean, column: Int, ratings: List<HomeRati
if (!detailsOnly) PickerButton("Details", focused && column == 1, false, "about", onFocus = { onHover(1) }, textStyle = T.button) { onButton(1) }
}
}
}
} else {
HomeAmbient(Modifier.wrapContentSize(Alignment.TopStart, unbounded = true).requiredSize(WolfskinTokens.Shell.stageWidth, WolfskinTokens.Shell.stageHeight))
Column(Modifier.align(Alignment.BottomStart).padding(start = L.safeSide, bottom = L.heroCopyBottom)) {
Box(Modifier.size(H.heroSkeletonWidth, H.heroSkeletonHeight).background(C.surface, RoundedCornerShape(WolfskinTokens.Radius.card)))
Column(Modifier.align(Alignment.BottomStart).padding(start = L.safeSide, bottom = L.heroCopyBottom).skeletonPulse()) {
Box(Modifier.size(H.heroSkeletonWidth, H.heroSkeletonHeight).background(C.surface2, RoundedCornerShape(WolfskinTokens.Radius.card)))
Spacer(Modifier.height(H.heroSkeletonGap))
Box(Modifier.size(H.heroSkeletonFactsWidth, H.heroSkeletonFactsHeight).background(C.surface, RoundedCornerShape(WolfskinTokens.Radius.cardSheet)))
Box(Modifier.size(H.heroSkeletonFactsWidth, H.heroSkeletonFactsHeight).background(C.surface2, RoundedCornerShape(WolfskinTokens.Radius.cardSheet)))
Spacer(Modifier.height(H.heroSkeletonButtonGap))
Box(Modifier.size(H.heroSkeletonButtonWidth, L.buttonHeight).background(C.surface, RoundedCornerShape(WolfskinTokens.Radius.button)))
Box(Modifier.size(H.heroSkeletonButtonWidth, L.buttonHeight).background(C.surface2, RoundedCornerShape(WolfskinTokens.Radius.button)))
}
}
}

View file

@ -59,7 +59,7 @@ fun HomeRatingsRow(ratings: List<HomeRating>, modifier: Modifier = Modifier, fon
/** The plate is a prefix of the full ratings set; its first entry is never discarded. */
@Composable
fun HomeRatingPlate(ratings: List<HomeRating>, visible: Boolean, modifier: Modifier = Modifier) {
val alpha by animateFloatAsState(if (visible && ratings.isNotEmpty()) 1f else 0f, tween(WolfskinTokens.Motion.base), label = "rating-plate")
val alpha by animateFloatAsState(if (visible && ratings.isNotEmpty()) 1f else 0f, tween(motionDuration(WolfskinTokens.Motion.base, reducedMotion())), label = "rating-plate")
if (ratings.isEmpty()) return
val measurer = rememberTextMeasurer()
val density = LocalDensity.current

View file

@ -1,6 +1,5 @@
package org.justinvisser.wolfskin.design
import androidx.compose.animation.core.CubicBezierEasing
import androidx.compose.animation.core.tween
import androidx.compose.foundation.gestures.animateScrollBy
import androidx.compose.foundation.layout.*
@ -69,7 +68,7 @@ fun HomeScreen(
(heights.take(view.row).fold(topInset) { total, height -> total + height } - if (snapshot.rows.getOrNull(view.row)?.kind == HomeRowKind.Hero) WolfskinTokens.Shell.zero else L.pinnedLine).toPx()
}.coerceAtLeast(0f)
val current = with(density) { heights.take(view.listState.firstVisibleItemIndex).fold(WolfskinTokens.Shell.zero) { total, height -> total + height }.toPx() } + view.listState.firstVisibleItemScrollOffset
view.listState.animateScrollBy(target - current, tween(WolfskinTokens.Motion.scroll, easing = CubicBezierEasing(.33f, 1f, .68f, 1f)))
view.listState.animateScrollBy(target - current, tween(if (NetworkArtTransition.reduced) 0 else WolfskinTokens.Motion.scroll, easing = WolfskinTokens.Motion.easeOut))
view.home.positionedKey = key
view.home.positionedReset = view.home.resetScroll
}

View file

@ -50,15 +50,15 @@ fun HomeShelf(row: HomeRow, memory: HomeViewMemory, focused: Boolean, column: In
Box(Modifier.fillMaxWidth().height(L.rowHeadingBand + height + caption + L.rowGapAfter)) {
if (!row.skeleton) Text(row.title, color = C.fg, style = T.rowHeading, maxLines = 1, modifier = Modifier.offset(x = L.safeSide))
val cards = if (row.skeleton) List(if (row.cardKind == HomeCardKind.Poster) 8 else 5) { null } else row.cards + if (row.hasSeeAll) listOf(null) else emptyList()
LazyRow(state = track, modifier = Modifier.fillMaxWidth().offset(y = L.rowHeadingBand - L.rowGapAfter).height(height + L.rowGapAfter * 2), contentPadding = PaddingValues(horizontal = L.safeSide, vertical = L.rowGapAfter), horizontalArrangement = Arrangement.spacedBy(K.posterGap), userScrollEnabled = !row.skeleton) {
LazyRow(state = track, modifier = Modifier.fillMaxWidth().offset(y = L.rowHeadingBand - L.rowGapAfter).height(height + L.rowGapAfter * 2).skeletonPulse(row.skeleton).rowReveal(row.id, !row.skeleton), contentPadding = PaddingValues(horizontal = L.safeSide, vertical = L.rowGapAfter), horizontalArrangement = Arrangement.spacedBy(K.posterGap), userScrollEnabled = !row.skeleton) {
itemsIndexed(cards, key = { index, title -> title?.id ?: "${row.id}:tail:$index" }, contentType = { _, title -> if (row.skeleton) "skeleton" else if (title == null) "seeAll" else row.cardKind.name.lowercase() }) { index, title ->
val selected = focused && column == index
val selected = !row.skeleton && focused && column == index
val cardModifier = Modifier.zIndex(if (selected) 2f else 0f).testTag("home-card:${row.id}:$index")
.then(if (!row.skeleton) Modifier.focusOnPointerEnter { onHover(index) } else Modifier)
if (title == null && !row.skeleton) {
LibrarySeeAllCard(row.total, selected, { onCard(index) }, cardModifier, width, height, T.listTitle, T.label)
} else TitleCard(selected, cardModifier.then(if (!row.skeleton) Modifier.clickable { onCard(index) } else Modifier), width, height) {
if (title == null) Box(Modifier.fillMaxSize().background(C.surface)) else {
if (title == null) Box(Modifier.fillMaxSize().background(C.surface2)) else {
TitleArtwork(title.title, title.type, title.poster, width, height, Modifier.fillMaxSize(), backdrop = title.backdrop, posterOnly = title.posterOnly, landscape = row.cardKind != HomeCardKind.Poster)
if (row.cardKind != HomeCardKind.Poster) TileOverlay(title)
if (row.catalogue && title.held) Box(Modifier.align(Alignment.BottomEnd).padding(L.badgeInset).size(L.badge).background(C.ok, CircleShape), contentAlignment = Alignment.Center) { Text("✓", color = C.onFocus, style = T.button) }

View file

@ -120,8 +120,8 @@ fun LibraryScreen(
when (grid.status) {
LibraryStatus.Loading -> items(3, key = { "skeleton-$it" }, contentType = { "grid" }) {
Box(Modifier.fillMaxWidth().height(rowHeight)) {
Row(Modifier.padding(start = L.safeSide), horizontalArrangement = Arrangement.spacedBy(K.posterGap)) {
repeat(grid.columns) { Box(Modifier.size(K.posterW, K.posterH).background(C.surface, RoundedCornerShape(WolfskinTokens.Radius.card))) }
Row(Modifier.padding(start = L.safeSide).skeletonPulse(), horizontalArrangement = Arrangement.spacedBy(K.posterGap)) {
repeat(grid.columns) { Box(Modifier.size(K.posterW, K.posterH).background(C.surface2, RoundedCornerShape(WolfskinTokens.Radius.card))) }
}
}
}
@ -133,7 +133,7 @@ fun LibraryScreen(
"grid-${if (grid.catalogue) title.catalogueKey else title.id}"
}, contentType = { "grid" }) { row ->
Box(Modifier.fillMaxWidth().height(rowHeight)) {
Row(Modifier.padding(start = L.safeSide), horizontalArrangement = Arrangement.spacedBy(K.posterGap)) {
Row(Modifier.padding(start = L.safeSide).rowReveal("grid-$row"), horizontalArrangement = Arrangement.spacedBy(K.posterGap)) {
for (column in 0 until grid.columns) {
val index = row * grid.columns + column
val title = items.getOrNull(index) ?: break

View file

@ -0,0 +1,57 @@
package org.justinvisser.wolfskin.design
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.testTag
import org.justinvisser.wolfskin.design.WolfskinTokens.Card as K
import org.justinvisser.wolfskin.design.WolfskinTokens.Color as C
import org.justinvisser.wolfskin.design.WolfskinTokens.Layout as L
import org.justinvisser.wolfskin.design.WolfskinTokens.Title as D
/** Discover loads in its actual hero-and-lanes silhouette, not a library grid. */
@Composable
internal fun DiscoverLoading(modifier: Modifier = Modifier) {
Column(modifier.fillMaxSize().testTag("discover-loading")) {
Box(Modifier.fillMaxWidth().height(L.row_phero)) { TitleHeaderSkeleton() }
Spacer(Modifier.height(L.row_chips))
PosterSkeletonRow(WolfskinTokens.Discover.shelfHeadingBand)
}
}
/** A person read reserves its portrait/header and the six-column filmography grid. */
@Composable
internal fun PersonLoading(modifier: Modifier = Modifier) {
Column(modifier.fillMaxSize().testTag("person-loading")) {
Box(Modifier.fillMaxWidth().height(L.row_phead)) {
Row(Modifier.offset(L.safeSide, D.personTop).skeletonPulse(), horizontalArrangement = Arrangement.spacedBy(D.personGap)) {
Box(Modifier.size(L.personPortrait).background(C.surface2, CircleShape))
Column(verticalArrangement = Arrangement.spacedBy(D.skeletonTitleGap)) {
SkeletonBlock(D.skeletonTitleWidth, D.skeletonTitleHeight)
SkeletonBlock(D.skeletonFactsWidth, D.skeletonKickerHeight)
SkeletonBlock(D.skeletonOverviewWidth, D.skeletonOverviewHeight)
}
}
}
PosterSkeletonRow(L.row_gh)
}
}
/** All posters in a visible row share a single pulse layer. */
@Composable
private fun PosterSkeletonRow(heading: androidx.compose.ui.unit.Dp) {
Column {
Spacer(Modifier.height(heading))
Row(Modifier.padding(start = L.safeSide).skeletonPulse(), horizontalArrangement = Arrangement.spacedBy(K.posterGap)) {
repeat(K.posterSlots) { SkeletonBlock(K.posterW, K.posterH) }
}
}
}
@Composable
private fun SkeletonBlock(width: androidx.compose.ui.unit.Dp, height: androidx.compose.ui.unit.Dp) {
Box(Modifier.size(width, height).background(C.surface2, RoundedCornerShape(WolfskinTokens.Radius.card)))
}

View file

@ -0,0 +1,71 @@
package org.justinvisser.wolfskin.design
import androidx.compose.animation.core.Animatable
import androidx.compose.animation.core.RepeatMode
import androidx.compose.animation.core.animateFloat
import androidx.compose.animation.core.infiniteRepeatable
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.compositionLocalOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.ui.Modifier
import androidx.compose.ui.MotionDurationScale
import androidx.compose.ui.graphics.graphicsLayer
import androidx.compose.ui.platform.LocalDensity
import org.justinvisser.wolfskin.design.WolfskinTokens.Motion as M
/** A host may force reduced motion independently of the platform animator scale. */
val LocalReducedMotion = compositionLocalOf { false }
/** Compose's window duration-scale state follows system settings without UI-thread settings reads. */
@Composable
fun reducedMotion(): Boolean = LocalReducedMotion.current ||
(rememberCoroutineScope().coroutineContext[MotionDurationScale]?.scaleFactor == 0f)
/** 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
/** 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 }
}
/** Content enters on its row layer, never one animation per card or a full-screen transition. */
@Composable
fun Modifier.rowReveal(identity: Any, enabled: Boolean = true): Modifier {
val reduced = reducedMotion()
val progress = remember(identity, enabled) { Animatable(if (enabled) 0f else 1f) }
LaunchedEffect(progress, reduced) { if (enabled) progress.animateTo(1f, tween(motionDuration(M.rowEnter, reduced), easing = M.easeOut)) }
val rise = with(LocalDensity.current) { M.rowRise.toPx() }
return if (!enabled) this else graphicsLayer {
alpha = progress.value
translationY = if (reduced) 0f else rise * (1f - progress.value)
}
}
/** Late facts occupy an existing slot and fade without a translation. */
@Composable
internal fun Modifier.lateLineFade(identity: Any): Modifier {
val reduced = reducedMotion()
val alpha = remember(identity) { Animatable(0f) }
LaunchedEffect(alpha, reduced) { alpha.animateTo(1f, tween(motionDuration(M.lateLineFade, reduced), easing = M.ease)) }
return graphicsLayer { this.alpha = alpha.value }
}
/** Title copy enters as one bounded block, leaving measured layout unchanged. */
@Composable
internal fun Modifier.copyEntry(identity: Any): Modifier {
val reduced = reducedMotion()
val progress = remember(identity) { Animatable(0f) }
val rise = with(LocalDensity.current) { M.copyEntryRise.toPx() }
LaunchedEffect(progress, reduced) { progress.animateTo(1f, tween(motionDuration(M.rowEnter, reduced), easing = M.easeOut)) }
return graphicsLayer { alpha = progress.value; translationY = if (reduced) 0f else rise * (1f - progress.value) }
}

View file

@ -58,18 +58,20 @@ fun navBoxes(tabs: List<String>): List<NavBox> {
@Composable
fun NavBar(state: ShellState, scrolled: Boolean, modifier: Modifier = Modifier) {
val boxes = remember(state.person.hasAnime) { navBoxes(state.tabs) }
val labelFade = remember { tween<Color>(S.navLabelFade, easing = CubicBezierEasing(0f, 0f, .58f, 1f)) }
val reduced = reducedMotion()
val labelFade = tween<Color>(motionDuration(S.navLabelFade, reduced), easing = CubicBezierEasing(0f, 0f, .58f, 1f))
val index = if (state.navFocused) state.navIndex else state.currentTab
val box = boxes[index]
val avatarFocused = state.navFocused && index == state.tabs.size
val pad = if (avatarFocused) L.navBlobAvatarPad else S.zero
val damping = WolfskinTokens.Motion.navBlobDampingRatio
val stiffness = WolfskinTokens.Motion.navBlobStiffness
val x by animateFloatAsState((box.left - pad).value, spring(damping, stiffness), label = "blob-x")
val y by animateFloatAsState((box.top - pad).value, spring(damping, stiffness), label = "blob-y")
val width by animateFloatAsState((box.width + pad * 2).value, spring(damping, stiffness), label = "blob-width")
val height by animateFloatAsState((box.height + pad * 2).value, spring(damping, stiffness), label = "blob-height")
val color by animateColorAsState(if (state.navFocused) C.fg else C.navBlobRest, spring(damping, stiffness), label = "blob-color")
val geometry = if (reduced) tween<Float>(0) else spring<Float>(damping, stiffness)
val x by animateFloatAsState((box.left - pad).value, geometry, label = "blob-x")
val y by animateFloatAsState((box.top - pad).value, geometry, label = "blob-y")
val width by animateFloatAsState((box.width + pad * 2).value, geometry, label = "blob-width")
val height by animateFloatAsState((box.height + pad * 2).value, geometry, label = "blob-height")
val color by animateColorAsState(if (state.navFocused) C.fg else C.navBlobRest, if (reduced) tween(motionDuration(WolfskinTokens.Motion.base, true)) else spring(damping, stiffness), label = "blob-color")
val density = LocalDensity.current.density
val band = remember {
Brush.verticalGradient(listOf(C.bg.copy(alpha = S.navBandOpacity), C.bg.copy(alpha = 0f)))

View file

@ -0,0 +1,23 @@
package org.justinvisser.wolfskin.design
import coil3.decode.DataSource
import coil3.request.ImageResult
import coil3.request.SuccessResult
import coil3.transition.CrossfadeTransition
import coil3.transition.Transition
import coil3.transition.TransitionTarget
/** Both Coil loaders share the same source-aware reveal; cache and local reads never fade. */
object NetworkArtTransition : Transition.Factory {
@Volatile var reduced = false
/** The result source, not its URL, decides whether art needs a reveal. */
fun duration(source: DataSource, reduced: Boolean): Int = if (source == DataSource.NETWORK)
motionDuration(WolfskinTokens.Motion.artReveal, reduced) else 0
override fun create(target: TransitionTarget, result: ImageResult): Transition {
val duration = if (result is SuccessResult) duration(result.dataSource, reduced) else 0
return if (duration == 0) Transition.Factory.NONE.create(target, result)
else CrossfadeTransition(target, result, duration)
}
}

View file

@ -1,6 +1,5 @@
package org.justinvisser.wolfskin.design
import androidx.compose.animation.core.CubicBezierEasing
import androidx.compose.animation.core.tween
import androidx.compose.foundation.gestures.animateScrollBy
import androidx.compose.foundation.background
@ -63,7 +62,7 @@ fun SettingsList(state: ShellState, modifier: Modifier = Modifier, requestCount:
val target = if (y >= L.windowAhead) y - L.pinnedLine else S.zero
val current = if (list.firstVisibleItemIndex == 0) S.zero else L.row_lhead + L.row_srow * (list.firstVisibleItemIndex - 1)
val distance = with(density) { (target - current).toPx() } - list.firstVisibleItemScrollOffset
list.animateScrollBy(distance, tween(WolfskinTokens.Motion.scroll, easing = CubicBezierEasing(.33f, 1f, .68f, 1f)))
list.animateScrollBy(distance, tween(if (NetworkArtTransition.reduced) 0 else WolfskinTokens.Motion.scroll, easing = WolfskinTokens.Motion.easeOut))
}
LazyColumn(modifier.fillMaxSize(), state = list,
contentPadding = PaddingValues(bottom = S.stageHeight - L.pinnedLine - L.row_srow)) {

View file

@ -1,6 +1,5 @@
package org.justinvisser.wolfskin.design
import androidx.compose.animation.core.CubicBezierEasing
import androidx.compose.animation.core.tween
import androidx.compose.foundation.gestures.animateScrollBy
import androidx.compose.foundation.lazy.LazyListState
@ -16,7 +15,7 @@ internal fun shelfFirst(column: Int, previous: Int, slots: Int, count: Int): Int
/** Reconcile the actual track on every focus transition, including interrupted or restored offsets. */
internal suspend fun LazyListState.scrollShelfTo(first: Int, pitch: Float) {
val distance = (first - firstVisibleItemIndex) * pitch - firstVisibleItemScrollOffset
if (distance != 0f) animateScrollBy(distance, tween(WolfskinTokens.Motion.base,
easing = CubicBezierEasing(.25f, .1f, .25f, 1f)))
if (distance != 0f) animateScrollBy(distance, tween(if (NetworkArtTransition.reduced) 0 else WolfskinTokens.Motion.base,
easing = WolfskinTokens.Motion.ease))
if (firstVisibleItemIndex != first || firstVisibleItemScrollOffset != 0) scrollToItem(first)
}

View file

@ -70,7 +70,7 @@ fun LibraryRatingPlate(ratings: List<LibraryRating>, modifier: Modifier = Modifi
if (ratings.isEmpty()) return
val measurer = rememberTextMeasurer()
val density = LocalDensity.current
val alpha by animateFloatAsState(if (visible) 1f else 0f, tween(WolfskinTokens.Motion.base), label = "rating-plate")
val alpha by animateFloatAsState(if (visible) 1f else 0f, tween(motionDuration(WolfskinTokens.Motion.base, reducedMotion())), label = "rating-plate")
BoxWithConstraints(modifier.fillMaxWidth().graphicsLayer { this.alpha = alpha }
.background(Brush.verticalGradient(0f to C.bg.copy(alpha = P.plateOpacity), P.plateMidStop to C.bg.copy(alpha = P.plateMidOpacity), 1f to C.bg.copy(alpha = 0f)))
.testTag("rating-plate").padding(horizontal = P.plateSide)) {

View file

@ -43,18 +43,23 @@ import org.justinvisser.wolfskin.design.WolfskinTokens.Title as D
/** Local decoded fixture art keeps screenshot tests deterministic and completely offline. */
val LocalTitleBitmaps = compositionLocalOf<Map<String, ImageBitmap>> { emptyMap() }
/** Decode at the slot size, with no crossfade or oversized poster allocations. */
/** Decode at the slot size; network results reveal through Coil's painter without a new layer. */
@Composable
fun TitleArtwork(path: String?, width: Int, height: Int, modifier: Modifier = Modifier, poster: Boolean = false, colorFilter: ColorFilter? = null, onError: () -> Unit = {}) {
val bitmap = LocalTitleBitmaps.current[path]
if (bitmap != null) Image(bitmap, null, modifier, contentScale = ContentScale.Crop, colorFilter = colorFilter)
val heroReady = LocalHeroArtReady.current
if (bitmap != null) {
androidx.compose.runtime.SideEffect { heroReady?.invoke() }
Image(bitmap, null, modifier, contentScale = ContentScale.Crop, colorFilter = colorFilter)
}
else if (path != null) {
val context = LocalContext.current
val request = remember(context, path, width, height, poster) {
WolfskinImages.request(context, path, width, height, poster)
}
AsyncImage(model = request, imageLoader = WolfskinImages.loader(context),
contentDescription = null, modifier = modifier, contentScale = ContentScale.Crop, colorFilter = colorFilter, onError = { onError() })
contentDescription = null, modifier = modifier, contentScale = ContentScale.Crop, colorFilter = colorFilter,
onSuccess = { heroReady?.invoke() }, onError = { onError() })
}
}

View file

@ -12,7 +12,6 @@ import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxHeight
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.heightIn
import androidx.compose.foundation.layout.offset
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
@ -42,7 +41,7 @@ fun TitleHeader(state: TitleState, active: Boolean, modifier: Modifier = Modifie
else -> state.data.playTarget?.takeIf { it.kind == "episode" }
}
val type = if (item.kind == "movie") "Movie" else "Series"
CssColumn(modifier.offset(L.safeSide, L.detailCopyTop + D.lineLift).width(L.detailCopyWidth)) {
CssColumn(modifier.offset(L.safeSide, L.detailCopyTop + D.lineLift).width(L.detailCopyWidth).copyEntry(item.id)) {
Row(Modifier.exactLineBox(TitleTextStyles.dLine, bottom = D.lineGap), horizontalArrangement = Arrangement.spacedBy(L.navItemGap), verticalAlignment = Alignment.CenterVertically) {
Glyph(if (item.held) "check" else "sparkle", C.accentText, Modifier.size(D.lineIcon))
Text(buildString {
@ -60,12 +59,14 @@ fun TitleHeader(state: TitleState, active: Boolean, modifier: Modifier = Modifie
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))
}
state.data.requestedStage?.let { stage ->
val color = when (stage) { "Ready" -> C.ok; "Failed" -> C.fail; "Downloading", "Almost ready" -> C.accent; else -> C.muted }
Row(Modifier.exactLineBox(TitleTextStyles.dLine, bottom = D.lineGap), horizontalArrangement = Arrangement.spacedBy(L.navItemGap), verticalAlignment = Alignment.CenterVertically) {
Glyph(titleStageGlyph(stage), color, Modifier.size(D.lineIcon))
Text(stage, color = color, style = TitleTextStyles.label)
Text("for ${state.data.requestFor ?: "you"}" + state.data.requestActor?.let { " · by $it" }.orEmpty(), color = C.muted, style = TitleTextStyles.dLine)
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 ->
val color = when (stage) { "Ready" -> C.ok; "Failed" -> C.fail; "Downloading", "Almost ready" -> C.accent; else -> C.muted }
Row(Modifier.exactLineBox(TitleTextStyles.dLine, bottom = D.lineGap).lateLineFade(stage), horizontalArrangement = Arrangement.spacedBy(L.navItemGap), verticalAlignment = Alignment.CenterVertically) {
Glyph(titleStageGlyph(stage), color, Modifier.size(D.lineIcon))
Text(stage, color = color, style = TitleTextStyles.label)
Text("for ${state.data.requestFor ?: "you"}" + state.data.requestActor?.let { " · by $it" }.orEmpty(), color = C.muted, style = TitleTextStyles.dLine)
}
}
}
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"))
@ -105,18 +106,18 @@ fun TitleHeader(state: TitleState, active: Boolean, modifier: Modifier = Modifie
/** A no-copy load has no focusable content and never draws a spinner. */
@Composable
fun TitleHeaderSkeleton(modifier: Modifier = Modifier) {
Column(modifier.offset(L.safeSide, L.detailCopyTop)) {
Box(Modifier.size(D.skeletonKickerWidth, D.skeletonKickerHeight).background(C.surface, RoundedCornerShape(WolfskinTokens.Radius.cardSheet)))
Column(modifier.offset(L.safeSide, L.detailCopyTop).skeletonPulse()) {
Box(Modifier.size(D.skeletonKickerWidth, D.skeletonKickerHeight).background(C.surface2, RoundedCornerShape(WolfskinTokens.Radius.cardSheet)))
Spacer(Modifier.height(D.skeletonTitleGap))
Box(Modifier.size(D.skeletonTitleWidth, D.skeletonTitleHeight).background(C.surface, RoundedCornerShape(WolfskinTokens.Radius.card)))
Box(Modifier.size(D.skeletonTitleWidth, D.skeletonTitleHeight).background(C.surface2, RoundedCornerShape(WolfskinTokens.Radius.card)))
Spacer(Modifier.height(D.skeletonFactsGap))
Box(Modifier.size(D.skeletonFactsWidth, D.skeletonKickerHeight).background(C.surface, RoundedCornerShape(WolfskinTokens.Radius.cardSheet)))
Box(Modifier.size(D.skeletonFactsWidth, D.skeletonKickerHeight).background(C.surface2, RoundedCornerShape(WolfskinTokens.Radius.cardSheet)))
Spacer(Modifier.height(D.skeletonOverviewGap))
Box(Modifier.size(D.skeletonOverviewWidth, D.skeletonOverviewHeight).background(C.surface, RoundedCornerShape(WolfskinTokens.Radius.card)))
Box(Modifier.size(D.skeletonOverviewWidth, D.skeletonOverviewHeight).background(C.surface2, RoundedCornerShape(WolfskinTokens.Radius.card)))
Spacer(Modifier.height(D.overviewGap))
Row(horizontalArrangement = Arrangement.spacedBy(L.buttonGap)) {
Box(Modifier.size(D.skeletonPlayWidth, L.buttonHeight).background(C.surface, RoundedCornerShape(WolfskinTokens.Radius.button)))
repeat(2) { Box(Modifier.size(L.iconButton).background(C.surface, RoundedCornerShape(WolfskinTokens.Radius.button))) }
Box(Modifier.size(D.skeletonPlayWidth, L.buttonHeight).background(C.surface2, RoundedCornerShape(WolfskinTokens.Radius.button)))
repeat(2) { Box(Modifier.size(L.iconButton).background(C.surface2, RoundedCornerShape(WolfskinTokens.Radius.button))) }
}
}
}

View file

@ -30,8 +30,7 @@ import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.setValue
import androidx.compose.foundation.gestures.animateScrollBy
import androidx.compose.animation.core.tween
import androidx.compose.animation.core.CubicBezierEasing
import androidx.compose.ui.graphics.graphicsLayer
import androidx.compose.ui.draw.drawBehind
import androidx.compose.ui.Modifier
import androidx.compose.ui.Alignment
import androidx.compose.ui.focus.FocusRequester
@ -147,8 +146,9 @@ fun TitlePage(state: TitleState, shell: ShellState, modifier: Modifier = Modifie
}.focusable()) {
// This remains visible behind the scrolled rows; it is not an off-screen header image.
TitleBackdrop(state.data.item, Modifier.fillMaxSize())
Box(Modifier.fillMaxSize().graphicsLayer { alpha = dim }.background(C.bg))
Box(Modifier.fillMaxSize().drawBehind { drawRect(C.bg.copy(alpha = dim)) })
if (state.data.status == TitleReadStatus.Unavailable) TitleReadError(state)
else if (state.data.status == TitleReadStatus.Loading && state.data.item.kind == "person") PersonLoading()
else LazyColumn(state = list, modifier = Modifier.fillMaxSize()) {
displayedRows.forEach { id -> item(key = id, contentType = if (id == "header") "header" else if (id == "seasons") "chips" else "shelf") {
val active = state.row == id && !shell.navFocused && state.sheet == null
@ -185,7 +185,9 @@ private fun TitleSeasons(state: TitleState, active: Boolean) {
val list = androidx.compose.foundation.lazy.rememberLazyListState()
val visibleChip = if (active) state.column else state.data.seasonNumbers.indexOf(state.season)
LaunchedEffect(visibleChip, state.data.seasonNumbers) {
if (visibleChip >= 0) list.animateScrollToItem(visibleChip)
if (visibleChip >= 0) {
if (NetworkArtTransition.reduced) list.scrollToItem(visibleChip) else list.animateScrollToItem(visibleChip)
}
}
androidx.compose.foundation.lazy.LazyRow(Modifier.height(L.row_chips).padding(top = D.chipTop), state = list, contentPadding = androidx.compose.foundation.layout.PaddingValues(horizontal = L.safeSide), horizontalArrangement = Arrangement.spacedBy(D.chipGap)) {
items(state.data.seasonNumbers.size, key = { "season-${state.data.seasonNumbers[it]}" }, contentType = { "chip" }) { index ->
@ -220,4 +222,4 @@ private fun TitleReadError(state: TitleState) {
}
}
internal val titleFrameScrollSpec = tween<Float>(WolfskinTokens.Motion.scroll, easing = CubicBezierEasing(.33f, 1f, .68f, 1f))
internal val titleFrameScrollSpec get() = tween<Float>(if (NetworkArtTransition.reduced) 0 else WolfskinTokens.Motion.scroll, easing = WolfskinTokens.Motion.easeOut)

View file

@ -54,11 +54,11 @@ fun TitleCast(state: TitleState, active: Boolean) {
}
Box(Modifier.height(D.castHeight)) {
Text("Cast", color = C.fg, style = WolfskinTokens.Type.rowHeading, modifier = Modifier.padding(start = L.safeSide).height(L.rowHeadingBand))
LazyRow(state = track, modifier = Modifier.fillMaxWidth().offset(y = L.rowHeadingBand - L.rowGapAfter).height(WolfskinTokens.Card.personH + L.rowGapAfter * 2),
LazyRow(state = track, modifier = Modifier.fillMaxWidth().offset(y = L.rowHeadingBand - L.rowGapAfter).height(WolfskinTokens.Card.personH + L.rowGapAfter * 2).skeletonPulse(state.data.castStatus == TitleReadStatus.Loading),
contentPadding = PaddingValues(horizontal = L.safeSide, vertical = L.rowGapAfter), horizontalArrangement = Arrangement.spacedBy(WolfskinTokens.Card.personGap)) {
if (state.data.castStatus == TitleReadStatus.Loading) {
items(WolfskinTokens.Card.personSlots, key = { "cast-skeleton-$it" }, contentType = { "skeleton" }) {
Box(Modifier.size(L.castCircle).background(C.surface, CircleShape))
Box(Modifier.size(L.castCircle).background(C.surface2, CircleShape))
}
} else itemsIndexed(state.data.cast, key = { _, person -> person.id }, contentType = { _, _ -> "person" }) { index, person ->
val focused = active && state.column == index
@ -89,14 +89,14 @@ fun TitleMediaShelf(state: TitleState, episodes: Boolean, active: Boolean) {
LaunchedEffect(state.column, active, titles.size) {
if (active) track.keepColumnInWindow(state.column, visible, pitch, titles.size)
}
Box(Modifier.height(L.rowHeadingBand + height + L.rowGapAfter + if (episodes) WolfskinTokens.Shell.zero else WolfskinTokens.Card.posterCaptionBand)) {
Box(Modifier.height(L.rowHeadingBand + height + L.rowGapAfter + if (episodes) WolfskinTokens.Shell.zero else WolfskinTokens.Card.posterCaptionBand).testTag("title-shelf:$id")) {
Text(if (episodes) "Season ${state.season}" else "More like this", color = C.fg, style = WolfskinTokens.Type.rowHeading, modifier = Modifier.padding(start = L.safeSide).height(L.rowHeadingBand))
LazyRow(state = track, modifier = Modifier.fillMaxWidth().offset(y = L.rowHeadingBand - L.rowGapAfter)
.height(height + (if (episodes) WolfskinTokens.Shell.zero else WolfskinTokens.Card.posterCaptionBand) + L.rowGapAfter * 2),
.height(height + (if (episodes) WolfskinTokens.Shell.zero else WolfskinTokens.Card.posterCaptionBand) + L.rowGapAfter * 2).skeletonPulse(episodes && state.data.episodesStatus == TitleReadStatus.Loading),
contentPadding = PaddingValues(horizontal = L.safeSide, vertical = L.rowGapAfter), horizontalArrangement = Arrangement.spacedBy(WolfskinTokens.Card.posterGap)) {
if (episodes && state.data.episodesStatus == TitleReadStatus.Loading) {
items(visible, key = { "episode-skeleton-$it" }, contentType = { "skeleton" }) {
Box(Modifier.size(width, height).background(C.surface, RoundedCornerShape(WolfskinTokens.Radius.card)))
Box(Modifier.size(width, height).background(C.surface2, RoundedCornerShape(WolfskinTokens.Radius.card)))
}
} else itemsIndexed(titles, key = { _, item -> item.id }, contentType = { _, _ -> if (episodes) "tile" else "poster" }) { index, item ->
TitlePoster(item, active && state.column == index, Modifier.width(width)
@ -134,7 +134,7 @@ fun TitlePoster(item: TitleItem, focused: Boolean, modifier: Modifier = Modifier
Box(Modifier.fillMaxWidth(progress).fillMaxHeight().background(C.accent))
}
}
androidx.compose.animation.AnimatedVisibility(focused && ratingsVisible, enter = fadeIn(tween(WolfskinTokens.Motion.base)), exit = fadeOut(tween(WolfskinTokens.Motion.base))) {
androidx.compose.animation.AnimatedVisibility(focused && ratingsVisible, enter = fadeIn(tween(motionDuration(WolfskinTokens.Motion.base, reducedMotion()))), exit = fadeOut(tween(motionDuration(WolfskinTokens.Motion.base, reducedMotion())))) {
LibraryRatingPlate(item.ratings, Modifier.fillMaxWidth())
}
}

View file

@ -34,6 +34,8 @@ fun TitleArtwork(title: String, type: String, poster: String?, width: Dp, height
val source = if (landscape) backdrop ?: poster else poster
val failed = remember(source) { androidx.compose.runtime.mutableStateOf(false) }
val missing = source == null || failed.value
val heroReady = LocalHeroArtReady.current
if (missing) androidx.compose.runtime.SideEffect { heroReady?.invoke() }
Box(modifier.background(C.surface2)) {
if (!missing) {
if (landscape && (posterOnly || backdrop == null) && poster != null) {

View file

@ -9,7 +9,7 @@ import coil3.ImageLoader
import coil3.disk.DiskCache
import coil3.intercept.Interceptor
import coil3.disk.directory
import coil3.request.crossfade
import coil3.request.transitionFactory
import coil3.memory.MemoryCache
import coil3.request.ImageRequest
import coil3.request.allowRgb565
@ -61,7 +61,7 @@ object WolfskinImages {
networkConfiguration?.invoke(this)
add(Interceptor { chain -> readPermits.withPermit { chain.proceed() } })
}
.fetcherCoroutineContext(reads).decoderCoroutineContext(reads).crossfade(false).build()
.fetcherCoroutineContext(reads).decoderCoroutineContext(reads).transitionFactory(NetworkArtTransition).build()
}
return loader!!
}

View file

@ -35,6 +35,8 @@ object WolfskinColors {
/** Upstream content inherits tokens without a fork-specific theme enum. */
@Composable
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)
}

View file

@ -1,6 +1,7 @@
// Generated by generate_tokens.py; edit the JSON sources instead.
package org.justinvisser.wolfskin.design
import androidx.compose.animation.core.CubicBezierEasing
import androidx.compose.ui.graphics.Color as ComposeColor
import androidx.compose.ui.text.TextStyle
import androidx.compose.ui.text.font.FontWeight
@ -208,14 +209,35 @@ object WolfskinTokens {
const val fillInverse = "background color.fg, text color.onFocus"
}
object Motion {
const val fast = 140
const val skeletonCycle = 1400
const val skeletonLowAlpha = 0.55f
const val focusMove = 180
const val captionDelay = 60
const val captionOut = 100
const val artReveal = 160
const val heroFade = 320
const val heroCopyOut = 120
const val heroCopyDelay = 140
const val heroCopyIn = 200
const val rowEnter = 180
const val lateLineFade = 200
val copyEntryRise = 8.0.dp
const val reducedFade = 80
val rowRise = 12.0.dp
val captionRise = 5.0.dp
val copyRise = 7.0.dp
val readinessSlot = 28.0.dp
val ease = CubicBezierEasing(0.25f, 0.1f, 0.25f, 1.0f)
val easeOut = CubicBezierEasing(0.33f, 1.0f, 0.68f, 1.0f)
val easeInOut = CubicBezierEasing(0.65f, 0.0f, 0.35f, 1.0f)
val breatheEase = CubicBezierEasing(0.42f, 0.0f, 0.58f, 1.0f)
const val base = 160
const val scroll = 180
const val navBlobDampingRatio = 1.0f
const val navBlobStiffness = 650.0f
const val ratingDwell = 350
const val toast = 3200
const val reducedMotion = "all transitions instant"
const val reducedMotion = "no pulse or movement; fades at most 80ms"
}
object Image {
const val posterRequestWidth = 256

View file

@ -0,0 +1,97 @@
package org.justinvisser.wolfskin.design
import android.app.Application
import android.graphics.BitmapFactory
import androidx.activity.ComponentActivity
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.requiredSize
import androidx.compose.runtime.CompositionLocalProvider
import androidx.compose.runtime.SideEffect
import androidx.compose.runtime.mutableStateOf
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.toArgb
import androidx.compose.ui.test.junit4.createAndroidComposeRule
import androidx.compose.ui.test.onRoot
import androidx.compose.ui.test.onNodeWithTag
import androidx.compose.ui.unit.dp
import com.github.takahirom.roborazzi.captureRoboImage
import org.junit.Assert.*
import org.junit.Rule
import org.junit.Test
import org.junit.runner.RunWith
import org.robolectric.RobolectricTestRunner
import org.robolectric.annotation.Config
import org.robolectric.annotation.GraphicsMode
import java.io.File
/** A slow or interrupted artwork read cannot replace the last painted hero with a blank. */
@RunWith(RobolectricTestRunner::class)
@Config(sdk = [34], application = Application::class, qualifiers = "w960dp-h540dp-xhdpi")
@GraphicsMode(GraphicsMode.Mode.NATIVE)
class HeroMotionTest {
@get:Rule val rule = createAndroidComposeRule<ComponentActivity>()
@Test fun heroWaitsForArtBlendsOnlyItsBoundedRegionAndKeepsTheLatestSelection() {
rule.mainClock.autoAdvance = false
val selected = mutableStateOf("red")
val ready = mutableStateOf(true)
val reduced = mutableStateOf(false)
val colors = mapOf("red" to Color.Red, "blue" to Color.Blue, "green" to Color.Green)
rule.setContent { CompositionLocalProvider(LocalReducedMotion provides reduced.value) { WolfskinTheme {
Box(Modifier.requiredSize(960.dp, 540.dp)) {
HeroArtCrossfade(selected.value, selected.value, 200.dp) { title ->
val reportReady = LocalHeroArtReady.current
if (ready.value) SideEffect { reportReady?.invoke() }
Box(Modifier.fillMaxSize().background(colors.getValue(title)))
}
}
} } }
val directory = kotlin.io.path.createTempDirectory("wolfskin-hero-").toFile()
try {
rule.mainClock.advanceTimeBy(32)
update { selected.value = "blue"; ready.value = false }
rule.mainClock.advanceTimeBy(1000)
assertEquals(Color.Red.toArgb(), pixel(directory, 100))
val probeFile = File(directory, "hero.png")
val probe = BitmapFactory.decodeFile(probeFile.absolutePath)
try {
for (y in listOf(380, 400, 420, 600, 800)) {
assertEquals("The outgoing hero must stop at its 400px boundary: y=$y",
(if (y < 400) Color.Red else Color.Blue).toArgb(), probe.getPixel(100, y))
}
assertEquals(400f, rule.onNodeWithTag("hero-outgoing").fetchSemanticsNode().boundsInRoot.height, .01f)
} finally { probe.recycle() }
update { ready.value = true }
rule.mainClock.advanceTimeBy(176)
val mixed = pixel(directory, 100)
assertNotEquals(Color.Red.toArgb(), mixed)
assertNotEquals(Color.Blue.toArgb(), mixed)
rule.mainClock.advanceTimeBy(400)
assertEquals(Color.Blue.toArgb(), pixel(directory, 100))
update { selected.value = "green"; ready.value = false }
rule.mainClock.advanceTimeBy(32)
update { selected.value = "red"; ready.value = true }
rule.mainClock.advanceTimeBy(400)
assertEquals(Color.Red.toArgb(), pixel(directory, 100))
update { reduced.value = true; selected.value = "green" }
rule.mainClock.advanceTimeBy(128)
assertEquals(Color.Green.toArgb(), pixel(directory, 100))
} finally { directory.deleteRecursively() }
}
private fun update(block: () -> Unit) {
rule.runOnIdle { block(); androidx.compose.runtime.snapshots.Snapshot.sendApplyNotifications() }
rule.mainClock.advanceTimeByFrame()
}
private fun pixel(directory: File, y: Int): Int {
rule.waitForIdle()
val file = File(directory, "hero.png")
rule.onRoot().captureRoboImage(file.absolutePath)
val bitmap = BitmapFactory.decodeFile(file.absolutePath)
return try { bitmap.getPixel(100, y) } finally { bitmap.recycle() }
}
}

View file

@ -0,0 +1,138 @@
package org.justinvisser.wolfskin.design
import android.app.Application
import android.graphics.BitmapFactory
import android.os.StrictMode
import androidx.activity.ComponentActivity
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.requiredSize
import androidx.compose.foundation.layout.size
import androidx.compose.runtime.CompositionLocalProvider
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.getValue
import androidx.compose.runtime.setValue
import androidx.compose.runtime.mutableFloatStateOf
import androidx.compose.ui.MotionDurationScale
import androidx.compose.ui.Modifier
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.onRoot
import androidx.compose.ui.test.performKeyInput
import androidx.compose.ui.test.pressKey
import androidx.compose.ui.unit.dp
import com.github.takahirom.roborazzi.captureRoboImage
import org.junit.Assert.*
import org.junit.Rule
import org.junit.Test
import org.junit.runner.RunWith
import org.robolectric.RobolectricTestRunner
import org.robolectric.annotation.Config
import org.robolectric.annotation.GraphicsMode
import java.io.File
/** Delayed metadata and loading animations cannot move content or capture the remote's focus. */
@RunWith(RobolectricTestRunner::class)
@Config(sdk = [34], application = Application::class, qualifiers = "w960dp-h540dp-xhdpi")
@GraphicsMode(GraphicsMode.Mode.NATIVE)
class MotionSurfaceTest {
private val animatorScale = object : MotionDurationScale { override var scaleFactor by mutableFloatStateOf(1f) }
@get:Rule val rule = createAndroidComposeRule<ComponentActivity>(effectContext = animatorScale)
private val viewer = ShellPerson("justin", "Justin", "J", false)
@Test fun lateSeriesReadinessAndRatingsLeaveActionsOverviewAndEpisodesAtTheSameCoordinates() {
val shell = shell()
val series = TitleItem("series", "A series", "tv", seasons = 2, held = true, overview = "Series overview")
val episode = TitleItem("episode", "Pilot", "episode", season = 1, episode = 1, overview = "Episode overview")
val state = TitleState(TitlePageData(series, playTarget = episode, episodes = listOf(episode), connection = TitleConnection.SignedIn), viewer) {}
rule.setContent { WolfskinTheme { TitleSurface(state, shell, Modifier.requiredSize(960.dp, 540.dp)) } }
rule.waitForIdle()
val before = bounds()
for (stage in listOf("Ready", "Downloading", null)) {
rule.runOnIdle { state.update(state.data.copy(requestedStage = stage, requestActor = "Mindel", item = series.copy(ratings = listOf(LibraryRating("rt", 88.0))))) }
rule.mainClock.advanceTimeBy(3400)
rule.waitForIdle()
assertEquals("Late metadata cannot change reserved header geometry", before, bounds())
}
}
@Test fun titleSkeletonIgnoresDownAndBackPopsWithoutOpeningThePicker() {
val shell = shell()
val actions = mutableListOf<TitleAction>()
val state = TitleState(TitlePageData(TitleItem("loading", "", "movie"), status = TitleReadStatus.Loading), viewer, actions::add)
rule.setContent { WolfskinTheme { TitleSurface(state, shell) } }
rule.mainClock.advanceTimeBy(2100)
rule.onRoot().performKeyInput { pressKey(Key.DirectionDown) }
rule.runOnIdle { assertTrue(shell.navFocused); assertFalse(shell.pickerVisible) }
rule.onRoot().performKeyInput { pressKey(Key.Back) }
rule.runOnIdle { assertEquals(listOf(TitleAction.Pop), actions); assertFalse(shell.pickerVisible) }
}
@Test fun pendingCastAndEpisodesAreSkippedUntilRealCardsExist() {
val shell = shell()
val series = TitleItem("series", "Series", "tv", held = true, seasons = 1)
val related = TitleItem("related", "Related", "movie")
val state = TitleState(TitlePageData(series, castStatus = TitleReadStatus.Loading, episodesStatus = TitleReadStatus.Loading, related = listOf(related)), viewer) {}
assertEquals(listOf("header", "seasons", "related"), state.rows)
state.focus("seasons", 0)
state.key(ShellKey.Down, true, 0, shell)
assertEquals("related", state.row)
state.update(state.data.copy(castStatus = TitleReadStatus.Ready, cast = listOf(TitlePerson("person", "Actor")), episodesStatus = TitleReadStatus.Ready, episodes = listOf(TitleItem("episode", "Pilot", "episode"))))
assertEquals("related", state.row)
state.key(ShellKey.Up, true, 0, shell)
assertEquals("cast", state.row)
}
@Test fun reducedMotionFreezesPulseWhileNormalMotionBreathesWithoutRelayout() {
rule.mainClock.autoAdvance = false
val reduced = mutableStateOf(true)
rule.setContent { CompositionLocalProvider(LocalReducedMotion provides reduced.value) { WolfskinTheme {
Box(Modifier.requiredSize(960.dp, 540.dp).background(WolfskinTokens.Color.bg)) {
Box(Modifier.size(200.dp, 140.dp).skeletonPulse().background(WolfskinTokens.Color.surface2).testTag("shape"))
}
} } }
rule.mainClock.advanceTimeBy(32)
val directory = kotlin.io.path.createTempDirectory("wolfskin-motion-").toFile()
try {
val first = pixel(File(directory, "first.png"))
val bounds = rule.onNodeWithTag("shape").fetchSemanticsNode().boundsInRoot
rule.mainClock.advanceTimeBy(700)
assertEquals(first, pixel(File(directory, "reduced.png")))
rule.runOnIdle { reduced.value = false; animatorScale.scaleFactor = 0f; androidx.compose.runtime.snapshots.Snapshot.sendApplyNotifications() }
rule.mainClock.advanceTimeBy(32)
val systemReduced = pixel(File(directory, "system-reduced.png"))
rule.mainClock.advanceTimeBy(700)
assertEquals(systemReduced, pixel(File(directory, "system-later.png")))
rule.runOnIdle { animatorScale.scaleFactor = 1f; androidx.compose.runtime.snapshots.Snapshot.sendApplyNotifications() }
rule.mainClock.advanceTimeBy(32)
val low = pixel(File(directory, "low.png"))
rule.mainClock.advanceTimeBy(650)
assertNotEquals(low, pixel(File(directory, "high.png")))
assertEquals(bounds, rule.onNodeWithTag("shape").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)) } }
rule.waitForIdle()
var previous: StrictMode.ThreadPolicy? = null
rule.runOnIdle {
previous = StrictMode.getThreadPolicy()
StrictMode.setThreadPolicy(StrictMode.ThreadPolicy.Builder().detectDiskReads().detectDiskWrites().detectNetwork().penaltyDeath().build())
loading.value = true
}
try { rule.mainClock.advanceTimeBy(1500); rule.waitForIdle() }
finally { rule.runOnIdle { StrictMode.setThreadPolicy(requireNotNull(previous)) } }
}
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 {
rule.waitForIdle()
rule.onRoot().captureRoboImage(file.absolutePath)
return BitmapFactory.decodeFile(file.absolutePath).let { bitmap -> try { bitmap.getPixel(100, 100) } finally { bitmap.recycle() } }
}
}

View file

@ -0,0 +1,47 @@
package org.justinvisser.wolfskin.design
import android.app.Application
import android.graphics.Bitmap
import android.graphics.drawable.Drawable
import android.view.View
import coil3.asImage
import coil3.decode.DataSource
import coil3.request.ErrorResult
import coil3.request.ImageRequest
import coil3.request.SuccessResult
import coil3.transition.CrossfadeTransition
import coil3.transition.TransitionTarget
import org.junit.Assert.*
import org.junit.Test
import org.junit.runner.RunWith
import org.robolectric.RobolectricTestRunner
import org.robolectric.RuntimeEnvironment
import org.robolectric.annotation.Config
/** Provenance, errors and reduced motion determine the consumer-visible artwork transition. */
@RunWith(RobolectricTestRunner::class)
@Config(sdk = [34], application = Application::class)
class NetworkArtTransitionTest {
private val target = object : TransitionTarget {
override val view: View get() = error("Compose transition factories must not read a View")
override val drawable: Drawable? = null
}
@Test fun onlyNetworkSuccessFadesAndReducedMotionCapsItsDuration() {
val request = ImageRequest.Builder(RuntimeEnvironment.getApplication()).data("https://example.invalid/poster").build()
val bitmap = Bitmap.createBitmap(2, 2, Bitmap.Config.ARGB_8888)
try {
for (reduced in listOf(false, true)) {
NetworkArtTransition.reduced = reduced
for (source in DataSource.entries) {
val transition = NetworkArtTransition.create(target, SuccessResult(bitmap.asImage(), request, source))
if (source == DataSource.NETWORK) {
assertTrue(transition is CrossfadeTransition)
assertEquals(if (reduced) 80 else 160, (transition as CrossfadeTransition).durationMillis)
} else assertFalse("$source must paint instantly", transition is CrossfadeTransition)
}
assertFalse(NetworkArtTransition.create(target, ErrorResult(null, request, IllegalStateException("offline"))) is CrossfadeTransition)
}
} finally { NetworkArtTransition.reduced = false; bitmap.recycle() }
}
}

View file

@ -123,7 +123,27 @@
--layout-castCircle: 200.0px;
--layout-personPortrait: 240.0px;
--layout-row_chipsGrid: 136.0px;
--motion-fast: 140ms;
--motion-skeletonCycle: 1400ms;
--motion-focusMove: 180ms;
--motion-captionDelay: 60ms;
--motion-captionOut: 100ms;
--motion-artReveal: 160ms;
--motion-heroFade: 320ms;
--motion-heroCopyOut: 120ms;
--motion-heroCopyDelay: 140ms;
--motion-heroCopyIn: 200ms;
--motion-rowEnter: 180ms;
--motion-lateLineFade: 200ms;
--motion-copyEntryRise: 16.0px;
--motion-reducedFade: 80ms;
--motion-rowRise: 24.0px;
--motion-captionRise: 10.0px;
--motion-copyRise: 14.0px;
--motion-readinessSlot: 56.0px;
--motion-ease: cubic-bezier(0.25, 0.1, 0.25, 1);
--motion-easeOut: cubic-bezier(0.33, 1, 0.68, 1);
--motion-easeInOut: cubic-bezier(0.65, 0, 0.35, 1);
--motion-breatheEase: cubic-bezier(0.42, 0, 0.58, 1);
--motion-base: 160ms;
--motion-scroll: 180ms;
--motion-ratingDwell: 350ms;

View file

@ -1390,19 +1390,28 @@
}
},
"motion": {
"fast": {
"$type": "duration",
"$value": "140ms",
"easing": "ease = cubic-bezier(.25,.1,.25,1)",
"source": "index.html:125",
"page": {
".card/.btn/.chip/.pp/.prow/.crow/.srow/.pgear transform": "140ms",
".cap opacity (caption fade-in)": "120ms",
".dots svg opacity": "140ms"
},
"usedBy": "focus scale + ring in and out on every focusable; caption fade under a focused poster (page 120 ms folds in)",
"note": "q1, settled by Justin 2026-11-05 (corr=c248698d8546e1ab): the caption fade is 140 ms. Three durations in total (140, 160, 180) plus the blob spring."
},
"skeletonCycle": { "$type": "duration", "$value": "1400ms" },
"skeletonLowAlpha": { "$type": "number", "$value": 0.55 },
"focusMove": { "$type": "duration", "$value": "180ms" },
"captionDelay": { "$type": "duration", "$value": "60ms" },
"captionOut": { "$type": "duration", "$value": "100ms" },
"artReveal": { "$type": "duration", "$value": "160ms" },
"heroFade": { "$type": "duration", "$value": "320ms" },
"heroCopyOut": { "$type": "duration", "$value": "120ms" },
"heroCopyDelay": { "$type": "duration", "$value": "140ms" },
"heroCopyIn": { "$type": "duration", "$value": "200ms" },
"rowEnter": { "$type": "duration", "$value": "180ms" },
"lateLineFade": { "$type": "duration", "$value": "200ms" },
"copyEntryRise": { "$type": "dimension", "$value": "16px", "px": 16, "dp": 8 },
"reducedFade": { "$type": "duration", "$value": "80ms" },
"rowRise": { "$type": "dimension", "$value": "24px", "px": 24, "dp": 12 },
"captionRise": { "$type": "dimension", "$value": "10px", "px": 10, "dp": 5 },
"copyRise": { "$type": "dimension", "$value": "14px", "px": 14, "dp": 7 },
"readinessSlot": { "$type": "dimension", "$value": "56px", "px": 56, "dp": 28 },
"ease": { "$type": "cubicBezier", "$value": [0.25, 0.1, 0.25, 1] },
"easeOut": { "$type": "cubicBezier", "$value": [0.33, 1, 0.68, 1] },
"easeInOut": { "$type": "cubicBezier", "$value": [0.65, 0, 0.35, 1] },
"breatheEase": { "$type": "cubicBezier", "$value": [0.42, 0, 0.58, 1] },
"base": {
"$type": "duration",
"$value": "160ms",
@ -1475,9 +1484,9 @@
},
"reducedMotion": {
"$type": "rule",
"$value": "all transitions instant",
"source": "index.html:330",
"usedBy": "when Settings.Global ANIMATOR_DURATION_SCALE == 0: no scale animation, no scroll animation, blob snaps"
"$value": "no pulse or movement; fades at most 80ms",
"source": "tv-motion-speed direction A",
"usedBy": "system animator scale zero or Wolfskin reduced-motion flag"
}
},
"image": {
@ -1536,11 +1545,6 @@
"source": "index.html:550",
"usedBy": "cast portraits: decode min(source 185x278, slot)"
},
"crossfade": {
"$value": false,
"source": "index.html:536; answers p3",
"usedBy": "no crossfade on cards (Coil crossfade off)"
},
"memoryCacheMB": {
"$value": 64,
"source": "index.html:552; answers p3",

View file

@ -1,8 +1,8 @@
b3f096b692c395534177cc82ac68f5b801870bc7e28f0dd5cd7c17e2103920a3 discover-tokens.json
85109c394255f6120a621fbefe8e58df82832974ccd6a6a975a61567f641ea2a generate_tokens.py
c29dc64f3c3ca900c747b3db8b270e25908948bf5f76388d2c627a612fc7c99d generate_tokens.py
25985087986a46f00e2819917d0421ff2b437ec21c54f6ce6412b84c0eb05302 home-tokens.json
be3af0ddcb0d67abef0805d19d7a33cdb55428bcace656b50f0a38935f212028 shell-tokens.json
580ed1710468ccbd97082afaa445e96ae71510ebace711cc735ea389ecb9249d src/main/java/org/justinvisser/wolfskin/design/WolfskinTokens.kt
0446c2e85bbda72adde76329958931216fe81c43cebd47bbdb885f5dd59d87f8 src/main/java/org/justinvisser/wolfskin/design/WolfskinTokens.kt
e0a2f5df2f249bce3f6e13f4a0320c7b18b85777eaf04a27a428d6683a6aad28 title-tokens.json
36d5b2646dff2e4055d2683e4c284111a6886c311e9c924b73061fa785bc863d tokens.css
226f4bd07c0b105f8640beff71fe2c0c95d0f8862e624cea511384a4591b44ad tokens.json
faa4e0c5398d35ecab9b1088815b79c8de26a44d403c4cafae59c7c341992da7 tokens.css
f4424475291a745400a5daa071cb846838fb5e9ce889022288bcc6a7be758fe0 tokens.json