diff --git a/data/tv-spec/spec.md b/data/tv-spec/spec.md index bd84b0bd..3282a8d8 100644 --- a/data/tv-spec/spec.md +++ b/data/tv-spec/spec.md @@ -56,23 +56,23 @@ Every Discover lane, including Movies for you, Series for you, service entries, ## 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 | |---|---|---| | 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 | | 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 | | 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 | -| 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 | 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. @@ -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`. -`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. diff --git a/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/Caption.kt b/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/Caption.kt index 90bab00a..3b2b2f8e 100644 --- a/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/Caption.kt +++ b/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/Caption.kt @@ -5,6 +5,7 @@ import androidx.compose.animation.core.tween import androidx.compose.foundation.layout.Column import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.mutableFloatStateOf import androidx.compose.runtime.remember import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.graphicsLayer @@ -22,13 +23,16 @@ fun Caption(title: String, facts: String, visible: Boolean, modifier: Modifier = titleStyle: TextStyle = WolfskinTokens.Type.button, factsStyle: TextStyle = WolfskinTokens.Type.caption, titleOverflow: TextOverflow = TextOverflow.Ellipsis) { val alpha = remember(title, facts) { Animatable(0f) } + val entryProgress = remember(title, facts) { mutableFloatStateOf(0f) } val reduced = reducedMotion() val rise = with(androidx.compose.ui.platform.LocalDensity.current) { WolfskinTokens.Motion.captionRise.toPx() } LaunchedEffect(alpha, visible, reduced) { if (visible && !reduced) kotlinx.coroutines.delay(WolfskinTokens.Motion.captionDelay.toLong()) - alpha.animateTo(if (visible) 1f else 0f, tween(motionDuration(if (visible) WolfskinTokens.Motion.focusMove else WolfskinTokens.Motion.captionOut, reduced), easing = WolfskinTokens.Motion.easeOut)) + alpha.animateTo(if (visible) 1f else 0f, tween(motionDuration(if (visible) WolfskinTokens.Motion.focusMove else WolfskinTokens.Motion.captionOut, reduced), easing = WolfskinTokens.Motion.easeOut)) { + if (visible) entryProgress.floatValue = value + } } - Column(modifier.graphicsLayer { this.alpha = alpha.value; translationY = if (reduced) 0f else rise * (1f - alpha.value) }) { + Column(modifier.graphicsLayer { this.alpha = alpha.value; translationY = if (reduced) 0f else rise * (1f - entryProgress.floatValue) }) { Text(title, color = WolfskinTokens.Color.fg, style = titleStyle, modifier = Modifier.exactLineBox(titleStyle), maxLines = 1, overflow = titleOverflow) CaptionFacts(facts, accentFirstFact, accentLastFact, factsStyle) } diff --git a/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/Motion.kt b/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/Motion.kt index 72060c61..111966df 100644 --- a/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/Motion.kt +++ b/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/Motion.kt @@ -8,6 +8,16 @@ import androidx.compose.animation.core.rememberInfiniteTransition import androidx.compose.animation.core.tween import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.CompositionLocalProvider +import androidx.compose.runtime.DisposableEffect +import androidx.compose.runtime.SideEffect +import androidx.compose.runtime.Stable +import androidx.compose.runtime.State +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableFloatStateOf +import androidx.compose.runtime.mutableIntStateOf +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.setValue import androidx.compose.runtime.compositionLocalOf import androidx.compose.runtime.remember import androidx.compose.runtime.rememberCoroutineScope @@ -28,14 +38,40 @@ fun reducedMotion(): Boolean = LocalReducedMotion.current || /** Short fades remain available when movement and continuous animation are disabled. */ fun motionDuration(duration: Int, reduced: Boolean): Int = if (reduced) minOf(duration, M.reducedFade) else duration +@Stable +private class SkeletonPulseClock { + var subscribers by mutableIntStateOf(0) + val idle: State = mutableFloatStateOf(M.skeletonLowAlpha) + var alpha by mutableStateOf(idle) +} + +private val LocalSkeletonPulseClock = compositionLocalOf { + error("Skeleton loading requires WolfskinTheme") +} + +/** Share phase across shelves, including late arrivals; stop the clock when no shelf needs it. */ +@Composable +internal fun SkeletonPulseScope(content: @Composable () -> Unit) { + val clock = remember { SkeletonPulseClock() } + val reduced = reducedMotion() + val alpha = if (clock.subscribers > 0 && !reduced) { + rememberInfiniteTransition(label = "skeleton-screen").animateFloat(M.skeletonLowAlpha, 1f, + infiniteRepeatable(tween(M.skeletonCycle / 2, easing = M.breatheEase), RepeatMode.Reverse), label = "breathe") + } else clock.idle + SideEffect { clock.alpha = alpha } + CompositionLocalProvider(LocalSkeletonPulseClock provides clock, content = content) +} + /** One alpha layer owns all shapes in a shelf; individual placeholders never animate. */ @Composable fun Modifier.skeletonPulse(loading: Boolean = true): Modifier { if (!loading || reducedMotion()) return this - val clock = rememberInfiniteTransition(label = "skeleton-shelf") - val alpha = clock.animateFloat(M.skeletonLowAlpha, 1f, - infiniteRepeatable(tween(M.skeletonCycle / 2, easing = M.breatheEase), RepeatMode.Reverse), label = "breathe") - return graphicsLayer { this.alpha = alpha.value } + val clock = LocalSkeletonPulseClock.current + DisposableEffect(clock) { + clock.subscribers++ + onDispose { clock.subscribers-- } + } + return graphicsLayer { alpha = clock.alpha.value } } /** Content enters on its row layer, never one animation per card or a full-screen transition. */ diff --git a/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/SourceRatings.kt b/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/SourceRatings.kt index f07bce40..026d0a14 100644 --- a/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/SourceRatings.kt +++ b/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/SourceRatings.kt @@ -44,7 +44,7 @@ fun TitleRatings(ratings: List, modifier: Modifier = Modifier, st val numberStyle = remember(textStyle) { textStyle.copy(fontFeatureSettings = "tnum", fontWeight = FontWeight.Bold) } val gap = if (plate) with(density) { (style.fontSize * P.plateGapEm).toDp() } else D.ratingGap val markGap = if (plate) with(density) { (style.fontSize * P.markGapEm).toDp() } else D.ratingMarkGap - Row(modifier, horizontalArrangement = Arrangement.spacedBy(gap), verticalAlignment = Alignment.CenterVertically) { + Row(if (plate) modifier else modifier.lateLineFade(ratings), horizontalArrangement = Arrangement.spacedBy(gap), verticalAlignment = Alignment.CenterVertically) { ratings.forEach { rating -> Row(horizontalArrangement = Arrangement.spacedBy(markGap), verticalAlignment = Alignment.CenterVertically) { val resource = when (rating.source) { diff --git a/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/TitleHeader.kt b/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/TitleHeader.kt index aa432d70..5b2a7c47 100644 --- a/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/TitleHeader.kt +++ b/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/TitleHeader.kt @@ -57,7 +57,7 @@ fun TitleHeader(state: TitleState, active: Boolean, modifier: Modifier = Modifie if (item.kind == "movie" && item.runtimeSeconds > 0) Text(item.runtime, color = C.muted, style = TitleTextStyles.body, modifier = Modifier.exactLineBox(TitleTextStyles.body)) if (item.kind == "tv" && item.seasons > 0) Text("${item.seasons} season${if (item.seasons == 1) "" else "s"}", color = C.muted, style = TitleTextStyles.body, modifier = Modifier.exactLineBox(TitleTextStyles.body)) if (state.data.connection != TitleConnection.SignedOut) TitleRatings(item.ratings) - if (item.genres.isNotEmpty()) Text(item.genres.take(3).joinToString(", "), color = C.muted, style = TitleTextStyles.body, modifier = Modifier.exactLineBox(TitleTextStyles.body)) + if (item.genres.isNotEmpty()) Text(item.genres.take(3).joinToString(", "), color = C.muted, style = TitleTextStyles.body, modifier = Modifier.exactLineBox(TitleTextStyles.body).lateLineFade(item.genres)) } Box(Modifier.fillMaxWidth().height(if (item.kind == "tv" || state.data.requestedStage != null) WolfskinTokens.Motion.readinessSlot else WolfskinTokens.Shell.zero).testTag("title-readiness-slot")) { state.data.requestedStage?.let { stage -> @@ -69,7 +69,7 @@ fun TitleHeader(state: TitleState, active: Boolean, modifier: Modifier = Modifie } } } - Text((episode ?: item).overview, color = C.muted, style = TitleTextStyles.body, maxLines = 3, overflow = TextOverflow.Ellipsis, modifier = Modifier.exactLineBox(TitleTextStyles.body, bottom = D.overviewGap).testTag("title-overview")) + Text((episode ?: item).overview, color = C.muted, style = TitleTextStyles.body, maxLines = 3, overflow = TextOverflow.Ellipsis, modifier = Modifier.exactLineBox(TitleTextStyles.body, bottom = D.overviewGap).lateLineFade((episode ?: item).overview).testTag("title-overview")) if (!item.held && state.data.connection != TitleConnection.SignedIn) { Text(if (state.data.connection == TitleConnection.SignedOut) "Requests need this TV to be signed in to Conductarr." else "Requests need Conductarr, and it can’t be reached right now.", color = C.warn, style = TitleTextStyles.body, modifier = Modifier.exactLineBox(TitleTextStyles.body, bottom = D.overviewGap)) diff --git a/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/WolfskinTheme.kt b/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/WolfskinTheme.kt index d72f80f8..5a1fbe37 100644 --- a/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/WolfskinTheme.kt +++ b/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/WolfskinTheme.kt @@ -38,6 +38,8 @@ fun WolfskinTheme(content: @Composable () -> Unit) { val reduced = reducedMotion() androidx.compose.runtime.SideEffect { NetworkArtTransition.reduced = reduced } androidx.compose.material3.MaterialTheme(colorScheme = WolfskinColors.darkSchemeMaterial) { - MaterialTheme(colorScheme = WolfskinColors.darkScheme, content = content) + MaterialTheme(colorScheme = WolfskinColors.darkScheme) { + SkeletonPulseScope(content) + } } } diff --git a/wolfskin-design/src/test/java/org/justinvisser/wolfskin/design/MotionSurfaceTest.kt b/wolfskin-design/src/test/java/org/justinvisser/wolfskin/design/MotionSurfaceTest.kt index 7c23a5b3..12d6ddad 100644 --- a/wolfskin-design/src/test/java/org/justinvisser/wolfskin/design/MotionSurfaceTest.kt +++ b/wolfskin-design/src/test/java/org/justinvisser/wolfskin/design/MotionSurfaceTest.kt @@ -6,6 +6,7 @@ import android.os.StrictMode import androidx.activity.ComponentActivity import androidx.compose.foundation.background import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.offset import androidx.compose.foundation.layout.requiredSize import androidx.compose.foundation.layout.size import androidx.compose.runtime.CompositionLocalProvider @@ -19,6 +20,7 @@ import androidx.compose.ui.input.key.Key import androidx.compose.ui.platform.testTag import androidx.compose.ui.test.junit4.createAndroidComposeRule import androidx.compose.ui.test.onNodeWithTag +import androidx.compose.ui.test.onNodeWithText import androidx.compose.ui.test.onRoot import androidx.compose.ui.test.performKeyInput import androidx.compose.ui.test.pressKey @@ -114,6 +116,91 @@ class MotionSurfaceTest { } finally { directory.deleteRecursively() } } + @Test fun shelvesJoiningAnActiveLoadShareTheSameBreathingPhase() { + rule.mainClock.autoAdvance = false + val second = mutableStateOf(false) + rule.setContent { WolfskinTheme { + Box(Modifier.requiredSize(960.dp, 540.dp).background(WolfskinTokens.Color.bg)) { + Box(Modifier.size(200.dp, 140.dp).skeletonPulse().background(WolfskinTokens.Color.surface2)) + if (second.value) Box(Modifier.offset(y = 200.dp).size(200.dp, 140.dp).skeletonPulse().background(WolfskinTokens.Color.surface2)) + } + } } + rule.mainClock.advanceTimeBy(600) + update { second.value = true } + val directory = kotlin.io.path.createTempDirectory("wolfskin-shared-pulse-").toFile() + try { + var previous: Int? = null + repeat(2) { sample -> + val file = File(directory, "$sample.png") + rule.onRoot().captureRoboImage(file.absolutePath) + val bitmap = BitmapFactory.decodeFile(file.absolutePath) + try { + val first = bitmap.getPixel(100, 100) + assertEquals("Late shelves must join the existing phase", first, bitmap.getPixel(100, 500)) + if (previous != null) assertNotEquals("The shared clock must still breathe", previous, first) + previous = first + } finally { bitmap.recycle() } + rule.mainClock.advanceTimeBy(450) + } + } finally { directory.deleteRecursively() } + } + + @Test fun captionExitKeepsTranslationFixedAtRestAndDuringInterruptedEntry() { + rule.mainClock.autoAdvance = false + val title = mutableStateOf("Settled caption") + val visible = mutableStateOf(true) + rule.setContent { WolfskinTheme { Caption(title.value, "Facts", visible.value, Modifier.offset(40.dp, 80.dp)) } } + rule.mainClock.advanceTimeBy(400) + fun top() = rule.onNodeWithText(title.value).fetchSemanticsNode().boundsInRoot.top + val settled = top() + update { visible.value = false } + rule.mainClock.advanceTimeBy(48) + assertEquals("Exit cannot sink a settled caption", settled, top(), 0.01f) + update { title.value = "Interrupted caption"; visible.value = true } + rule.mainClock.advanceTimeBy(112) + val interrupted = top() + assertTrue("Entry must still be partway through its rise", interrupted > settled) + update { visible.value = false } + val exitTop = top() + assertTrue("Exit must retain the unfinished rise, not snap to its baseline", exitTop > settled) + rule.mainClock.advanceTimeBy(48) + assertEquals("An interrupted entry must fade exactly where it stopped", exitTop, top(), 0.01f) + } + + @Test fun lateRatingsGenresAndOverviewFadeInsideTheirExistingSlots() { + rule.mainClock.autoAdvance = false + val series = TitleItem("series", "A series", "tv", held = true) + val state = TitleState(TitlePageData(series, connection = TitleConnection.SignedIn), viewer) {} + val shell = shell() + rule.setContent { WolfskinTheme { TitleSurface(state, shell, Modifier.requiredSize(960.dp, 540.dp)) } } + rule.mainClock.advanceTimeBy(500) + val actions = rule.onNodeWithTag("title-actions").fetchSemanticsNode().boundsInRoot + update { state.update(state.data.copy(item = series.copy(ratings = listOf(LibraryRating("rt", 88.0)), genres = listOf("Drama"), overview = "Late overview"))) } + rule.mainClock.advanceTimeBy(16) + val regions = listOf("88%", "Drama", "Late overview").map { rule.onNodeWithText(it).fetchSemanticsNode().boundsInRoot } + val directory = kotlin.io.path.createTempDirectory("wolfskin-late-fade-").toFile() + fun brightness(name: String): List { + val file = File(directory, name) + rule.onRoot().captureRoboImage(file.absolutePath) + val bitmap = BitmapFactory.decodeFile(file.absolutePath) + return try { regions.map { bounds -> + var sum = 0L + for (y in bounds.top.toInt() until bounds.bottom.toInt()) for (x in bounds.left.toInt() until bounds.right.toInt()) { + val pixel = bitmap.getPixel(x, y) + sum += android.graphics.Color.red(pixel) + android.graphics.Color.green(pixel) + android.graphics.Color.blue(pixel) + } + sum + } } finally { bitmap.recycle() } + } + try { + val entering = brightness("entering.png") + rule.mainClock.advanceTimeBy(240) + brightness("settled.png").zip(entering).forEach { (settled, early) -> assertTrue("Late text must fade rather than pop", settled > early) } + assertEquals(regions, listOf("88%", "Drama", "Late overview").map { rule.onNodeWithText(it).fetchSemanticsNode().boundsInRoot }) + assertEquals(actions, rule.onNodeWithTag("title-actions").fetchSemanticsNode().boundsInRoot) + } finally { directory.deleteRecursively() } + } + @Test fun animatingSkeletonDoesNotPerformMainThreadDiskOrNetworkWork() { val loading = mutableStateOf(false) rule.setContent { WolfskinTheme { Box(Modifier.size(200.dp, 140.dp).skeletonPulse(loading.value).background(WolfskinTokens.Color.surface2)) } } @@ -128,6 +215,11 @@ class MotionSurfaceTest { finally { rule.runOnIdle { StrictMode.setThreadPolicy(requireNotNull(previous)) } } } + private fun update(block: () -> Unit) { + rule.runOnIdle { block(); androidx.compose.runtime.snapshots.Snapshot.sendApplyNotifications() } + rule.mainClock.advanceTimeByFrame() + } + private fun shell() = ShellState(listOf(viewer), viewer.key).apply { pickerVisible = false; pickerLaunch = false; enterDestination("title/series") } private fun bounds() = listOf("title-overview", "title-actions", "title-shelf:episodes").map { rule.onNodeWithTag(it).fetchSemanticsNode().boundsInRoot } private fun pixel(file: File): Int {