Refine title episode cards, section stacking and action menus
Some checks failed
review/code-claude Code review: pass, no blocking findings (9 non-blocking notes)
review/design-claude Blocked: card height 126 vs 140 dp, Menu to Media info flash, poster/cast overlap

This commit is contained in:
justinzeus 2026-11-07 15:05:07 +01:00
parent c2f7ccc438
commit 6d7513bd0e
19 changed files with 379 additions and 85 deletions

View file

@ -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" })
}
}
}

View file

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

View file

@ -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")
}

View file

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

View file

@ -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<ComponentActivity>()
private val effects = mutableListOf<TitleAction>()
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<String>()
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<Int>) {
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<Int>()
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)
}
}

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -24,6 +24,13 @@ class TitleState(
var sheet by mutableStateOf<TitleSheet?>(null)
private set
var sheetIndex by mutableStateOf(0)
private var mediaInfoFromMenu = false
val menuOptions: List<TitleOption> 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<TitleOption> = 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))
}

View file

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

View file

@ -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"},

View file

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

View file

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