From e6ddf1d7e55d6c4538f646cf2841028bea0be343 Mon Sep 17 00:00:00 2001 From: justinzeus Date: Sat, 7 Nov 2026 16:41:07 +0100 Subject: [PATCH] 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 + } +}