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
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:
parent
fd48426fa7
commit
1668445418
86 changed files with 2026 additions and 121 deletions
|
|
@ -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)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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 ->
|
||||
|
|
|
|||
|
|
@ -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 }
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
|
@ -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)))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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) }
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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)))
|
||||
}
|
||||
|
|
@ -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) }
|
||||
}
|
||||
|
|
@ -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)))
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
|
@ -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)) {
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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)) {
|
||||
|
|
|
|||
|
|
@ -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() })
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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))) }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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())
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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!!
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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() }
|
||||
}
|
||||
}
|
||||
|
|
@ -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() } }
|
||||
}
|
||||
}
|
||||
|
|
@ -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() }
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue