From 6d7513bd0e0c55a2308141c24006471a8ca1096b Mon Sep 17 00:00:00 2001 From: justinzeus Date: Sat, 7 Nov 2026 15:05:07 +0100 Subject: [PATCH 1/9] Refine title episode cards, section stacking and action menus --- .../wolfskin/tv/ConductarrTitlePagesTest.kt | 2 +- .../wolfskin/tv/ConductarrTitleSurfaceTest.kt | 6 +- .../wolfskin/tv/TitleCaptureTest.kt | 11 +- .../justinvisser/wolfskin/tv/TitleKeysTest.kt | 33 --- .../wolfskin/tv/TitleRound2Test.kt | 197 ++++++++++++++++++ docker/README.md | 35 +++- .../wolfskin/design/TitleArtwork.kt | 4 +- .../wolfskin/design/TitleHeader.kt | 3 +- .../justinvisser/wolfskin/design/TitleMenu.kt | 69 ++++++ .../wolfskin/design/TitleModel.kt | 2 +- .../wolfskin/design/TitleOptionsSheet.kt | 2 +- .../justinvisser/wolfskin/design/TitlePage.kt | 10 +- .../wolfskin/design/TitlePrefetch.kt | 2 +- .../wolfskin/design/TitleShelf.kt | 27 ++- .../wolfskin/design/TitleState.kt | 43 +++- .../wolfskin/design/WolfskinTokens.kt | 4 +- wolfskin-design/title-tokens.json | 4 +- wolfskin-design/tokens.css | 4 +- wolfskin-design/tokens.sha256 | 6 +- 19 files changed, 379 insertions(+), 85 deletions(-) create mode 100644 app/src/test/java/org/justinvisser/wolfskin/tv/TitleRound2Test.kt create mode 100644 wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/TitleMenu.kt diff --git a/app/src/test/java/org/justinvisser/wolfskin/tv/ConductarrTitlePagesTest.kt b/app/src/test/java/org/justinvisser/wolfskin/tv/ConductarrTitlePagesTest.kt index 3c77fa66..d3ea3804 100644 --- a/app/src/test/java/org/justinvisser/wolfskin/tv/ConductarrTitlePagesTest.kt +++ b/app/src/test/java/org/justinvisser/wolfskin/tv/ConductarrTitlePagesTest.kt @@ -70,7 +70,7 @@ class ConductarrTitlePagesTest { assertEquals(TitleConnection.SignedOut, revoked.connection) assertTrue(revoked.item.ratings.isEmpty()) assertTrue(revoked.playTarget!!.ratings.isEmpty()) - assertFalse(TitleState(revoked, ShellPerson("shared", "Shared", "S", true)) {}.options.any { it.id == "add" }) + assertFalse(TitleState(revoked, ShellPerson("shared", "Shared", "S", true)) {}.menuOptions.any { it.id == "add" }) } } } diff --git a/app/src/test/java/org/justinvisser/wolfskin/tv/ConductarrTitleSurfaceTest.kt b/app/src/test/java/org/justinvisser/wolfskin/tv/ConductarrTitleSurfaceTest.kt index e41862d8..27d2d214 100644 --- a/app/src/test/java/org/justinvisser/wolfskin/tv/ConductarrTitleSurfaceTest.kt +++ b/app/src/test/java/org/justinvisser/wolfskin/tv/ConductarrTitleSurfaceTest.kt @@ -76,6 +76,9 @@ class ConductarrTitleSurfaceTest { val directory = File(requireNotNull(System.getProperty("wolfskin.captureDirectory"))).apply { mkdirs() } rule.onRoot().captureRoboImage(File(directory, "conductarr-title-real.png").absolutePath) rule.onRoot().performKeyInput { pressKey(Key.DirectionRight); pressKey(Key.DirectionCenter) } + rule.mainClock.advanceTimeBy(300) + rule.onNodeWithText("Add for someone").assertIsDisplayed() + rule.onRoot().performKeyInput { pressKey(Key.DirectionCenter) } rule.onNodeWithText("Request for Mindel").assertIsDisplayed() rule.onRoot().performKeyInput { pressKey(Key.DirectionDown); pressKey(Key.DirectionCenter) } rule.waitUntil(30_000) { @@ -97,7 +100,8 @@ class ConductarrTitleSurfaceTest { viewModel.title?.data?.connection == TitleConnection.SignedOut && viewModel.title?.data?.status == TitleReadStatus.Unavailable } assertTrue(viewModel.title!!.data.item.ratings.isEmpty()) - assertFalse(viewModel.title!!.options.any { it.id in listOf("request", "other", "add") }) + assertFalse(viewModel.title!!.options.any { it.id == "request" }) + assertFalse(viewModel.title!!.menuOptions.any { it.id == "add" }) } finally { viewModel.viewModelScope.cancel() } } } diff --git a/app/src/test/java/org/justinvisser/wolfskin/tv/TitleCaptureTest.kt b/app/src/test/java/org/justinvisser/wolfskin/tv/TitleCaptureTest.kt index 55f9d747..270271b1 100644 --- a/app/src/test/java/org/justinvisser/wolfskin/tv/TitleCaptureTest.kt +++ b/app/src/test/java/org/justinvisser/wolfskin/tv/TitleCaptureTest.kt @@ -87,7 +87,7 @@ class TitleCaptureTest { data.cast.forEach { person -> person.portrait?.let { add(Triple(it, 200, 200)) } } data.related.forEach { item -> item.poster?.let { add(Triple(it, 256, 384)) } } data.episodes.forEach { item -> - (if (item.isPosterBackdrop) item.poster else item.backdrop)?.let { add(Triple(it, (WolfskinTokens.Card.tileW.value * 2).toInt(), (WolfskinTokens.Card.tileH.value * 2).toInt())) } + (if (item.isPosterBackdrop) item.poster else item.backdrop)?.let { add(Triple(it, (WolfskinTokens.Title.episodeCardWidth.value * 2).toInt(), (WolfskinTokens.Title.episodeCardHeight.value * 2).toInt())) } } } return requests.distinctBy { it.first }.associate { (path, width, height) -> @@ -170,7 +170,10 @@ class TitleCaptureTest { @Test fun st044_versions() = capture(44) { state, shell -> open(state, shell, "version") } @Test fun st045_rememberedVersion() = capture(45) { state, _ -> state.versionId = "v2"; option(state, "version") } @Test fun st046_tracks() = capture(46) { state, shell -> open(state, shell, "tracks") } - @Test fun st047_mediaInfo() = capture(47) { state, shell -> open(state, shell, "more") } + @Test fun st047_mediaInfo() = capture(47) { state, shell -> + open(state, shell, "menu") + state.clickSheet(0) + } @Test fun st048_trailerNoApp() = capture(48) { state, _ -> option(state, "trailer"); state.toast = "No YouTube app is available to play this trailer." } @Test fun st049_watched() = capture(49, transform = { it.copy(watchTarget = it.watchTarget.copy(watched = true)) }) { state, _ -> option(state, "watched") } @Test fun st050_seriesConfirm() = capture(50, "Andor") { state, shell -> open(state, shell, "watched") } @@ -216,4 +219,8 @@ class TitleCaptureTest { @Test fun st108_resumingEpisodeRatings() = capture(108, "episode-sex-and-the-country-2001-s4e9", "mindel") @Test fun st109_noRatings() = capture(109, "The Amazing Spider-Man") @Test fun spacingCastFirstFocused() = capture(114) { state, _ -> state.focus("cast", 0) } + @Test fun round2Menu() = capture(120) { state, shell -> open(state, shell, "menu") } + @Test fun round2MussoliniResume() = capture(121, "episode-episode-1-2025-s1e1", "shared", transform = { it.copy(episodes = it.episodes.distinctBy { episode -> episode.id }) }) + @Test fun round2Episodes() = capture(122, "episode-episode-1-2025-s1e1", "shared", transform = { it.copy(episodes = it.episodes.distinctBy { episode -> episode.id }) }) { state, _ -> state.focus("episodes", 0) } + @Test fun round2NotHeldSeries() = capture(123, "90 Day Fiancé: Before the 90 Days") } diff --git a/app/src/test/java/org/justinvisser/wolfskin/tv/TitleKeysTest.kt b/app/src/test/java/org/justinvisser/wolfskin/tv/TitleKeysTest.kt index 0b6dac33..4e664da3 100644 --- a/app/src/test/java/org/justinvisser/wolfskin/tv/TitleKeysTest.kt +++ b/app/src/test/java/org/justinvisser/wolfskin/tv/TitleKeysTest.kt @@ -14,9 +14,6 @@ import androidx.compose.ui.test.onNodeWithText import androidx.compose.ui.test.assertIsDisplayed import androidx.compose.ui.test.hasClickAction import androidx.compose.ui.test.hasText -import androidx.compose.ui.test.performSemanticsAction -import androidx.compose.ui.semantics.SemanticsActions -import androidx.compose.ui.text.TextLayoutResult import org.justinvisser.wolfskin.design.WolfskinTokens import org.json.JSONArray import org.json.JSONObject @@ -36,7 +33,6 @@ import org.justinvisser.wolfskin.design.TitleSurface import org.justinvisser.wolfskin.design.WolfskinTheme import org.robolectric.RobolectricTestRunner import org.robolectric.annotation.Config -import org.robolectric.annotation.GraphicsMode /** Approved title-entry suffixes replay Android keys through the rendered title and shared shell. */ @RunWith(RobolectricTestRunner::class) @@ -284,15 +280,6 @@ class TitleKeysTest { press(Key.DirectionDown) assertEquals(expected, state.row) } - @Test fun seriesOptionsKeepTheCompleteHouseholdActionLabel() { - render("Andor") - val layouts = mutableListOf() - rule.onNodeWithText("Add for someone", useUnmergedTree = true) - .performSemanticsAction(SemanticsActions.GetTextLayoutResult) { action -> action(layouts) } - val layout = layouts.single() - assertFalse("The final pill must not shrink to its icon or a clipped label", layout.didOverflowWidth) - assertEquals(1, layout.lineCount) - } @Test fun anInitialLoadNeverExposesStaleCastBelowItsSkeleton() { render(transform = { it.copy(status = TitleReadStatus.Loading) }) @@ -315,26 +302,6 @@ class TitleKeysTest { assertEquals(WolfskinTokens.Title.factsGap.value * 2, genre.left - year.right, 1f) } - @GraphicsMode(GraphicsMode.Mode.NATIVE) - @Test fun mediaInfoRowsPreserveTheirDeclaredLineBoxes() { - render() - rule.runOnIdle { state.focus("header", state.options.indexOfFirst { it.id == "more" }) } - press(Key.DirectionCenter) - val container = rule.onNodeWithText("Container", useUnmergedTree = true).fetchSemanticsNode().boundsInRoot - val size = rule.onNodeWithText("Size", useUnmergedTree = true).fetchSemanticsNode().boundsInRoot - val expectedPitch = (WolfskinTokens.Title.infoKey.lineHeight.value + WolfskinTokens.Title.infoRowGap.value) * 2 - assertEquals("Stacked facts retain the CSS line-box rhythm", expectedPitch, size.top - container.top, 1f) - val lastSubtitle = rule.onNodeWithText("Dutch", useUnmergedTree = true).fetchSemanticsNode().boundsInRoot - assertEquals("Fourteen stacked lines do not accumulate native pixel rounding", 526.2f, lastSubtitle.top - container.top, 1f) - } - - @GraphicsMode(GraphicsMode.Mode.NATIVE) - @Test fun filmHeaderKeepsItsFixedCssStack() { - render() - assertEquals(358f, rule.onNodeWithTag("title-facts").fetchSemanticsNode().boundsInRoot.top, 1f) - assertEquals(410f, rule.onNodeWithTag("title-overview").fetchSemanticsNode().boundsInRoot.top, 1f) - assertEquals(553f, rule.onNodeWithTag("title-actions").fetchSemanticsNode().boundsInRoot.top, 1f) - } @Test fun everyLongTrackChoiceAndDoneRemainInsideTheVisibleSheet() { diff --git a/app/src/test/java/org/justinvisser/wolfskin/tv/TitleRound2Test.kt b/app/src/test/java/org/justinvisser/wolfskin/tv/TitleRound2Test.kt new file mode 100644 index 00000000..d0c503e4 --- /dev/null +++ b/app/src/test/java/org/justinvisser/wolfskin/tv/TitleRound2Test.kt @@ -0,0 +1,197 @@ +package org.justinvisser.wolfskin.tv + +import android.app.Application +import android.graphics.BitmapFactory +import androidx.activity.ComponentActivity +import androidx.activity.compose.BackHandler +import androidx.compose.foundation.layout.requiredSize +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 androidx.compose.ui.unit.dp +import com.github.takahirom.roborazzi.captureRoboImage +import org.json.JSONObject +import org.junit.Assert.* +import org.junit.Rule +import org.junit.Test +import org.junit.runner.RunWith +import org.justinvisser.wolfskin.design.* +import org.robolectric.RobolectricTestRunner +import org.robolectric.annotation.Config +import org.robolectric.annotation.GraphicsMode +import java.io.File + +/** Native title interactions keep secondary actions, focus and section geometry intact. */ +@RunWith(RobolectricTestRunner::class) +@Config(sdk = [34], application = Application::class, qualifiers = "w960dp-h540dp-xhdpi") +@GraphicsMode(GraphicsMode.Mode.NATIVE) +class TitleRound2Test { + @get:Rule val rule = createAndroidComposeRule() + private val effects = mutableListOf() + private lateinit var state: TitleState + private lateinit var shell: ShellState + + private fun fixture() = TitleFixture(rule.activity.assets.open("wolfskin-titles.json").bufferedReader().use { JSONObject(it.readText()) }) + private fun render(title: String = "Locke", person: String = "justin", transform: (TitlePageData) -> TitlePageData = { it }) { + val fixture = fixture() + shell = ShellState(fixture.people, person).apply { pickerVisible = false; pickerLaunch = false; enterDestination("title/test") } + state = TitleState(transform(fixture.title(title, person)), shell.person, effects::add) + rule.setContent { WolfskinTheme { + BackHandler { state.back(shell) } + TitleSurface(state, shell, Modifier.requiredSize(960.dp, 540.dp)) + } } + } + private fun press(key: Key) = rule.onRoot().performKeyInput { pressKey(key) } + private fun menu() { + rule.runOnIdle { state.focus("header", state.options.indexOfFirst { it.id == "menu" }) } + press(Key.DirectionCenter) + rule.mainClock.advanceTimeBy(300) + rule.waitForIdle() + rule.onNodeWithTag("title-menu", useUnmergedTree = true).assertIsDisplayed() + } + + @Test fun menuBackRestoresItsButtonAndKeepsMediaInfoAndRequestReachable() { + render() + menu() + val opener = state.column + press(Key.Back) + assertNull(state.sheet) + assertEquals(opener, state.column) + assertFalse(shell.navFocused) + press(Key.DirectionCenter) + assertEquals(TitleSheet.Menu, state.sheet) + press(Key.DirectionCenter) + assertEquals(TitleSheet.MediaInfo, state.sheet) + rule.onNodeWithText("Container", useUnmergedTree = true).assertIsDisplayed() + press(Key.Back) + assertEquals(TitleSheet.Menu, state.sheet) + press(Key.DirectionDown) + press(Key.DirectionCenter) + assertNull(state.sheet) + assertEquals(TitleAction.Request(state.data.item, true), effects.single()) + assertEquals(opener, state.column) + } + + @Test fun notHeldMovieKeepsRequestAndHideOutsideTheMenu() { + render("The Amazing Spider-Man") + rule.onNodeWithTag("title-option-request").assertIsDisplayed() + rule.onNodeWithTag("title-option-hide").assertIsDisplayed() + menu() + rule.onNodeWithText("Add for someone").assertIsDisplayed() + press(Key.DirectionCenter) + assertEquals(TitleAction.Request(state.data.item, true), effects.single()) + } + + @Test fun movieCastStacksAfterActionsInsteadOfAReservedHeaderHeight() { + render("The Amazing Spider-Man") + val actions = rule.onNodeWithTag("title-actions").fetchSemanticsNode().boundsInRoot + val cast = rule.onNodeWithTag("title-cast").fetchSemanticsNode().boundsInRoot + val gap = cast.top - actions.bottom + assertTrue("Cast must follow the action caption and normal section gap: $gap", gap in 40f..110f) + rule.runOnIdle { state.update(state.data.copy(requestedStage = "Searching", requestActor = "Justin")) } + val requestedActions = rule.onNodeWithTag("title-actions").fetchSemanticsNode().boundsInRoot + val requestedCast = rule.onNodeWithTag("title-cast").fetchSemanticsNode().boundsInRoot + assertEquals("An actual readiness line belongs in the stack, not a fixed empty header", gap, requestedCast.top - requestedActions.bottom, 1f) + } + + @Test fun mussoliniResumeHasOnlyFactsRatingsAndNoRedundantSeasonsAction() { + render("episode-episode-1-2025-s1e1", "shared") { data -> data.copy(episodes = data.episodes.distinctBy { it.id }) } + assertTrue(state.data.playTarget!!.resumeSeconds > 0) + rule.onNodeWithTag("title-progress").assertIsDisplayed() + rule.onAllNodesWithContentDescription("rtCritics", useUnmergedTree = true).assertCountEquals(1) + rule.onNodeWithText("Ep", useUnmergedTree = true).assertDoesNotExist() + rule.onNodeWithTag("title-option-seasons").assertDoesNotExist() + press(Key.DirectionDown) + assertEquals("seasons", state.row) + rule.onNode(hasText("Season 1") and hasClickAction()).assertIsDisplayed() + } + + @Test fun seriesKeepsItsSeasonsShortcutAndSecondaryRequestUsesTheMenu() { + render("Andor") + rule.onNodeWithTag("title-option-seasons").assertIsDisplayed() + menu() + rule.onNodeWithText("Add for someone").assertIsDisplayed() + press(Key.Back) + press(Key.DirectionRight) + press(Key.DirectionCenter) + assertEquals("seasons", state.row) + } + + @Test fun episodeSkeletonAndReadySlotsMatchWhileMetadataFitsOneLine() { + render("Andor") { it.copy(episodesStatus = TitleReadStatus.Loading) } + rule.runOnIdle { shell.view.listState.requestScrollToItem(2) } + rule.waitForIdle() + val skeleton = rule.onNodeWithTag("title-episode-slot-0").fetchSemanticsNode().boundsInRoot + rule.runOnIdle { state.update(state.data.copy(episodesStatus = TitleReadStatus.Ready)) } + val ready = rule.onNodeWithTag("title-episode-slot-0").fetchSemanticsNode().boundsInRoot + assertEquals(skeleton, ready) + assertEquals(16f / 9f, ready.width / ready.height, 0.01f) + rule.runOnIdle { state.focus("episodes", 0) } + val episode = state.data.episodes.first() + rule.onNodeWithText(episode.episodeLabel + " · " + episode.runtime).assertIsDisplayed() + rule.onNode(hasText(episode.seriesName!!) and hasAnyAncestor(hasTestTag("title-episode-slot-0")), useUnmergedTree = true).assertDoesNotExist() + } + + @Test fun revokedRequestsDisappearFromMenuWithoutRemovingMediaInfo() { + render() + menu() + rule.runOnIdle { state.update(state.data.copy(requestsAllowed = false)) } + rule.onNodeWithText("Add for someone").assertDoesNotExist() + press(Key.DirectionCenter) + assertEquals(TitleSheet.MediaInfo, state.sheet) + rule.onNodeWithText("Container", useUnmergedTree = true).assertIsDisplayed() + } + + @Test fun menuFadeFramesStayMonotonicAndNeverTranslate() { + render { it.copy(item = it.item.copy(backdrop = null, poster = null), cast = emptyList(), related = emptyList()) } + rule.mainClock.autoAdvance = false + val directory = File(requireNotNull(System.getProperty("wolfskin.captureDirectory")), "title-menu-motion").apply { mkdirs() } + rule.runOnIdle { state.click("header", state.options.indexOfFirst { it.id == "menu" }) } + var waited = 0 + while (rule.onAllNodesWithTag("title-menu", useUnmergedTree = true).fetchSemanticsNodes().isEmpty() && waited <= WolfskinTokens.Motion.base + 100) { + rule.mainClock.advanceTimeByFrame() + rule.waitForIdle() + waited += 16 + } + val bounds = rule.onNodeWithTag("title-menu", useUnmergedTree = true).fetchSemanticsNode().boundsInRoot + val samples = mutableListOf() + fun frame(phase: String, index: Int): Int { + val file = File(directory, "$phase-${index.toString().padStart(2, '0')}.png") + rule.onRoot().captureRoboImage(file.absolutePath) + val image = BitmapFactory.decodeFile(file.absolutePath) + val brightness = try { + val pixel = image.getPixel((bounds.left + 40).toInt(), (bounds.top + 6).toInt()) + android.graphics.Color.red(pixel) + android.graphics.Color.green(pixel) + android.graphics.Color.blue(pixel) + } finally { image.recycle() } + samples.add("$phase,${index * 16},$brightness,${bounds.left},${bounds.top}") + return brightness + } + fun assertMonotonic(values: List) { + assertNotEquals("Fade must visibly change the menu pixel", values.first(), values.last()) + val direction = if (values.last() > values.first()) 1 else -1 + values.zipWithNext().forEach { (before, after) -> + assertTrue("Menu alpha must not flicker or snap back: $values", (after - before) * direction >= -1) + } + } + val entering = List(14) { index -> + rule.waitForIdle() + assertEquals(bounds, rule.onNodeWithTag("title-menu", useUnmergedTree = true).fetchSemanticsNode().boundsInRoot) + frame("open", index).also { rule.mainClock.advanceTimeBy(16); rule.waitForIdle() } + } + assertMonotonic(entering) + rule.runOnIdle { state.back(shell) } + val leaving = mutableListOf() + repeat(9) { index -> + rule.waitForIdle() + if (rule.onAllNodesWithTag("title-menu", useUnmergedTree = true).fetchSemanticsNodes().isNotEmpty()) leaving.add(frame("close", index)) + rule.mainClock.advanceTimeBy(16) + } + assertMonotonic(leaving) + rule.onNodeWithTag("title-menu").assertDoesNotExist() + File(directory, "samples.csv").writeText("phase,timeMs,brightness,leftPx,topPx\n" + samples.joinToString("\n")) + rule.mainClock.autoAdvance = true + press(Key.DirectionCenter) + assertEquals(TitleSheet.Menu, state.sheet) + } +} diff --git a/docker/README.md b/docker/README.md index 5cd6f6fa..c342ac2f 100644 --- a/docker/README.md +++ b/docker/README.md @@ -627,18 +627,18 @@ retain the approved frames except for the requested feedback changes: that may still be cancelled; first mount restores the intended shelf item. Discover uses all six approved poster slots. Person keeps the final column at full poster width. -- Episode images choose episode Primary, then Thumb, then the already-read - series Backdrop. Episode cards are 400×280 px; cast retains its 68 px clearance. - Episode context uses the episode overview, with two summary lines on the - focused card. Held-series headers measure their copy/actions and leave 40 px - before seasons instead of reserving a fixed empty 900 px header. +- Episode images choose episode Primary, then Thumb, then the already-read series Backdrop. + Title episode cards now use 448×252 px (16:9), with a one-line focused summary, + no show-name overlay, and `S1 E3 · duration` plus the watched check on one line. + Ready title headers measure their copy/actions and leave 40 px before the + following section; series retain the empty 56 px readiness reservation. - Every Discover heading uses a 104 px band, including a 60 px title-to-card gap. Home heading geometry is unchanged. - Hero/backdrop decoding is bounded to 1920×1080; upright hero sheets request 420×630. The shared request builder replaces conflicting Jellyfin size/encoding parameters: cards use WebP quality 80, while hero backdrops/sheets retain JPEG quality 96. Poster/tile/wide/episode requests are 256×384, 400×225, 544×306 and - 400×280; Jellyfin fill preserves aspect ratio before the UI crops. Tags and + 448×252; Jellyfin fill preserves aspect ratio before the UI crops. Tags and authentication query parameters remain intact. TMDB uses w342 for posters and portraits, w780 for landscape cards and w1280 for hero backdrops, never original. Lossy WebP decoding is guaranteed below minSdk 23; API 28+ additionally checks @@ -690,6 +690,24 @@ pop a pushed title/person from content **or** nav. Initial loading has no conten target: Down stays in nav, while Back can still leave the title. A missing rating set emits no layout node, so it cannot reserve an invisible extra facts gap. +The title Menu replaces the old More/media-info icon action and contains Media +info plus Add for someone when requests are permitted. It is shared by held +movie, series and episode pages; non-held pages expose Add for someone in the +same menu while Request/status and Not interested/Show again stay in the row. +Version, tracks, watched and trailer controls keep their existing positions. +Back from Media info returns to the menu; Back from the menu restores its +opening button. The compact menu fades in for 160 ms and out for 100 ms using +the motion ease-out token, with reduced-motion fades capped at 80 ms. Only the +bounded menu panel owns an alpha layer; the scrim is static. Controls +use the shared 4 px straddle focus ring. Episode details omit the redundant +Seasons shortcut; series pages retain it. Ratings appear only in the header +facts line, not again beside progress, on movie, series and episode pages. + +Episode shelf skeletons use the same width, height and pitch as ready cards. +Person filmography and related-title shelves remain posters; Home continuation +tiles are a separate component and retain their series-name context. + + `TitleReadFacade` confines SDK reads, server/user-scoped last-good disk copies, person credits and playback mutations to one boundary. `ConductarrTitlePages` adds real detail, cast, related titles, performer/filmography, readiness/receipts @@ -714,7 +732,10 @@ headers, sheets, cast and poster captions without changing shared type tokens. Fractional stack placement rounds only the accumulated position, preventing a one-pixel-per-row drift. Focused controls share the canonical lift shadow. `TitleKeysTest` replays real Android keys, including modal Back/hold, nearest-row -entry, remembered columns, long-sheet visibility and full media-table geometry. +entry, remembered columns and long-sheet visibility. `TitleRound2Test` covers +menu Back/focus, preserved media info and household requests, movie stacking, +the Mussolini resume example, request revocation, episode skeleton geometry, +and native menu fade frames sampled in 16 ms steps. `TitleAsyncFocusTest` delays title and person replies through the real destination and view-model, then exercises content focus and hardware Back. Subtitles Off maps to the player's explicit disabled sentinel, not its unspecified default. diff --git a/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/TitleArtwork.kt b/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/TitleArtwork.kt index 3a3efd9b..8adac820 100644 --- a/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/TitleArtwork.kt +++ b/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/TitleArtwork.kt @@ -136,13 +136,13 @@ fun TitleTileArtwork(item: TitleItem) { val filter = remember { ColorFilter.colorMatrix(ColorMatrix().apply { for (index in 0 until 15) values[index] *= D.tileAmbientBrightness }) } - TitleArtwork(item.poster, WolfskinTokens.Image.episodeRequestWidth, WolfskinTokens.Image.episodeRequestHeight, Modifier.fillMaxSize().artOverscan(D.tileAmbientInset).blur(D.tileAmbientBlur), colorFilter = filter) + TitleArtwork(item.poster, (D.episodeCardWidth.value * 2).toInt(), (D.episodeCardHeight.value * 2).toInt(), Modifier.fillMaxSize().artOverscan(D.tileAmbientInset).blur(D.tileAmbientBlur), colorFilter = filter) val height = D.episodeCardHeight - D.tileSheetInset * 2 val shape = remember { RoundedCornerShape(D.tileSheetRadius) } val shadow = remember { Shadow(radius = D.tileShadowRadius, offset = DpOffset.Zero.copy(y = D.tileShadowY), color = Color.Black.copy(alpha = D.tileShadowOpacity)) } TitleArtwork(item.poster, (height.value * 2 * 2 / 3).toInt(), (height.value * 2).toInt(), Modifier.offset(D.tileSheetLeft, D.tileSheetInset).size(height * (2f / 3), height).dropShadow(shape, shadow).clip(shape), poster = true) - } else TitleArtwork(item.backdrop, WolfskinTokens.Image.episodeRequestWidth, WolfskinTokens.Image.episodeRequestHeight, Modifier.fillMaxSize()) + } else TitleArtwork(item.backdrop, (D.episodeCardWidth.value * 2).toInt(), (D.episodeCardHeight.value * 2).toInt(), Modifier.fillMaxSize()) } } 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 5b2a7c47..85c224bf 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 @@ -74,7 +74,7 @@ fun TitleHeader(state: TitleState, active: Boolean, modifier: Modifier = Modifie 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)) } - state.data.playTarget?.let { target -> + state.data.playTarget?.takeIf { it.resumeSeconds > 0 || it.kind == "episode" }?.let { target -> Row(Modifier.exactLineBox(TitleTextStyles.progress, top = D.progressLift, bottom = D.progressBottom).testTag("title-progress"), horizontalArrangement = Arrangement.spacedBy(D.progressGap), verticalAlignment = Alignment.CenterVertically) { if (target.resumeSeconds > 0) { Box(Modifier.size(D.progressWidth, L.progressBar).background(C.progressTrack, RoundedCornerShape(WolfskinTokens.Radius.progress))) { @@ -83,7 +83,6 @@ fun TitleHeader(state: TitleState, active: Boolean, modifier: Modifier = Modifie } Text((if (target.kind == "episode") "${target.episodeLabel} · " else "") + "${((target.runtimeSeconds - target.resumeSeconds + 30) / 60).coerceAtLeast(1)} min left", color = C.muted, style = TitleTextStyles.progress) } else if (target.kind == "episode") Text("Up next: ${target.episodeLabel} · ${target.name}", color = C.muted, style = TitleTextStyles.progress) - if (state.data.connection != TitleConnection.SignedOut) TitleRatings(target.ratings, style = TitleTextStyles.progress) } } Row(Modifier.wrapContentWidth(Alignment.Start, unbounded = true).testTag("title-actions"), horizontalArrangement = Arrangement.spacedBy(L.buttonGap)) { diff --git a/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/TitleMenu.kt b/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/TitleMenu.kt new file mode 100644 index 00000000..2195ff72 --- /dev/null +++ b/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/TitleMenu.kt @@ -0,0 +1,69 @@ +package org.justinvisser.wolfskin.design + +import androidx.compose.animation.core.Animatable +import androidx.compose.animation.core.tween +import androidx.compose.foundation.background +import androidx.compose.foundation.clickable +import androidx.compose.foundation.focusable +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.offset +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.remember +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.focus.FocusRequester +import androidx.compose.ui.focus.focusRequester +import androidx.compose.ui.input.key.KeyEventType +import androidx.compose.ui.input.key.onPreviewKeyEvent +import androidx.compose.ui.input.key.type +import androidx.compose.ui.platform.testTag +import androidx.compose.ui.graphics.graphicsLayer +import androidx.tv.material3.Text +import org.justinvisser.wolfskin.design.WolfskinTokens.Color as C +import org.justinvisser.wolfskin.design.WolfskinTokens.Layout as L +import org.justinvisser.wolfskin.design.WolfskinTokens.Title as D + +/** A compact modal keeps secondary actions reachable without growing the header row. */ +@Composable +internal fun TitleMenu(state: TitleState, shell: ShellState) { + val visible = state.sheet == TitleSheet.Menu && !shell.pickerVisible + val focus = remember { FocusRequester() } + val reduced = reducedMotion() + val opacity = remember { Animatable(0f) } + LaunchedEffect(visible, reduced) { + if (visible) focus.requestFocus() + opacity.animateTo(if (visible) 1f else 0f, + tween(motionDuration(if (visible) WolfskinTokens.Motion.base else WolfskinTokens.Motion.captionOut, reduced), easing = WolfskinTokens.Motion.easeOut)) + } + if (visible || opacity.value > 0f) { + Box(Modifier.fillMaxSize().background(C.sheetScrim) + .clickable { if (visible) state.back(shell) } + .focusRequester(focus).onPreviewKeyEvent { event -> + val key = readShellKey(event) + visible && key != null && state.key(key, event.type == KeyEventType.KeyDown, event.nativeKeyEvent.repeatCount, shell) + }.focusable()) { + Column(Modifier.align(Alignment.TopEnd).offset(-L.safeSide, D.heroSheetTop).width(D.actionMenuWidth) + .graphicsLayer { alpha = opacity.value } + .background(C.surface, RoundedCornerShape(WolfskinTokens.Radius.panel)) + .padding(D.receiptSide).testTag("title-menu"), verticalArrangement = Arrangement.spacedBy(D.sheetGap)) { + Text("Menu", color = C.fg, style = WolfskinTokens.Type.rowHeading) + state.menuOptions.forEachIndexed { index, option -> + val focused = visible && state.sheetIndex == index + TitleControl(option, focused, + Modifier.fillMaxWidth(), + onFocus = { if (visible) state.sheetIndex = index }, overArtwork = false) { + if (visible) state.clickSheet(index) + } + } + } + } + } +} diff --git a/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/TitleModel.kt b/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/TitleModel.kt index 1fb71dd9..ab6379fc 100644 --- a/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/TitleModel.kt +++ b/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/TitleModel.kt @@ -63,7 +63,7 @@ data class TitleVersion( enum class TitleReadStatus { Ready, Loading, Unavailable } enum class TitleConnection { SignedIn, SignedOut, Offline } -enum class TitleSheet { Version, Tracks, MediaInfo, ConfirmWatched, HiddenReceipt } +enum class TitleSheet { Menu, Version, Tracks, MediaInfo, ConfirmWatched, HiddenReceipt } /** A title read includes related titles and the selected season; reads happen outside composition. */ @Immutable diff --git a/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/TitleOptionsSheet.kt b/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/TitleOptionsSheet.kt index 3a87dcdd..49039828 100644 --- a/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/TitleOptionsSheet.kt +++ b/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/TitleOptionsSheet.kt @@ -62,7 +62,7 @@ fun TitleOptionsSheet(state: TitleState, shell: ShellState) { TitleSheet.MediaInfo -> MediaInformation(state) TitleSheet.ConfirmWatched -> WatchedConfirmation(state) TitleSheet.HiddenReceipt -> HiddenReceipt(state) - null -> Unit + TitleSheet.Menu, null -> Unit } } } diff --git a/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/TitlePage.kt b/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/TitlePage.kt index cefe5064..44800e1b 100644 --- a/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/TitlePage.kt +++ b/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/TitlePage.kt @@ -69,7 +69,8 @@ fun TitleSurface(state: TitleState, shell: ShellState, modifier: Modifier = Modi androidx.compose.runtime.CompositionLocalProvider(LocalTitleFocusOwner provides focusOwner) { ShellSurface(shell, handlesContentKeys = false) { TitlePage(state, shell, it) } } - if (!shell.pickerVisible && state.sheet != null) TitleOptionsSheet(state, shell) + if (!shell.pickerVisible && state.sheet != null && state.sheet != TitleSheet.Menu) TitleOptionsSheet(state, shell) + TitleMenu(state, shell) if (!shell.pickerVisible) TitleNotice(state.toast, Modifier.align(Alignment.TopEnd)) { state.toast = null } } } @@ -91,9 +92,8 @@ fun TitlePage(state: TitleState, shell: ShellState, modifier: Modifier = Modifie var wheelPending by remember { mutableStateOf(false) } val focus = remember { FocusRequester() } var measuredHeaderHeight by remember(state.data.item.id) { mutableStateOf(L.row_dhead) } - val compactSeries = state.data.status == TitleReadStatus.Ready && state.data.item.held && - state.data.item.kind == "tv" && state.data.item.seasons > 0 - val headerHeight = if (compactSeries) measuredHeaderHeight else L.row_dhead + val compactHeader = state.data.status == TitleReadStatus.Ready + val headerHeight = if (compactHeader) measuredHeaderHeight else L.row_dhead LaunchedEffect(state.sheet, shell.navFocused, shell.pickerVisible) { if (state.sheet == null && !shell.navFocused && !shell.pickerVisible) focus.requestFocus() } @@ -154,7 +154,7 @@ fun TitlePage(state: TitleState, shell: ShellState, modifier: Modifier = Modifie val active = state.row == id && !shell.navFocused && state.sheet == null when (id) { "header" -> Box(Modifier.height(headerHeight)) { - TitleHeader(state, active, if (compactSeries) Modifier.onSizeChanged { size -> + TitleHeader(state, active, if (compactHeader) Modifier.onSizeChanged { size -> measuredHeaderHeight = L.detailCopyTop + D.lineLift + with(density) { size.height.toDp() } + D.seriesHeaderGap } else Modifier) } diff --git a/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/TitlePrefetch.kt b/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/TitlePrefetch.kt index d4a956fc..fdf8b69d 100644 --- a/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/TitlePrefetch.kt +++ b/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/TitlePrefetch.kt @@ -22,7 +22,7 @@ internal fun TitleRowPrefetch(data: TitlePageData, row: String?) { queue(data.cast[index].portrait, WolfskinTokens.Image.castRequestWidth, WolfskinTokens.Image.castRequestHeight) "episodes" -> for (index in 0 until minOf(data.episodes.size, WolfskinTokens.Card.tileSlots)) { val item = data.episodes[index] - queue(if (item.isPosterBackdrop) item.poster else item.backdrop, WolfskinTokens.Image.episodeRequestWidth, WolfskinTokens.Image.episodeRequestHeight) + queue(if (item.isPosterBackdrop) item.poster else item.backdrop, (WolfskinTokens.Title.episodeCardWidth.value * 2).toInt(), (WolfskinTokens.Title.episodeCardHeight.value * 2).toInt()) } "related" -> for (index in 0 until minOf(data.related.size, WolfskinTokens.Card.posterSlots)) queue(data.related[index].poster, WolfskinTokens.Image.posterRequestWidth, WolfskinTokens.Image.posterRequestHeight, true) diff --git a/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/TitleShelf.kt b/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/TitleShelf.kt index 429a38ce..708bd7ca 100644 --- a/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/TitleShelf.kt +++ b/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/TitleShelf.kt @@ -51,7 +51,7 @@ fun TitleCast(state: TitleState, active: Boolean) { LaunchedEffect(state.column, active, state.data.cast.size) { if (active) track.keepColumnInWindow(state.column, WolfskinTokens.Card.personSlots, pitch, state.data.cast.size) } - Box(Modifier.height(D.castHeight)) { + Box(Modifier.height(D.castHeight).testTag("title-cast")) { Text("Cast", color = C.fg, style = WolfskinTokens.Type.rowHeading, modifier = Modifier.padding(start = L.safeSide).height(L.rowHeadingBand)) LazyRow(state = track, modifier = Modifier.fillMaxWidth().offset(y = L.rowHeadingBand - L.rowGapAfter).height(WolfskinTokens.Card.personH + L.rowGapAfter * 2).skeletonPulse(state.data.castStatus == TitleReadStatus.Loading), contentPadding = PaddingValues(horizontal = L.safeSide, vertical = L.rowGapAfter), horizontalArrangement = Arrangement.spacedBy(WolfskinTokens.Card.personGap)) { @@ -80,11 +80,11 @@ fun TitleCast(state: TitleState, active: Boolean) { fun TitleMediaShelf(state: TitleState, episodes: Boolean, active: Boolean) { val titles = if (episodes) state.data.episodes else state.data.related val id = if (episodes) "episodes" else "related" - val width = if (episodes) WolfskinTokens.Card.tileW else WolfskinTokens.Card.posterW + val width = if (episodes) D.episodeCardWidth else WolfskinTokens.Card.posterW val height = if (episodes) D.episodeCardHeight else WolfskinTokens.Card.posterH val track = rememberLazyListState() - val visible = if (episodes) WolfskinTokens.Card.tileSlots else WolfskinTokens.Card.posterSlots - val pitch = with(LocalDensity.current) { (if (episodes) WolfskinTokens.Card.tilePitch else WolfskinTokens.Card.posterPitch).toPx() } + val visible = if (episodes) ((WolfskinTokens.Shell.stageWidth - L.safeSide * 2 + WolfskinTokens.Card.tileGap) / (D.episodeCardWidth + WolfskinTokens.Card.tileGap)).toInt() else WolfskinTokens.Card.posterSlots + val pitch = with(LocalDensity.current) { (if (episodes) D.episodeCardWidth + WolfskinTokens.Card.tileGap else WolfskinTokens.Card.posterPitch).toPx() } LaunchedEffect(state.column, active, titles.size) { if (active) track.keepColumnInWindow(state.column, visible, pitch, titles.size) } @@ -92,13 +92,13 @@ fun TitleMediaShelf(state: TitleState, episodes: Boolean, active: Boolean) { Text(if (episodes) "Season ${state.season}" else "More like this", color = C.fg, style = WolfskinTokens.Type.rowHeading, modifier = Modifier.padding(start = L.safeSide).height(L.rowHeadingBand)) LazyRow(state = track, modifier = Modifier.fillMaxWidth().offset(y = L.rowHeadingBand - L.rowGapAfter) .height(height + (if (episodes) WolfskinTokens.Shell.zero else WolfskinTokens.Card.posterCaptionBand) + L.rowGapAfter * 2).skeletonPulse(episodes && state.data.episodesStatus == TitleReadStatus.Loading), - contentPadding = PaddingValues(horizontal = L.safeSide, vertical = L.rowGapAfter), horizontalArrangement = Arrangement.spacedBy(WolfskinTokens.Card.posterGap)) { + contentPadding = PaddingValues(horizontal = L.safeSide, vertical = L.rowGapAfter), horizontalArrangement = Arrangement.spacedBy(if (episodes) WolfskinTokens.Card.tileGap else WolfskinTokens.Card.posterGap)) { if (episodes && state.data.episodesStatus == TitleReadStatus.Loading) { - items(visible, key = { "episode-skeleton-$it" }, contentType = { "skeleton" }) { - Box(Modifier.size(width, height).background(C.surface2, RoundedCornerShape(WolfskinTokens.Radius.card))) + items(visible, key = { "episode-skeleton-$it" }, contentType = { "skeleton" }) { index -> + Box(Modifier.size(width, height).testTag("title-episode-slot-$index").background(C.surface2, RoundedCornerShape(WolfskinTokens.Radius.card))) } } else itemsIndexed(titles, key = { _, item -> item.id }, contentType = { _, _ -> if (episodes) "tile" else "poster" }) { index, item -> - TitlePoster(item, active && state.column == index, Modifier.width(width) + TitlePoster(item, active && state.column == index, Modifier.width(width).then(if (episodes) Modifier.testTag("title-episode-slot-$index") else Modifier) .onGloballyPositioned { state.recordCenter(id, index, it.boundsInRoot().center.x) }, episodes, state.data.connection != TitleConnection.SignedOut, onFocus = { state.focus(id, index) }) { state.click(id, index) } } @@ -117,17 +117,16 @@ fun TitlePoster(item: TitleItem, focused: Boolean, modifier: Modifier = Modifier LibraryPoster(item, focused, focused && ratingsVisible, catalogue = alwaysShowCaption, showWatchState = true, modifier = Modifier.titlePointerFocus(onFocus).clickable { focusOwner?.invoke(); onClick() }, offlineArt = offlineArt) } else TitleCard(focused, Modifier.titlePointerFocus(onFocus).clickable { focusOwner?.invoke(); onClick() }, - WolfskinTokens.Card.tileW, D.episodeCardHeight, watched = item.watched, + D.episodeCardWidth, D.episodeCardHeight, watched = item.watched, progress = item.progress.takeIf { it < 1f } ?: 0f) { TitleTileArtwork(item) Column(Modifier.align(Alignment.BottomStart).fillMaxWidth().background(Brush.verticalGradient(listOf(C.bg.copy(alpha = 0f), C.bg.copy(alpha = D.cardOverlayOpacity)))) .padding(start = D.tileOverlaySide, end = D.tileOverlaySide, top = D.tileOverlayTop, bottom = D.tileOverlayBottom)) { - Text(item.seriesName ?: item.name, color = C.fg, style = WolfskinTokens.Type.button, maxLines = 1, overflow = TextOverflow.Ellipsis) - Text("${item.episodeLabel} · ${item.name}", color = C.muted, style = WolfskinTokens.Type.caption, maxLines = 1, overflow = TextOverflow.Ellipsis) - if (focused && item.overview.isNotBlank()) Text(item.overview, color = C.muted, style = WolfskinTokens.Type.caption, maxLines = 2, overflow = TextOverflow.Ellipsis) - if (focused) CardMetadata(null, if (item.runtimeSeconds > 0) item.runtime else null, item.watched && item.resumeSeconds <= 0) + Text(item.name, color = C.fg, style = WolfskinTokens.Type.button, maxLines = 1, overflow = TextOverflow.Ellipsis) + if (focused && item.overview.isNotBlank()) Text(item.overview, color = C.muted, style = WolfskinTokens.Type.caption, maxLines = 1, overflow = TextOverflow.Ellipsis) + CardMetadata(null, item.episodeLabel + if (item.runtimeSeconds > 0) " · ${item.runtime}" else "", item.watched && item.resumeSeconds <= 0) } - androidx.compose.animation.AnimatedVisibility(focused && ratingsVisible, enter = fadeIn(tween(motionDuration(WolfskinTokens.Motion.base, reducedMotion()))), exit = fadeOut(tween(motionDuration(WolfskinTokens.Motion.base, reducedMotion())))) { + androidx.compose.animation.AnimatedVisibility(focused && ratingsVisible, enter = fadeIn(tween(motionDuration(WolfskinTokens.Motion.base, reducedMotion()), easing = WolfskinTokens.Motion.easeOut)), exit = fadeOut(tween(motionDuration(WolfskinTokens.Motion.base, reducedMotion()), easing = WolfskinTokens.Motion.easeOut))) { LibraryRatingPlate(item.ratings, Modifier.fillMaxWidth()) } } diff --git a/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/TitleState.kt b/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/TitleState.kt index d8a6d560..af484aeb 100644 --- a/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/TitleState.kt +++ b/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/TitleState.kt @@ -24,6 +24,13 @@ class TitleState( var sheet by mutableStateOf(null) private set var sheetIndex by mutableStateOf(0) + private var mediaInfoFromMenu = false + val menuOptions: List by derivedStateOf { buildList { + if (target != null) add(TitleOption("info", "Media info", "more", icon = false)) + if (data.connection == TitleConnection.SignedIn && data.requestsAllowed) { + add(TitleOption("add", "Add for someone", "users", icon = false)) + } + } } var season by mutableStateOf(initial.selectedSeason ?: initial.playTarget?.season?.takeIf { it in initial.seasonNumbers } ?: initial.seasonNumbers.firstOrNull() ?: 1) var versionId by mutableStateOf(initial.selectedVersion) var audioIndex by mutableStateOf(initial.selectedAudio) @@ -140,7 +147,7 @@ class TitleState( /** Sheets and shell overlays close first; a pushed title/person pops from content or nav. */ fun back(shell: ShellState) { when { - sheet != null -> sheet = null + sheet != null -> closeSheet() shell.pickerVisible || shell.requestsVisible -> shell.key(ShellKey.Back) else -> onAction(TitleAction.Pop) } @@ -182,13 +189,13 @@ class TitleState( "play", "restart" -> if (data.watchReady) target?.let { onAction(TitleAction.Play(it, id == "restart")) } "version" -> { sheetIndex = data.versions.indexOf(version).coerceAtLeast(0); sheet = TitleSheet.Version } "tracks" -> { sheetIndex = 0; sheet = TitleSheet.Tracks } - "more" -> { sheetIndex = 0; sheet = TitleSheet.MediaInfo } + "menu" -> { sheetIndex = 0; sheet = TitleSheet.Menu } "watched" -> if (data.watchTarget.kind == "tv") { sheetIndex = 0; sheet = TitleSheet.ConfirmWatched } else toggleWatched() "trailer" -> data.trailerUrl?.let { onAction(TitleAction.Trailer(it)) } "seasons" -> focus("seasons", data.seasonNumbers.indexOf(season).coerceAtLeast(0)) "retry" -> onAction(TitleAction.Retry) "back" -> onAction(TitleAction.Pop) - "request", "other", "add" -> onAction(TitleAction.Request(data.item, id != "request")) + "request", "add" -> onAction(TitleAction.Request(data.item, id != "request")) "status" -> onAction(TitleAction.Requests(data.item)) "hide", "unhide" -> { val hidden = id == "hide" @@ -205,9 +212,10 @@ class TitleState( } private fun sheetKey(key: ShellKey) { - if (key == ShellKey.Back) { sheet = null; return } + if (key == ShellKey.Back) { closeSheet(); return } val current = version val size = when (sheet) { + TitleSheet.Menu -> menuOptions.size TitleSheet.Version -> data.versions.size TitleSheet.Tracks -> (current?.audio?.size ?: 0) + (current?.subtitles?.size ?: 0) + 2 TitleSheet.ConfirmWatched, TitleSheet.HiddenReceipt -> 2 @@ -217,6 +225,16 @@ class TitleState( ShellKey.Up, ShellKey.Left -> sheetIndex = (sheetIndex - 1).coerceAtLeast(0) ShellKey.Down, ShellKey.Right -> sheetIndex = (sheetIndex + 1).coerceAtMost(size - 1) ShellKey.Ok -> when (sheet) { + TitleSheet.Menu -> menuOptions.getOrNull(sheetIndex)?.let { option -> + if (option.id == "info") { + mediaInfoFromMenu = true + sheetIndex = 0 + sheet = TitleSheet.MediaInfo + } else { + sheet = null + activateOption(option.id) + } + } TitleSheet.Version -> { val selected = data.versions[sheetIndex] versionId = selected.id @@ -234,11 +252,19 @@ class TitleState( onAction(TitleAction.Hide(data.item, false)) } else onAction(TitleAction.Pop) } - else -> sheet = null + else -> closeSheet() } } } + private fun closeSheet() { + if (sheet == TitleSheet.MediaInfo && mediaInfoFromMenu) { + mediaInfoFromMenu = false + sheetIndex = 0 + sheet = TitleSheet.Menu + } else sheet = null + } + private fun selectTrack(current: TitleVersion) { when { sheetIndex < current.audio.size -> audioIndex = current.audio[sheetIndex].index @@ -274,14 +300,13 @@ fun titleOptions(state: TitleState): List = buildList { val series = data.watchTarget.kind == "tv" add(TitleOption("watched", "Mark ${if (series) "series " else ""}as ${if (state.watched) "unwatched" else "watched"}", if (state.watched) "eye" else "eyeoff", if (data.watchTarget.kind == "episode") data.watchTarget.episodeLabel else "", on = state.watched)) if (data.trailerUrl != null) add(TitleOption("trailer", "Play trailer", "trailer")) - if (target != null) add(TitleOption("more", "More", "more", "Media info")) - if (item.kind == "tv" && item.seasons > 0) add(TitleOption("seasons", "Seasons", "tv", icon = false)) - if (data.connection == TitleConnection.SignedIn && data.requestsAllowed) add(TitleOption("add", "Add for someone", "users", icon = false)) + if (target != null || (data.connection == TitleConnection.SignedIn && data.requestsAllowed)) add(TitleOption("menu", "Menu", "more")) + if (item.kind == "tv" && item.seasons > 0 && data.watchTarget.kind != "episode") add(TitleOption("seasons", "Seasons", "tv", icon = false)) } else if (data.connection != TitleConnection.SignedIn || !data.requestsAllowed) { add(TitleOption("retry", "Try again", "wifi", primary = true, icon = false)) } else { add(TitleOption(if (data.requestedStage == null) "request" else "status", data.requestedStage?.let { "Requested · $it" } ?: "Request", data.requestedStage?.let(::titleStageGlyph) ?: "plus", primary = true, icon = false)) - add(TitleOption("other", "For someone else", "users", icon = false)) + add(TitleOption("menu", "Menu", "more")) if (data.hidden) add(TitleOption("unhide", "Show again", "undo", icon = false)) else if (data.pick) add(TitleOption("hide", "Not interested", "eyeoff", icon = false)) } diff --git a/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/WolfskinTokens.kt b/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/WolfskinTokens.kt index 6c014755..459fd3fc 100644 --- a/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/WolfskinTokens.kt +++ b/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/WolfskinTokens.kt @@ -451,7 +451,9 @@ object WolfskinTokens { const val sheetOpacity = 0.5f } object Title { - val episodeCardHeight = 140.0.dp + val episodeCardWidth = 224.0.dp + val episodeCardHeight = 126.0.dp + val actionMenuWidth = 260.0.dp val seriesHeaderGap = 20.0.dp val lineGap = 11.0.dp val lineLift = -2.0.dp diff --git a/wolfskin-design/title-tokens.json b/wolfskin-design/title-tokens.json index 309913d4..cdcda86e 100644 --- a/wolfskin-design/title-tokens.json +++ b/wolfskin-design/title-tokens.json @@ -1,6 +1,8 @@ { "title": { - "episodeCardHeight": {"dp": 140, "px": 280, "source": "feedback-2026-11-06: episode art height +24.4%"}, + "episodeCardWidth": {"dp": 224, "px": 448, "source": "tv-round2: episode width +12%; 16:9 art"}, + "episodeCardHeight": {"dp": 126, "px": 252, "source": "tv-round2: 16:9 episode art"}, + "actionMenuWidth": {"dp": 260, "px": 520, "source": "tv-round2: compact title action menu"}, "seriesHeaderGap": {"dp": 20, "px": 40, "source": "feedback-2026-11-06: measured series header to seasons"}, "lineGap": {"dp": 11, "px": 22, "source": "index.html:214"}, "lineLift": {"dp": -2, "px": -4, "source": "index.html:214"}, diff --git a/wolfskin-design/tokens.css b/wolfskin-design/tokens.css index cb81f2a5..8ca184ac 100644 --- a/wolfskin-design/tokens.css +++ b/wolfskin-design/tokens.css @@ -290,7 +290,9 @@ --lift-heroY: 30.0px; --lift-sheetBlur: 80.0px; --lift-sheetX: -30.0px; - --title-episodeCardHeight: 280.0px; + --title-episodeCardWidth: 448.0px; + --title-episodeCardHeight: 252.0px; + --title-actionMenuWidth: 520.0px; --title-seriesHeaderGap: 40.0px; --title-lineGap: 22.0px; --title-lineLift: -4.0px; diff --git a/wolfskin-design/tokens.sha256 b/wolfskin-design/tokens.sha256 index a45b7f17..3ef0cd19 100644 --- a/wolfskin-design/tokens.sha256 +++ b/wolfskin-design/tokens.sha256 @@ -2,7 +2,7 @@ b3f096b692c395534177cc82ac68f5b801870bc7e28f0dd5cd7c17e2103920a3 discover-token c29dc64f3c3ca900c747b3db8b270e25908948bf5f76388d2c627a612fc7c99d generate_tokens.py eef4e9f8c91306362540eda617c9efad4e19ed688f205f6f486625a5918d8105 home-tokens.json 192beb887fc34005db99c87ffbcbaf4a057ee2728475fc96224cf89445019b51 shell-tokens.json -3e2ad9a57c15c2ed4713abf383807bb567b27b38eddaaa36c00025e078864e12 src/main/java/org/justinvisser/wolfskin/design/WolfskinTokens.kt -062597ea50dff9e6987485f9a83d412918dba3e10109524c26d3c2332a2f5b73 title-tokens.json -d7d6b17f8258083c879365f01e06729aa0fa12b69b7cd81e1bceabbbe6ab2a7d tokens.css +12420580fed6e6977c8762fb0649f0ed22ca6a8df32d65ea69b3965cd851bb65 src/main/java/org/justinvisser/wolfskin/design/WolfskinTokens.kt +98054296d1de4df2f9f4808d505e512d5906f5f23f7a05e6d846391d93effa5e title-tokens.json +40d763d9023abb56c197201a6a13d3fefacb681713010f849d450c96216e37cb tokens.css ba0da02b03047fbc2f29d538d980e87aa237f8ae36545aa4234600243fe14929 tokens.json From a159c835c54564ce1b935aeda91436811bfb570f Mon Sep 17 00:00:00 2001 From: justinzeus Date: Sat, 7 Nov 2026 15:46:56 +0100 Subject: [PATCH 2/9] Preserve episode height and fix title overlay and poster collisions --- .../wolfskin/tv/TitleCaptureTest.kt | 2 + .../wolfskin/tv/TitleRound2Test.kt | 83 ++++++++++++++++++- docker/README.md | 13 ++- .../wolfskin/design/TitleArtwork.kt | 7 +- .../justinvisser/wolfskin/design/TitleMenu.kt | 2 +- .../justinvisser/wolfskin/design/TitlePage.kt | 5 +- .../wolfskin/design/TitlePrefetch.kt | 2 +- .../wolfskin/design/TitleState.kt | 3 +- .../wolfskin/design/WolfskinTokens.kt | 5 +- .../wolfskin/design/WolfskinImagesTest.kt | 2 +- wolfskin-design/title-tokens.json | 5 +- wolfskin-design/tokens.css | 3 +- wolfskin-design/tokens.json | 4 +- wolfskin-design/tokens.sha256 | 8 +- 14 files changed, 118 insertions(+), 26 deletions(-) diff --git a/app/src/test/java/org/justinvisser/wolfskin/tv/TitleCaptureTest.kt b/app/src/test/java/org/justinvisser/wolfskin/tv/TitleCaptureTest.kt index 270271b1..01a166a0 100644 --- a/app/src/test/java/org/justinvisser/wolfskin/tv/TitleCaptureTest.kt +++ b/app/src/test/java/org/justinvisser/wolfskin/tv/TitleCaptureTest.kt @@ -223,4 +223,6 @@ class TitleCaptureTest { @Test fun round2MussoliniResume() = capture(121, "episode-episode-1-2025-s1e1", "shared", transform = { it.copy(episodes = it.episodes.distinctBy { episode -> episode.id }) }) @Test fun round2Episodes() = capture(122, "episode-episode-1-2025-s1e1", "shared", transform = { it.copy(episodes = it.episodes.distinctBy { episode -> episode.id }) }) { state, _ -> state.focus("episodes", 0) } @Test fun round2NotHeldSeries() = capture(123, "90 Day Fiancé: Before the 90 Days") + @Test fun round2HeldPosterFallback() = capture(124, "The Girl Who Leapt Through Time", transform = { it.copy(item = it.item.copy(backdrop = null, isPosterBackdrop = true, overview = "Short overview.")) }) + @Test fun round2NotHeldShortOverview() = capture(125, "The Amazing Spider-Man", transform = { it.copy(item = it.item.copy(backdrop = null, isPosterBackdrop = true, overview = "Short overview.")) }) } diff --git a/app/src/test/java/org/justinvisser/wolfskin/tv/TitleRound2Test.kt b/app/src/test/java/org/justinvisser/wolfskin/tv/TitleRound2Test.kt index d0c503e4..ff2edf66 100644 --- a/app/src/test/java/org/justinvisser/wolfskin/tv/TitleRound2Test.kt +++ b/app/src/test/java/org/justinvisser/wolfskin/tv/TitleRound2Test.kt @@ -88,11 +88,12 @@ class TitleRound2Test { val actions = rule.onNodeWithTag("title-actions").fetchSemanticsNode().boundsInRoot val cast = rule.onNodeWithTag("title-cast").fetchSemanticsNode().boundsInRoot val gap = cast.top - actions.bottom - assertTrue("Cast must follow the action caption and normal section gap: $gap", gap in 40f..110f) + assertTrue("Cast follows the actions without the old fixed header reservation", gap > 0 && cast.top < WolfskinTokens.Layout.row_dhead.value * 2) rule.runOnIdle { state.update(state.data.copy(requestedStage = "Searching", requestActor = "Justin")) } val requestedActions = rule.onNodeWithTag("title-actions").fetchSemanticsNode().boundsInRoot val requestedCast = rule.onNodeWithTag("title-cast").fetchSemanticsNode().boundsInRoot - assertEquals("An actual readiness line belongs in the stack, not a fixed empty header", gap, requestedCast.top - requestedActions.bottom, 1f) + assertTrue(requestedActions.bottom < requestedCast.top) + assertTrue("A readiness line cannot move Cast backwards or recreate the old reservation", requestedCast.top >= cast.top && requestedCast.top < WolfskinTokens.Layout.row_dhead.value * 2) } @Test fun mussoliniResumeHasOnlyFactsRatingsAndNoRedundantSeasonsAction() { @@ -126,7 +127,8 @@ class TitleRound2Test { rule.runOnIdle { state.update(state.data.copy(episodesStatus = TitleReadStatus.Ready)) } val ready = rule.onNodeWithTag("title-episode-slot-0").fetchSemanticsNode().boundsInRoot assertEquals(skeleton, ready) - assertEquals(16f / 9f, ready.width / ready.height, 0.01f) + assertEquals(448f, ready.width, 0.1f) + assertEquals("Wider cards retain the approved taller episode art", 280f, ready.height, 0.1f) rule.runOnIdle { state.focus("episodes", 0) } val episode = state.data.episodes.first() rule.onNodeWithText(episode.episodeLabel + " · " + episode.runtime).assertIsDisplayed() @@ -143,6 +145,81 @@ class TitleRound2Test { rule.onNodeWithText("Container", useUnmergedTree = true).assertIsDisplayed() } + @Test fun shortOverviewMoviesKeepCastBelowThePosterForHeldAndNotAddedTitles() { + render("The Amazing Spider-Man") { it.copy(item = it.item.copy(overview = "Short overview.", backdrop = null, isPosterBackdrop = true)) } + for (held in listOf(false, true)) { + rule.runOnIdle { state.update(state.data.copy(item = state.data.item.copy(held = held))) } + val poster = rule.onNodeWithTag("title-poster-backdrop").fetchSemanticsNode().boundsInRoot + val firstActor = rule.onNodeWithTag("title-cast-${state.data.cast.first().id}").fetchSemanticsNode().boundsInRoot + assertTrue("Cast clears the poster on held=$held: $firstActor vs $poster", firstActor.top >= poster.bottom + 20f - 0.5f) + } + } + + @Test fun revokingTheOnlyMenuActionClosesAnOtherwiseEmptyMenu() { + render("The Amazing Spider-Man") + menu() + rule.runOnIdle { state.update(state.data.copy(requestsAllowed = false)) } + assertNull(state.sheet) + rule.mainClock.advanceTimeBy(300) + rule.onNodeWithTag("title-menu", useUnmergedTree = true).assertDoesNotExist() + } + + @Test fun mediaInfoKeepsTableTextSeparatedInsteadOfAccumulatingOverlap() { + render() + menu() + press(Key.DirectionCenter) + rule.onNodeWithText("Container", useUnmergedTree = true).assertIsDisplayed() + rule.onNodeWithText("Dutch", useUnmergedTree = true).assertIsDisplayed() + val text = rule.onAllNodes(SemanticsMatcher.keyIsDefined(androidx.compose.ui.semantics.SemanticsActions.GetTextLayoutResult) and hasAnyAncestor(hasTestTag("title-sheet")), useUnmergedTree = true) + .fetchSemanticsNodes().map { it.boundsInRoot }.filter { it.width > 0 && it.height > 0 } + text.forEachIndexed { index, first -> text.drop(index + 1).forEach { second -> + if (minOf(first.right, second.right) > maxOf(first.left, second.left)) { + assertTrue("Media info text must never collide vertically: $first vs $second", minOf(first.bottom, second.bottom) <= maxOf(first.top, second.top)) + } + } } + } + + @Test fun menuToMediaInfoNeverDoubleDimsOrPopsAndBackKeepsOneScrim() { + render { it.copy(item = it.item.copy(backdrop = null, poster = null), cast = emptyList(), related = emptyList()) } + menu() + rule.mainClock.autoAdvance = false + val directory = File(requireNotNull(System.getProperty("wolfskin.captureDirectory")), "title-menu-info-motion").apply { mkdirs() } + rule.runOnIdle { state.clickSheet(0) } + var waited = 0 + while (rule.onAllNodesWithTag("title-sheet", useUnmergedTree = true).fetchSemanticsNodes().isEmpty() && waited <= WolfskinTokens.Motion.base + 100) { + rule.mainClock.advanceTimeByFrame() + rule.waitForIdle() + waited += 16 + } + val records = mutableListOf() + fun frame(phase: String, index: Int): Pair { + val path = File(directory, "$phase-${index.toString().padStart(2, '0')}.png") + rule.onRoot().captureRoboImage(path.absolutePath) + val bitmap = BitmapFactory.decodeFile(path.absolutePath) + fun brightness(x: Int, y: Int): Int { + val pixel = bitmap.getPixel(x, y) + return android.graphics.Color.red(pixel) + android.graphics.Color.green(pixel) + android.graphics.Color.blue(pixel) + } + return try { + (brightness(1900, 900) to brightness(100, 900)).also { records.add("$phase,${index * 16},${it.first},${it.second}") } + } finally { bitmap.recycle() } + } + val info = List(14) { index -> + rule.waitForIdle() + frame("info", index).also { rule.mainClock.advanceTimeBy(16); rule.waitForIdle() } + } + info.forEach { assertEquals("No extra scrim or disappearing overlay may darken or pop over Media info", info.last(), it) } + rule.runOnIdle { state.back(shell) } + repeat(14) { index -> + rule.mainClock.advanceTimeBy(16) + rule.waitForIdle() + assertEquals("Returning to Menu keeps exactly one scrim", info.last().second, frame("back", index).second) + } + rule.onNodeWithTag("title-menu", useUnmergedTree = true).assertExists() + File(directory, "samples.csv").writeText("phase,timeMs,sheetBrightness,leftBrightness\n" + records.joinToString("\n")) + rule.mainClock.autoAdvance = true + } + @Test fun menuFadeFramesStayMonotonicAndNeverTranslate() { render { it.copy(item = it.item.copy(backdrop = null, poster = null), cast = emptyList(), related = emptyList()) } rule.mainClock.autoAdvance = false diff --git a/docker/README.md b/docker/README.md index c342ac2f..5522ca13 100644 --- a/docker/README.md +++ b/docker/README.md @@ -628,17 +628,20 @@ retain the approved frames except for the requested feedback changes: Discover uses all six approved poster slots. Person keeps the final column at full poster width. - Episode images choose episode Primary, then Thumb, then the already-read series Backdrop. - Title episode cards now use 448×252 px (16:9), with a one-line focused summary, - no show-name overlay, and `S1 E3 · duration` plus the watched check on one line. + Title episode cards now use 448×280 px, preserving the taller approved crop. + Focused summaries ellipsise to one line, the show-name overlay is absent, and + `S1 E3 · duration` plus the watched check share one line. Ready title headers measure their copy/actions and leave 40 px before the following section; series retain the empty 56 px readiness reservation. + Poster-only movies floor that stack so Cast circles clear the poster by at + least 20 px, including held movie fallbacks and short overviews. - Every Discover heading uses a 104 px band, including a 60 px title-to-card gap. Home heading geometry is unchanged. - Hero/backdrop decoding is bounded to 1920×1080; upright hero sheets request 420×630. The shared request builder replaces conflicting Jellyfin size/encoding parameters: cards use WebP quality 80, while hero backdrops/sheets retain JPEG quality 96. Poster/tile/wide/episode requests are 256×384, 400×225, 544×306 and - 448×252; Jellyfin fill preserves aspect ratio before the UI crops. Tags and + 448×280; Jellyfin fill preserves aspect ratio before the UI crops. Tags and authentication query parameters remain intact. TMDB uses w342 for posters and portraits, w780 for landscape cards and w1280 for hero backdrops, never original. Lossy WebP decoding is guaranteed below minSdk 23; API 28+ additionally checks @@ -698,7 +701,9 @@ Version, tracks, watched and trailer controls keep their existing positions. Back from Media info returns to the menu; Back from the menu restores its opening button. The compact menu fades in for 160 ms and out for 100 ms using the motion ease-out token, with reduced-motion fades capped at 80 ms. Only the -bounded menu panel owns an alpha layer; the scrim is static. Controls +bounded menu panel owns an alpha layer; the scrim is static. Opening another +sheet immediately removes the menu panel and scrim, preventing double darkening. +An empty menu closes if its last permitted action disappears. Controls use the shared 4 px straddle focus ring. Episode details omit the redundant Seasons shortcut; series pages retain it. Ratings appear only in the header facts line, not again beside progress, on movie, series and episode pages. diff --git a/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/TitleArtwork.kt b/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/TitleArtwork.kt index 8adac820..25f5cffa 100644 --- a/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/TitleArtwork.kt +++ b/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/TitleArtwork.kt @@ -32,6 +32,7 @@ import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.ImageBitmap import androidx.compose.ui.layout.ContentScale import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.platform.testTag import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.text.style.TextOverflow import androidx.tv.material3.Text @@ -110,7 +111,7 @@ fun TitleBackdrop(item: TitleItem, modifier: Modifier = Modifier) { val posterShadow = remember { Shadow(radius = D.posterShadowRadius, offset = DpOffset.Zero.copy(y = D.posterShadowY), color = Color.Black.copy(alpha = D.posterShadowOpacity)) } TitleArtwork(item.poster, (L.heroSheetWidth.value * 2).toInt(), (L.heroSheetHeight.value * 2).toInt(), Modifier.offset(WolfskinTokens.Shell.stageWidth - L.heroSheetWidth - D.heroSheetRight, D.heroSheetTop) - .size(L.heroSheetWidth, L.heroSheetHeight).dropShadow(posterShape, posterShadow).clip(posterShape), poster = true) + .size(L.heroSheetWidth, L.heroSheetHeight).testTag("title-poster-backdrop").dropShadow(posterShape, posterShadow).clip(posterShape), poster = true) } Box(Modifier.fillMaxSize().background(horizontal)) Box(Modifier.fillMaxSize().background(vertical)) @@ -136,13 +137,13 @@ fun TitleTileArtwork(item: TitleItem) { val filter = remember { ColorFilter.colorMatrix(ColorMatrix().apply { for (index in 0 until 15) values[index] *= D.tileAmbientBrightness }) } - TitleArtwork(item.poster, (D.episodeCardWidth.value * 2).toInt(), (D.episodeCardHeight.value * 2).toInt(), Modifier.fillMaxSize().artOverscan(D.tileAmbientInset).blur(D.tileAmbientBlur), colorFilter = filter) + TitleArtwork(item.poster, WolfskinTokens.Image.episodeRequestWidth, WolfskinTokens.Image.episodeRequestHeight, Modifier.fillMaxSize().artOverscan(D.tileAmbientInset).blur(D.tileAmbientBlur), colorFilter = filter) val height = D.episodeCardHeight - D.tileSheetInset * 2 val shape = remember { RoundedCornerShape(D.tileSheetRadius) } val shadow = remember { Shadow(radius = D.tileShadowRadius, offset = DpOffset.Zero.copy(y = D.tileShadowY), color = Color.Black.copy(alpha = D.tileShadowOpacity)) } TitleArtwork(item.poster, (height.value * 2 * 2 / 3).toInt(), (height.value * 2).toInt(), Modifier.offset(D.tileSheetLeft, D.tileSheetInset).size(height * (2f / 3), height).dropShadow(shape, shadow).clip(shape), poster = true) - } else TitleArtwork(item.backdrop, (D.episodeCardWidth.value * 2).toInt(), (D.episodeCardHeight.value * 2).toInt(), Modifier.fillMaxSize()) + } else TitleArtwork(item.backdrop, WolfskinTokens.Image.episodeRequestWidth, WolfskinTokens.Image.episodeRequestHeight, Modifier.fillMaxSize()) } } diff --git a/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/TitleMenu.kt b/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/TitleMenu.kt index 2195ff72..64e848e1 100644 --- a/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/TitleMenu.kt +++ b/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/TitleMenu.kt @@ -43,7 +43,7 @@ internal fun TitleMenu(state: TitleState, shell: ShellState) { opacity.animateTo(if (visible) 1f else 0f, tween(motionDuration(if (visible) WolfskinTokens.Motion.base else WolfskinTokens.Motion.captionOut, reduced), easing = WolfskinTokens.Motion.easeOut)) } - if (visible || opacity.value > 0f) { + if ((visible || opacity.value > 0f) && (state.sheet == null || state.sheet == TitleSheet.Menu)) { Box(Modifier.fillMaxSize().background(C.sheetScrim) .clickable { if (visible) state.back(shell) } .focusRequester(focus).onPreviewKeyEvent { event -> diff --git a/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/TitlePage.kt b/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/TitlePage.kt index 44800e1b..4a79a147 100644 --- a/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/TitlePage.kt +++ b/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/TitlePage.kt @@ -93,7 +93,10 @@ fun TitlePage(state: TitleState, shell: ShellState, modifier: Modifier = Modifie val focus = remember { FocusRequester() } var measuredHeaderHeight by remember(state.data.item.id) { mutableStateOf(L.row_dhead) } val compactHeader = state.data.status == TitleReadStatus.Ready - val headerHeight = if (compactHeader) measuredHeaderHeight else L.row_dhead + val item = state.data.item + val posterFloor = if (item.kind == "movie" && item.poster != null && (item.backdrop == null || item.isPosterBackdrop)) + D.heroSheetTop + L.heroSheetHeight + D.posterCastGap - L.rowHeadingBand else WolfskinTokens.Shell.zero + val headerHeight = if (compactHeader) measuredHeaderHeight.coerceAtLeast(posterFloor) else L.row_dhead LaunchedEffect(state.sheet, shell.navFocused, shell.pickerVisible) { if (state.sheet == null && !shell.navFocused && !shell.pickerVisible) focus.requestFocus() } diff --git a/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/TitlePrefetch.kt b/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/TitlePrefetch.kt index fdf8b69d..d4a956fc 100644 --- a/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/TitlePrefetch.kt +++ b/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/TitlePrefetch.kt @@ -22,7 +22,7 @@ internal fun TitleRowPrefetch(data: TitlePageData, row: String?) { queue(data.cast[index].portrait, WolfskinTokens.Image.castRequestWidth, WolfskinTokens.Image.castRequestHeight) "episodes" -> for (index in 0 until minOf(data.episodes.size, WolfskinTokens.Card.tileSlots)) { val item = data.episodes[index] - queue(if (item.isPosterBackdrop) item.poster else item.backdrop, (WolfskinTokens.Title.episodeCardWidth.value * 2).toInt(), (WolfskinTokens.Title.episodeCardHeight.value * 2).toInt()) + queue(if (item.isPosterBackdrop) item.poster else item.backdrop, WolfskinTokens.Image.episodeRequestWidth, WolfskinTokens.Image.episodeRequestHeight) } "related" -> for (index in 0 until minOf(data.related.size, WolfskinTokens.Card.posterSlots)) queue(data.related[index].poster, WolfskinTokens.Image.posterRequestWidth, WolfskinTokens.Image.posterRequestHeight, true) diff --git a/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/TitleState.kt b/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/TitleState.kt index af484aeb..fad34b31 100644 --- a/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/TitleState.kt +++ b/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/TitleState.kt @@ -26,7 +26,7 @@ class TitleState( var sheetIndex by mutableStateOf(0) private var mediaInfoFromMenu = false val menuOptions: List by derivedStateOf { buildList { - if (target != null) add(TitleOption("info", "Media info", "more", icon = false)) + if (target != null) add(TitleOption("info", "Media info", "", icon = false)) if (data.connection == TitleConnection.SignedIn && data.requestsAllowed) { add(TitleOption("add", "Add for someone", "users", icon = false)) } @@ -72,6 +72,7 @@ class TitleState( if (!pendingSubtitle) subtitleIndex = value.selectedSubtitle if (row !in rows) { row = "header"; column = 0 } column = column.coerceAtMost(rowSize(row).minus(1).coerceAtLeast(0)) + if (sheet == TitleSheet.Menu && menuOptions.isEmpty()) sheet = null } /** Pointer focus and D-pad use the same row memory. */ diff --git a/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/WolfskinTokens.kt b/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/WolfskinTokens.kt index 459fd3fc..e10e109f 100644 --- a/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/WolfskinTokens.kt +++ b/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/WolfskinTokens.kt @@ -262,7 +262,7 @@ object WolfskinTokens { const val heroRequestHeight = 1080 const val heroPosterRequestWidth = 420 const val heroPosterRequestHeight = 630 - const val episodeRequestWidth = 400 + const val episodeRequestWidth = 448 const val episodeRequestHeight = 280 const val castRequestWidth = 200 const val castRequestHeight = 200 @@ -452,9 +452,10 @@ object WolfskinTokens { } object Title { val episodeCardWidth = 224.0.dp - val episodeCardHeight = 126.0.dp + val episodeCardHeight = 140.0.dp val actionMenuWidth = 260.0.dp val seriesHeaderGap = 20.0.dp + val posterCastGap = 10.0.dp val lineGap = 11.0.dp val lineLift = -2.0.dp val overviewGap = 17.0.dp diff --git a/wolfskin-design/src/test/java/org/justinvisser/wolfskin/design/WolfskinImagesTest.kt b/wolfskin-design/src/test/java/org/justinvisser/wolfskin/design/WolfskinImagesTest.kt index dfefe549..a027f92f 100644 --- a/wolfskin-design/src/test/java/org/justinvisser/wolfskin/design/WolfskinImagesTest.kt +++ b/wolfskin-design/src/test/java/org/justinvisser/wolfskin/design/WolfskinImagesTest.kt @@ -18,7 +18,7 @@ class WolfskinImagesTest { Surface("grid/Home/Discover/person poster", 256, 384, "w342"), Surface("Home tile", 400, 225, "w780"), Surface("Home wide", 544, 306, "w780"), - Surface("episode still", 400, 280, "w780"), + Surface("episode still", 448, 280, "w780"), Surface("cast portrait", 200, 200, "w342"), Surface("person portrait", 240, 240, "w342"), Surface("hero poster", 420, 630, "w342", true), diff --git a/wolfskin-design/title-tokens.json b/wolfskin-design/title-tokens.json index cdcda86e..99f15d01 100644 --- a/wolfskin-design/title-tokens.json +++ b/wolfskin-design/title-tokens.json @@ -1,9 +1,10 @@ { "title": { - "episodeCardWidth": {"dp": 224, "px": 448, "source": "tv-round2: episode width +12%; 16:9 art"}, - "episodeCardHeight": {"dp": 126, "px": 252, "source": "tv-round2: 16:9 episode art"}, + "episodeCardWidth": {"dp": 224, "px": 448, "source": "tv-round2: episode width +12%"}, + "episodeCardHeight": {"dp": 140, "px": 280, "source": "feedback-2026-11-06: preserve taller episode cards"}, "actionMenuWidth": {"dp": 260, "px": 520, "source": "tv-round2: compact title action menu"}, "seriesHeaderGap": {"dp": 20, "px": 40, "source": "feedback-2026-11-06: measured series header to seasons"}, + "posterCastGap": {"dp": 10, "px": 20, "source": "tv-round2: minimum poster-to-cast clearance"}, "lineGap": {"dp": 11, "px": 22, "source": "index.html:214"}, "lineLift": {"dp": -2, "px": -4, "source": "index.html:214"}, "overviewGap": {"dp": 17, "px": 34, "source": "index.html:216"}, diff --git a/wolfskin-design/tokens.css b/wolfskin-design/tokens.css index 8ca184ac..8967d79f 100644 --- a/wolfskin-design/tokens.css +++ b/wolfskin-design/tokens.css @@ -291,9 +291,10 @@ --lift-sheetBlur: 80.0px; --lift-sheetX: -30.0px; --title-episodeCardWidth: 448.0px; - --title-episodeCardHeight: 252.0px; + --title-episodeCardHeight: 280.0px; --title-actionMenuWidth: 520.0px; --title-seriesHeaderGap: 40.0px; + --title-posterCastGap: 20.0px; --title-lineGap: 22.0px; --title-lineLift: -4.0px; --title-overviewGap: 34.0px; diff --git a/wolfskin-design/tokens.json b/wolfskin-design/tokens.json index d7db186e..ea56e6d1 100644 --- a/wolfskin-design/tokens.json +++ b/wolfskin-design/tokens.json @@ -1543,9 +1543,9 @@ "source": "feedback-2026-11-06: largest upright hero poster slot" }, "episodeRequest": { - "px": [400, 280], + "px": [448, 280], "decode": "ARGB_8888", - "source": "feedback-2026-11-06: taller episode cards" + "source": "tv-round2: wider episode cards at their preserved height" }, "castRequest": { "px": [ diff --git a/wolfskin-design/tokens.sha256 b/wolfskin-design/tokens.sha256 index 3ef0cd19..bd2e8257 100644 --- a/wolfskin-design/tokens.sha256 +++ b/wolfskin-design/tokens.sha256 @@ -2,7 +2,7 @@ b3f096b692c395534177cc82ac68f5b801870bc7e28f0dd5cd7c17e2103920a3 discover-token c29dc64f3c3ca900c747b3db8b270e25908948bf5f76388d2c627a612fc7c99d generate_tokens.py eef4e9f8c91306362540eda617c9efad4e19ed688f205f6f486625a5918d8105 home-tokens.json 192beb887fc34005db99c87ffbcbaf4a057ee2728475fc96224cf89445019b51 shell-tokens.json -12420580fed6e6977c8762fb0649f0ed22ca6a8df32d65ea69b3965cd851bb65 src/main/java/org/justinvisser/wolfskin/design/WolfskinTokens.kt -98054296d1de4df2f9f4808d505e512d5906f5f23f7a05e6d846391d93effa5e title-tokens.json -40d763d9023abb56c197201a6a13d3fefacb681713010f849d450c96216e37cb tokens.css -ba0da02b03047fbc2f29d538d980e87aa237f8ae36545aa4234600243fe14929 tokens.json +0d69fccfbfada103268bfc38186205cf5f94daeb345a00a6aec585609e7ddb1e src/main/java/org/justinvisser/wolfskin/design/WolfskinTokens.kt +98fa6414cc6663624d09329615cdf0e6599baec570a8d878f5214c95cb33cb48 title-tokens.json +a68f336e8b4373e97a7acaeb02383b4a40dc9f7cc6f6e5413341f14fb2d308eb tokens.css +b1cf8f8caea9bfacb7994e0ba39e71ffab33452f6f5227a524ba71874b045b4d tokens.json From 12e908389ff1be65ea6e1b1dc94df52751f02ca6 Mon Sep 17 00:00:00 2001 From: justinzeus Date: Sat, 7 Nov 2026 15:56:19 +0100 Subject: [PATCH 3/9] Smooth Discover hero handoffs and scope lane focus to each visit --- .../wolfskin/tv/DiscoverRefreshMotionTest.kt | 114 +++++++++ data/tv-spec/spec.md | 13 +- .../wolfskin/design/DiscoverArtwork.kt | 7 +- .../wolfskin/design/DiscoverScreen.kt | 31 ++- .../wolfskin/design/DiscoverShelf.kt | 8 +- .../wolfskin/design/DiscoverState.kt | 35 ++- .../wolfskin/design/HeroMotion.kt | 167 +++++++----- .../wolfskin/design/HomeHeader.kt | 8 +- .../wolfskin/design/TitleArtwork.kt | 5 +- .../design/DiscoverDetailsFocusTest.kt | 65 +++++ .../design/DiscoverHeroSequenceTest.kt | 240 ++++++++++++++++++ .../wolfskin/design/DiscoverKeysTest.kt | 72 +++++- .../design/DiscoverNetworkHeroTest.kt | 91 +++++++ .../wolfskin/design/HeroMotionTest.kt | 32 +++ 14 files changed, 801 insertions(+), 87 deletions(-) create mode 100644 app/src/test/java/org/justinvisser/wolfskin/tv/DiscoverRefreshMotionTest.kt create mode 100644 wolfskin-design/src/test/java/org/justinvisser/wolfskin/design/DiscoverDetailsFocusTest.kt create mode 100644 wolfskin-design/src/test/java/org/justinvisser/wolfskin/design/DiscoverHeroSequenceTest.kt create mode 100644 wolfskin-design/src/test/java/org/justinvisser/wolfskin/design/DiscoverNetworkHeroTest.kt diff --git a/app/src/test/java/org/justinvisser/wolfskin/tv/DiscoverRefreshMotionTest.kt b/app/src/test/java/org/justinvisser/wolfskin/tv/DiscoverRefreshMotionTest.kt new file mode 100644 index 00000000..044ead23 --- /dev/null +++ b/app/src/test/java/org/justinvisser/wolfskin/tv/DiscoverRefreshMotionTest.kt @@ -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() + + @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() + 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() + 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() } + } +} diff --git a/data/tv-spec/spec.md b/data/tv-spec/spec.md index 913f31f5..a614e360 100644 --- a/data/tv-spec/spec.md +++ b/data/tv-spec/spec.md @@ -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. +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 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 | | 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 | +| 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 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 | | 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. @@ -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. +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 diff --git a/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/DiscoverArtwork.kt b/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/DiscoverArtwork.kt index 905deb57..8ba618c3 100644 --- a/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/DiscoverArtwork.kt +++ b/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/DiscoverArtwork.kt @@ -33,6 +33,8 @@ import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.platform.testTag import coil3.compose.AsyncImage 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.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) } }) else null } 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 WolfskinImages.request(context, path, if (upright) WolfskinTokens.Image.heroPosterRequestWidth else WolfskinTokens.Image.heroRequestWidth, 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) { androidx.compose.runtime.SideEffect { heroReady?.invoke() } Image(painter, null, modifier, contentScale = ContentScale.Crop, colorFilter = filter) diff --git a/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/DiscoverScreen.kt b/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/DiscoverScreen.kt index e3137e45..d6aeec0f 100644 --- a/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/DiscoverScreen.kt +++ b/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/DiscoverScreen.kt @@ -1,6 +1,8 @@ // SPDX-License-Identifier: GPL-2.0-only package org.justinvisser.wolfskin.design +import androidx.compose.animation.core.Animatable +import androidx.compose.animation.core.tween import kotlinx.coroutines.coroutineScope import kotlinx.coroutines.launch @@ -47,7 +49,10 @@ fun DiscoverScreen(state: DiscoverState, modifier: Modifier = Modifier) { val focus = remember(state) { object : ShellContentFocus by state {} } DisposableEffect(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(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) } } + HeroMotionScope { Box(modifier.fillMaxSize().testTag("discover-screen")) { 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) } @@ -129,13 +135,15 @@ fun DiscoverScreen(state: DiscoverState, modifier: Modifier = Modifier) { } } } + } } @Composable private fun DiscoverHero(state: DiscoverState) { 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) { 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) @@ -151,17 +159,32 @@ private fun DiscoverHero(state: DiscoverState) { } Spacer(Modifier.height(D.heroMetaGap)) Text(copy.overview, Modifier.width(L.heroOverviewMax), color = C.muted, style = DiscoverType.body, maxLines = 2, overflow = TextOverflow.Ellipsis) + } + } Spacer(Modifier.height(D.heroOverviewGap)) Row(verticalAlignment = Alignment.CenterVertically) { DiscoverButton("Details", state.contentFocused && state.focusedRow == 0, "about") { state.openTitle(title) } 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)) - 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)) } } } +} + +/** 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 diff --git a/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/DiscoverShelf.kt b/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/DiscoverShelf.kt index ed527a73..a4c4faab 100644 --- a/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/DiscoverShelf.kt +++ b/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/DiscoverShelf.kt @@ -40,14 +40,14 @@ fun DiscoverShelf(state: DiscoverState, lane: DiscoverLane, row: Int) { val height = if (services) K.entryH else K.posterH val cards = rememberLazyListState() 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 slots = K.posterSlots - val first = state.shell.view.cards["first:${lane.id}"] ?: 0 + val first = state.laneFirsts[lane.id] ?: 0 val density = LocalDensity.current - LaunchedEffect(column, count, active) { + LaunchedEffect(state.service?.id, column, count, active) { 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() }) } Box(Modifier.fillMaxWidth().height(D.shelfHeadingBand + height + (if (services) WolfskinTokens.Shell.zero else K.posterCaptionBand) + L.rowGapAfter).testTag("discover-lane-${lane.id}")) { diff --git a/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/DiscoverState.kt b/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/DiscoverState.kt index 8b0d8209..b629497e 100644 --- a/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/DiscoverState.kt +++ b/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/DiscoverState.kt @@ -10,7 +10,7 @@ import kotlinx.coroutines.launch import kotlinx.coroutines.sync.Mutex 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( val shell: ShellState, initial: DiscoverSnapshot, @@ -75,6 +75,20 @@ class DiscoverState( private val pendingDismissals = mutableMapOf() private var rootFilter = "all" + private val rootLaneMemory = DiscoverLaneMemory() + private val serviceLaneMemory = mutableMapOf() + 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) { snapshot = value 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 focusedRow: Int get() = shell.view.row.coerceIn(0, (rowCount - 1).coerceAtLeast(0)) 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 isCard: Boolean get() = title == null && focusedRow >= firstShelf && focusedLane != null && focusedLane?.pending != true val contentFocused: Boolean get() = !shell.navFocused && !shell.pickerVisible && overlay == null @@ -152,7 +166,8 @@ class DiscoverState( fun focus(row: Int, column: Int = 0) { 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) { @@ -161,10 +176,7 @@ class DiscoverState( if (target < if (hasHero) 0 else 1) { shell.focusNav(shell.currentTab); return } if (target >= rowCount) return val key = when (target) { 0 -> "phero"; 1 -> "dchips"; else -> lanes[target - firstShelf].id } - val nearest = if (focusedRow >= firstShelf && target >= firstShelf) - column - shell.view.cards["first:$rowKey"].orZero() + shell.view.cards["first:$key"].orZero() - else 0 - focus(target, shell.view.cards[key] ?: nearest.coerceAtLeast(0)) + focus(target, if (target >= firstShelf) laneColumn(key) else shell.view.cards[key].orZero()) } 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 openService(item: DiscoverService) { + serviceLaneMemory.getOrPut(item.id) { DiscoverLaneMemory() } rootFilter = filter; service = item; filter = "movie"; revision++ onPush("discover-service/${item.id}") focus(firstShelf) } 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 } else if (route.startsWith("discover-service") || route.startsWith("discover-see-all")) title = null } @@ -261,6 +275,7 @@ class DiscoverState( ShellKey.Left, ShellKey.Right -> { val direction = if (key == ShellKey.Left) -1 else 1 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()) } ShellKey.Ok -> when { @@ -417,6 +432,12 @@ class DiscoverState( } } +private class DiscoverLaneMemory { + val columns = mutableStateMapOf() + val firsts = mutableStateMapOf() + fun clear() { columns.clear(); firsts.clear() } +} + private fun Int?.orZero(): Int = this ?: 0 private val DISCOVER_FILTERS = listOf("all", "movie", "tv") diff --git a/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/HeroMotion.kt b/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/HeroMotion.kt index 13faf0f5..5211dfd7 100644 --- a/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/HeroMotion.kt +++ b/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/HeroMotion.kt @@ -6,95 +6,138 @@ import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.requiredSize import androidx.compose.foundation.layout.wrapContentSize import androidx.compose.runtime.Composable +import androidx.compose.runtime.CompositionLocalProvider import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.SideEffect +import androidx.compose.runtime.compositionLocalOf import androidx.compose.runtime.getValue +import androidx.compose.runtime.key +import androidx.compose.runtime.mutableStateListOf import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.runtime.setValue +import androidx.compose.runtime.snapshotFlow import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.graphicsLayer -import androidx.compose.ui.unit.Dp +import androidx.compose.ui.platform.LocalDensity import androidx.compose.ui.platform.testTag -import org.justinvisser.wolfskin.design.WolfskinTokens.Motion as M -import kotlinx.coroutines.launch -import androidx.compose.runtime.CompositionLocalProvider -import androidx.compose.runtime.compositionLocalOf -import androidx.compose.runtime.rememberUpdatedState -import androidx.compose.runtime.snapshotFlow +import androidx.compose.ui.semantics.SemanticsPropertyKey +import androidx.compose.ui.semantics.semantics +import androidx.compose.ui.unit.Dp +import kotlinx.coroutines.coroutineScope 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. */ internal val LocalHeroArtReady = compositionLocalOf<(() -> Unit)?> { null } +internal val HeroLayerAlpha = SemanticsPropertyKey("HeroLayerAlpha") +internal val HeroLayerTranslation = SemanticsPropertyKey("HeroLayerTranslation") +internal val HeroLayerIdentity = SemanticsPropertyKey("HeroLayerIdentity") -/** Only the upper hero region blends; the lower backdrop changes without an animated screen layer. */ +private class HeroPresentation { + var identity by mutableStateOf(null) +} +private val LocalHeroPresentation = compositionLocalOf { 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(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(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 fun HeroArtCrossfade(identity: Any, target: T, height: Dp, modifier: Modifier = Modifier, content: @Composable (T) -> Unit) { - var displayed by remember { mutableStateOf(target) } - var displayedIdentity by remember { mutableStateOf(identity) } - var outgoing by remember { mutableStateOf(null) } - val alpha = remember { Animatable(0f) } + val initial = remember { HeroLayer(identity, target, 1f).apply { presented = true } } + val layers = remember { mutableStateListOf(initial) } + val selected = remember(identity) { layers.firstOrNull { it.identity == identity && it.presented && !it.outgoing } ?: HeroLayer(identity, target, 1f) } val reduced = reducedMotion() - var readyIdentity by remember { mutableStateOf(identity) } - val latestIdentity by rememberUpdatedState(identity) - val onReady = remember(identity) { { if (latestIdentity == identity) readyIdentity = identity } } - LaunchedEffect(identity, reduced) { - if (displayedIdentity != identity) { - outgoing = displayed - alpha.snapTo(1f) - snapshotFlow { readyIdentity == identity }.first { it } - displayed = target - displayedIdentity = identity - } - if (outgoing != null) { - alpha.animateTo(0f, tween(motionDuration(M.heroFade, reduced), easing = M.easeInOut)) - outgoing = null - } + val presentation = LocalHeroPresentation.current + LaunchedEffect(selected, reduced) { + layers.removeAll { it !== selected && !it.presented } + if (selected !in layers) layers.add(0, selected) + snapshotFlow { selected.ready }.first { it } + selected.presented = true + presentation?.identity = identity + layers.forEach { if (it !== selected) it.outgoing = true } } Box(modifier) { - CompositionLocalProvider(LocalHeroArtReady provides onReady) { content(target) } - outgoing?.let { old -> - Box(Modifier.requiredSize(WolfskinTokens.Shell.stageWidth, height).graphicsLayer { this.alpha = alpha.value; clip = true }.testTag("hero-outgoing")) { - Box(Modifier.wrapContentSize(Alignment.TopStart, unbounded = true).requiredSize(WolfskinTokens.Shell.stageWidth, WolfskinTokens.Shell.stageHeight)) { content(old) } + layers.forEachIndexed { index, 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.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 fun 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 alpha = remember { Animatable(0f) } - val progress = remember { Animatable(0f) } - var displayed by remember { mutableStateOf(target) } - var previous by remember { mutableStateOf(identity) } - var handingOff by remember { mutableStateOf(false) } - var entered by remember { mutableStateOf(false) } - val rise = with(androidx.compose.ui.platform.LocalDensity.current) { (if (entered) M.copyRise else M.copyEntryRise).toPx() } - LaunchedEffect(identity, reduced) { - if (previous != identity || handingOff) { - handingOff = true - alpha.animateTo(0f, tween(motionDuration(M.heroCopyOut, reduced), easing = M.ease)) - displayed = target - previous = identity - progress.snapTo(0f) - if (!reduced) kotlinx.coroutines.delay((M.heroCopyDelay - M.heroCopyOut).toLong()) - kotlinx.coroutines.coroutineScope { - launch { alpha.animateTo(1f, tween(motionDuration(M.heroCopyIn, reduced), easing = M.easeOut)) } - launch { progress.animateTo(1f, tween(motionDuration(M.heroCopyIn, reduced), easing = M.easeOut)) } - } - handingOff = false - } else if (!entered) { - kotlinx.coroutines.coroutineScope { - launch { alpha.animateTo(1f, tween(motionDuration(M.rowEnter, reduced), easing = M.easeOut)) } - launch { progress.animateTo(1f, tween(motionDuration(M.rowEnter, reduced), easing = M.easeOut)) } - } + val presentation = LocalHeroPresentation.current + val rise = with(LocalDensity.current) { M.copyRise.toPx() } + LaunchedEffect(selected, reduced) { + if (selected !in layers) { + if (presentation != null) snapshotFlow { presentation.identity == identity }.first { it } + layers.add(selected) + layers.forEach { if (it !== selected) it.outgoing = true } } - entered = true - displayed = target } - Box(modifier.graphicsLayer { this.alpha = alpha.value; translationY = if (reduced) 0f else rise * (1f - progress.value) }) { - content(if (handingOff || previous != identity) displayed else target) + Box(modifier, contentAlignment = Alignment.BottomStart) { + 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) } + } } } } diff --git a/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/HomeHeader.kt b/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/HomeHeader.kt index 8057e033..fccf4166 100644 --- a/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/HomeHeader.kt +++ b/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/HomeHeader.kt @@ -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. */ @Composable fun HomeHero(row: HomeRow, focused: Boolean, column: Int, ratings: List, onButton: (Int) -> Unit, onHover: (Int) -> Unit) { + HeroMotionScope { Box(Modifier.fillMaxWidth().height(L.row_hero)) { if (!row.skeleton) { val title = row.cards.first() @@ -28,7 +29,8 @@ fun HomeHero(row: HomeRow, focused: Boolean, column: Int, ratings: List + Column(Modifier.align(Alignment.BottomStart).padding(start = L.safeSide, bottom = L.heroCopyBottom).width(L.heroCopyWidth)) { + HeroCopyHandoff(title.id, title) { copy -> Column { Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(H.kickerGap)) { Glyph(if (row.heroKind == "pick") "seerr" else "playback", C.accentText, Modifier.size(L.buttonIcon * (28f / 30f))) @@ -44,6 +46,8 @@ fun HomeHero(row: HomeRow, focused: Boolean, column: Int, ratings: List() + + @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) + } + } +} diff --git a/wolfskin-design/src/test/java/org/justinvisser/wolfskin/design/DiscoverHeroSequenceTest.kt b/wolfskin-design/src/test/java/org/justinvisser/wolfskin/design/DiscoverHeroSequenceTest.kt new file mode 100644 index 00000000..25c7aa5a --- /dev/null +++ b/wolfskin-design/src/test/java/org/justinvisser/wolfskin/design/DiscoverHeroSequenceTest.kt @@ -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() + 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>() + private val previousDotWidths = mutableMapOf() + 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>() + 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)) +} diff --git a/wolfskin-design/src/test/java/org/justinvisser/wolfskin/design/DiscoverKeysTest.kt b/wolfskin-design/src/test/java/org/justinvisser/wolfskin/design/DiscoverKeysTest.kt index e5bd3c3a..8f057bbd 100644 --- a/wolfskin-design/src/test/java/org/justinvisser/wolfskin/design/DiscoverKeysTest.kt +++ b/wolfskin-design/src/test/java/org/justinvisser/wolfskin/design/DiscoverKeysTest.kt @@ -2,6 +2,9 @@ package org.justinvisser.wolfskin.design 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 androidx.compose.ui.input.key.Key import androidx.compose.ui.input.key.KeyEvent @@ -19,10 +22,12 @@ import org.junit.Test import org.junit.runner.RunWith import org.robolectric.RobolectricTestRunner import org.robolectric.annotation.Config +import org.robolectric.annotation.GraphicsMode /** Acceptance data drives real Android events through the shell's production focus adapter. */ @RunWith(RobolectricTestRunner::class) @Config(sdk = [34], application = Application::class, qualifiers = "w960dp-h540dp-xhdpi") +@GraphicsMode(GraphicsMode.Mode.NATIVE) class DiscoverKeysTest { @get:Rule val rule = createComposeRule() private val fixture = discoverTestFixture() @@ -33,11 +38,14 @@ class DiscoverKeysTest { { shell.enterDestination("discover") }, fixture::service) private fun launch() { + val art = Bitmap.createBitmap(8, 8, Bitmap.Config.ARGB_8888).apply { eraseColor(android.graphics.Color.DKGRAY) }.asImageBitmap() rule.setContent { WolfskinTheme { + CompositionLocalProvider(LocalDiscoverArt provides { art }) { 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) } + } } } } @@ -256,7 +264,69 @@ class DiscoverKeysTest { assertNull(state.collection) assertSame(origin, shell.view) 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 diff --git a/wolfskin-design/src/test/java/org/justinvisser/wolfskin/design/DiscoverNetworkHeroTest.kt b/wolfskin-design/src/test/java/org/justinvisser/wolfskin/design/DiscoverNetworkHeroTest.kt new file mode 100644 index 00000000..a351057c --- /dev/null +++ b/wolfskin-design/src/test/java/org/justinvisser/wolfskin/design/DiscoverNetworkHeroTest.kt @@ -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() + + @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() + 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() + } +} diff --git a/wolfskin-design/src/test/java/org/justinvisser/wolfskin/design/HeroMotionTest.kt b/wolfskin-design/src/test/java/org/justinvisser/wolfskin/design/HeroMotionTest.kt index 440c0fe3..49320a65 100644 --- a/wolfskin-design/src/test/java/org/justinvisser/wolfskin/design/HeroMotionTest.kt +++ b/wolfskin-design/src/test/java/org/justinvisser/wolfskin/design/HeroMotionTest.kt @@ -13,6 +13,9 @@ import androidx.compose.runtime.mutableStateOf import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.toArgb +import androidx.compose.ui.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.onRoot import androidx.compose.ui.test.onNodeWithTag @@ -82,6 +85,35 @@ class HeroMotionTest { } 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) { rule.runOnIdle { block(); androidx.compose.runtime.snapshots.Snapshot.sendApplyNotifications() } rule.mainClock.advanceTimeByFrame() From 60b0c307aa22039c897146bbc45c67d13e7de5aa Mon Sep 17 00:00:00 2001 From: justinzeus Date: Sat, 7 Nov 2026 15:59:00 +0100 Subject: [PATCH 4/9] Keep refresh capture artwork on the local fixture server --- .../org/justinvisser/wolfskin/tv/DiscoverRefreshMotionTest.kt | 1 + 1 file changed, 1 insertion(+) diff --git a/app/src/test/java/org/justinvisser/wolfskin/tv/DiscoverRefreshMotionTest.kt b/app/src/test/java/org/justinvisser/wolfskin/tv/DiscoverRefreshMotionTest.kt index 044ead23..5be264e6 100644 --- a/app/src/test/java/org/justinvisser/wolfskin/tv/DiscoverRefreshMotionTest.kt +++ b/app/src/test/java/org/justinvisser/wolfskin/tv/DiscoverRefreshMotionTest.kt @@ -50,6 +50,7 @@ class DiscoverRefreshMotionTest { 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 -> + fake.localArt = true ConductarrSession(MemoryCacheStore()).use { session -> session.select("server:shared", SHARED_TOKEN, fake.url) runBlocking { session.authenticate() } From 9988a193de00418bd0bcece7a731a48daa2d6619 Mon Sep 17 00:00:00 2001 From: justinzeus Date: Sat, 7 Nov 2026 16:22:09 +0100 Subject: [PATCH 5/9] Hold the full painted backdrop until incoming hero art is ready --- data/tv-spec/spec.md | 2 +- .../justinvisser/wolfskin/design/HeroMotion.kt | 4 ++-- .../wolfskin/design/DiscoverHeroSequenceTest.kt | 15 ++++++++++++--- .../wolfskin/design/HeroMotionTest.kt | 12 +++++++++--- 4 files changed, 24 insertions(+), 9 deletions(-) diff --git a/data/tv-spec/spec.md b/data/tv-spec/spec.md index a614e360..2cbca740 100644 --- a/data/tv-spec/spec.md +++ b/data/tv-spec/spec.md @@ -73,7 +73,7 @@ Home heroes and shelves, Library rows, Discover hero/lanes, title headers, episo | 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 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. +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 entire painted backdrop stays static while incoming artwork loads, and the lower backdrop changes only when artwork or its final fallback is ready, 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. 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. diff --git a/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/HeroMotion.kt b/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/HeroMotion.kt index 5211dfd7..44dc46a7 100644 --- a/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/HeroMotion.kt +++ b/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/HeroMotion.kt @@ -78,7 +78,7 @@ fun HeroArtCrossfade(identity: Any, target: T, height: Dp, modifier: Modifie layers.forEach { if (it !== selected) it.outgoing = true } } Box(modifier) { - layers.forEachIndexed { index, layer -> key(layer) { + layers.forEach { layer -> key(layer) { SideEffect { if (layer.identity == identity) layer.target = target } LaunchedEffect(layer.outgoing, reduced) { if (layer.outgoing) { @@ -86,7 +86,7 @@ fun HeroArtCrossfade(identity: Any, target: T, height: Dp, modifier: Modifie layers.remove(layer) } } - val outgoing = index != 0 + val outgoing = layer.outgoing 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 } diff --git a/wolfskin-design/src/test/java/org/justinvisser/wolfskin/design/DiscoverHeroSequenceTest.kt b/wolfskin-design/src/test/java/org/justinvisser/wolfskin/design/DiscoverHeroSequenceTest.kt index 25c7aa5a..68b49c7a 100644 --- a/wolfskin-design/src/test/java/org/justinvisser/wolfskin/design/DiscoverHeroSequenceTest.kt +++ b/wolfskin-design/src/test/java/org/justinvisser/wolfskin/design/DiscoverHeroSequenceTest.kt @@ -46,6 +46,7 @@ class DiscoverHeroSequenceTest { private val artAvailable = mutableStateOf(true) private val reduced = mutableStateOf(false) private var previousPixel: Int? = null + private var lowerPixel = 0 private var largestJump = 0 private var previousLayers = emptyMap>() private val previousDotWidths = mutableMapOf() @@ -99,9 +100,14 @@ class DiscoverHeroSequenceTest { launch("slow") sample("settled", 0) val original = previousPixel + val originalLower = lowerPixel rule.runOnIdle { artAvailable.value = false } press(Key.DirectionRight) - frames("pending", 24) + repeat(24) { frame -> + rule.mainClock.advanceTimeBy(16) + sample("pending", frame) + assertEquals("Pending art cannot expose a placeholder beneath the hero", originalLower, lowerPixel) + } assertEquals("Pending art must not replace the painted backdrop", original, previousPixel) assertEquals(state.heroes.first().key, copyIdentities().single()) rule.runOnIdle { artAvailable.value = true } @@ -169,7 +175,10 @@ class DiscoverHeroSequenceTest { 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 pixel = try { + lowerPixel = bitmap.getPixel(1600, 800) + 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 @@ -197,7 +206,7 @@ class DiscoverHeroSequenceTest { } } 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)) + .put("selection", state.slide).put("rgb", JSONArray(channels)).put("lowerRgb", JSONArray(channels(lowerPixel))).put("copy", copy).put("layers", layers).put("dots", dots).put("navFocused", shell.navFocused)) } private fun layerSamples(): JSONArray { diff --git a/wolfskin-design/src/test/java/org/justinvisser/wolfskin/design/HeroMotionTest.kt b/wolfskin-design/src/test/java/org/justinvisser/wolfskin/design/HeroMotionTest.kt index 49320a65..01f96558 100644 --- a/wolfskin-design/src/test/java/org/justinvisser/wolfskin/design/HeroMotionTest.kt +++ b/wolfskin-design/src/test/java/org/justinvisser/wolfskin/design/HeroMotionTest.kt @@ -62,16 +62,22 @@ class HeroMotionTest { val probe = BitmapFactory.decodeFile(probeFile.absolutePath) try { for (y in listOf(380, 400, 420, 600, 800)) { - assertEquals("The outgoing hero must stop at its 400px boundary: y=$y", - (if (y < 400) Color.Red else Color.Blue).toArgb(), probe.getPixel(100, y)) + assertEquals("Pending artwork cannot replace any part of the painted backdrop: y=$y", + Color.Red.toArgb(), probe.getPixel(100, y)) } - assertEquals(400f, rule.onNodeWithTag("hero-outgoing").fetchSemanticsNode().boundsInRoot.height, .01f) } finally { probe.recycle() } update { ready.value = true } rule.mainClock.advanceTimeBy(176) val mixed = pixel(directory, 100) assertNotEquals(Color.Red.toArgb(), mixed) assertNotEquals(Color.Blue.toArgb(), mixed) + val blendedProbe = BitmapFactory.decodeFile(probeFile.absolutePath) + try { + assertEquals("Only the outgoing blend is clipped at its 400px boundary", + 400f, rule.onNodeWithTag("hero-outgoing").fetchSemanticsNode().boundsInRoot.height, .01f) + assertEquals(Color.Blue.toArgb(), blendedProbe.getPixel(100, 400)) + assertEquals(Color.Blue.toArgb(), blendedProbe.getPixel(100, 800)) + } finally { blendedProbe.recycle() } rule.mainClock.advanceTimeBy(400) assertEquals(Color.Blue.toArgb(), pixel(directory, 100)) update { selected.value = "green"; ready.value = false } From e6ddf1d7e55d6c4538f646cf2841028bea0be343 Mon Sep 17 00:00:00 2001 From: justinzeus Date: Sat, 7 Nov 2026 16:41:07 +0100 Subject: [PATCH 6/9] Show episode ratings and animate title readiness without empty space --- .../wolfskin/tv/TitleRound2Test.kt | 24 ++++- data/tv-spec/spec.md | 2 +- docker/README.md | 13 ++- .../wolfskin/design/TitleHeader.kt | 13 ++- .../justinvisser/wolfskin/design/TitlePage.kt | 11 ++ .../wolfskin/design/TitleState.kt | 2 - .../wolfskin/design/MotionSurfaceTest.kt | 17 ++- .../design/TitleReadinessMotionTest.kt | 100 ++++++++++++++++++ 8 files changed, 159 insertions(+), 23 deletions(-) create mode 100644 wolfskin-design/src/test/java/org/justinvisser/wolfskin/design/TitleReadinessMotionTest.kt diff --git a/app/src/test/java/org/justinvisser/wolfskin/tv/TitleRound2Test.kt b/app/src/test/java/org/justinvisser/wolfskin/tv/TitleRound2Test.kt index ff2edf66..91c9387e 100644 --- a/app/src/test/java/org/justinvisser/wolfskin/tv/TitleRound2Test.kt +++ b/app/src/test/java/org/justinvisser/wolfskin/tv/TitleRound2Test.kt @@ -101,22 +101,36 @@ class TitleRound2Test { assertTrue(state.data.playTarget!!.resumeSeconds > 0) rule.onNodeWithTag("title-progress").assertIsDisplayed() rule.onAllNodesWithContentDescription("rtCritics", useUnmergedTree = true).assertCountEquals(1) - rule.onNodeWithText("Ep", useUnmergedTree = true).assertDoesNotExist() + rule.onNodeWithText("Ep", useUnmergedTree = true).assertIsDisplayed() + rule.onAllNodesWithText("7.4", useUnmergedTree = true).assertCountEquals(1) + rule.onNode(hasText("7.4") and hasAnyAncestor(hasTestTag("title-facts")), useUnmergedTree = true).assertIsDisplayed() rule.onNodeWithTag("title-option-seasons").assertDoesNotExist() press(Key.DirectionDown) assertEquals("seasons", state.row) rule.onNode(hasText("Season 1") and hasClickAction()).assertIsDisplayed() } - @Test fun seriesKeepsItsSeasonsShortcutAndSecondaryRequestUsesTheMenu() { + @Test fun seriesUsesDownToReachSeasonsWithoutARedundantShortcut() { render("Andor") - rule.onNodeWithTag("title-option-seasons").assertIsDisplayed() + rule.onNodeWithTag("title-option-seasons").assertDoesNotExist() menu() rule.onNodeWithText("Add for someone").assertIsDisplayed() press(Key.Back) - press(Key.DirectionRight) - press(Key.DirectionCenter) + press(Key.DirectionDown) assertEquals("seasons", state.row) + rule.onNode(hasText("Season 1") and hasClickAction()).assertIsDisplayed() + } + + @Test fun episodeFactsUseTheEpisodeSetRatherThanTheSeriesSet() { + render("episode-episode-1-2025-s1e1", "shared") { data -> + data.copy(item = data.item.copy(ratings = listOf(LibraryRating("rtCritics", 20.0, "Rotten"))), + watchTarget = data.watchTarget.copy(ratings = listOf(LibraryRating("rtCritics", 84.0, "Fresh"), LibraryRating("tmdb", 8.7, episode = true))), + episodes = data.episodes.distinctBy { it.id }) + } + rule.onAllNodesWithText("84%", useUnmergedTree = true).assertCountEquals(1) + rule.onAllNodesWithText("8.7", useUnmergedTree = true).assertCountEquals(1) + rule.onNodeWithText("20%", useUnmergedTree = true).assertDoesNotExist() + rule.onNode(hasText("8.7") and hasAnyAncestor(hasTestTag("title-facts")), useUnmergedTree = true).assertIsDisplayed() } @Test fun episodeSkeletonAndReadySlotsMatchWhileMetadataFitsOneLine() { diff --git a/data/tv-spec/spec.md b/data/tv-spec/spec.md index 913f31f5..7660ed8e 100644 --- a/data/tv-spec/spec.md +++ b/data/tv-spec/spec.md @@ -74,7 +74,7 @@ Row reveals are grouped rather than the browser prototype's per-card staging: th The title page's scroll dim is a draw-only colour overlay, not an alpha-animated full-screen layer. Discover scrolls its backdrop through placement offsets rather than a full-screen translation layer. Initial loading animated layers are Home **2**, Library **up to 3** lazy rows, Discover **2**, title header **1**, person **2**; pending title episode/cast shelves add **1 each**, and each pending Discover lane owns **1**. Deep Home loading is bounded to **4** visible shelf layers. These are animated layers, not independent pulse clocks or every static display-list node: one shared pulse clock drives the loading screen without adding a layer. A hero hand-off adds at most **2** bounded owners (outgoing art and copy). Focus changes can animate **2** card transforms, **1** caption and at most **2** rating plates; artwork fades are painter draws without additional compositing layers. Late title metadata adds at most **4** bounded alpha layers: title ratings, play-target ratings, genres and overview. No animated owner is full-screen. -The series readiness band reserves **56 px (28 dp)** from its first content paint, including when empty or when readiness later disappears. Ratings remain inside the fixed-height facts band. The series header's measured height therefore cannot change when late ratings/readiness arrive; overview, actions, season and episode anchors retain their coordinates. Skeletons have no click/hover focus targets; pending episode/cast rows remain absent from the key-navigation state until real cards exist. Initial loading keeps nav focus and Back still pops the title/person route. +The approved title follow-up (2026-11-07) supersedes the fixed readiness reservation: an absent readiness line occupies **0 px**, while a present line occupies **56 px (28 dp)**. Late arrival/removal animates that height over the **200 ms late-line duration with token ease-out**; reduced motion snaps. Overview, actions and following rows move monotonically with that band; an already-pinned focused row stays pinned through same-remeasure scroll compensation. Ratings remain inside the fixed-height facts band and appear once: an explicit episode page uses its opened episode's own set, including the episode score, rather than the series-only set. Series and episode pages have no redundant Seasons shortcut; Down reaches the season chips. 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. Discover key traversal skips pending lanes in both directions. If every remaining lane is pending, Down stays on the real filter chips and Back returns to nav; a skeleton must never create an empty-column focus range. diff --git a/docker/README.md b/docker/README.md index 5198772e..c8dae34c 100644 --- a/docker/README.md +++ b/docker/README.md @@ -648,7 +648,10 @@ retain the approved frames except for the requested feedback changes: Focused summaries ellipsise to one line, the show-name overlay is absent, and `S1 E3 · duration` plus the watched check share one line. Ready title headers measure their copy/actions and leave 40 px before the - following section; series retain the empty 56 px readiness reservation. + following section. Missing readiness lines collapse completely on every title + kind; late lines expand/collapse over the 200 ms late-line token with ease-out, + or snap with reduced motion. Already-pinned rows compensate in the same + remeasure rather than bouncing through a second focus-follow animation. Poster-only movies floor that stack so Cast circles clear the poster by at least 20 px, including held movie fallbacks and short overviews. - Every Discover heading uses a 104 px band, including a 60 px title-to-card @@ -720,9 +723,11 @@ the motion ease-out token, with reduced-motion fades capped at 80 ms. Only the bounded menu panel owns an alpha layer; the scrim is static. Opening another sheet immediately removes the menu panel and scrim, preventing double darkening. An empty menu closes if its last permitted action disappears. Controls -use the shared 4 px straddle focus ring. Episode details omit the redundant -Seasons shortcut; series pages retain it. Ratings appear only in the header -facts line, not again beside progress, on movie, series and episode pages. +use the shared 4 px straddle focus ring. Neither series nor episode details have +a redundant Seasons shortcut; Down reaches the season chips directly. Ratings +appear only in the header facts line, not again beside progress. Explicit episode +pages use the opened episode's own set, including its `Ep` score; movie and series +pages keep their own sets. Episode shelf skeletons use the same width, height and pitch as ready cards. Person filmography and related-title shelves remain posters; Home continuation 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 85c224bf..f7cfe54f 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 @@ -1,5 +1,9 @@ package org.justinvisser.wolfskin.design +import androidx.compose.animation.core.animateDpAsState +import androidx.compose.animation.core.tween +import androidx.compose.runtime.getValue +import androidx.compose.ui.draw.clipToBounds import androidx.compose.ui.layout.onGloballyPositioned import androidx.compose.ui.layout.boundsInRoot import androidx.compose.ui.platform.testTag @@ -35,6 +39,11 @@ import org.justinvisser.wolfskin.design.WolfskinTokens.Title as D fun TitleHeader(state: TitleState, active: Boolean, modifier: Modifier = Modifier) { if (state.data.status == TitleReadStatus.Loading) { TitleHeaderSkeleton(modifier); return } val item = state.data.item + val factsItem = state.data.watchTarget.takeIf { it.kind == "episode" } ?: item + val readinessHeight by animateDpAsState( + if (state.data.requestedStage == null) WolfskinTokens.Shell.zero else WolfskinTokens.Motion.readinessSlot, + tween(if (reducedMotion()) 0 else WolfskinTokens.Motion.lateLineFade, easing = WolfskinTokens.Motion.easeOut), + label = "title-readiness-height") val episode = when { state.row == "episodes" -> state.data.episodes.getOrNull(state.column) state.data.watchTarget.kind == "episode" -> state.data.watchTarget @@ -56,10 +65,10 @@ fun TitleHeader(state: TitleState, active: Boolean, modifier: Modifier = Modifie item.year?.let { Text(it.toString(), color = C.muted, style = TitleTextStyles.body, modifier = Modifier.exactLineBox(TitleTextStyles.body)) } if (item.kind == "movie" && item.runtimeSeconds > 0) Text(item.runtime, color = C.muted, style = TitleTextStyles.body, modifier = Modifier.exactLineBox(TitleTextStyles.body)) if (item.kind == "tv" && item.seasons > 0) Text("${item.seasons} season${if (item.seasons == 1) "" else "s"}", color = C.muted, style = TitleTextStyles.body, modifier = Modifier.exactLineBox(TitleTextStyles.body)) - if (state.data.connection != TitleConnection.SignedOut) TitleRatings(item.ratings) + if (state.data.connection != TitleConnection.SignedOut) TitleRatings(factsItem.ratings) if (item.genres.isNotEmpty()) Text(item.genres.take(3).joinToString(", "), color = C.muted, style = TitleTextStyles.body, modifier = Modifier.exactLineBox(TitleTextStyles.body).lateLineFade(item.genres)) } - Box(Modifier.fillMaxWidth().height(if (item.kind == "tv" || state.data.requestedStage != null) WolfskinTokens.Motion.readinessSlot else WolfskinTokens.Shell.zero).testTag("title-readiness-slot")) { + Box(Modifier.fillMaxWidth().height(readinessHeight).clipToBounds().testTag("title-readiness-slot")) { state.data.requestedStage?.let { stage -> val color = when (stage) { "Ready" -> C.ok; "Failed" -> C.fail; "Downloading", "Almost ready" -> C.accent; else -> C.muted } Row(Modifier.exactLineBox(TitleTextStyles.dLine, bottom = D.lineGap).lateLineFade(stage), horizontalArrangement = Arrangement.spacedBy(L.navItemGap), verticalAlignment = Alignment.CenterVertically) { diff --git a/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/TitlePage.kt b/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/TitlePage.kt index 4a79a147..c423ad3e 100644 --- a/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/TitlePage.kt +++ b/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/TitlePage.kt @@ -120,7 +120,18 @@ fun TitlePage(state: TitleState, shell: ShellState, modifier: Modifier = Modifie val position by remember(list, rowOffsets) { derivedStateOf { rowOffsets[list.firstVisibleItemIndex.coerceAtMost(rowOffsets.lastIndex)] + list.firstVisibleItemScrollOffset } } + val headerPixels = with(density) { headerHeight.roundToPx() } + val previousHeaderPixels = remember(list, item.id, density) { intArrayOf(headerPixels) } + val compensateHeader = previousHeaderPixels[0] != headerPixels && !shell.navFocused && !list.isScrollInProgress && + list.firstVisibleItemIndex == 0 && list.firstVisibleItemScrollOffset > 0 && + list.layoutInfo.visibleItemsInfo.any { it.key == state.row && kotlin.math.abs(it.offset - with(density) { L.pinnedLine.roundToPx() }) <= 1 } + // Compensate in the same remeasure; an asynchronous scroll would chase the animated header. + androidx.compose.runtime.SideEffect { + if (compensateHeader) list.requestScrollToItem(0, (list.firstVisibleItemScrollOffset + headerPixels - previousHeaderPixels[0]).coerceAtLeast(0)) + previousHeaderPixels[0] = headerPixels + } LaunchedEffect(state.row, shell.navFocused, state.data.status, displayedRows, rowOffsets) { + if (compensateHeader) return@LaunchedEffect if (state.data.status == TitleReadStatus.Loading) shell.focusNav(shell.currentTab) val index = if (shell.navFocused) 0 else displayedRows.indexOf(state.row).coerceAtLeast(0) val y = rowOffsets[index] diff --git a/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/TitleState.kt b/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/TitleState.kt index fad34b31..1d363e19 100644 --- a/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/TitleState.kt +++ b/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/TitleState.kt @@ -193,7 +193,6 @@ class TitleState( "menu" -> { sheetIndex = 0; sheet = TitleSheet.Menu } "watched" -> if (data.watchTarget.kind == "tv") { sheetIndex = 0; sheet = TitleSheet.ConfirmWatched } else toggleWatched() "trailer" -> data.trailerUrl?.let { onAction(TitleAction.Trailer(it)) } - "seasons" -> focus("seasons", data.seasonNumbers.indexOf(season).coerceAtLeast(0)) "retry" -> onAction(TitleAction.Retry) "back" -> onAction(TitleAction.Pop) "request", "add" -> onAction(TitleAction.Request(data.item, id != "request")) @@ -302,7 +301,6 @@ fun titleOptions(state: TitleState): List = buildList { add(TitleOption("watched", "Mark ${if (series) "series " else ""}as ${if (state.watched) "unwatched" else "watched"}", if (state.watched) "eye" else "eyeoff", if (data.watchTarget.kind == "episode") data.watchTarget.episodeLabel else "", on = state.watched)) if (data.trailerUrl != null) add(TitleOption("trailer", "Play trailer", "trailer")) if (target != null || (data.connection == TitleConnection.SignedIn && data.requestsAllowed)) add(TitleOption("menu", "Menu", "more")) - if (item.kind == "tv" && item.seasons > 0 && data.watchTarget.kind != "episode") add(TitleOption("seasons", "Seasons", "tv", icon = false)) } else if (data.connection != TitleConnection.SignedIn || !data.requestsAllowed) { add(TitleOption("retry", "Try again", "wifi", primary = true, icon = false)) } else { 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 12d6ddad..47dac75a 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 @@ -44,20 +44,19 @@ class MotionSurfaceTest { @get:Rule val rule = createAndroidComposeRule(effectContext = animatorScale) private val viewer = ShellPerson("justin", "Justin", "J", false) - @Test fun lateSeriesReadinessAndRatingsLeaveActionsOverviewAndEpisodesAtTheSameCoordinates() { + @Test fun reducedMotionReadinessSnapsAndRemovingItRestoresTheCollapsedStack() { val shell = shell() val series = TitleItem("series", "A series", "tv", seasons = 2, held = true, overview = "Series overview") val episode = TitleItem("episode", "Pilot", "episode", season = 1, episode = 1, overview = "Episode overview") val state = TitleState(TitlePageData(series, playTarget = episode, episodes = listOf(episode), connection = TitleConnection.SignedIn), viewer) {} - rule.setContent { WolfskinTheme { TitleSurface(state, shell, Modifier.requiredSize(960.dp, 540.dp)) } } - rule.waitForIdle() + rule.setContent { CompositionLocalProvider(LocalReducedMotion provides true) { WolfskinTheme { TitleSurface(state, shell, Modifier.requiredSize(960.dp, 540.dp)) } } } val before = bounds() - for (stage in listOf("Ready", "Downloading", null)) { - rule.runOnIdle { state.update(state.data.copy(requestedStage = stage, requestActor = "Mindel", item = series.copy(ratings = listOf(LibraryRating("rt", 88.0))))) } - rule.mainClock.advanceTimeBy(3400) - rule.waitForIdle() - assertEquals("Late metadata cannot change reserved header geometry", before, bounds()) - } + assertEquals(0f, rule.onNodeWithTag("title-readiness-slot").fetchSemanticsNode().boundsInRoot.height, 0.1f) + rule.runOnIdle { state.update(state.data.copy(requestedStage = "Ready", requestActor = "Mindel")) } + assertEquals(56f, rule.onNodeWithTag("title-readiness-slot").fetchSemanticsNode().boundsInRoot.height, 0.1f) + bounds().zip(before).forEach { (after, initial) -> assertEquals(56f, after.top - initial.top, 1f) } + rule.runOnIdle { state.update(state.data.copy(requestedStage = null)) } + assertEquals(before, bounds()) } @Test fun titleSkeletonIgnoresDownAndBackPopsWithoutOpeningThePicker() { diff --git a/wolfskin-design/src/test/java/org/justinvisser/wolfskin/design/TitleReadinessMotionTest.kt b/wolfskin-design/src/test/java/org/justinvisser/wolfskin/design/TitleReadinessMotionTest.kt new file mode 100644 index 00000000..01bc734a --- /dev/null +++ b/wolfskin-design/src/test/java/org/justinvisser/wolfskin/design/TitleReadinessMotionTest.kt @@ -0,0 +1,100 @@ +package org.justinvisser.wolfskin.design + +import android.app.Application +import androidx.activity.ComponentActivity +import androidx.compose.foundation.layout.requiredSize +import androidx.compose.runtime.mutableFloatStateOf +import androidx.compose.runtime.getValue +import androidx.compose.runtime.setValue +import androidx.compose.ui.Modifier +import androidx.compose.ui.MotionDurationScale +import androidx.compose.ui.test.junit4.createAndroidComposeRule +import androidx.compose.ui.test.onNodeWithTag +import androidx.compose.ui.test.onRoot +import androidx.compose.ui.unit.dp +import com.github.takahirom.roborazzi.captureRoboImage +import org.junit.Assert.* +import org.junit.Rule +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner +import org.robolectric.annotation.Config +import org.robolectric.annotation.GraphicsMode +import java.io.File + +/** Readiness changes move real title content continuously, without reserving an empty band. */ +@RunWith(RobolectricTestRunner::class) +@Config(sdk = [34], application = Application::class, qualifiers = "w960dp-h540dp-xhdpi") +@GraphicsMode(GraphicsMode.Mode.NATIVE) +class TitleReadinessMotionTest { + private val animatorScale = object : MotionDurationScale { override var scaleFactor by mutableFloatStateOf(1f) } + @get:Rule val rule = createAndroidComposeRule(effectContext = animatorScale) + private lateinit var state: TitleState + + private fun render() { + val viewer = ShellPerson("justin", "Justin", "J", false) + val shell = ShellState(listOf(viewer), viewer.key).apply { pickerVisible = false; pickerLaunch = false; enterDestination("title/series") } + val series = TitleItem("series", "A series", "tv", seasons = 1, held = true, overview = "A series overview") + val episode = TitleItem("episode", "Pilot", "episode", held = true, season = 1, episode = 1, overview = "Episode overview") + state = TitleState(TitlePageData(series, playTarget = episode, episodes = listOf(episode), connection = TitleConnection.SignedIn), viewer) {} + rule.setContent { WolfskinTheme { TitleSurface(state, shell, Modifier.requiredSize(960.dp, 540.dp)) } } + rule.mainClock.advanceTimeBy(500) + rule.waitForIdle() + } + + private fun positions(): List = listOf("title-readiness-slot", "title-overview", "title-actions", "title-shelf:episodes").mapIndexed { index, tag -> + val bounds = rule.onNodeWithTag(tag).fetchSemanticsNode().boundsInRoot + if (index == 0) bounds.height else bounds.top + } + + private fun frames(stage: String?, name: String, count: Int = 22): List> { + val directory = File(requireNotNull(System.getProperty("wolfskin.captureDirectory")), "title-readiness-motion/$name").apply { mkdirs() } + rule.runOnIdle { state.update(state.data.copy(requestedStage = stage, requestActor = "Justin")); androidx.compose.runtime.snapshots.Snapshot.sendApplyNotifications() } + val values = List(count) { index -> + rule.waitForIdle() + positions().also { + rule.onRoot().captureRoboImage(File(directory, "${index.toString().padStart(2, '0')}.png").absolutePath) + rule.mainClock.advanceTimeBy(16) + rule.waitForIdle() + } + } + File(directory, "samples.csv").writeText("timeMs,slotHeightPx,overviewTopPx,actionsTopPx,episodesTopPx\n" + values.mapIndexed { index, row -> "${index * 16},${row.joinToString(",")}" }.joinToString("\n")) + return values + } + + @Test fun lateReadinessExpansionAndRemovalStayMonotonicAndMoveTheWholeStack() { + render() + val collapsed = positions() + assertEquals(0f, collapsed[0], 0.1f) + rule.mainClock.autoAdvance = false + val opening = frames("Ready", "expand") + val expanded = opening.last() + assertEquals(56f, expanded[0], 0.1f) + expanded.drop(1).zip(collapsed.drop(1)).forEach { (after, before) -> assertEquals(56f, after - before, 1f) } + val closing = frames(null, "collapse") + assertEquals(collapsed, closing.last()) + for ((samples, direction) in listOf(opening to 1f, closing to -1f)) { + samples.zipWithNext().forEach { (before, after) -> before.zip(after).forEach { (first, second) -> + assertTrue("Readiness content must not reverse or overshoot: $samples", (second - first) * direction >= -0.1f) + assertTrue("Height change must not jump in one frame", kotlin.math.abs(second - first) < 56f) + } } + samples.forEach { assertTrue(it[0] in 0f..56f) } + } + assertFalse(state.options.any { it.id == "seasons" }) + rule.mainClock.autoAdvance = true + } + + @Test fun aPinnedEpisodeShelfDoesNotBounceWhenReadinessChangesAboveIt() { + render() + rule.runOnIdle { state.focus("episodes", 0) } + rule.mainClock.advanceTimeBy(500) + rule.waitForIdle() + val pinned = positions().last() + rule.mainClock.autoAdvance = false + for (samples in listOf(frames("Ready", "pinned", count = 34), frames(null, "pinned-collapse", count = 34))) { + samples.forEach { assertEquals("A header change cannot bounce a pinned focused shelf", pinned, it.last(), 1f) } + } + assertEquals("episodes", state.row) + rule.mainClock.autoAdvance = true + } +} From f9bd630ea72f672edbf4a680dcbbb2f6d77421ef Mon Sep 17 00:00:00 2001 From: justinzeus Date: Sat, 7 Nov 2026 16:56:49 +0100 Subject: [PATCH 7/9] Keep title destination focus and preserve ready catalogue refreshes --- .../wolfskin/tv/TitleAsyncFocusTest.kt | 17 ++++++++++++----- .../wolfskin/tv/TitleDestination.kt | 2 +- data/tv-spec/spec.md | 2 +- .../justinvisser/wolfskin/design/TitlePage.kt | 3 +-- .../wolfskin/design/MotionSurfaceTest.kt | 9 ++++++++- 5 files changed, 23 insertions(+), 10 deletions(-) diff --git a/app/src/test/java/org/justinvisser/wolfskin/tv/TitleAsyncFocusTest.kt b/app/src/test/java/org/justinvisser/wolfskin/tv/TitleAsyncFocusTest.kt index 9809967f..0ef13b60 100644 --- a/app/src/test/java/org/justinvisser/wolfskin/tv/TitleAsyncFocusTest.kt +++ b/app/src/test/java/org/justinvisser/wolfskin/tv/TitleAsyncFocusTest.kt @@ -6,6 +6,7 @@ import androidx.activity.ComponentActivity import androidx.compose.ui.input.key.Key import androidx.compose.ui.test.junit4.createAndroidComposeRule import androidx.compose.ui.test.assertIsDisplayed +import androidx.compose.ui.test.assertIsFocused import androidx.compose.ui.test.onNodeWithTag import androidx.compose.ui.test.onRoot import androidx.compose.ui.test.performKeyInput @@ -69,9 +70,12 @@ class TitleAsyncFocusTest { rule.setContent { WolfskinTheme { TitleDestination(route, shell, preferences, viewModel = viewModel) } } rule.waitUntil { viewModel.title?.data?.status == TitleReadStatus.Loading } rule.waitForIdle() - assertTrue(shell.navFocused) + assertFalse("Loading retains destination focus instead of focusing the root nav", shell.navFocused) + rule.onNodeWithTag("title-page").assertIsFocused() press(Key.DirectionDown) - assertTrue("The initial skeleton cannot receive nav Down", shell.navFocused) + assertFalse("Down cannot transfer focus to nav or an initial skeleton", shell.navFocused) + assertEquals("header", viewModel.title!!.row) + assertEquals(0, viewModel.title!!.column) val source = TitleVersion("first", "Original", "1080p", "", "", "MKV", listOf(TitleTrack(3, "English", true)), emptyList()) rule.runOnIdle { @@ -109,9 +113,12 @@ class TitleAsyncFocusTest { } } rule.waitUntil { viewModel.title?.data?.status == TitleReadStatus.Loading } rule.waitForIdle() - assertTrue(shell.navFocused) + assertFalse(shell.navFocused) + rule.onNodeWithTag("title-page").assertIsFocused() press(Key.DirectionDown) - assertTrue(shell.navFocused) + assertFalse("Down cannot enter a pending person skeleton", shell.navFocused) + assertEquals("header", viewModel.title!!.row) + assertEquals(0, viewModel.title!!.column) rule.runOnIdle { reply.complete(org.justinvisser.wolfskin.tv.read.PersonRead( org.justinvisser.wolfskin.design.TitlePerson(id.toString(), "Actor"), @@ -121,7 +128,7 @@ class TitleAsyncFocusTest { rule.waitUntil(timeoutMillis = 5000) { viewModel.person != null || viewModel.title?.data?.status == TitleReadStatus.Unavailable } assertNotNull("A real person reply must populate the performer destination", viewModel.person) rule.waitForIdle() - assertFalse("The loaded person must transfer nav ownership to its first credit", shell.navFocused) + assertFalse("The loaded person retains destination focus on its first credit", shell.navFocused) assertEquals(0, viewModel.person!!.index) press(Key.DirectionRight) assertEquals(1, viewModel.person!!.index) diff --git a/app/src/wolfskin/java/org/justinvisser/wolfskin/tv/TitleDestination.kt b/app/src/wolfskin/java/org/justinvisser/wolfskin/tv/TitleDestination.kt index 4db5512d..37c320fa 100644 --- a/app/src/wolfskin/java/org/justinvisser/wolfskin/tv/TitleDestination.kt +++ b/app/src/wolfskin/java/org/justinvisser/wolfskin/tv/TitleDestination.kt @@ -90,7 +90,7 @@ class WolfskinTitleViewModel @Inject constructor( fun load(destination: String, selected: ShellPerson, userPreferences: UserPreferences, fixtures: Boolean = BuildConfig.DEBUG, force: Boolean = false) { if (!force && route == destination && ::viewer.isInitialized && viewer.key == selected.key) return if (force && route == destination && ::viewer.isInitialized && viewer.key == selected.key && - title?.data?.status == TitleReadStatus.Ready && title?.data?.item?.held == true && + title?.data?.status == TitleReadStatus.Ready && (title?.data?.connection != TitleConnection.SignedIn || conductarr.identity.value?.signedIn == true) && !destination.startsWith("person/") && fixture == null) { loadJob?.cancel() diff --git a/data/tv-spec/spec.md b/data/tv-spec/spec.md index 2cbca740..fc3b8f93 100644 --- a/data/tv-spec/spec.md +++ b/data/tv-spec/spec.md @@ -77,7 +77,7 @@ Row reveals are grouped rather than the browser prototype's per-card staging: th 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 title/person loading retains destination focus rather than focusing the root nav; Down cannot enter a skeleton, and Back still pops the route without opening the picker. An already-ready catalogue or held title stays Ready while a same-route, same-person refresh reads new data; the existing signed-in authentication guard remains authoritative, and loaded copy/rows do not remount or replay their entry motion. Discover key traversal skips pending lanes in both directions. If every remaining lane is pending, Down stays on the real filter chips and Back returns to nav; a skeleton must never create an empty-column focus range. diff --git a/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/TitlePage.kt b/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/TitlePage.kt index 4a79a147..fa87184b 100644 --- a/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/TitlePage.kt +++ b/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/TitlePage.kt @@ -54,7 +54,7 @@ fun TitleSurface(state: TitleState, shell: ShellState, modifier: Modifier = Modi val view = shell.view LaunchedEffect(view, state.data.savedAt) { view.savedAt = state.data.savedAt } Box(modifier.fillMaxSize().onPreviewKeyEvent { event -> - // No content target exists during an initial load; nav Down must not enter a skeleton. + // No content target exists during an initial load; Down must not enter a skeleton. if (state.data.status != TitleReadStatus.Loading || shell.pickerVisible || shell.requestsVisible) false else when (readShellKey(event)) { ShellKey.Down -> true @@ -121,7 +121,6 @@ fun TitlePage(state: TitleState, shell: ShellState, modifier: Modifier = Modifie rowOffsets[list.firstVisibleItemIndex.coerceAtMost(rowOffsets.lastIndex)] + list.firstVisibleItemScrollOffset } } LaunchedEffect(state.row, shell.navFocused, state.data.status, displayedRows, rowOffsets) { - if (state.data.status == TitleReadStatus.Loading) shell.focusNav(shell.currentTab) val index = if (shell.navFocused) 0 else displayedRows.indexOf(state.row).coerceAtLeast(0) val y = rowOffsets[index] val height = with(density) { titleRowHeight(state.row, headerHeight).toPx() } 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 12d6ddad..7e648d28 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 @@ -19,6 +19,7 @@ import androidx.compose.ui.Modifier import androidx.compose.ui.input.key.Key import androidx.compose.ui.platform.testTag import androidx.compose.ui.test.junit4.createAndroidComposeRule +import androidx.compose.ui.test.assertIsFocused import androidx.compose.ui.test.onNodeWithTag import androidx.compose.ui.test.onNodeWithText import androidx.compose.ui.test.onRoot @@ -67,7 +68,13 @@ class MotionSurfaceTest { rule.setContent { WolfskinTheme { TitleSurface(state, shell) } } rule.mainClock.advanceTimeBy(2100) rule.onRoot().performKeyInput { pressKey(Key.DirectionDown) } - rule.runOnIdle { assertTrue(shell.navFocused); assertFalse(shell.pickerVisible) } + rule.onNodeWithTag("title-page").assertIsFocused() + rule.runOnIdle { + assertFalse(shell.navFocused) + assertFalse(shell.pickerVisible) + assertEquals("header", state.row) + assertEquals(0, state.column) + } rule.onRoot().performKeyInput { pressKey(Key.Back) } rule.runOnIdle { assertEquals(listOf(TitleAction.Pop), actions); assertFalse(shell.pickerVisible) } } From 7bfa43135081595b7ae83ac177133286573c782d Mon Sep 17 00:00:00 2001 From: justinzeus Date: Sat, 7 Nov 2026 17:48:54 +0100 Subject: [PATCH 8/9] Retain destination focus in the initial title loading regression --- .../java/org/justinvisser/wolfskin/tv/TitleKeysTest.kt | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/app/src/test/java/org/justinvisser/wolfskin/tv/TitleKeysTest.kt b/app/src/test/java/org/justinvisser/wolfskin/tv/TitleKeysTest.kt index 4e664da3..272e4a8d 100644 --- a/app/src/test/java/org/justinvisser/wolfskin/tv/TitleKeysTest.kt +++ b/app/src/test/java/org/justinvisser/wolfskin/tv/TitleKeysTest.kt @@ -12,6 +12,7 @@ import androidx.compose.ui.test.onNodeWithTag import androidx.compose.ui.test.performMouseInput import androidx.compose.ui.test.onNodeWithText import androidx.compose.ui.test.assertIsDisplayed +import androidx.compose.ui.test.assertIsFocused import androidx.compose.ui.test.hasClickAction import androidx.compose.ui.test.hasText import org.justinvisser.wolfskin.design.WolfskinTokens @@ -285,10 +286,14 @@ class TitleKeysTest { render(transform = { it.copy(status = TitleReadStatus.Loading) }) rule.onNodeWithText("Cast").assertDoesNotExist() press(Key.DirectionDown) - assertTrue(shell.navFocused) + assertFalse(shell.navFocused) + rule.onNodeWithTag("title-page").assertIsFocused() assertEquals("header", state.row) + assertEquals(0, state.column) + assertFalse(shell.pickerVisible) rule.runOnIdle { rule.activity.onBackPressedDispatcher.onBackPressed() } assertEquals(listOf(TitleAction.Pop), effects) + assertFalse(shell.pickerVisible) } From 79f64f6e18aba3eff2938831061d1d53532ba98e Mon Sep 17 00:00:00 2001 From: justinzeus Date: Sat, 7 Nov 2026 18:44:00 +0100 Subject: [PATCH 9/9] Restore sequential hero copy and first-appearance entry --- data/tv-spec/spec.md | 8 +- .../wolfskin/design/HeroMotion.kt | 90 +++++++----- .../design/DiscoverHeroSequenceTest.kt | 47 +++++-- .../wolfskin/design/HeroMotionTest.kt | 2 +- .../wolfskin/design/HomeHeroSequenceTest.kt | 132 ++++++++++++++++++ 5 files changed, 226 insertions(+), 53 deletions(-) create mode 100644 wolfskin-design/src/test/java/org/justinvisser/wolfskin/design/HomeHeroSequenceTest.kt diff --git a/data/tv-spec/spec.md b/data/tv-spec/spec.md index 9ced0894..7de1c44a 100644 --- a/data/tv-spec/spec.md +++ b/data/tv-spec/spec.md @@ -66,16 +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 | | 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 title copy | 180 ms, ease-out | 16 px rise; already-loaded Home/Discover hero copy paints immediately | +| Initial hero/title copy | 180 ms, ease-out | 16 px rise (`copyEntryRise`); saved hero entry state suppresses replay on remount, not first appearance | | 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 | 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 | +| Hero copy hand-off | Outgoing 120 ms ease (`.25,.1,.25,1`); incoming starts after 140 ms and enters over 200 ms ease-out, gated on artwork or final fallback readiness | One copy slot: no overlapping text; 14 px incoming rise, outgoing translation held where entry stopped | | 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 | | 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 entire painted backdrop stays static while incoming artwork loads, and the lower backdrop changes only when artwork or its final fallback is ready, 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. 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 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 **2** bounded owners (outgoing art and one sequential copy block) above an opaque artwork base. Art interruptions retain the painted composite without resetting alpha; each outgoing art owner keeps its original 320 ms lifetime. Copy retargets its current alpha, fades out before replacing text, then uses the approved delay/entry tokens; never two copy layers are visible together. 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 approved title follow-up (2026-11-07) supersedes the fixed readiness reservation: an absent readiness line occupies **0 px**, while a present line occupies **56 px (28 dp)**. Late arrival/removal animates that height over the **200 ms late-line duration with token ease-out**; reduced motion snaps. Overview, actions and following rows move monotonically with that band; an already-pinned focused row stays pinned through same-remeasure scroll compensation. Ratings remain inside the fixed-height facts band and appear once: an explicit episode page uses its opened episode's own set, including the episode score, rather than the series-only set. Series and episode pages have no redundant Seasons shortcut; Down reaches the season chips. Skeletons have no click/hover focus targets; pending episode/cast rows remain absent from the key-navigation state until real cards exist. Initial title/person loading retains destination focus rather than focusing the root nav; Down cannot enter a skeleton, and Back still pops the route without opening the picker. An already-ready catalogue or held title stays Ready while a same-route, same-person refresh reads new data; the existing signed-in authentication guard remains authoritative, and loaded copy/rows do not remount or replay their entry motion. @@ -85,7 +85,7 @@ 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. -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. +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. The reviewed counter-fade remains available separately for comparison with the corrected sequential copy; initial-entry/remount and Home hero series are also published. The regression suite checks an opaque artwork base, at most one visible copy, monotonic per-phase motion, stationary controls and token-bounded outgoing artwork lifetimes. The approved fade-through may briefly have no visible copy; it never paints overlapping titles. These are native-rendering proofs, not on-box frame-pacing measurements. ## Card metadata and bottom state line — revision 6, option B diff --git a/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/HeroMotion.kt b/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/HeroMotion.kt index 44dc46a7..3d46efa0 100644 --- a/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/HeroMotion.kt +++ b/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/HeroMotion.kt @@ -15,6 +15,8 @@ import androidx.compose.runtime.key import androidx.compose.runtime.mutableStateListOf import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberUpdatedState +import androidx.compose.runtime.saveable.rememberSaveable import androidx.compose.runtime.setValue import androidx.compose.runtime.snapshotFlow import androidx.compose.ui.Alignment @@ -27,6 +29,9 @@ import androidx.compose.ui.semantics.semantics import androidx.compose.ui.unit.Dp import kotlinx.coroutines.coroutineScope import kotlinx.coroutines.flow.first +import kotlinx.coroutines.flow.collectLatest +import kotlinx.coroutines.flow.distinctUntilChangedBy +import kotlinx.coroutines.flow.filterNotNull import kotlinx.coroutines.launch import org.justinvisser.wolfskin.design.WolfskinTokens.Motion as M @@ -55,11 +60,7 @@ private class HeroLayer(val identity: Any, var target: T, initialAlpha: Float var outgoing by mutableStateOf(false) } -private class HeroCopyLayer(val identity: Any, var target: T, initialAlpha: Float) { - val alpha = Animatable(initialAlpha) - val progress = Animatable(initialAlpha) - var outgoing by mutableStateOf(false) -} +private class HeroCopyTarget(val identity: Any, var target: T) /** Keep the painted composite while loading; interruptions fade each retained layer from its current alpha. */ @Composable @@ -102,42 +103,59 @@ fun HeroArtCrossfade(identity: Any, target: T, height: Dp, modifier: Modifie } } -/** Copy counter-fades after artwork is ready; outgoing blocks hold their current translation. */ +/** One copy slot fades through after artwork readiness; no two titles can be painted together. */ @Composable fun 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) } + var entered by rememberSaveable { mutableStateOf(false) } + val selected = remember(identity) { HeroCopyTarget(identity, target) } + val currentSelected by rememberUpdatedState(selected) + var displayed by remember { mutableStateOf(selected) } + val alpha = remember { Animatable(if (entered) 1f else 0f) } + val progress = remember { Animatable(if (entered) 1f else 0f) } + var initialEntry by remember { mutableStateOf(!entered) } + var handingOff by remember { mutableStateOf(false) } + var outgoing by remember { mutableStateOf(false) } val reduced = reducedMotion() val presentation = LocalHeroPresentation.current - val rise = with(LocalDensity.current) { M.copyRise.toPx() } - LaunchedEffect(selected, reduced) { - if (selected !in layers) { - if (presentation != null) snapshotFlow { presentation.identity == identity }.first { it } - layers.add(selected) - layers.forEach { if (it !== selected) it.outgoing = true } - } - } - Box(modifier, contentAlignment = Alignment.BottomStart) { - 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)) } + val rise = with(LocalDensity.current) { (if (initialEntry) M.copyEntryRise else M.copyRise).toPx() } + SideEffect { selected.target = target } + LaunchedEffect(presentation, reduced) { + snapshotFlow { currentSelected.takeIf { presentation == null || presentation.identity == it.identity } } + .filterNotNull() + .distinctUntilChangedBy { it.identity } + .collectLatest { next -> + if (!entered) { + displayed = next + entered = true + } else if (displayed.identity != next.identity || handingOff) { + handingOff = true + outgoing = true + coroutineScope { + val fade = if (alpha.value > 0f) launch { + alpha.animateTo(0f, tween(motionDuration(M.heroCopyOut, reduced), easing = M.ease)) + } else null + if (!reduced) kotlinx.coroutines.delay(M.heroCopyDelay.toLong()) + fade?.join() + } + outgoing = false + displayed = next + initialEntry = false + progress.snapTo(0f) } + if (alpha.value != 1f || progress.value != 1f) coroutineScope { + val duration = motionDuration(if (initialEntry) M.rowEnter else M.heroCopyIn, reduced) + launch { alpha.animateTo(1f, tween(duration, easing = M.easeOut)) } + launch { progress.animateTo(1f, tween(duration, easing = M.easeOut)) } + } + handingOff = false } - 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) } - } } } + Box(modifier.graphicsLayer { + this.alpha = alpha.value + translationY = if (reduced) 0f else rise * (1f - progress.value) + }.testTag(if (outgoing) "hero-copy-outgoing" else "hero-copy").semantics { + this[HeroLayerAlpha] = alpha.value + this[HeroLayerTranslation] = if (reduced) 0f else rise * (1f - progress.value) + this[HeroLayerIdentity] = displayed.identity.toString() + }) { content(if (displayed.identity == identity) target else displayed.target) } } diff --git a/wolfskin-design/src/test/java/org/justinvisser/wolfskin/design/DiscoverHeroSequenceTest.kt b/wolfskin-design/src/test/java/org/justinvisser/wolfskin/design/DiscoverHeroSequenceTest.kt index 68b49c7a..193a1348 100644 --- a/wolfskin-design/src/test/java/org/justinvisser/wolfskin/design/DiscoverHeroSequenceTest.kt +++ b/wolfskin-design/src/test/java/org/justinvisser/wolfskin/design/DiscoverHeroSequenceTest.kt @@ -9,6 +9,7 @@ import androidx.activity.ComponentActivity import androidx.compose.foundation.layout.requiredSize import androidx.compose.runtime.CompositionLocalProvider import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.saveable.rememberSaveableStateHolder import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.asImageBitmap import androidx.compose.ui.input.key.Key @@ -46,13 +47,14 @@ class DiscoverHeroSequenceTest { private val artAvailable = mutableStateOf(true) private val reduced = mutableStateOf(false) private var previousPixel: Int? = null + private val mounted = mutableStateOf(true) private var lowerPixel = 0 private var largestJump = 0 private var previousLayers = emptyMap>() private val previousDotWidths = mutableMapOf() private var previousSelection: Int? = null - private fun launch(scenario: String, failedArt: Boolean = false) { + private fun launch(scenario: String, failedArt: Boolean = false, settle: Boolean = true) { 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") @@ -73,13 +75,37 @@ class DiscoverHeroSequenceTest { 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 { + val saved = rememberSaveableStateHolder() 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) } + ShellSurface(shell, Modifier.requiredSize(WolfskinTokens.Shell.stageWidth, WolfskinTokens.Shell.stageHeight)) { modifier -> + if (mounted.value) saved.SaveableStateProvider("discover") { DiscoverScreen(state, modifier) } + } } } } - rule.mainClock.advanceTimeBy(640) + if (settle) rule.mainClock.advanceTimeBy(640) else rule.mainClock.advanceTimeByFrame() + } + + @Test fun initialCopyEntersOnceAndDoesNotReplayOnDestinationRemount() { + launch("initial", settle = false) + sample("initial", 0) + fun copy() = rule.onAllNodes(SemanticsMatcher.keyIsDefined(HeroLayerAlpha), useUnmergedTree = true) + .fetchSemanticsNodes().single { it.config[SemanticsProperties.TestTag].startsWith("hero-copy") } + assertEquals(0f, copy().config[HeroLayerAlpha], .001f) + assertEquals(with(rule.density) { WolfskinTokens.Motion.copyEntryRise.toPx() }, copy().config[HeroLayerTranslation], .001f) + frames("entry", 16) + assertEquals(1f, copy().config[HeroLayerAlpha], .001f) + rule.runOnIdle { mounted.value = false } + frames("absent", 1) + rule.runOnIdle { mounted.value = true } + repeat(8) { frame -> + rule.mainClock.advanceTimeBy(16) + sample("remount", frame) + assertEquals("Returning to a saved destination cannot replay its entrance", 1f, copy().config[HeroLayerAlpha], .001f) + assertEquals(0f, copy().config[HeroLayerTranslation], .001f) + } + finish() } @Test fun interruptedHeroNeverSnapsToAnIntermediateSelection() { @@ -206,7 +232,7 @@ class DiscoverHeroSequenceTest { } } 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("lowerRgb", JSONArray(channels(lowerPixel))).put("copy", copy).put("layers", layers).put("dots", dots).put("navFocused", shell.navFocused)) + .put("selection", state.slide).put("mounted", mounted.value).put("rgb", JSONArray(channels)).put("lowerRgb", JSONArray(channels(lowerPixel))).put("copy", copy).put("layers", layers).put("dots", dots).put("navFocused", shell.navFocused)) } private fun layerSamples(): JSONArray { @@ -214,14 +240,11 @@ class DiscoverHeroSequenceTest { val current = mutableMapOf>() 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) + if (mounted.value) assertTrue("The artwork composite always has an opaque base", nodes.any { it.config[SemanticsProperties.TestTag] == "hero-incoming" && it.config[HeroLayerAlpha] == 1f }) + val visibleCopy = nodes.count { it.config[SemanticsProperties.TestTag].startsWith("hero-copy") && it.config[HeroLayerAlpha] > 0f } + assertTrue("Sequential copy cannot paint overlapping titles, including interruptions", visibleCopy <= 1) 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] @@ -229,10 +252,10 @@ class DiscoverHeroSequenceTest { 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-outgoing" && alpha > 0f) assertEquals("Visible 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) + if (alpha > 0f && previousAlpha > 0f) assertTrue("Visible copy translation cannot snap backwards", translation <= previousTranslation + .001f) } } current[key] = alpha to translation diff --git a/wolfskin-design/src/test/java/org/justinvisser/wolfskin/design/HeroMotionTest.kt b/wolfskin-design/src/test/java/org/justinvisser/wolfskin/design/HeroMotionTest.kt index 01f96558..f316001a 100644 --- a/wolfskin-design/src/test/java/org/justinvisser/wolfskin/design/HeroMotionTest.kt +++ b/wolfskin-design/src/test/java/org/justinvisser/wolfskin/design/HeroMotionTest.kt @@ -110,7 +110,7 @@ class HeroMotionTest { val anchor = rule.onNodeWithText(first.title).fetchSemanticsNode().boundsInRoot val actionAnchor = rule.onNodeWithText("Play").fetchSemanticsNode().boundsInRoot update { selected.value = next } - repeat(8) { + repeat(WolfskinTokens.Motion.heroCopyOut / 16) { 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) diff --git a/wolfskin-design/src/test/java/org/justinvisser/wolfskin/design/HomeHeroSequenceTest.kt b/wolfskin-design/src/test/java/org/justinvisser/wolfskin/design/HomeHeroSequenceTest.kt new file mode 100644 index 00000000..899c36af --- /dev/null +++ b/wolfskin-design/src/test/java/org/justinvisser/wolfskin/design/HomeHeroSequenceTest.kt @@ -0,0 +1,132 @@ +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.background +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.requiredSize +import androidx.compose.runtime.CompositionLocalProvider +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.saveable.rememberSaveableStateHolder +import androidx.compose.ui.Modifier +import androidx.compose.ui.geometry.Rect +import androidx.compose.ui.graphics.asImageBitmap +import androidx.compose.ui.semantics.SemanticsProperties +import androidx.compose.ui.test.* +import androidx.compose.ui.test.junit4.createAndroidComposeRule +import com.github.takahirom.roborazzi.captureRoboImage +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 + +/** Real Home hero entry, interrupted fade-through and saved remount sampled at native 16 ms steps. */ +@RunWith(RobolectricTestRunner::class) +@Config(sdk = [34], application = Application::class, qualifiers = "w960dp-h540dp-xhdpi") +@GraphicsMode(GraphicsMode.Mode.NATIVE) +class HomeHeroSequenceTest { + @get:Rule val rule = createAndroidComposeRule() + private val first = HomeTitle("first", "Short title", "movie", backdrop = "red") + private val next = HomeTitle("next", "A much longer title that must wrap onto a second line in this hero", "movie", backdrop = "blue") + private val third = HomeTitle("third", "The final selected title", "movie", backdrop = "green") + private val selected = mutableStateOf(first) + private val reduced = mutableStateOf(false) + private val mounted = mutableStateOf(true) + private val samples = JSONArray() + private lateinit var directory: File + private lateinit var actionBounds: Rect + + @Test fun initialSequentialBurstAndRemountKeepHomeCopyReadable() { + 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 + directory = File(System.getProperty("wolfskin.captureDirectory"), "home-hero-copy").apply { mkdirs() } + val art = mapOf("red" to android.graphics.Color.RED, "blue" to android.graphics.Color.BLUE, "green" to android.graphics.Color.GREEN).mapValues { (_, color) -> + Bitmap.createBitmap(8, 8, Bitmap.Config.ARGB_8888).apply { eraseColor(color) }.asImageBitmap() + } + rule.setContent { WolfskinTheme { + val saved = rememberSaveableStateHolder() + CompositionLocalProvider(LocalReducedMotion provides reduced.value, LocalTitleBitmaps provides art) { + Box(Modifier.requiredSize(WolfskinTokens.Shell.stageWidth, WolfskinTokens.Shell.stageHeight).background(WolfskinTokens.Color.bg)) { + if (mounted.value) saved.SaveableStateProvider("home-hero") { + HomeHero(HomeRow("hero", HomeRowKind.Hero, cards = listOf(selected.value)), true, 0, emptyList(), {}, {}) + } + } + } + } } + rule.mainClock.advanceTimeByFrame() + actionBounds = rule.onNodeWithText("Play").fetchSemanticsNode().boundsInRoot + sample("initial", 0) + assertEquals(0f, copy().config[HeroLayerAlpha], .001f) + assertEquals(with(rule.density) { WolfskinTokens.Motion.copyEntryRise.toPx() }, copy().config[HeroLayerTranslation], .001f) + frames("entry", 16) + assertEquals(1f, copy().config[HeroLayerAlpha], .001f) + rule.runOnIdle { selected.value = next } + frames("handoff", 26) + assertEquals(next.id, copy().config[HeroLayerIdentity]) + assertEquals(1f, copy().config[HeroLayerAlpha], .001f) + repeat(24) { index -> + rule.runOnIdle { selected.value = if (index % 2 == 0) first else third } + frames("burst-$index", 1) + } + frames("settled", 28) + assertEquals(third.id, copy().config[HeroLayerIdentity]) + assertEquals(1f, copy().config[HeroLayerAlpha], .001f) + rule.runOnIdle { reduced.value = true; selected.value = next } + frames("reduced", 20) + assertEquals(next.id, copy().config[HeroLayerIdentity]) + assertEquals(1f, copy().config[HeroLayerAlpha], .001f) + rule.runOnIdle { mounted.value = false } + frames("absent", 1) + rule.runOnIdle { mounted.value = true } + repeat(8) { frame -> + rule.mainClock.advanceTimeBy(16) + sample("remount", frame) + assertEquals("Returning Home cannot replay the initial entrance", 1f, copy().config[HeroLayerAlpha], .001f) + assertEquals(0f, copy().config[HeroLayerTranslation], .001f) + } + File(directory, "series.json").writeText(samples.toString(2)) + } + + private fun frames(phase: String, count: Int) = repeat(count) { frame -> rule.mainClock.advanceTimeBy(16); sample(phase, frame) } + private fun copy() = rule.onAllNodes(SemanticsMatcher.keyIsDefined(HeroLayerAlpha), useUnmergedTree = true).fetchSemanticsNodes() + .single { it.config[SemanticsProperties.TestTag].startsWith("hero-copy") } + + private fun sample(phase: String, frame: Int) { + rule.waitForIdle() + val file = File(directory, "$phase-${frame.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 nodes = rule.onAllNodes(SemanticsMatcher.keyIsDefined(HeroLayerAlpha), useUnmergedTree = true).fetchSemanticsNodes() + val copyNodes = nodes.filter { it.config[SemanticsProperties.TestTag].startsWith("hero-copy") } + assertTrue("Home must never overlap visible titles, even during bursts", copyNodes.count { it.config[HeroLayerAlpha] > 0f } <= 1) + if (mounted.value) { + assertTrue("Home artwork retains an opaque base", nodes.any { it.config[SemanticsProperties.TestTag] == "hero-incoming" && it.config[HeroLayerAlpha] == 1f }) + assertEquals("Copy motion cannot move Home's controls", actionBounds, rule.onNodeWithText("Play").fetchSemanticsNode().boundsInRoot) + } + val layers = JSONArray() + nodes.forEach { node -> + val translation = node.config[HeroLayerTranslation] + if (reduced.value) assertEquals(0f, translation, .001f) + layers.put(JSONObject().put("kind", node.config[SemanticsProperties.TestTag]).put("identity", node.config[HeroLayerIdentity]) + .put("layerId", node.id).put("alpha", node.config[HeroLayerAlpha]).put("translationY", translation) + .put("x", node.boundsInRoot.left).put("y", node.boundsInRoot.top)) + } + samples.put(JSONObject().put("phase", phase).put("frame", frame).put("timeMs", rule.mainClock.currentTime) + .put("mounted", mounted.value).put("selection", selected.value.id).put("reduced", reduced.value) + .put("rgb", JSONArray(listOf(android.graphics.Color.red(pixel), android.graphics.Color.green(pixel), android.graphics.Color.blue(pixel)))) + .put("actionX", actionBounds.left).put("actionY", actionBounds.top).put("layers", layers)) + } +}