Show episode ratings and animate title readiness without empty space
All checks were successful
review/code-claude Reviewed e6ddf1d7: his board picks implemented, no blocking finding
review/design-claude Reviewed e6ddf1d7: his board picks implemented, no blocking finding

This commit is contained in:
justinzeus 2026-11-07 16:41:07 +01:00
parent 174e1ae5c9
commit e6ddf1d7e5
8 changed files with 159 additions and 23 deletions

View file

@ -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() {

View file

@ -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.

View file

@ -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

View file

@ -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) {

View file

@ -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]

View file

@ -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<TitleOption> = 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 {

View file

@ -44,20 +44,19 @@ class MotionSurfaceTest {
@get:Rule val rule = createAndroidComposeRule<ComponentActivity>(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() {

View file

@ -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<ComponentActivity>(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<Float> = 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<List<Float>> {
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
}
}