Smooth Discover hero handoffs and scope lane focus to each visit

This commit is contained in:
justinzeus 2026-11-07 15:56:19 +01:00
parent c2f7ccc438
commit 12e908389f
14 changed files with 801 additions and 87 deletions

View file

@ -0,0 +1,114 @@
package org.justinvisser.wolfskin.tv
import android.app.Application
import android.graphics.Bitmap
import android.graphics.BitmapFactory
import android.view.View
import android.view.WindowManager
import androidx.activity.ComponentActivity
import androidx.compose.foundation.layout.requiredSize
import androidx.compose.runtime.CompositionLocalProvider
import androidx.compose.ui.Modifier
import androidx.compose.ui.geometry.Rect
import androidx.compose.ui.graphics.asImageBitmap
import androidx.compose.ui.test.junit4.createAndroidComposeRule
import androidx.compose.ui.test.onNodeWithTag
import androidx.compose.ui.test.onRoot
import androidx.lifecycle.viewModelScope
import com.github.damontecres.wholphin.preferences.AppPreferences
import com.github.damontecres.wholphin.preferences.UserPreferences
import com.github.damontecres.wholphin.services.NavigationManager
import com.github.takahirom.roborazzi.captureRoboImage
import io.mockk.coEvery
import io.mockk.mockk
import kotlinx.coroutines.CompletableDeferred
import kotlinx.coroutines.cancel
import kotlinx.coroutines.runBlocking
import org.junit.Assert.*
import org.junit.Rule
import org.junit.Test
import org.junit.runner.RunWith
import org.justinvisser.wolfskin.conductarr.MemoryCacheStore
import org.justinvisser.wolfskin.design.*
import org.justinvisser.wolfskin.tv.read.TitleReadFacade
import org.robolectric.RobolectricTestRunner
import org.robolectric.Shadows
import org.robolectric.annotation.Config
import org.robolectric.annotation.GraphicsMode
import java.io.File
/** Refreshing a loaded Discover title must preserve its real header instead of rebuilding a skeleton. */
@RunWith(RobolectricTestRunner::class)
@Config(sdk = [34], application = Application::class, qualifiers = "w960dp-h540dp-xhdpi")
@GraphicsMode(GraphicsMode.Mode.NATIVE)
class DiscoverRefreshMotionTest {
@get:Rule val rule = createAndroidComposeRule<ComponentActivity>()
@Test fun loadedCatalogueRefreshKeepsReadyActionsOpaqueAndStationary() {
rule.activity.window.setFlags(WindowManager.LayoutParams.FLAG_FULLSCREEN, WindowManager.LayoutParams.FLAG_FULLSCREEN)
@Suppress("DEPRECATION")
rule.activity.window.decorView.systemUiVisibility = View.SYSTEM_UI_FLAG_FULLSCREEN or View.SYSTEM_UI_FLAG_HIDE_NAVIGATION or View.SYSTEM_UI_FLAG_IMMERSIVE_STICKY
rule.mainClock.autoAdvance = false
FakeConductarr().use { fake ->
ConductarrSession(MemoryCacheStore()).use { session ->
session.select("server:shared", SHARED_TOKEN, fake.url)
runBlocking { session.authenticate() }
val facade = mockk<TitleReadFacade>()
coEvery { facade.findHeld(any(), any()) } returns null
val model = WolfskinTitleViewModel(rule.activity, facade, mockk(), NavigationManager(), session, mockk(relaxed = true))
val person = ShellPerson("shared", "Shared", "S", false)
val route = titleRoute("tv:44")
val shell = ShellState(listOf(person), person.key).apply { pickerVisible = false; enterDestination("discover"); enterDestination(route) }
val preferences = UserPreferences(AppPreferences.getDefaultInstance(), null)
val art = Bitmap.createBitmap(8, 8, Bitmap.Config.ARGB_8888).apply { eraseColor(android.graphics.Color.DKGRAY) }.asImageBitmap()
val refreshLookup = CompletableDeferred<java.util.UUID?>()
try {
rule.setContent { WolfskinTheme {
model.title?.let { state ->
val bitmaps = listOfNotNull(state.data.item.poster, state.data.item.backdrop).associateWith { art }
CompositionLocalProvider(LocalTitleBitmaps provides bitmaps) {
TitleSurface(state, shell, Modifier.requiredSize(WolfskinTokens.Shell.stageWidth, WolfskinTokens.Shell.stageHeight))
}
}
} }
rule.runOnIdle { model.load(route, person, preferences, fixtures = false) }
rule.waitUntil(30_000) {
Shadows.shadowOf(android.os.Looper.getMainLooper()).idle()
model.title?.data?.status == TitleReadStatus.Ready && model.title?.data?.cast?.isNotEmpty() == true && model.title?.data?.related?.isNotEmpty() == true
}
rule.mainClock.advanceTimeBy(640)
val directory = File(System.getProperty("wolfskin.captureDirectory"), "discover-refresh").apply { mkdirs() }
val bounds = rule.onNodeWithTag("title-actions").fetchSemanticsNode().boundsInRoot
val pixels = IntArray(bounds.width.toInt() * bounds.height.toInt())
val baseline = brightness(File(directory, "loaded.png"), bounds, pixels)
coEvery { facade.findHeld(any(), any()) } coAnswers { refreshLookup.await() }
rule.runOnIdle {
model.load(route, person, preferences, fixtures = false, force = true)
assertEquals("An already-loaded catalogue title must never emit Loading on refresh", TitleReadStatus.Ready, model.title!!.data.status)
}
repeat(12) { index ->
rule.mainClock.advanceTimeBy(16)
assertEquals(TitleReadStatus.Ready, model.title!!.data.status)
assertFalse("A refresh cannot focus the root navigation", shell.navFocused)
assertEquals("Loaded actions cannot replay their rise", bounds, rule.onNodeWithTag("title-actions").fetchSemanticsNode().boundsInRoot)
assertEquals("Loaded actions cannot replay their fade", baseline, brightness(File(directory, "refresh-${index.toString().padStart(2, '0')}.png"), bounds, pixels))
}
} finally {
model.viewModelScope.cancel()
refreshLookup.complete(null)
}
}
}
}
private fun brightness(file: File, bounds: Rect, pixels: IntArray): Long {
rule.onRoot().captureRoboImage(file.absolutePath)
val bitmap = BitmapFactory.decodeFile(file.absolutePath)
return try {
val width = bounds.width.toInt()
val height = bounds.height.toInt()
bitmap.getPixels(pixels, 0, width, bounds.left.toInt(), bounds.top.toInt(), width, height)
pixels.sumOf { (android.graphics.Color.red(it) + android.graphics.Color.green(it) + android.graphics.Color.blue(it)).toLong() }
} finally { bitmap.recycle() }
}
}

View file

@ -54,6 +54,8 @@ Read-only source measurement: Desperate Housewives has a 2732×1536 Backdrop. Wi
Every Discover lane, including Movies for you, Series for you, service entries, service-specific lanes and Hidden picks, uses a **104 px heading band**: heading line plus **60 px heading-to-card gap**, up from 40 px. This is a consistent Level 2 extension, not a Series-only exception. Poster shelves become 652 px high and service-entry shelves 316 px. Captions and frame scroll offsets use the same heading band. Home headings remain unchanged. Every Discover lane, including Movies for you, Series for you, service entries, service-specific lanes and Hidden picks, uses a **104 px heading band**: heading line plus **60 px heading-to-card gap**, up from 40 px. This is a consistent Level 2 extension, not a Series-only exception. Poster shelves become 652 px high and service-entry shelves 316 px. Captions and frame scroll offsets use the same heading band. Home headings remain unchanged.
Round-2 Discover navigation amendment: Up/Down into an untouched lane starts at its leftmost item, not the previous lane's nearest column. A moved lane restores its own selected item and horizontal track for this visit. Pushed titles and service/collection pages preserve that visit; selecting another root tab or rebuilding Discover clears its lane positions. Root vertical position, filters and hero selection retain their existing shell policy. Home's nearest-centre shelves and Library's column-preserving continuous grids are unchanged pending a separate behavior decision.
## 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. 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. 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.
@ -64,15 +66,16 @@ Home heroes and shelves, Library rows, Discover hero/lanes, title headers, episo
| 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 | | 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 title copy | 180 ms, ease-out | 16 px rise; already-loaded Home/Discover hero copy paints immediately |
| 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 | Incoming and outgoing alpha counter-fade over 200 ms ease-out, after artwork or final fallback is ready | 14 px rise for incoming copy only; interrupted outgoing copy holds its current translation |
| 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 | | 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 |
| Discover pager | 180 ms, ease-out, retargeting current width and opacity | Buttons and pager remain outside the copy transition; reduced motion snaps width and caps opacity fades at 80 ms |
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 with stationary action controls. Ordinary image reveal uses Coil's painter; hero-controlled requests bypass that independent fade so only the token-backed hero blend owns the transition. 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 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 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. An ordinary hero hand-off animates **3** bounded owners (outgoing art and two copy blocks) above an opaque artwork base. Interruptions retain the painted composite without resetting alpha; each outgoing owner keeps its own original 320 ms artwork / 200 ms copy lifetime rather than restarting with later presses. Focus changes can animate **2** card transforms, **1** caption and at most **2** rating plates; ordinary 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.
@ -82,6 +85,8 @@ Discover key traversal skips pending lanes in both directions. If every remainin
Native **1920×1080** before/after frames and the frame-budget inventory are published at [the motion build proof](https://pages.justinvisser.org/wolfskin/tv-motion-build/). Before frames use the pre-change surface sources in this same isolated worktree; after frames use the shipped Compose surfaces. No device access, installation or optimized APK is part of this lane. On-box acceptance remains: no series layout change after first content, Home scrolling no worse than **39.6% janky / p90 65 ms**. Native JVM rendering does not measure that GPU/frame-timing target. Native **1920×1080** before/after frames and the frame-budget inventory are published at [the motion build proof](https://pages.justinvisser.org/wolfskin/tv-motion-build/). Before frames use the pre-change surface sources in this same isolated worktree; after frames use the shipped Compose surfaces. No device access, installation or optimized APK is part of this lane. On-box acceptance remains: no series layout change after first content, Home scrolling no worse than **39.6% janky / p90 65 ms**. Native JVM rendering does not measure that GPU/frame-timing target.
The [round-2 Discover native proof](https://pages.justinvisser.org/wolfskin/tv-round2-discover/) publishes paired 1920×1080 frame strips and exact 16 ms alpha/translation/position series for ready, slow, failed, sustained rapid-selection and reduced-motion scenarios. Both versions use identical fixture data, immediate key-down/up, and the same selection timestamps. The regression suite checks an opaque artwork base, visible copy composite, monotonic per-layer transitions, stationary controls and token-bounded outgoing lifetimes. These are native-rendering proofs, not on-box frame-pacing measurements.
## Card metadata and bottom state line — revision 6, option B ## Card metadata and bottom state line — revision 6, option B

View file

@ -33,6 +33,8 @@ import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.platform.testTag import androidx.compose.ui.platform.testTag
import coil3.compose.AsyncImage import coil3.compose.AsyncImage
import org.justinvisser.wolfskin.design.WolfskinTokens.Color as C import org.justinvisser.wolfskin.design.WolfskinTokens.Color as C
import coil3.request.transitionFactory
import coil3.transition.Transition
import org.justinvisser.wolfskin.design.WolfskinTokens.Discover as D import org.justinvisser.wolfskin.design.WolfskinTokens.Discover as D
import org.justinvisser.wolfskin.design.WolfskinTokens.Layout as L import org.justinvisser.wolfskin.design.WolfskinTokens.Layout as L
@ -50,13 +52,14 @@ fun DiscoverArtwork(path: String?, modifier: Modifier, poster: Boolean = false,
this *= ColorMatrix().apply { setToScale(D.ambientBrightness, D.ambientBrightness, D.ambientBrightness, 1f) } this *= ColorMatrix().apply { setToScale(D.ambientBrightness, D.ambientBrightness, D.ambientBrightness, 1f) }
}) else null } }) else null }
val context = LocalContext.current val context = LocalContext.current
val request = remember(context, path, poster, dim) { val heroReady = LocalHeroArtReady.current
val request = remember(context, path, poster, dim, heroReady != null) {
val upright = poster || dim val upright = poster || dim
WolfskinImages.request(context, path, WolfskinImages.request(context, path,
if (upright) WolfskinTokens.Image.heroPosterRequestWidth else WolfskinTokens.Image.heroRequestWidth, if (upright) WolfskinTokens.Image.heroPosterRequestWidth else WolfskinTokens.Image.heroRequestWidth,
if (upright) WolfskinTokens.Image.heroPosterRequestHeight else WolfskinTokens.Image.heroRequestHeight, upright) if (upright) WolfskinTokens.Image.heroPosterRequestHeight else WolfskinTokens.Image.heroRequestHeight, upright)
.let { if (heroReady != null) it.newBuilder().transitionFactory(Transition.Factory.NONE).build() else it }
} }
val heroReady = LocalHeroArtReady.current
if (painter != null) { if (painter != null) {
androidx.compose.runtime.SideEffect { heroReady?.invoke() } androidx.compose.runtime.SideEffect { heroReady?.invoke() }
Image(painter, null, modifier, contentScale = ContentScale.Crop, colorFilter = filter) Image(painter, null, modifier, contentScale = ContentScale.Crop, colorFilter = filter)

View file

@ -1,6 +1,8 @@
// SPDX-License-Identifier: GPL-2.0-only // SPDX-License-Identifier: GPL-2.0-only
package org.justinvisser.wolfskin.design package org.justinvisser.wolfskin.design
import androidx.compose.animation.core.Animatable
import androidx.compose.animation.core.tween
import kotlinx.coroutines.coroutineScope import kotlinx.coroutines.coroutineScope
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
@ -47,7 +49,10 @@ fun DiscoverScreen(state: DiscoverState, modifier: Modifier = Modifier) {
val focus = remember(state) { object : ShellContentFocus by state {} } val focus = remember(state) { object : ShellContentFocus by state {} }
DisposableEffect(focus) { DisposableEffect(focus) {
shell.contentFocus = focus shell.contentFocus = focus
onDispose { if (shell.contentFocus === focus) { shell.contentFocus = null; shell.contentOverlayVisible = false } } onDispose {
if (shell.tabs.getOrNull(shell.currentTab) != "discover") state.resetLaneMemory()
if (shell.contentFocus === focus) { shell.contentFocus = null; shell.contentOverlayVisible = false }
}
} }
LaunchedEffect(shell.route, state.snapshot.savedAt) { shell.view.savedAt = state.snapshot.savedAt } LaunchedEffect(shell.route, state.snapshot.savedAt) { shell.view.savedAt = state.snapshot.savedAt }
LaunchedEffect(state.toast) { if (state.toast != null) { kotlinx.coroutines.delay(WolfskinTokens.Motion.toast.toLong()); state.toast = null } } LaunchedEffect(state.toast) { if (state.toast != null) { kotlinx.coroutines.delay(WolfskinTokens.Motion.toast.toLong()); state.toast = null } }
@ -103,6 +108,7 @@ fun DiscoverScreen(state: DiscoverState, modifier: Modifier = Modifier) {
animateFrameScroll(list, target - with(density) { current.toPx() } - list.firstVisibleItemScrollOffset) animateFrameScroll(list, target - with(density) { current.toPx() } - list.firstVisibleItemScrollOffset)
} }
} }
HeroMotionScope {
Box(modifier.fillMaxSize().testTag("discover-screen")) { Box(modifier.fillMaxSize().testTag("discover-screen")) {
if (state.hasHero && state.service == null && list.firstVisibleItemIndex == 0) state.hero?.let { title -> 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) } HeroArtCrossfade(title.key, title, L.row_phero, Modifier.offset { IntOffset(0, -list.firstVisibleItemScrollOffset) }) { DiscoverBackdrop(it) }
@ -129,13 +135,15 @@ fun DiscoverScreen(state: DiscoverState, modifier: Modifier = Modifier) {
} }
} }
} }
}
} }
@Composable @Composable
private fun DiscoverHero(state: DiscoverState) { private fun DiscoverHero(state: DiscoverState) {
val title = state.hero ?: return val title = state.hero ?: return
HeroCopyHandoff(title.key, title) { copy -> Column(Modifier.fillMaxWidth().height(L.row_phero).padding(start = L.safeSide, bottom = L.heroCopyBottom), verticalArrangement = Arrangement.Bottom) {
Column(Modifier.fillMaxWidth().height(L.row_phero).padding(start = L.safeSide, bottom = L.heroCopyBottom), verticalArrangement = Arrangement.Bottom) { HeroCopyHandoff(title.key, title) { copy ->
Column {
Row(horizontalArrangement = Arrangement.spacedBy(WolfskinTokens.Card.posterGap), verticalAlignment = Alignment.CenterVertically) { Row(horizontalArrangement = Arrangement.spacedBy(WolfskinTokens.Card.posterGap), verticalAlignment = Alignment.CenterVertically) {
Glyph("seerr", C.accentText, Modifier.size(L.buttonIcon)) 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) Text(if (state.snapshot.status == DiscoverStatus.Ready) "For you" else "Trending", color = C.accentText, style = DiscoverType.label)
@ -151,17 +159,32 @@ private fun DiscoverHero(state: DiscoverState) {
} }
Spacer(Modifier.height(D.heroMetaGap)) Spacer(Modifier.height(D.heroMetaGap))
Text(copy.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)) Spacer(Modifier.height(D.heroOverviewGap))
Row(verticalAlignment = Alignment.CenterVertically) { Row(verticalAlignment = Alignment.CenterVertically) {
DiscoverButton("Details", state.contentFocused && state.focusedRow == 0, "about") { state.openTitle(title) } DiscoverButton("Details", state.contentFocused && state.focusedRow == 0, "about") { state.openTitle(title) }
Row(Modifier.padding(start = D.dotMargin), horizontalArrangement = Arrangement.spacedBy(D.dotGap), verticalAlignment = Alignment.CenterVertically) { Row(Modifier.padding(start = D.dotMargin), horizontalArrangement = Arrangement.spacedBy(D.dotGap), verticalAlignment = Alignment.CenterVertically) {
Glyph("left", C.fg.copy(alpha = if (state.contentFocused && state.focusedRow == 0) 1f else 0f), Modifier.size(D.dotChevron)) Glyph("left", C.fg.copy(alpha = if (state.contentFocused && state.focusedRow == 0) 1f else 0f), Modifier.size(D.dotChevron))
state.heroes.forEachIndexed { index, _ -> Box(Modifier.size(if (index == state.slide) D.dotActive else D.dotSize, D.dotSize).background(if (index == state.slide) C.fg else C.fg.copy(alpha = .3f), CircleShape)) } state.heroes.forEachIndexed { index, _ -> DiscoverHeroDot(index, index == state.slide) }
Glyph("right", C.fg.copy(alpha = if (state.contentFocused && state.focusedRow == 0) 1f else 0f), Modifier.size(D.dotChevron)) Glyph("right", C.fg.copy(alpha = if (state.contentFocused && state.focusedRow == 0) 1f else 0f), Modifier.size(D.dotChevron))
} }
} }
} }
}
/** Pager geometry retargets from its current value independently of artwork loading. */
@Composable
private fun DiscoverHeroDot(index: Int, selected: Boolean) {
val progress = remember { Animatable(if (selected) 1f else 0f) }
val reduced = reducedMotion()
LaunchedEffect(selected, reduced) {
progress.animateTo(if (selected) 1f else 0f,
tween(motionDuration(WolfskinTokens.Motion.focusMove, reduced), easing = WolfskinTokens.Motion.easeOut))
} }
val widthProgress = if (reduced) { if (selected) 1f else 0f } else progress.value
Box(Modifier.testTag("hero-dot-$index").size(D.dotSize + (D.dotActive - D.dotSize) * widthProgress, D.dotSize)
.background(C.fg.copy(alpha = .3f + .7f * progress.value), CircleShape))
} }
@Composable @Composable

View file

@ -40,14 +40,14 @@ fun DiscoverShelf(state: DiscoverState, lane: DiscoverLane, row: Int) {
val height = if (services) K.entryH else K.posterH val height = if (services) K.entryH else K.posterH
val cards = rememberLazyListState() val cards = rememberLazyListState()
val count = if (services) state.snapshot.services.size else lane.titles.size + if (lane.tail == DiscoverTail.None) 0 else 1 val count = if (services) state.snapshot.services.size else lane.titles.size + if (lane.tail == DiscoverTail.None) 0 else 1
val column = (state.shell.view.cards[lane.id] ?: 0).coerceIn(0, (count - 1).coerceAtLeast(0)) val column = state.laneColumn(lane.id).coerceIn(0, (count - 1).coerceAtLeast(0))
val active = state.contentFocused && state.focusedRow == row val active = state.contentFocused && state.focusedRow == row
val slots = K.posterSlots val slots = K.posterSlots
val first = state.shell.view.cards["first:${lane.id}"] ?: 0 val first = state.laneFirsts[lane.id] ?: 0
val density = LocalDensity.current val density = LocalDensity.current
LaunchedEffect(column, count, active) { LaunchedEffect(state.service?.id, column, count, active) {
val target = shelfFirst(column, first, slots, count) val target = shelfFirst(column, first, slots, count)
state.shell.view.cards["first:${lane.id}"] = target state.laneFirsts[lane.id] = target
cards.scrollShelfTo(target, with(density) { K.posterPitch.toPx() }) cards.scrollShelfTo(target, with(density) { K.posterPitch.toPx() })
} }
Box(Modifier.fillMaxWidth().height(D.shelfHeadingBand + height + (if (services) WolfskinTokens.Shell.zero else K.posterCaptionBand) + L.rowGapAfter).testTag("discover-lane-${lane.id}")) { Box(Modifier.fillMaxWidth().height(D.shelfHeadingBand + height + (if (services) WolfskinTokens.Shell.zero else K.posterCaptionBand) + L.rowGapAfter).testTag("discover-lane-${lane.id}")) {

View file

@ -10,7 +10,7 @@ import kotlinx.coroutines.launch
import kotlinx.coroutines.sync.Mutex import kotlinx.coroutines.sync.Mutex
import kotlinx.coroutines.sync.withLock import kotlinx.coroutines.sync.withLock
/** One person owns a Discover view; the shell retains its root list and card memory. */ /** Each person retains the root viewport; lane movement belongs only to this Discover visit. */
class DiscoverState( class DiscoverState(
val shell: ShellState, val shell: ShellState,
initial: DiscoverSnapshot, initial: DiscoverSnapshot,
@ -75,6 +75,20 @@ class DiscoverState(
private val pendingDismissals = mutableMapOf<String, DiscoverTitle?>() private val pendingDismissals = mutableMapOf<String, DiscoverTitle?>()
private var rootFilter = "all" private var rootFilter = "all"
private val rootLaneMemory = DiscoverLaneMemory()
private val serviceLaneMemory = mutableMapOf<Long, DiscoverLaneMemory>()
private val laneMemory get() = service?.let { serviceLaneMemory.getValue(it.id) } ?: rootLaneMemory
private val laneColumns get() = laneMemory.columns
internal val laneFirsts get() = laneMemory.firsts
internal fun laneColumn(id: String): Int = laneColumns[id] ?: 0
/** Pushed titles preserve this visit; selecting another root tab discards lane movement. */
internal fun resetLaneMemory() {
rootLaneMemory.clear()
serviceLaneMemory.values.forEach { it.clear() }
}
fun updateSnapshot(value: DiscoverSnapshot) { fun updateSnapshot(value: DiscoverSnapshot) {
snapshot = value snapshot = value
if (value.savedAt == null && value.status in setOf(DiscoverStatus.Ready, DiscoverStatus.NoSeeds, DiscoverStatus.PicksUnavailable)) savedTime = formatSavedTime() if (value.savedAt == null && value.status in setOf(DiscoverStatus.Ready, DiscoverStatus.NoSeeds, DiscoverStatus.PicksUnavailable)) savedTime = formatSavedTime()
@ -139,7 +153,7 @@ class DiscoverState(
val rowCount: Int get() = firstShelf + lanes.size val rowCount: Int get() = firstShelf + lanes.size
val focusedRow: Int get() = shell.view.row.coerceIn(0, (rowCount - 1).coerceAtLeast(0)) val focusedRow: Int get() = shell.view.row.coerceIn(0, (rowCount - 1).coerceAtLeast(0))
val focusedLane: DiscoverLane? get() = lanes.getOrNull(focusedRow - firstShelf) val focusedLane: DiscoverLane? get() = lanes.getOrNull(focusedRow - firstShelf)
val column: Int get() = shell.view.cards[rowKey].orZero().coerceAtMost(maxColumn()) val column: Int get() = (if (focusedRow >= firstShelf) laneColumn(rowKey) else shell.view.cards[rowKey].orZero()).coerceAtMost(maxColumn())
val rowKey: String get() = when (focusedRow) { 0 -> "phero"; 1 -> "dchips"; else -> focusedLane?.id.orEmpty() } val rowKey: String get() = when (focusedRow) { 0 -> "phero"; 1 -> "dchips"; else -> focusedLane?.id.orEmpty() }
val isCard: Boolean get() = title == null && focusedRow >= firstShelf && focusedLane != null && focusedLane?.pending != true val isCard: Boolean get() = title == null && focusedRow >= firstShelf && focusedLane != null && focusedLane?.pending != true
val contentFocused: Boolean get() = !shell.navFocused && !shell.pickerVisible && overlay == null val contentFocused: Boolean get() = !shell.navFocused && !shell.pickerVisible && overlay == null
@ -152,7 +166,8 @@ class DiscoverState(
fun focus(row: Int, column: Int = 0) { fun focus(row: Int, column: Int = 0) {
shell.navFocused = false; shell.view.row = row shell.navFocused = false; shell.view.row = row
shell.view.cards[rowKey] = column.coerceIn(0, maxColumn()) val selected = column.coerceIn(0, maxColumn())
if (focusedRow >= firstShelf) laneColumns[rowKey] = selected else shell.view.cards[rowKey] = selected
} }
private fun moveRow(direction: Int) { private fun moveRow(direction: Int) {
@ -161,10 +176,7 @@ class DiscoverState(
if (target < if (hasHero) 0 else 1) { shell.focusNav(shell.currentTab); return } if (target < if (hasHero) 0 else 1) { shell.focusNav(shell.currentTab); return }
if (target >= rowCount) return if (target >= rowCount) return
val key = when (target) { 0 -> "phero"; 1 -> "dchips"; else -> lanes[target - firstShelf].id } val key = when (target) { 0 -> "phero"; 1 -> "dchips"; else -> lanes[target - firstShelf].id }
val nearest = if (focusedRow >= firstShelf && target >= firstShelf) focus(target, if (target >= firstShelf) laneColumn(key) else shell.view.cards[key].orZero())
column - shell.view.cards["first:$rowKey"].orZero() + shell.view.cards["first:$key"].orZero()
else 0
focus(target, shell.view.cards[key] ?: nearest.coerceAtLeast(0))
} }
fun openTitle(item: DiscoverTitle) { fun openTitle(item: DiscoverTitle) {
@ -225,12 +237,14 @@ class DiscoverState(
fun isPick(item: DiscoverTitle): Boolean = snapshot.picks.any { it.key == item.key } || isHidden(item) fun isPick(item: DiscoverTitle): Boolean = snapshot.picks.any { it.key == item.key } || isHidden(item)
fun openService(item: DiscoverService) { fun openService(item: DiscoverService) {
serviceLaneMemory.getOrPut(item.id) { DiscoverLaneMemory() }
rootFilter = filter; service = item; filter = "movie"; revision++ rootFilter = filter; service = item; filter = "movie"; revision++
onPush("discover-service/${item.id}") onPush("discover-service/${item.id}")
focus(firstShelf) focus(firstShelf)
} }
fun enterRoute(route: String) { fun enterRoute(route: String) {
if (route in shell.tabs && route != "discover") resetLaneMemory()
if (route == "discover") { title = null; collection = null; if (service != null) filter = rootFilter; service = null } if (route == "discover") { title = null; collection = null; if (service != null) filter = rootFilter; service = null }
else if (route.startsWith("discover-service") || route.startsWith("discover-see-all")) title = null else if (route.startsWith("discover-service") || route.startsWith("discover-see-all")) title = null
} }
@ -261,6 +275,7 @@ class DiscoverState(
ShellKey.Left, ShellKey.Right -> { ShellKey.Left, ShellKey.Right -> {
val direction = if (key == ShellKey.Left) -1 else 1 val direction = if (key == ShellKey.Left) -1 else 1
if (focusedRow == 0) slide = (slide + direction).coerceIn(0, (heroes.size - 1).coerceAtLeast(0)) if (focusedRow == 0) slide = (slide + direction).coerceIn(0, (heroes.size - 1).coerceAtLeast(0))
else if (focusedRow >= firstShelf) laneColumns[rowKey] = (column + direction).coerceIn(0, maxColumn())
else shell.view.cards[rowKey] = (column + direction).coerceIn(0, maxColumn()) else shell.view.cards[rowKey] = (column + direction).coerceIn(0, maxColumn())
} }
ShellKey.Ok -> when { ShellKey.Ok -> when {
@ -417,6 +432,12 @@ class DiscoverState(
} }
} }
private class DiscoverLaneMemory {
val columns = mutableStateMapOf<String, Int>()
val firsts = mutableStateMapOf<String, Int>()
fun clear() { columns.clear(); firsts.clear() }
}
private fun Int?.orZero(): Int = this ?: 0 private fun Int?.orZero(): Int = this ?: 0
private val DISCOVER_FILTERS = listOf("all", "movie", "tv") private val DISCOVER_FILTERS = listOf("all", "movie", "tv")

View file

@ -6,95 +6,138 @@ import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.requiredSize import androidx.compose.foundation.layout.requiredSize
import androidx.compose.foundation.layout.wrapContentSize import androidx.compose.foundation.layout.wrapContentSize
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.CompositionLocalProvider
import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.SideEffect
import androidx.compose.runtime.compositionLocalOf
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
import androidx.compose.runtime.key
import androidx.compose.runtime.mutableStateListOf
import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue import androidx.compose.runtime.setValue
import androidx.compose.runtime.snapshotFlow
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.graphicsLayer import androidx.compose.ui.graphics.graphicsLayer
import androidx.compose.ui.unit.Dp import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.platform.testTag import androidx.compose.ui.platform.testTag
import org.justinvisser.wolfskin.design.WolfskinTokens.Motion as M import androidx.compose.ui.semantics.SemanticsPropertyKey
import kotlinx.coroutines.launch import androidx.compose.ui.semantics.semantics
import androidx.compose.runtime.CompositionLocalProvider import androidx.compose.ui.unit.Dp
import androidx.compose.runtime.compositionLocalOf import kotlinx.coroutines.coroutineScope
import androidx.compose.runtime.rememberUpdatedState
import androidx.compose.runtime.snapshotFlow
import kotlinx.coroutines.flow.first import kotlinx.coroutines.flow.first
import kotlinx.coroutines.launch
import org.justinvisser.wolfskin.design.WolfskinTokens.Motion as M
/** Artwork success or its final fallback releases a pending hero hand-off. */ /** Artwork success or its final fallback releases a pending hero hand-off. */
internal val LocalHeroArtReady = compositionLocalOf<(() -> Unit)?> { null } internal val LocalHeroArtReady = compositionLocalOf<(() -> Unit)?> { null }
internal val HeroLayerAlpha = SemanticsPropertyKey<Float>("HeroLayerAlpha")
internal val HeroLayerTranslation = SemanticsPropertyKey<Float>("HeroLayerTranslation")
internal val HeroLayerIdentity = SemanticsPropertyKey<String>("HeroLayerIdentity")
/** Only the upper hero region blends; the lower backdrop changes without an animated screen layer. */ private class HeroPresentation {
var identity by mutableStateOf<Any?>(null)
}
private val LocalHeroPresentation = compositionLocalOf<HeroPresentation?> { null }
/** Artwork and copy share readiness without coupling either layer to a screen's selection state. */
@Composable
internal fun HeroMotionScope(content: @Composable () -> Unit) {
val presentation = remember { HeroPresentation() }
CompositionLocalProvider(LocalHeroPresentation provides presentation, content = content)
}
private class HeroLayer<T>(val identity: Any, var target: T, initialAlpha: Float) {
val alpha = Animatable(initialAlpha)
var ready by mutableStateOf(false)
var presented = false
var outgoing by mutableStateOf(false)
}
private class HeroCopyLayer<T>(val identity: Any, var target: T, initialAlpha: Float) {
val alpha = Animatable(initialAlpha)
val progress = Animatable(initialAlpha)
var outgoing by mutableStateOf(false)
}
/** Keep the painted composite while loading; interruptions fade each retained layer from its current alpha. */
@Composable @Composable
fun <T> HeroArtCrossfade(identity: Any, target: T, height: Dp, modifier: Modifier = Modifier, content: @Composable (T) -> Unit) { fun <T> HeroArtCrossfade(identity: Any, target: T, height: Dp, modifier: Modifier = Modifier, content: @Composable (T) -> Unit) {
var displayed by remember { mutableStateOf(target) } val initial = remember { HeroLayer(identity, target, 1f).apply { presented = true } }
var displayedIdentity by remember { mutableStateOf(identity) } val layers = remember { mutableStateListOf(initial) }
var outgoing by remember { mutableStateOf<T?>(null) } val selected = remember(identity) { layers.firstOrNull { it.identity == identity && it.presented && !it.outgoing } ?: HeroLayer(identity, target, 1f) }
val alpha = remember { Animatable(0f) }
val reduced = reducedMotion() val reduced = reducedMotion()
var readyIdentity by remember { mutableStateOf(identity) } val presentation = LocalHeroPresentation.current
val latestIdentity by rememberUpdatedState(identity) LaunchedEffect(selected, reduced) {
val onReady = remember(identity) { { if (latestIdentity == identity) readyIdentity = identity } } layers.removeAll { it !== selected && !it.presented }
LaunchedEffect(identity, reduced) { if (selected !in layers) layers.add(0, selected)
if (displayedIdentity != identity) { snapshotFlow { selected.ready }.first { it }
outgoing = displayed selected.presented = true
alpha.snapTo(1f) presentation?.identity = identity
snapshotFlow { readyIdentity == identity }.first { it } layers.forEach { if (it !== selected) it.outgoing = true }
displayed = target
displayedIdentity = identity
}
if (outgoing != null) {
alpha.animateTo(0f, tween(motionDuration(M.heroFade, reduced), easing = M.easeInOut))
outgoing = null
}
} }
Box(modifier) { Box(modifier) {
CompositionLocalProvider(LocalHeroArtReady provides onReady) { content(target) } layers.forEachIndexed { index, layer -> key(layer) {
outgoing?.let { old -> SideEffect { if (layer.identity == identity) layer.target = target }
Box(Modifier.requiredSize(WolfskinTokens.Shell.stageWidth, height).graphicsLayer { this.alpha = alpha.value; clip = true }.testTag("hero-outgoing")) { LaunchedEffect(layer.outgoing, reduced) {
Box(Modifier.wrapContentSize(Alignment.TopStart, unbounded = true).requiredSize(WolfskinTokens.Shell.stageWidth, WolfskinTokens.Shell.stageHeight)) { content(old) } if (layer.outgoing) {
layer.alpha.animateTo(0f, tween(motionDuration(M.heroFade, reduced), easing = M.easeInOut))
layers.remove(layer)
}
} }
} val outgoing = index != 0
val onReady = remember(layer) { { layer.ready = true } }
val bounds = if (outgoing) Modifier.requiredSize(WolfskinTokens.Shell.stageWidth, height) else Modifier
Box(bounds.graphicsLayer { alpha = layer.alpha.value; clip = outgoing }
.testTag(if (outgoing) "hero-outgoing" else "hero-incoming")
.semantics { this[HeroLayerAlpha] = layer.alpha.value; this[HeroLayerTranslation] = 0f; this[HeroLayerIdentity] = layer.identity.toString() }) {
CompositionLocalProvider(LocalHeroArtReady provides onReady) {
Box(Modifier.wrapContentSize(Alignment.TopStart, unbounded = true).requiredSize(WolfskinTokens.Shell.stageWidth, WolfskinTokens.Shell.stageHeight)) {
content(if (layer.identity == identity) target else layer.target)
}
}
}
} }
} }
} }
/** One bounded copy layer fades out, hands off, then rises; controls keep their logical owner. */ /** Copy counter-fades after artwork is ready; outgoing blocks hold their current translation. */
@Composable @Composable
fun <T> HeroCopyHandoff(identity: Any, target: T, modifier: Modifier = Modifier, content: @Composable (T) -> Unit) { fun <T> HeroCopyHandoff(identity: Any, target: T, modifier: Modifier = Modifier, content: @Composable (T) -> Unit) {
val initial = remember { HeroCopyLayer(identity, target, 1f) }
val layers = remember { mutableStateListOf(initial) }
val selected = remember(identity) { layers.lastOrNull { it.identity == identity && !it.outgoing } ?: HeroCopyLayer(identity, target, 0f) }
val reduced = reducedMotion() val reduced = reducedMotion()
val alpha = remember { Animatable(0f) } val presentation = LocalHeroPresentation.current
val progress = remember { Animatable(0f) } val rise = with(LocalDensity.current) { M.copyRise.toPx() }
var displayed by remember { mutableStateOf(target) } LaunchedEffect(selected, reduced) {
var previous by remember { mutableStateOf(identity) } if (selected !in layers) {
var handingOff by remember { mutableStateOf(false) } if (presentation != null) snapshotFlow { presentation.identity == identity }.first { it }
var entered by remember { mutableStateOf(false) } layers.add(selected)
val rise = with(androidx.compose.ui.platform.LocalDensity.current) { (if (entered) M.copyRise else M.copyEntryRise).toPx() } layers.forEach { if (it !== selected) it.outgoing = true }
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) }) { Box(modifier, contentAlignment = Alignment.BottomStart) {
content(if (handingOff || previous != identity) displayed else target) layers.forEach { layer -> key(layer) {
SideEffect { if (layer.identity == identity) layer.target = target }
LaunchedEffect(layer.outgoing, reduced) {
if (layer.outgoing) {
layer.alpha.animateTo(0f, tween(motionDuration(M.heroCopyIn, reduced), easing = M.easeOut))
layers.remove(layer)
} else if (layer.alpha.value != 1f) coroutineScope {
launch { layer.alpha.animateTo(1f, tween(motionDuration(M.heroCopyIn, reduced), easing = M.easeOut)) }
launch { layer.progress.animateTo(1f, tween(motionDuration(M.heroCopyIn, reduced), easing = M.easeOut)) }
}
}
Box(Modifier.graphicsLayer {
alpha = layer.alpha.value
translationY = if (reduced) 0f else rise * (1f - layer.progress.value)
}.testTag(if (layer === layers.last()) "hero-copy" else "hero-copy-outgoing").semantics {
this[HeroLayerAlpha] = layer.alpha.value
this[HeroLayerTranslation] = if (reduced) 0f else rise * (1f - layer.progress.value)
this[HeroLayerIdentity] = layer.identity.toString()
}) { content(if (layer.identity == identity) target else layer.target) }
} }
} }
} }

View file

@ -19,6 +19,7 @@ import org.justinvisser.wolfskin.design.HomeType as T
/** The hero occupies one row while its backdrop extends beneath the first shelves. */ /** The hero occupies one row while its backdrop extends beneath the first shelves. */
@Composable @Composable
fun HomeHero(row: HomeRow, focused: Boolean, column: Int, ratings: List<HomeRating>, onButton: (Int) -> Unit, onHover: (Int) -> Unit) { fun HomeHero(row: HomeRow, focused: Boolean, column: Int, ratings: List<HomeRating>, onButton: (Int) -> Unit, onHover: (Int) -> Unit) {
HeroMotionScope {
Box(Modifier.fillMaxWidth().height(L.row_hero)) { Box(Modifier.fillMaxWidth().height(L.row_hero)) {
if (!row.skeleton) { if (!row.skeleton) {
val title = row.cards.first() val title = row.cards.first()
@ -28,7 +29,8 @@ fun HomeHero(row: HomeRow, focused: Boolean, column: Int, ratings: List<HomeRati
} }
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.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)))) 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))))
HeroCopyHandoff(title.id, title, Modifier.align(Alignment.BottomStart).padding(start = L.safeSide, bottom = L.heroCopyBottom).width(L.heroCopyWidth)) { copy -> Column(Modifier.align(Alignment.BottomStart).padding(start = L.safeSide, bottom = L.heroCopyBottom).width(L.heroCopyWidth)) {
HeroCopyHandoff(title.id, title) { copy ->
Column { Column {
Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(H.kickerGap)) { 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))) Glyph(if (row.heroKind == "pick") "seerr" else "playback", C.accentText, Modifier.size(L.buttonIcon * (28f / 30f)))
@ -44,6 +46,8 @@ fun HomeHero(row: HomeRow, focused: Boolean, column: Int, ratings: List<HomeRati
} }
Spacer(Modifier.height(H.factsBottom)) Spacer(Modifier.height(H.factsBottom))
Text(copy.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)) Spacer(Modifier.height(H.overviewBottom))
Row(horizontalArrangement = Arrangement.spacedBy(L.buttonGap)) { Row(horizontalArrangement = Arrangement.spacedBy(L.buttonGap)) {
val detailsOnly = row.heroKind == "pick" || !row.watchReady val detailsOnly = row.heroKind == "pick" || !row.watchReady
@ -52,7 +56,6 @@ 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) } if (!detailsOnly) PickerButton("Details", focused && column == 1, false, "about", onFocus = { onHover(1) }, textStyle = T.button) { onButton(1) }
} }
} }
}
} else { } else {
HomeAmbient(Modifier.wrapContentSize(Alignment.TopStart, unbounded = true).requiredSize(WolfskinTokens.Shell.stageWidth, WolfskinTokens.Shell.stageHeight)) 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).skeletonPulse()) { Column(Modifier.align(Alignment.BottomStart).padding(start = L.safeSide, bottom = L.heroCopyBottom).skeletonPulse()) {
@ -64,6 +67,7 @@ fun HomeHero(row: HomeRow, focused: Boolean, column: Int, ratings: List<HomeRati
} }
} }
} }
}
} }
/** Discover continues in the same scroller, under the active person's identity. */ /** Discover continues in the same scroller, under the active person's identity. */

View file

@ -36,6 +36,8 @@ import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.text.style.TextOverflow
import androidx.tv.material3.Text import androidx.tv.material3.Text
import coil3.compose.AsyncImage import coil3.compose.AsyncImage
import coil3.request.transitionFactory
import coil3.transition.Transition
import org.justinvisser.wolfskin.design.WolfskinTokens.Color as C import org.justinvisser.wolfskin.design.WolfskinTokens.Color as C
import org.justinvisser.wolfskin.design.WolfskinTokens.Layout as L import org.justinvisser.wolfskin.design.WolfskinTokens.Layout as L
import org.justinvisser.wolfskin.design.WolfskinTokens.Title as D import org.justinvisser.wolfskin.design.WolfskinTokens.Title as D
@ -54,8 +56,9 @@ fun TitleArtwork(path: String?, width: Int, height: Int, modifier: Modifier = Mo
} }
else if (path != null) { else if (path != null) {
val context = LocalContext.current val context = LocalContext.current
val request = remember(context, path, width, height, poster) { val request = remember(context, path, width, height, poster, heroReady != null) {
WolfskinImages.request(context, path, width, height, poster) WolfskinImages.request(context, path, width, height, poster)
.let { if (heroReady != null) it.newBuilder().transitionFactory(Transition.Factory.NONE).build() else it }
} }
AsyncImage(model = request, imageLoader = WolfskinImages.loader(context), AsyncImage(model = request, imageLoader = WolfskinImages.loader(context),
contentDescription = null, modifier = modifier, contentScale = ContentScale.Crop, colorFilter = colorFilter, contentDescription = null, modifier = modifier, contentScale = ContentScale.Crop, colorFilter = colorFilter,

View file

@ -0,0 +1,65 @@
package org.justinvisser.wolfskin.design
import android.app.Application
import androidx.activity.ComponentActivity
import androidx.compose.foundation.layout.requiredSize
import androidx.compose.runtime.CompositionLocalProvider
import androidx.compose.ui.graphics.asImageBitmap
import androidx.compose.ui.Modifier
import androidx.compose.ui.input.key.Key
import androidx.compose.ui.test.*
import androidx.compose.ui.test.junit4.createAndroidComposeRule
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
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.GraphicsMode
import org.robolectric.annotation.Config
/** A catalogue read has no right to move the destination's focus into the root navigation. */
@RunWith(RobolectricTestRunner::class)
@Config(sdk = [34], application = Application::class, qualifiers = "w960dp-h540dp-xhdpi")
@GraphicsMode(GraphicsMode.Mode.NATIVE)
class DiscoverDetailsFocusTest {
@get:Rule val rule = createAndroidComposeRule<ComponentActivity>()
@Test fun heroDetailsRetainsDestinationFocusDuringLoadingAndLateMetadata() {
rule.mainClock.autoAdvance = false
val shell = ShellState(fixturePeople(), "justin").apply { pickerVisible = false; enterDestination("discover") }
val snapshot = discoverTestFixture().snapshot("justin")
val discover = DiscoverState(shell, snapshot, RecordingDiscoverOperations(snapshot, "justin"), CoroutineScope(Dispatchers.Main.immediate), shell::enterDestination)
val selected = requireNotNull(discover.hero)
val title = TitleState(TitlePageData(TitleItem(selected.key, selected.title, selected.type), status = TitleReadStatus.Loading), shell.person) {}
val art = android.graphics.Bitmap.createBitmap(8, 8, android.graphics.Bitmap.Config.ARGB_8888).apply { eraseColor(android.graphics.Color.DKGRAY) }.asImageBitmap()
rule.setContent { WolfskinTheme {
CompositionLocalProvider(LocalDiscoverArt provides { art }) {
val modifier = Modifier.requiredSize(WolfskinTokens.Shell.stageWidth, WolfskinTokens.Shell.stageHeight)
if (shell.route.startsWith("title/")) TitleSurface(title, shell, modifier)
else ShellSurface(shell, modifier) { DiscoverScreen(discover, it) }
}
} }
rule.mainClock.advanceTimeBy(640)
rule.onNodeWithTag("wolfskin-shell").performKeyInput { pressKey(Key.DirectionCenter) }
repeat(6) {
rule.mainClock.advanceTimeBy(16)
rule.waitForIdle()
assertFalse("Loading cannot steal destination focus on a later frame", shell.navFocused)
}
assertEquals("title/${android.net.Uri.encode(selected.key)}", shell.route)
assertFalse("The destination owns focus even while its read is pending", shell.navFocused)
rule.onRoot().performKeyInput { pressKey(Key.DirectionDown) }
assertFalse("Down on a loading title must not enter nav or a skeleton", shell.navFocused)
rule.runOnIdle { title.update(title.data.copy(status = TitleReadStatus.Ready)) }
rule.mainClock.advanceTimeBy(640)
val before = rule.onNodeWithTag("title-actions").fetchSemanticsNode().boundsInRoot
rule.runOnIdle { title.update(title.data.copy(item = title.data.item.copy(overview = "Late overview", ratings = listOf(LibraryRating("rt", 88.0))))) }
repeat(24) {
rule.mainClock.advanceTimeBy(16)
assertFalse(shell.navFocused)
assertEquals("Loaded actions cannot replay their entry or move after late metadata", before, rule.onNodeWithTag("title-actions").fetchSemanticsNode().boundsInRoot)
}
}
}

View file

@ -0,0 +1,240 @@
package org.justinvisser.wolfskin.design
import android.app.Application
import android.graphics.Bitmap
import android.graphics.BitmapFactory
import android.view.View
import android.view.WindowManager
import androidx.activity.ComponentActivity
import androidx.compose.foundation.layout.requiredSize
import androidx.compose.runtime.CompositionLocalProvider
import androidx.compose.runtime.mutableStateOf
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.asImageBitmap
import androidx.compose.ui.input.key.Key
import androidx.compose.ui.semantics.SemanticsProperties
import androidx.compose.ui.test.*
import androidx.compose.ui.test.junit4.createAndroidComposeRule
import coil3.intercept.Interceptor
import coil3.request.ErrorResult
import com.github.takahirom.roborazzi.captureRoboImage
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.awaitCancellation
import org.json.JSONArray
import org.json.JSONObject
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
import java.io.IOException
/** Real Discover artwork, copy and pager are sampled on every manually advanced native frame. */
@RunWith(RobolectricTestRunner::class)
@Config(sdk = [34], application = Application::class, qualifiers = "w960dp-h540dp-xhdpi")
@GraphicsMode(GraphicsMode.Mode.NATIVE)
class DiscoverHeroSequenceTest {
@get:Rule val rule = createAndroidComposeRule<ComponentActivity>()
private val samples = JSONArray()
private lateinit var directory: File
private lateinit var state: DiscoverState
private lateinit var shell: ShellState
private val artAvailable = mutableStateOf(true)
private val reduced = mutableStateOf(false)
private var previousPixel: Int? = null
private var largestJump = 0
private var previousLayers = emptyMap<String, Pair<Float, Float>>()
private val previousDotWidths = mutableMapOf<Int, Float>()
private var previousSelection: Int? = null
private fun launch(scenario: String, failedArt: Boolean = false) {
directory = File(System.getProperty("wolfskin.captureDirectory"), "discover-hero/$scenario").apply { mkdirs() }
rule.activity.window.setFlags(WindowManager.LayoutParams.FLAG_FULLSCREEN, WindowManager.LayoutParams.FLAG_FULLSCREEN)
@Suppress("DEPRECATION")
rule.activity.window.decorView.systemUiVisibility = View.SYSTEM_UI_FLAG_FULLSCREEN or View.SYSTEM_UI_FLAG_HIDE_NAVIGATION or View.SYSTEM_UI_FLAG_IMMERSIVE_STICKY
rule.mainClock.autoAdvance = false
WolfskinImages.configureNetwork(rule.activity) { add(Interceptor { chain ->
if (chain.request.data.toString().startsWith("sequence:")) {
if (failedArt) ErrorResult(image = null, request = chain.request, throwable = IOException("Controlled artwork failure")) else awaitCancellation()
} else chain.proceed()
}) }
val fixture = discoverTestFixture()
val colors = listOf(android.graphics.Color.RED, android.graphics.Color.BLUE, android.graphics.Color.GREEN)
val titles = fixture.snapshot("justin").picks.take(3).mapIndexed { index, title ->
title.copy(backdrop = "sequence:$index", poster = null, posterOnly = false, score = 100.0 - index)
}
val art = colors.map { color -> Bitmap.createBitmap(8, 8, Bitmap.Config.ARGB_8888).apply { eraseColor(color) }.asImageBitmap() }
val snapshot = fixture.snapshot("justin").copy(picks = titles)
shell = ShellState(fixturePeople(), "justin").apply { pickerVisible = false; enterDestination("discover") }
state = DiscoverState(shell, snapshot, RecordingDiscoverOperations(snapshot, "justin"), CoroutineScope(Dispatchers.Main.immediate), shell::enterDestination)
rule.setContent { WolfskinTheme {
CompositionLocalProvider(LocalReducedMotion provides reduced.value, LocalDiscoverArt provides { path ->
path.removePrefix("sequence:").toIntOrNull()?.let { if (it != 1 || artAvailable.value) art[it] else null }
}) {
ShellSurface(shell, Modifier.requiredSize(WolfskinTokens.Shell.stageWidth, WolfskinTokens.Shell.stageHeight)) { DiscoverScreen(state, it) }
}
} }
rule.mainClock.advanceTimeBy(640)
}
@Test fun interruptedHeroNeverSnapsToAnIntermediateSelection() {
launch("interrupted")
sample("settled", 0)
press(Key.DirectionRight)
frames("first", 12)
press(Key.DirectionRight)
frames("interrupted", 28)
press(Key.DirectionLeft)
frames("reverse", 28)
finish()
assertSmoothPixels()
assertEquals(1, state.slide)
}
@Test fun slowArtRetainsBothThePaintedHeroAndItsCopyUntilReady() {
launch("slow")
sample("settled", 0)
val original = previousPixel
rule.runOnIdle { artAvailable.value = false }
press(Key.DirectionRight)
frames("pending", 24)
assertEquals("Pending art must not replace the painted backdrop", original, previousPixel)
assertEquals(state.heroes.first().key, copyIdentities().single())
rule.runOnIdle { artAvailable.value = true }
frames("ready", 28)
finish()
assertSmoothPixels()
assertEquals(state.hero!!.key, copyIdentities().single())
}
@Test fun failedArtBlendsToItsFinalFallbackWithoutErasingThePreviousHero() {
launch("failed", failedArt = true)
sample("settled", 0)
rule.runOnIdle { artAvailable.value = false }
press(Key.DirectionRight)
frames("fallback", 28)
rule.onNodeWithTag("discover-missing-art").assertExists()
finish()
assertSmoothPixels()
assertEquals(state.hero!!.key, copyIdentities().single())
}
@Test fun repeatedPressesRetargetTheCurrentCompositeWithoutSnapBack() {
launch("rapid")
sample("settled", 0)
listOf(Key.DirectionRight, Key.DirectionRight, Key.DirectionLeft, Key.DirectionLeft, Key.DirectionRight, Key.DirectionRight).forEachIndexed { index, key ->
press(key)
frames("press-$index", 3)
}
repeat(48) { index ->
press(if (index % 2 == 0) Key.DirectionLeft else Key.DirectionRight)
frames("burst-$index", 1)
}
frames("settled-latest", 28)
finish()
assertSmoothPixels()
assertEquals(state.hero!!.key, copyIdentities().single())
}
@Test fun reducedMotionKeepsTheBlendButRemovesCopyTranslation() {
launch("reduced")
rule.runOnIdle { reduced.value = true }
press(Key.DirectionRight)
frames("reduced", (WolfskinTokens.Motion.heroFade + 15) / 16)
finish()
assertEquals(state.hero!!.key, copyIdentities().single())
assertEquals(1, rule.onAllNodesWithTag("hero-incoming", useUnmergedTree = true).fetchSemanticsNodes().size)
assertTrue(rule.onAllNodesWithTag("hero-outgoing", useUnmergedTree = true).fetchSemanticsNodes().isEmpty())
val moving = (0 until samples.length()).map(samples::getJSONObject).filter { row ->
val layers = row.getJSONArray("layers")
(0 until layers.length()).map(layers::getJSONObject).any {
it.getString("kind") == "hero-outgoing" && it.getDouble("alpha") > 0.0 && it.getDouble("alpha") < 1.0
}
}.map { it.getLong("timeMs") }
assertTrue("Reduced artwork fade cannot retain the ordinary 320ms lifetime", moving.last() - moving.first() <= WolfskinTokens.Motion.reducedFade)
}
private fun copyIdentities() = rule.onAllNodesWithTag("hero-copy", useUnmergedTree = true).fetchSemanticsNodes().map { it.config[HeroLayerIdentity] }
private fun press(key: Key) = rule.onNodeWithTag("wolfskin-shell").performKeyInput { keyDown(key); keyUp(key) }
private fun frames(phase: String, count: Int) = repeat(count) { index -> rule.mainClock.advanceTimeBy(16); sample(phase, index) }
private fun finish() = File(directory, "series.json").writeText(samples.toString(2))
private fun assertSmoothPixels() = assertTrue("A 16ms hero frame cannot snap to a full intermediate selection; largest channel jump=$largestJump", largestJump <= 48)
private fun sample(phase: String, index: Int) {
rule.waitForIdle()
val file = File(directory, "$phase-${index.toString().padStart(2, '0')}.png")
rule.onRoot().captureRoboImage(file.absolutePath)
val bitmap = BitmapFactory.decodeFile(file.absolutePath)
val pixel = try { bitmap.getPixel(1600, 200) } finally { bitmap.recycle() }
val channels = channels(pixel)
previousPixel?.let { old -> largestJump = maxOf(largestJump, channels.zip(channels(old)).maxOf { (a, b) -> kotlin.math.abs(a - b) }) }
previousPixel = pixel
val copy = JSONArray()
state.heroes.forEach { title -> rule.onAllNodesWithText(title.title, useUnmergedTree = true).fetchSemanticsNodes().forEach { node ->
copy.put(JSONObject().put("title", title.key).put("x", node.boundsInRoot.left).put("y", node.boundsInRoot.top))
} }
val layers = layerSamples()
val dots = JSONArray()
state.heroes.indices.forEach { dot -> rule.onAllNodesWithTag("hero-dot-$dot", useUnmergedTree = true).fetchSemanticsNodes().forEach { node ->
previousDotWidths[dot]?.let { previous ->
val range = with(rule.density) { (WolfskinTokens.Discover.dotActive - WolfskinTokens.Discover.dotSize).toPx() }
if (!reduced.value) assertTrue("Pager retarget must not snap a full dot-width step", kotlin.math.abs(node.boundsInRoot.width - previous) <= range / 2)
if (reduced.value) {
val rest = with(rule.density) { WolfskinTokens.Discover.dotSize.toPx() }
assertTrue("Reduced motion snaps pager geometry instead of moving it", node.boundsInRoot.width == rest || node.boundsInRoot.width == rest + range)
}
if (previousSelection == state.slide) {
if (dot == state.slide) assertTrue("The selected pager dot cannot shrink backwards", node.boundsInRoot.width >= previous - .01f)
else assertTrue("An outgoing pager dot cannot grow backwards", node.boundsInRoot.width <= previous + .01f)
}
}
previousDotWidths[dot] = node.boundsInRoot.width
dots.put(JSONObject().put("index", dot).put("x", node.boundsInRoot.left).put("y", node.boundsInRoot.top).put("width", node.boundsInRoot.width))
} }
previousSelection = state.slide
samples.put(JSONObject().put("phase", phase).put("frame", index).put("timeMs", rule.mainClock.currentTime)
.put("selection", state.slide).put("rgb", JSONArray(channels)).put("copy", copy).put("layers", layers).put("dots", dots).put("navFocused", shell.navFocused))
}
private fun layerSamples(): JSONArray {
val result = JSONArray()
val current = mutableMapOf<String, Pair<Float, Float>>()
val nodes = rule.onAllNodes(SemanticsMatcher.keyIsDefined(HeroLayerAlpha), useUnmergedTree = true).fetchSemanticsNodes()
val unique = nodes.groupBy { it.config[SemanticsProperties.TestTag] + ":" + it.id }
assertTrue("The artwork composite always has an opaque base", nodes.any { it.config[SemanticsProperties.TestTag] == "hero-incoming" && it.config[HeroLayerAlpha] == 1f })
val copyResidual = nodes.filter { it.config[SemanticsProperties.TestTag].startsWith("hero-copy") }
.fold(1f) { residual, node -> residual * (1f - node.config[HeroLayerAlpha]) }
assertTrue("Copy composite cannot fade through a blank frame", 1f - copyResidual >= .5f)
val artCount = nodes.count { !it.config[SemanticsProperties.TestTag].startsWith("hero-copy") }
val copyCount = nodes.size - artCount
assertTrue("Sustained presses cannot extend outgoing art lifetimes", artCount <= (WolfskinTokens.Motion.heroFade + 15) / 16 + 3)
assertTrue("Sustained presses cannot accumulate outgoing copy layers", copyCount <= (WolfskinTokens.Motion.heroCopyIn + 15) / 16 + 3)
unique.forEach { (key, matching) -> matching.forEach { node ->
val alpha = node.config[HeroLayerAlpha]
val translation = node.config[HeroLayerTranslation]
val tag = node.config[SemanticsProperties.TestTag]
if (matching.size == 1) {
previousLayers[key]?.let { (previousAlpha, previousTranslation) ->
if (tag == "hero-outgoing" || tag == "hero-copy-outgoing") assertTrue("Outgoing layers cannot restart at full opacity: $key", alpha <= previousAlpha + .001f)
if (tag == "hero-copy-outgoing") assertEquals("Outgoing copy must fade where its entry stopped", previousTranslation, translation, .001f)
if (tag == "hero-copy") {
assertTrue("Copy alpha cannot snap backwards: $key", alpha >= previousAlpha - .001f)
assertTrue("Copy translation cannot snap backwards: $key", translation <= previousTranslation + .001f)
}
}
current[key] = alpha to translation
}
if (reduced.value) assertEquals(0f, translation, .001f)
result.put(JSONObject().put("kind", tag).put("identity", node.config[HeroLayerIdentity]).put("layerId", node.id).put("alpha", alpha)
.put("translationY", translation).put("x", node.boundsInRoot.left).put("y", node.boundsInRoot.top))
} }
previousLayers = current
return result
}
private fun channels(pixel: Int) = listOf(android.graphics.Color.red(pixel), android.graphics.Color.green(pixel), android.graphics.Color.blue(pixel))
}

View file

@ -2,6 +2,9 @@
package org.justinvisser.wolfskin.design package org.justinvisser.wolfskin.design
import android.app.Application import android.app.Application
import android.graphics.Bitmap
import androidx.compose.runtime.CompositionLocalProvider
import androidx.compose.ui.graphics.asImageBitmap
import android.view.KeyEvent as AndroidKeyEvent import android.view.KeyEvent as AndroidKeyEvent
import androidx.compose.ui.input.key.Key import androidx.compose.ui.input.key.Key
import androidx.compose.ui.input.key.KeyEvent import androidx.compose.ui.input.key.KeyEvent
@ -19,10 +22,12 @@ import org.junit.Test
import org.junit.runner.RunWith import org.junit.runner.RunWith
import org.robolectric.RobolectricTestRunner import org.robolectric.RobolectricTestRunner
import org.robolectric.annotation.Config import org.robolectric.annotation.Config
import org.robolectric.annotation.GraphicsMode
/** Acceptance data drives real Android events through the shell's production focus adapter. */ /** Acceptance data drives real Android events through the shell's production focus adapter. */
@RunWith(RobolectricTestRunner::class) @RunWith(RobolectricTestRunner::class)
@Config(sdk = [34], application = Application::class, qualifiers = "w960dp-h540dp-xhdpi") @Config(sdk = [34], application = Application::class, qualifiers = "w960dp-h540dp-xhdpi")
@GraphicsMode(GraphicsMode.Mode.NATIVE)
class DiscoverKeysTest { class DiscoverKeysTest {
@get:Rule val rule = createComposeRule() @get:Rule val rule = createComposeRule()
private val fixture = discoverTestFixture() private val fixture = discoverTestFixture()
@ -33,11 +38,14 @@ class DiscoverKeysTest {
{ shell.enterDestination("discover") }, fixture::service) { shell.enterDestination("discover") }, fixture::service)
private fun launch() { private fun launch() {
val art = Bitmap.createBitmap(8, 8, Bitmap.Config.ARGB_8888).apply { eraseColor(android.graphics.Color.DKGRAY) }.asImageBitmap()
rule.setContent { rule.setContent {
WolfskinTheme { WolfskinTheme {
CompositionLocalProvider(LocalDiscoverArt provides { art }) {
ShellSurface(shell, overlay = { DiscoverOverlay(state) }) { modifier -> ShellSurface(shell, overlay = { DiscoverOverlay(state) }) { modifier ->
if (shell.route == "discover" || shell.route.startsWith("discover-") || shell.route.startsWith("title/")) DiscoverScreen(state, modifier) else ShellPlaceholder(shell.route, modifier) if (shell.route == "discover" || shell.route.startsWith("discover-") || shell.route.startsWith("title/")) DiscoverScreen(state, modifier) else ShellPlaceholder(shell.route, modifier)
} }
}
} }
} }
} }
@ -256,7 +264,69 @@ class DiscoverKeysTest {
assertNull(state.collection) assertNull(state.collection)
assertSame(origin, shell.view) assertSame(origin, shell.view)
assertEquals(3, shell.view.row) assertEquals(3, shell.view.row)
assertEquals(2, shell.view.cards[state.rowKey]) assertEquals(2, state.column)
}
@Test fun firstEntryStartsLeftmostAndOnlyMovedLanesRememberTheirPosition() {
replay("Open Discover")
press(Key.DirectionDown, Key.DirectionDown)
assertEquals(0, state.column)
repeat(7) { press(Key.DirectionRight) }
val moved = state.column
assertTrue(moved > 0)
press(Key.DirectionDown)
assertEquals("An untouched lane starts at its own left edge, not the previous lane's column", 0, state.column)
press(Key.DirectionUp)
assertEquals(moved, state.column)
}
@Test fun leavingTheTabResetsLaneMovementButPushedTitlesDoNot() {
replay("Open Discover")
press(Key.DirectionDown, Key.DirectionDown)
press(Key.DirectionDown)
repeat(4) { press(Key.DirectionRight) }
val row = state.focusedRow
val moved = state.column
press(Key.DirectionCenter)
assertNotNull("This round trip must open a title, not a service lane", state.title)
press(Key.Back)
assertEquals(moved, state.column)
press(Key.Back)
press(Key.DirectionLeft, Key.DirectionCenter)
assertEquals("home", shell.route)
press(Key.DirectionUp, Key.DirectionRight)
press(Key.DirectionCenter)
assertEquals("discover", shell.route)
assertEquals(row, state.focusedRow)
assertEquals(0, state.column)
assertEquals(0, state.laneFirsts[state.rowKey] ?: 0)
}
@Test fun rebuildingDiscoverDoesNotReuseThePreviousInstancesLaneColumns() {
shell.pickerVisible = false
shell.enterDestination("discover")
state.focus(state.firstShelf, 4)
val rebuilt = DiscoverState(shell, snapshot, operations, CoroutineScope(Dispatchers.Main.immediate))
assertEquals(0, rebuilt.column)
}
@Test fun serviceLanesKeepIndependentPositionsAndResetWithTheRootVisit() {
shell.pickerVisible = false
shell.enterDestination("discover")
state.focus(state.firstShelf + 1, 3)
state.openService(snapshot.services[0])
state.focus(state.firstShelf + 1, 4)
state.key(ShellKey.Back)
assertEquals("A service must not overwrite the root lane", 3, state.column)
state.openService(snapshot.services[1])
state.key(ShellKey.Down)
assertEquals("Different services can reuse lane ids without sharing columns", 0, state.column)
state.key(ShellKey.Back)
state.openService(snapshot.services[0])
state.key(ShellKey.Down)
assertEquals(4, state.column)
state.enterRoute("home")
assertEquals("Leaving Discover clears service positions too", 0, state.column)
} }
@Test @Test

View file

@ -0,0 +1,91 @@
package org.justinvisser.wolfskin.design
import android.app.Application
import android.graphics.Bitmap
import android.graphics.BitmapFactory
import android.view.View
import android.view.WindowManager
import androidx.activity.ComponentActivity
import androidx.compose.foundation.layout.requiredSize
import androidx.compose.runtime.CompositionLocalProvider
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.asImageBitmap
import androidx.compose.ui.input.key.Key
import androidx.compose.ui.test.*
import androidx.compose.ui.test.junit4.createAndroidComposeRule
import coil3.asImage
import coil3.decode.DataSource
import coil3.intercept.Interceptor
import coil3.request.SuccessResult
import com.github.takahirom.roborazzi.captureRoboImage
import kotlinx.coroutines.CompletableDeferred
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import org.json.JSONArray
import org.json.JSONObject
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
/** Network-success artwork must not add Coil's independent alpha ramp beneath the hero blend. */
@RunWith(RobolectricTestRunner::class)
@Config(sdk = [34], application = Application::class, qualifiers = "w960dp-h540dp-xhdpi")
@GraphicsMode(GraphicsMode.Mode.NATIVE)
class DiscoverNetworkHeroTest {
@get:Rule val rule = createAndroidComposeRule<ComponentActivity>()
@Test fun delayedNetworkSuccessCannotDipThePaintedHeroOpacity() {
rule.activity.window.setFlags(WindowManager.LayoutParams.FLAG_FULLSCREEN, WindowManager.LayoutParams.FLAG_FULLSCREEN)
@Suppress("DEPRECATION")
rule.activity.window.decorView.systemUiVisibility = View.SYSTEM_UI_FLAG_FULLSCREEN or View.SYSTEM_UI_FLAG_HIDE_NAVIGATION or View.SYSTEM_UI_FLAG_IMMERSIVE_STICKY
rule.mainClock.autoAdvance = false
val red = Bitmap.createBitmap(8, 8, Bitmap.Config.ARGB_8888).apply { eraseColor(android.graphics.Color.RED) }.asImageBitmap()
val blue = Bitmap.createBitmap(8, 8, Bitmap.Config.ARGB_8888).apply { eraseColor(android.graphics.Color.BLUE) }.asImage()
val release = CompletableDeferred<Unit>()
WolfskinImages.configureNetwork(rule.activity) { add(Interceptor { chain ->
if (chain.request.data == "network:blue") {
release.await()
SuccessResult(blue, chain.request, DataSource.NETWORK)
} else chain.proceed()
}) }
val fixture = discoverTestFixture()
val titles = fixture.snapshot("justin").picks.take(2).mapIndexed { index, title ->
title.copy(backdrop = if (index == 0) "network:red" else "network:blue", poster = null, posterOnly = false, score = 100.0 - index)
}
val snapshot = fixture.snapshot("justin").copy(picks = titles)
val shell = ShellState(fixturePeople(), "justin").apply { pickerVisible = false; enterDestination("discover") }
val state = DiscoverState(shell, snapshot, RecordingDiscoverOperations(snapshot, "justin"), CoroutineScope(Dispatchers.Main.immediate))
rule.setContent { WolfskinTheme {
CompositionLocalProvider(LocalDiscoverArt provides { path -> if (path == "network:red") red else null }) {
ShellSurface(shell, Modifier.requiredSize(WolfskinTokens.Shell.stageWidth, WolfskinTokens.Shell.stageHeight)) { DiscoverScreen(state, it) }
}
} }
rule.mainClock.advanceTimeBy(640)
rule.onNodeWithTag("wolfskin-shell").performKeyInput { keyDown(Key.DirectionRight); keyUp(Key.DirectionRight) }
rule.mainClock.advanceTimeBy(192)
rule.onNodeWithText(titles.first().title).assertIsDisplayed()
val directory = File(System.getProperty("wolfskin.captureDirectory"), "discover-network-hero").apply { mkdirs() }
val samples = JSONArray()
rule.runOnIdle { release.complete(Unit) }
var lastPixel = 0
repeat(40) { index ->
rule.mainClock.advanceTimeBy(16)
val file = File(directory, "network-${index.toString().padStart(2, '0')}.png")
rule.onRoot().captureRoboImage(file.absolutePath)
val bitmap = BitmapFactory.decodeFile(file.absolutePath)
lastPixel = try { bitmap.getPixel(1600, 200) } finally { bitmap.recycle() }
val redChannel = android.graphics.Color.red(lastPixel)
val blueChannel = android.graphics.Color.blue(lastPixel)
samples.put(JSONObject().put("timeMs", rule.mainClock.currentTime).put("red", redChannel).put("blue", blueChannel))
assertTrue("A second network-image fade exposes the dark placeholder beneath both heroes at frame $index: red=$redChannel blue=$blueChannel", redChannel + blueChannel >= 250)
}
File(directory, "series.json").writeText(samples.toString(2))
assertEquals(android.graphics.Color.BLUE, lastPixel)
rule.onNodeWithText(titles.last().title).assertIsDisplayed()
}
}

View file

@ -13,6 +13,9 @@ import androidx.compose.runtime.mutableStateOf
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.toArgb import androidx.compose.ui.graphics.toArgb
import androidx.compose.ui.graphics.asImageBitmap
import androidx.compose.ui.test.onNodeWithText
import androidx.compose.ui.test.assertIsDisplayed
import androidx.compose.ui.test.junit4.createAndroidComposeRule import androidx.compose.ui.test.junit4.createAndroidComposeRule
import androidx.compose.ui.test.onRoot import androidx.compose.ui.test.onRoot
import androidx.compose.ui.test.onNodeWithTag import androidx.compose.ui.test.onNodeWithTag
@ -82,6 +85,35 @@ class HeroMotionTest {
} finally { directory.deleteRecursively() } } finally { directory.deleteRecursively() }
} }
@Test fun homeHeroRetainsTheOutgoingCopyAnchorWhenTheNextTitleWraps() {
rule.mainClock.autoAdvance = false
val first = HomeTitle("first", "Short title", "movie", backdrop = "red")
val next = HomeTitle("next", "A much longer title that must wrap onto a second line in this hero", "movie", backdrop = "blue")
val selected = mutableStateOf(first)
val art = mapOf("red" to Color.Red, "blue" to Color.Blue).mapValues { (_, color) ->
android.graphics.Bitmap.createBitmap(8, 8, android.graphics.Bitmap.Config.ARGB_8888).apply { eraseColor(color.toArgb()) }.asImageBitmap()
}
rule.setContent { WolfskinTheme {
CompositionLocalProvider(LocalTitleBitmaps provides art) {
Box(Modifier.requiredSize(960.dp, 540.dp)) {
HomeHero(HomeRow("hero", HomeRowKind.Hero, cards = listOf(selected.value)), true, 0, emptyList(), {}, {})
}
}
} }
rule.mainClock.advanceTimeBy(640)
val anchor = rule.onNodeWithText(first.title).fetchSemanticsNode().boundsInRoot
val actionAnchor = rule.onNodeWithText("Play").fetchSemanticsNode().boundsInRoot
update { selected.value = next }
repeat(8) {
rule.mainClock.advanceTimeBy(16)
assertEquals("A taller incoming copy cannot move the outgoing title", anchor, rule.onNodeWithText(first.title).fetchSemanticsNode().boundsInRoot)
assertEquals("Hero actions remain stationary outside the copy handoff", actionAnchor, rule.onNodeWithText("Play").fetchSemanticsNode().boundsInRoot)
}
rule.mainClock.advanceTimeBy(320)
rule.onNodeWithText(first.title).assertDoesNotExist()
rule.onNodeWithText(next.title).assertIsDisplayed()
}
private fun update(block: () -> Unit) { private fun update(block: () -> Unit) {
rule.runOnIdle { block(); androidx.compose.runtime.snapshots.Snapshot.sendApplyNotifications() } rule.runOnIdle { block(); androidx.compose.runtime.snapshots.Snapshot.sendApplyNotifications() }
rule.mainClock.advanceTimeByFrame() rule.mainClock.advanceTimeByFrame()