Implement approved TV motion and shaped loading states #51
7 changed files with 149 additions and 15 deletions
|
|
@ -56,23 +56,23 @@ Every Discover lane, including Movies for you, Series for you, service entries,
|
||||||
|
|
||||||
## Motion and loading — revision 6
|
## Motion and loading — revision 6
|
||||||
|
|
||||||
Home heroes and shelves, Library rows, Discover hero/lanes, title headers, episodes and cast, and person headers/filmography use shaped placeholders without loading sentences. The placeholder fill is `surface2` (`#1b1b25`). A complete shelf shares **one** alpha layer: **0.55 → 1 → 0.55 over 1400 ms**, CSS ease-in-out (`.42,0,.58,1`), 700 ms each way. No per-card clocks, staggered pulse phases, shimmer, moving gradient, or animated blur.
|
Home heroes and shelves, Library rows, Discover hero/lanes, title headers, episodes and cast, and person headers/filmography use shaped placeholders without loading sentences. The placeholder fill is `surface2` (`#1b1b25`). A complete shelf shares **one** alpha layer: **0.55 → 1 → 0.55 over 1400 ms**, CSS ease-in-out (`.42,0,.58,1`), 700 ms each way. All loading shelves under the screen's theme share one clock, so late arrivals join the current phase. The clock stops when the last loading shelf leaves. No per-card clocks, staggered pulse phases, shimmer, moving gradient, or animated blur.
|
||||||
|
|
||||||
| Transition | Duration and curve | Transform |
|
| Transition | Duration and curve | Transform |
|
||||||
|---|---|---|
|
|---|---|---|
|
||||||
| Focus scale/ring | 180 ms, ease-out (`.33,1,.68,1`) | Existing locked focus scale; no geometry change |
|
| Focus scale/ring | 180 ms, ease-out (`.33,1,.68,1`) | Existing locked focus scale; no geometry change |
|
||||||
| Poster caption in / out | 60 ms delay + 180 ms ease-out / 100 ms ease-out | 10 px rise on entry |
|
| Poster caption in / out | 60 ms delay + 180 ms ease-out / 100 ms ease-out | 10 px rise on entry only; exit holds its position, including when entry was interrupted |
|
||||||
| Network artwork only | 160 ms, Coil painter alpha | None; memory, disk and local hits are instant |
|
| Network artwork only | 160 ms, Coil painter alpha | None; memory, disk and local hits are instant |
|
||||||
| Row/tab content reveal | 180 ms, ease-out | One layer per row, 24 px rise |
|
| Row/tab content reveal | 180 ms, ease-out | One layer per row, 24 px rise |
|
||||||
| Initial hero/title copy | 180 ms, ease-out | 16 px rise |
|
| Initial hero/title copy | 180 ms, ease-out | 16 px rise |
|
||||||
| Hero backdrop hand-off | 320 ms, ease-in-out (`.65,0,.35,1`) | Alpha only; retain previous art until incoming art or its final fallback is ready |
|
| Hero backdrop hand-off | 320 ms, ease-in-out (`.65,0,.35,1`) | Alpha only; retain previous art until incoming art or its final fallback is ready |
|
||||||
| Hero copy hand-off | Old copy out 120 ms ease (`.25,.1,.25,1`); new copy starts at 140 ms and enters over 200 ms ease-out | 14 px rise on entry |
|
| Hero copy hand-off | Old copy out 120 ms ease (`.25,.1,.25,1`); new copy starts at 140 ms and enters over 200 ms ease-out | 14 px rise on entry |
|
||||||
| Late readiness line | 200 ms, ease | Alpha only, in its reserved slot |
|
| Late ratings, genres, overview and readiness | 200 ms, ease | Alpha only, inside existing measured slots; rating plates keep their existing plate fade rather than adding a nested fade |
|
||||||
| Horizontal / vertical focus-follow scroll | 160 ms ease / 180 ms ease-out | Existing scroll geometry |
|
| Horizontal / vertical focus-follow scroll | 160 ms ease / 180 ms ease-out | Existing scroll geometry |
|
||||||
|
|
||||||
Row reveals are grouped rather than the browser prototype's per-card staging: the armv7 frame budget permits one animated layer per shelf, not one per card. Tabs have no full-screen transition layer. The upper Home hero blend is bounded to **1920×820 px**, Discover to **1920×740 px**; the lower backdrop changes without an animated screen-sized layer. Hero copy is a separate bounded block. Image reveal uses Coil's painter, not a new full-screen compositing layer. Existing poster-only blur is static.
|
Row reveals are grouped rather than the browser prototype's per-card staging: the armv7 frame budget permits one animated layer per shelf, not one per card. Tabs have no full-screen transition layer. The upper Home hero blend is bounded to **1920×820 px**, Discover to **1920×740 px**; the lower backdrop changes without an animated screen-sized layer. Hero copy is a separate bounded block. Image reveal uses Coil's painter, not a new full-screen compositing layer. Existing poster-only blur is static.
|
||||||
|
|
||||||
The title page's scroll dim is a draw-only colour overlay, not an alpha-animated full-screen layer. Discover scrolls its backdrop through placement offsets rather than a full-screen translation layer. Initial loading animation owners are Home **2**, Library **up to 3** lazy rows, Discover **2**, title header **1**, person **2**; pending title episode/cast shelves add **1 each**, and each pending Discover lane owns **1**. Deep Home loading is bounded to **4** visible shelf layers. These are active animation owners, not every static display-list node. A hero hand-off adds at most **2** bounded owners (outgoing art and copy). Focus changes can animate **2** card transforms, **1** caption and at most **2** rating plates; artwork fades are painter draws without additional compositing layers. No animated owner is full-screen.
|
The title page's scroll dim is a draw-only colour overlay, not an alpha-animated full-screen layer. Discover scrolls its backdrop through placement offsets rather than a full-screen translation layer. Initial loading animated layers are Home **2**, Library **up to 3** lazy rows, Discover **2**, title header **1**, person **2**; pending title episode/cast shelves add **1 each**, and each pending Discover lane owns **1**. Deep Home loading is bounded to **4** visible shelf layers. These are animated layers, not independent pulse clocks or every static display-list node: one shared pulse clock drives the loading screen without adding a layer. A hero hand-off adds at most **2** bounded owners (outgoing art and copy). Focus changes can animate **2** card transforms, **1** caption and at most **2** rating plates; artwork fades are painter draws without additional compositing layers. Late title metadata adds at most **4** bounded alpha layers: title ratings, play-target ratings, genres and overview. No animated owner is full-screen.
|
||||||
|
|
||||||
The series readiness band reserves **56 px (28 dp)** from its first content paint, including when empty or when readiness later disappears. Ratings remain inside the fixed-height facts band. The series header's measured height therefore cannot change when late ratings/readiness arrive; overview, actions, season and episode anchors retain their coordinates. Skeletons have no click/hover focus targets; pending episode/cast rows remain absent from the key-navigation state until real cards exist. Initial loading keeps nav focus and Back still pops the title/person route.
|
The series readiness band reserves **56 px (28 dp)** from its first content paint, including when empty or when readiness later disappears. Ratings remain inside the fixed-height facts band. The series header's measured height therefore cannot change when late ratings/readiness arrive; overview, actions, season and episode anchors retain their coordinates. Skeletons have no click/hover focus targets; pending episode/cast rows remain absent from the key-navigation state until real cards exist. Initial loading keeps nav focus and Back still pops the title/person route.
|
||||||
|
|
||||||
|
|
@ -87,6 +87,6 @@ Native **1920×1080** before/after frames and the frame-budget inventory are pub
|
||||||
|
|
||||||
Authoritative changed values live in `wolfskin-design/tokens.json`, `title-tokens.json` and `discover-tokens.json`; regenerate Kotlin, CSS and the drift manifest with `python3 wolfskin-design/generate_tokens.py`.
|
Authoritative changed values live in `wolfskin-design/tokens.json`, `title-tokens.json` and `discover-tokens.json`; regenerate Kotlin, CSS and the drift manifest with `python3 wolfskin-design/generate_tokens.py`.
|
||||||
|
|
||||||
`MotionSurfaceTest`, `HeroMotionTest`, `NetworkArtTransitionTest` and `DiscoverSkeletonKeysTest` cover late-line geometry, loading/ready row eligibility, Back/nav ownership, rendered pulse suppression under both motion controls, strict main-thread I/O policy, network-versus-cache reveal, bounded hero blending, delayed art and interrupted hand-offs. Manually clocked captures explicitly deliver snapshot changes before advancing animation time. The frozen prototype includes `index.html`, its local media, the original CSS/JS/builder and a complete SHA-256 manifest; replay the HTML, not the preserved builder's live-publishing entry point.
|
`MotionSurfaceTest`, `HeroMotionTest`, `NetworkArtTransitionTest` and `DiscoverSkeletonKeysTest` cover late-line geometry, loading/ready row eligibility, Back/nav ownership, rendered pulse suppression under both motion controls, strict main-thread I/O policy, network-versus-cache reveal, bounded hero blending, delayed art and interrupted hand-offs. Native follow-up cases compare the pulse pixels of a late-joining shelf, hold caption translation through both settled and interrupted exits, and observe late ratings/genres/overview fading without moving their slots or actions. Manually clocked captures explicitly deliver snapshot changes before advancing animation time; interrupted-exit assertions sample after the hide has committed. The frozen prototype includes `index.html`, its local media, the original CSS/JS/builder and a complete SHA-256 manifest; replay the HTML, not the preserved builder's live-publishing entry point.
|
||||||
|
|
||||||
The feedback prototype delta links the native 1920×1080 before/after frames. `HomeViewportTest` exercises short and long poster/tile/wide rows, first focus, both row directions and native Back from the shipped title surface. The real-Bear capture harness reads recorded Jellyfin metadata through `TitleReadFacade`; it is a throwaway proof and is not shipped. The final gates are `make test` and `make apk-debug`. No TV access or optimized-APK build is part of this worker's validation.
|
The feedback prototype delta links the native 1920×1080 before/after frames. `HomeViewportTest` exercises short and long poster/tile/wide rows, first focus, both row directions and native Back from the shipped title surface. The real-Bear capture harness reads recorded Jellyfin metadata through `TitleReadFacade`; it is a throwaway proof and is not shipped. The final gates are `make test` and `make apk-debug`. No TV access or optimized-APK build is part of this worker's validation.
|
||||||
|
|
|
||||||
|
|
@ -5,6 +5,7 @@ import androidx.compose.animation.core.tween
|
||||||
import androidx.compose.foundation.layout.Column
|
import androidx.compose.foundation.layout.Column
|
||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
import androidx.compose.runtime.LaunchedEffect
|
import androidx.compose.runtime.LaunchedEffect
|
||||||
|
import androidx.compose.runtime.mutableFloatStateOf
|
||||||
import androidx.compose.runtime.remember
|
import androidx.compose.runtime.remember
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
import androidx.compose.ui.graphics.graphicsLayer
|
import androidx.compose.ui.graphics.graphicsLayer
|
||||||
|
|
@ -22,13 +23,16 @@ fun Caption(title: String, facts: String, visible: Boolean, modifier: Modifier =
|
||||||
titleStyle: TextStyle = WolfskinTokens.Type.button, factsStyle: TextStyle = WolfskinTokens.Type.caption,
|
titleStyle: TextStyle = WolfskinTokens.Type.button, factsStyle: TextStyle = WolfskinTokens.Type.caption,
|
||||||
titleOverflow: TextOverflow = TextOverflow.Ellipsis) {
|
titleOverflow: TextOverflow = TextOverflow.Ellipsis) {
|
||||||
val alpha = remember(title, facts) { Animatable(0f) }
|
val alpha = remember(title, facts) { Animatable(0f) }
|
||||||
|
val entryProgress = remember(title, facts) { mutableFloatStateOf(0f) }
|
||||||
val reduced = reducedMotion()
|
val reduced = reducedMotion()
|
||||||
val rise = with(androidx.compose.ui.platform.LocalDensity.current) { WolfskinTokens.Motion.captionRise.toPx() }
|
val rise = with(androidx.compose.ui.platform.LocalDensity.current) { WolfskinTokens.Motion.captionRise.toPx() }
|
||||||
LaunchedEffect(alpha, visible, reduced) {
|
LaunchedEffect(alpha, visible, reduced) {
|
||||||
if (visible && !reduced) kotlinx.coroutines.delay(WolfskinTokens.Motion.captionDelay.toLong())
|
if (visible && !reduced) kotlinx.coroutines.delay(WolfskinTokens.Motion.captionDelay.toLong())
|
||||||
alpha.animateTo(if (visible) 1f else 0f, tween(motionDuration(if (visible) WolfskinTokens.Motion.focusMove else WolfskinTokens.Motion.captionOut, reduced), easing = WolfskinTokens.Motion.easeOut))
|
alpha.animateTo(if (visible) 1f else 0f, tween(motionDuration(if (visible) WolfskinTokens.Motion.focusMove else WolfskinTokens.Motion.captionOut, reduced), easing = WolfskinTokens.Motion.easeOut)) {
|
||||||
|
if (visible) entryProgress.floatValue = value
|
||||||
|
}
|
||||||
}
|
}
|
||||||
Column(modifier.graphicsLayer { this.alpha = alpha.value; translationY = if (reduced) 0f else rise * (1f - alpha.value) }) {
|
Column(modifier.graphicsLayer { this.alpha = alpha.value; translationY = if (reduced) 0f else rise * (1f - entryProgress.floatValue) }) {
|
||||||
Text(title, color = WolfskinTokens.Color.fg, style = titleStyle, modifier = Modifier.exactLineBox(titleStyle), maxLines = 1, overflow = titleOverflow)
|
Text(title, color = WolfskinTokens.Color.fg, style = titleStyle, modifier = Modifier.exactLineBox(titleStyle), maxLines = 1, overflow = titleOverflow)
|
||||||
CaptionFacts(facts, accentFirstFact, accentLastFact, factsStyle)
|
CaptionFacts(facts, accentFirstFact, accentLastFact, factsStyle)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -8,6 +8,16 @@ import androidx.compose.animation.core.rememberInfiniteTransition
|
||||||
import androidx.compose.animation.core.tween
|
import androidx.compose.animation.core.tween
|
||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
import androidx.compose.runtime.LaunchedEffect
|
import androidx.compose.runtime.LaunchedEffect
|
||||||
|
import androidx.compose.runtime.CompositionLocalProvider
|
||||||
|
import androidx.compose.runtime.DisposableEffect
|
||||||
|
import androidx.compose.runtime.SideEffect
|
||||||
|
import androidx.compose.runtime.Stable
|
||||||
|
import androidx.compose.runtime.State
|
||||||
|
import androidx.compose.runtime.getValue
|
||||||
|
import androidx.compose.runtime.mutableFloatStateOf
|
||||||
|
import androidx.compose.runtime.mutableIntStateOf
|
||||||
|
import androidx.compose.runtime.mutableStateOf
|
||||||
|
import androidx.compose.runtime.setValue
|
||||||
import androidx.compose.runtime.compositionLocalOf
|
import androidx.compose.runtime.compositionLocalOf
|
||||||
import androidx.compose.runtime.remember
|
import androidx.compose.runtime.remember
|
||||||
import androidx.compose.runtime.rememberCoroutineScope
|
import androidx.compose.runtime.rememberCoroutineScope
|
||||||
|
|
@ -28,14 +38,40 @@ fun reducedMotion(): Boolean = LocalReducedMotion.current ||
|
||||||
/** Short fades remain available when movement and continuous animation are disabled. */
|
/** 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
|
fun motionDuration(duration: Int, reduced: Boolean): Int = if (reduced) minOf(duration, M.reducedFade) else duration
|
||||||
|
|
||||||
|
@Stable
|
||||||
|
private class SkeletonPulseClock {
|
||||||
|
var subscribers by mutableIntStateOf(0)
|
||||||
|
val idle: State<Float> = mutableFloatStateOf(M.skeletonLowAlpha)
|
||||||
|
var alpha by mutableStateOf(idle)
|
||||||
|
}
|
||||||
|
|
||||||
|
private val LocalSkeletonPulseClock = compositionLocalOf<SkeletonPulseClock> {
|
||||||
|
error("Skeleton loading requires WolfskinTheme")
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Share phase across shelves, including late arrivals; stop the clock when no shelf needs it. */
|
||||||
|
@Composable
|
||||||
|
internal fun SkeletonPulseScope(content: @Composable () -> Unit) {
|
||||||
|
val clock = remember { SkeletonPulseClock() }
|
||||||
|
val reduced = reducedMotion()
|
||||||
|
val alpha = if (clock.subscribers > 0 && !reduced) {
|
||||||
|
rememberInfiniteTransition(label = "skeleton-screen").animateFloat(M.skeletonLowAlpha, 1f,
|
||||||
|
infiniteRepeatable(tween(M.skeletonCycle / 2, easing = M.breatheEase), RepeatMode.Reverse), label = "breathe")
|
||||||
|
} else clock.idle
|
||||||
|
SideEffect { clock.alpha = alpha }
|
||||||
|
CompositionLocalProvider(LocalSkeletonPulseClock provides clock, content = content)
|
||||||
|
}
|
||||||
|
|
||||||
/** One alpha layer owns all shapes in a shelf; individual placeholders never animate. */
|
/** One alpha layer owns all shapes in a shelf; individual placeholders never animate. */
|
||||||
@Composable
|
@Composable
|
||||||
fun Modifier.skeletonPulse(loading: Boolean = true): Modifier {
|
fun Modifier.skeletonPulse(loading: Boolean = true): Modifier {
|
||||||
if (!loading || reducedMotion()) return this
|
if (!loading || reducedMotion()) return this
|
||||||
val clock = rememberInfiniteTransition(label = "skeleton-shelf")
|
val clock = LocalSkeletonPulseClock.current
|
||||||
val alpha = clock.animateFloat(M.skeletonLowAlpha, 1f,
|
DisposableEffect(clock) {
|
||||||
infiniteRepeatable(tween(M.skeletonCycle / 2, easing = M.breatheEase), RepeatMode.Reverse), label = "breathe")
|
clock.subscribers++
|
||||||
return graphicsLayer { this.alpha = alpha.value }
|
onDispose { clock.subscribers-- }
|
||||||
|
}
|
||||||
|
return graphicsLayer { alpha = clock.alpha.value }
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Content enters on its row layer, never one animation per card or a full-screen transition. */
|
/** Content enters on its row layer, never one animation per card or a full-screen transition. */
|
||||||
|
|
|
||||||
|
|
@ -44,7 +44,7 @@ fun TitleRatings(ratings: List<LibraryRating>, modifier: Modifier = Modifier, st
|
||||||
val numberStyle = remember(textStyle) { textStyle.copy(fontFeatureSettings = "tnum", fontWeight = FontWeight.Bold) }
|
val numberStyle = remember(textStyle) { textStyle.copy(fontFeatureSettings = "tnum", fontWeight = FontWeight.Bold) }
|
||||||
val gap = if (plate) with(density) { (style.fontSize * P.plateGapEm).toDp() } else D.ratingGap
|
val gap = if (plate) with(density) { (style.fontSize * P.plateGapEm).toDp() } else D.ratingGap
|
||||||
val markGap = if (plate) with(density) { (style.fontSize * P.markGapEm).toDp() } else D.ratingMarkGap
|
val markGap = if (plate) with(density) { (style.fontSize * P.markGapEm).toDp() } else D.ratingMarkGap
|
||||||
Row(modifier, horizontalArrangement = Arrangement.spacedBy(gap), verticalAlignment = Alignment.CenterVertically) {
|
Row(if (plate) modifier else modifier.lateLineFade(ratings), horizontalArrangement = Arrangement.spacedBy(gap), verticalAlignment = Alignment.CenterVertically) {
|
||||||
ratings.forEach { rating ->
|
ratings.forEach { rating ->
|
||||||
Row(horizontalArrangement = Arrangement.spacedBy(markGap), verticalAlignment = Alignment.CenterVertically) {
|
Row(horizontalArrangement = Arrangement.spacedBy(markGap), verticalAlignment = Alignment.CenterVertically) {
|
||||||
val resource = when (rating.source) {
|
val resource = when (rating.source) {
|
||||||
|
|
|
||||||
|
|
@ -57,7 +57,7 @@ fun TitleHeader(state: TitleState, active: Boolean, modifier: Modifier = Modifie
|
||||||
if (item.kind == "movie" && item.runtimeSeconds > 0) Text(item.runtime, color = C.muted, style = TitleTextStyles.body, modifier = Modifier.exactLineBox(TitleTextStyles.body))
|
if (item.kind == "movie" && item.runtimeSeconds > 0) Text(item.runtime, color = C.muted, style = TitleTextStyles.body, modifier = Modifier.exactLineBox(TitleTextStyles.body))
|
||||||
if (item.kind == "tv" && item.seasons > 0) Text("${item.seasons} season${if (item.seasons == 1) "" else "s"}", color = C.muted, style = TitleTextStyles.body, modifier = Modifier.exactLineBox(TitleTextStyles.body))
|
if (item.kind == "tv" && item.seasons > 0) Text("${item.seasons} season${if (item.seasons == 1) "" else "s"}", color = C.muted, style = TitleTextStyles.body, modifier = Modifier.exactLineBox(TitleTextStyles.body))
|
||||||
if (state.data.connection != TitleConnection.SignedOut) TitleRatings(item.ratings)
|
if (state.data.connection != TitleConnection.SignedOut) TitleRatings(item.ratings)
|
||||||
if (item.genres.isNotEmpty()) Text(item.genres.take(3).joinToString(", "), color = C.muted, style = TitleTextStyles.body, modifier = Modifier.exactLineBox(TitleTextStyles.body))
|
if (item.genres.isNotEmpty()) Text(item.genres.take(3).joinToString(", "), color = C.muted, style = TitleTextStyles.body, modifier = Modifier.exactLineBox(TitleTextStyles.body).lateLineFade(item.genres))
|
||||||
}
|
}
|
||||||
Box(Modifier.fillMaxWidth().height(if (item.kind == "tv" || state.data.requestedStage != null) WolfskinTokens.Motion.readinessSlot else WolfskinTokens.Shell.zero).testTag("title-readiness-slot")) {
|
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 ->
|
state.data.requestedStage?.let { stage ->
|
||||||
|
|
@ -69,7 +69,7 @@ fun TitleHeader(state: TitleState, active: Boolean, modifier: Modifier = Modifie
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
Text((episode ?: item).overview, color = C.muted, style = TitleTextStyles.body, maxLines = 3, overflow = TextOverflow.Ellipsis, modifier = Modifier.exactLineBox(TitleTextStyles.body, bottom = D.overviewGap).testTag("title-overview"))
|
Text((episode ?: item).overview, color = C.muted, style = TitleTextStyles.body, maxLines = 3, overflow = TextOverflow.Ellipsis, modifier = Modifier.exactLineBox(TitleTextStyles.body, bottom = D.overviewGap).lateLineFade((episode ?: item).overview).testTag("title-overview"))
|
||||||
if (!item.held && state.data.connection != TitleConnection.SignedIn) {
|
if (!item.held && state.data.connection != TitleConnection.SignedIn) {
|
||||||
Text(if (state.data.connection == TitleConnection.SignedOut) "Requests need this TV to be signed in to Conductarr." else "Requests need Conductarr, and it can’t be reached right now.",
|
Text(if (state.data.connection == TitleConnection.SignedOut) "Requests need this TV to be signed in to Conductarr." else "Requests need Conductarr, and it can’t be reached right now.",
|
||||||
color = C.warn, style = TitleTextStyles.body, modifier = Modifier.exactLineBox(TitleTextStyles.body, bottom = D.overviewGap))
|
color = C.warn, style = TitleTextStyles.body, modifier = Modifier.exactLineBox(TitleTextStyles.body, bottom = D.overviewGap))
|
||||||
|
|
|
||||||
|
|
@ -38,6 +38,8 @@ fun WolfskinTheme(content: @Composable () -> Unit) {
|
||||||
val reduced = reducedMotion()
|
val reduced = reducedMotion()
|
||||||
androidx.compose.runtime.SideEffect { NetworkArtTransition.reduced = reduced }
|
androidx.compose.runtime.SideEffect { NetworkArtTransition.reduced = reduced }
|
||||||
androidx.compose.material3.MaterialTheme(colorScheme = WolfskinColors.darkSchemeMaterial) {
|
androidx.compose.material3.MaterialTheme(colorScheme = WolfskinColors.darkSchemeMaterial) {
|
||||||
MaterialTheme(colorScheme = WolfskinColors.darkScheme, content = content)
|
MaterialTheme(colorScheme = WolfskinColors.darkScheme) {
|
||||||
|
SkeletonPulseScope(content)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -6,6 +6,7 @@ import android.os.StrictMode
|
||||||
import androidx.activity.ComponentActivity
|
import androidx.activity.ComponentActivity
|
||||||
import androidx.compose.foundation.background
|
import androidx.compose.foundation.background
|
||||||
import androidx.compose.foundation.layout.Box
|
import androidx.compose.foundation.layout.Box
|
||||||
|
import androidx.compose.foundation.layout.offset
|
||||||
import androidx.compose.foundation.layout.requiredSize
|
import androidx.compose.foundation.layout.requiredSize
|
||||||
import androidx.compose.foundation.layout.size
|
import androidx.compose.foundation.layout.size
|
||||||
import androidx.compose.runtime.CompositionLocalProvider
|
import androidx.compose.runtime.CompositionLocalProvider
|
||||||
|
|
@ -19,6 +20,7 @@ import androidx.compose.ui.input.key.Key
|
||||||
import androidx.compose.ui.platform.testTag
|
import androidx.compose.ui.platform.testTag
|
||||||
import androidx.compose.ui.test.junit4.createAndroidComposeRule
|
import androidx.compose.ui.test.junit4.createAndroidComposeRule
|
||||||
import androidx.compose.ui.test.onNodeWithTag
|
import androidx.compose.ui.test.onNodeWithTag
|
||||||
|
import androidx.compose.ui.test.onNodeWithText
|
||||||
import androidx.compose.ui.test.onRoot
|
import androidx.compose.ui.test.onRoot
|
||||||
import androidx.compose.ui.test.performKeyInput
|
import androidx.compose.ui.test.performKeyInput
|
||||||
import androidx.compose.ui.test.pressKey
|
import androidx.compose.ui.test.pressKey
|
||||||
|
|
@ -114,6 +116,91 @@ class MotionSurfaceTest {
|
||||||
} finally { directory.deleteRecursively() }
|
} finally { directory.deleteRecursively() }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Test fun shelvesJoiningAnActiveLoadShareTheSameBreathingPhase() {
|
||||||
|
rule.mainClock.autoAdvance = false
|
||||||
|
val second = mutableStateOf(false)
|
||||||
|
rule.setContent { WolfskinTheme {
|
||||||
|
Box(Modifier.requiredSize(960.dp, 540.dp).background(WolfskinTokens.Color.bg)) {
|
||||||
|
Box(Modifier.size(200.dp, 140.dp).skeletonPulse().background(WolfskinTokens.Color.surface2))
|
||||||
|
if (second.value) Box(Modifier.offset(y = 200.dp).size(200.dp, 140.dp).skeletonPulse().background(WolfskinTokens.Color.surface2))
|
||||||
|
}
|
||||||
|
} }
|
||||||
|
rule.mainClock.advanceTimeBy(600)
|
||||||
|
update { second.value = true }
|
||||||
|
val directory = kotlin.io.path.createTempDirectory("wolfskin-shared-pulse-").toFile()
|
||||||
|
try {
|
||||||
|
var previous: Int? = null
|
||||||
|
repeat(2) { sample ->
|
||||||
|
val file = File(directory, "$sample.png")
|
||||||
|
rule.onRoot().captureRoboImage(file.absolutePath)
|
||||||
|
val bitmap = BitmapFactory.decodeFile(file.absolutePath)
|
||||||
|
try {
|
||||||
|
val first = bitmap.getPixel(100, 100)
|
||||||
|
assertEquals("Late shelves must join the existing phase", first, bitmap.getPixel(100, 500))
|
||||||
|
if (previous != null) assertNotEquals("The shared clock must still breathe", previous, first)
|
||||||
|
previous = first
|
||||||
|
} finally { bitmap.recycle() }
|
||||||
|
rule.mainClock.advanceTimeBy(450)
|
||||||
|
}
|
||||||
|
} finally { directory.deleteRecursively() }
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test fun captionExitKeepsTranslationFixedAtRestAndDuringInterruptedEntry() {
|
||||||
|
rule.mainClock.autoAdvance = false
|
||||||
|
val title = mutableStateOf("Settled caption")
|
||||||
|
val visible = mutableStateOf(true)
|
||||||
|
rule.setContent { WolfskinTheme { Caption(title.value, "Facts", visible.value, Modifier.offset(40.dp, 80.dp)) } }
|
||||||
|
rule.mainClock.advanceTimeBy(400)
|
||||||
|
fun top() = rule.onNodeWithText(title.value).fetchSemanticsNode().boundsInRoot.top
|
||||||
|
val settled = top()
|
||||||
|
update { visible.value = false }
|
||||||
|
rule.mainClock.advanceTimeBy(48)
|
||||||
|
assertEquals("Exit cannot sink a settled caption", settled, top(), 0.01f)
|
||||||
|
update { title.value = "Interrupted caption"; visible.value = true }
|
||||||
|
rule.mainClock.advanceTimeBy(112)
|
||||||
|
val interrupted = top()
|
||||||
|
assertTrue("Entry must still be partway through its rise", interrupted > settled)
|
||||||
|
update { visible.value = false }
|
||||||
|
val exitTop = top()
|
||||||
|
assertTrue("Exit must retain the unfinished rise, not snap to its baseline", exitTop > settled)
|
||||||
|
rule.mainClock.advanceTimeBy(48)
|
||||||
|
assertEquals("An interrupted entry must fade exactly where it stopped", exitTop, top(), 0.01f)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test fun lateRatingsGenresAndOverviewFadeInsideTheirExistingSlots() {
|
||||||
|
rule.mainClock.autoAdvance = false
|
||||||
|
val series = TitleItem("series", "A series", "tv", held = true)
|
||||||
|
val state = TitleState(TitlePageData(series, connection = TitleConnection.SignedIn), viewer) {}
|
||||||
|
val shell = shell()
|
||||||
|
rule.setContent { WolfskinTheme { TitleSurface(state, shell, Modifier.requiredSize(960.dp, 540.dp)) } }
|
||||||
|
rule.mainClock.advanceTimeBy(500)
|
||||||
|
val actions = rule.onNodeWithTag("title-actions").fetchSemanticsNode().boundsInRoot
|
||||||
|
update { state.update(state.data.copy(item = series.copy(ratings = listOf(LibraryRating("rt", 88.0)), genres = listOf("Drama"), overview = "Late overview"))) }
|
||||||
|
rule.mainClock.advanceTimeBy(16)
|
||||||
|
val regions = listOf("88%", "Drama", "Late overview").map { rule.onNodeWithText(it).fetchSemanticsNode().boundsInRoot }
|
||||||
|
val directory = kotlin.io.path.createTempDirectory("wolfskin-late-fade-").toFile()
|
||||||
|
fun brightness(name: String): List<Long> {
|
||||||
|
val file = File(directory, name)
|
||||||
|
rule.onRoot().captureRoboImage(file.absolutePath)
|
||||||
|
val bitmap = BitmapFactory.decodeFile(file.absolutePath)
|
||||||
|
return try { regions.map { bounds ->
|
||||||
|
var sum = 0L
|
||||||
|
for (y in bounds.top.toInt() until bounds.bottom.toInt()) for (x in bounds.left.toInt() until bounds.right.toInt()) {
|
||||||
|
val pixel = bitmap.getPixel(x, y)
|
||||||
|
sum += android.graphics.Color.red(pixel) + android.graphics.Color.green(pixel) + android.graphics.Color.blue(pixel)
|
||||||
|
}
|
||||||
|
sum
|
||||||
|
} } finally { bitmap.recycle() }
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
val entering = brightness("entering.png")
|
||||||
|
rule.mainClock.advanceTimeBy(240)
|
||||||
|
brightness("settled.png").zip(entering).forEach { (settled, early) -> assertTrue("Late text must fade rather than pop", settled > early) }
|
||||||
|
assertEquals(regions, listOf("88%", "Drama", "Late overview").map { rule.onNodeWithText(it).fetchSemanticsNode().boundsInRoot })
|
||||||
|
assertEquals(actions, rule.onNodeWithTag("title-actions").fetchSemanticsNode().boundsInRoot)
|
||||||
|
} finally { directory.deleteRecursively() }
|
||||||
|
}
|
||||||
|
|
||||||
@Test fun animatingSkeletonDoesNotPerformMainThreadDiskOrNetworkWork() {
|
@Test fun animatingSkeletonDoesNotPerformMainThreadDiskOrNetworkWork() {
|
||||||
val loading = mutableStateOf(false)
|
val loading = mutableStateOf(false)
|
||||||
rule.setContent { WolfskinTheme { Box(Modifier.size(200.dp, 140.dp).skeletonPulse(loading.value).background(WolfskinTokens.Color.surface2)) } }
|
rule.setContent { WolfskinTheme { Box(Modifier.size(200.dp, 140.dp).skeletonPulse(loading.value).background(WolfskinTokens.Color.surface2)) } }
|
||||||
|
|
@ -128,6 +215,11 @@ class MotionSurfaceTest {
|
||||||
finally { rule.runOnIdle { StrictMode.setThreadPolicy(requireNotNull(previous)) } }
|
finally { rule.runOnIdle { StrictMode.setThreadPolicy(requireNotNull(previous)) } }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private fun update(block: () -> Unit) {
|
||||||
|
rule.runOnIdle { block(); androidx.compose.runtime.snapshots.Snapshot.sendApplyNotifications() }
|
||||||
|
rule.mainClock.advanceTimeByFrame()
|
||||||
|
}
|
||||||
|
|
||||||
private fun shell() = ShellState(listOf(viewer), viewer.key).apply { pickerVisible = false; pickerLaunch = false; enterDestination("title/series") }
|
private fun 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 bounds() = listOf("title-overview", "title-actions", "title-shelf:episodes").map { rule.onNodeWithTag(it).fetchSemanticsNode().boundsInRoot }
|
||||||
private fun pixel(file: File): Int {
|
private fun pixel(file: File): Int {
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue