Compare commits

...
Sign in to create a new pull request.

11 commits

Author SHA1 Message Date
271cee2cea Merge pull request 'Merge upstream Wholphin v1.1.1' (#53) from fm/wolfskin-upstream-1-1-1 into main 2026-11-07 17:05:40 +01:00
e189d9318a Merge current main into upstream integration
All checks were successful
review/code-claude Reviewed at 00e8e83b; delta is a clean merge of main (diff vs main still 2 files, +12); full gate green except the known WatchReturnTest flake
2026-11-07 16:02:58 +01:00
19bb587c95 Merge pull request 'Implement option B card metadata (stacked on #51)' (#54) from fm/tv-card-meta-build into main 2026-11-07 16:01:06 +01:00
165a3f5e7d Merge main into the card metadata branch
All checks were successful
review/code-claude Reviews carried: clean merge of main into c2f7ccc4
review/design-claude Reviews carried: clean merge of main into c2f7ccc4
2026-11-07 15:52:30 +01:00
c3e9b3cd3b Merge pull request 'Implement approved TV motion and shaped loading states' (#51) from fm/tv-motion-build into main 2026-11-07 15:51:38 +01:00
c2f7ccc438 Fix focused card captions and leave loading bands blank
All checks were successful
review/code-claude Blocker fixed on c2f7ccc4, delta reviewed
review/design-claude Blocker fixed on c2f7ccc4, delta reviewed
2026-11-07 03:53:45 +01:00
fe44a3c24f Implement option B card metadata and solid bottom edges
Some checks failed
review/code-claude Blocking: CardCaption focus-only caption invisible after first focus move in a shelf
review/design-claude Design review: 1 blocking (unframed skeleton caption bars); other differences are renderer/fixture artifacts
2026-11-07 03:06:26 +01:00
00e8e83bb6 Document upstream v1.1.1 setup fix
All checks were successful
review/code-claude No blocking findings on 00e8e83b; waits for Justin go
2026-11-07 02:31:05 +01:00
b80b67f736 v1.1.1
-----BEGIN SSH SIGNATURE-----
 U1NIU0lHAAAAAQAAADMAAAALc3NoLWVkMjU1MTkAAAAgrqffh93Kw9xITbVISXK/jkih9K
 Q1NSQeC6TBqiLCg2oAAAADZ2l0AAAAAAAAAAZzaGE1MTIAAABTAAAAC3NzaC1lZDI1NTE5
 AAAAQMNewXnxMn5HlyJcrG3vlqPg4lBjF2q9uyedLQQ2S+G4PZQf1XxPTy4q9DKBcT8UIN
 XSrDmvm0HJci43S6FMTgE=
 -----END SSH SIGNATURE-----

Merge tag 'v1.1.1' into fm/wolfskin-upstream-1-1-1

v1.1.1

# -----BEGIN SSH SIGNATURE-----
# U1NIU0lHAAAAAQAAADMAAAALc3NoLWVkMjU1MTkAAAAgrqffh93Kw9xITbVISXK/jkih9K
# Q1NSQeC6TBqiLCg2oAAAADZ2l0AAAAAAAAAAZzaGE1MTIAAABTAAAAC3NzaC1lZDI1NTE5
# AAAAQMNewXnxMn5HlyJcrG3vlqPg4lBjF2q9uyedLQQ2S+G4PZQf1XxPTy4q9DKBcT8UIN
# XSrDmvm0HJci43S6FMTgE=
# -----END SSH SIGNATURE-----
# gpg verification failed.
2026-11-07 02:16:52 +01:00
Damontecres
159edc875d
Release v1.1.1 2026-11-06 15:37:29 -05:00
Damontecres
bd46ff3e03
Fix adding Seerr server if plugin is not installed (#2179)
## Description
If the server plugin is not installed, trying to add Seerr server will
show an endless loading page. This PR fixes that.

### Related issues
Fixes #2177

### Testing
Emulator against server with & without the plugin

## Screenshots
N/A

## AI or LLM usage
None
2026-11-05 17:13:09 -05:00
29 changed files with 751 additions and 233 deletions

View file

@ -52,6 +52,9 @@ class SwitchSeerrViewModel
_state.update { it.copy(loading = LoadingState.Success) } _state.update { it.copy(loading = LoadingState.Success) }
} }
} }
} else {
// Nothing to check so set success
_state.update { it.copy(loading = LoadingState.Success) }
} }
} }

View file

@ -1,6 +1,6 @@
# Wolfskin TV specification — revision 6 # Wolfskin TV specification — revision 6
2026-11-06. Revision 6 adds the approved direction-A motion and loading states from [the motion reference](https://pages.justinvisser.org/wolfskin/tv-motion-speed.html), frozen with SHA-256 checksums at `data/tv-v2/motion-2026-11-06/`. Revision-5 feedback below still applies. Unchanged static geometry, navigation, typography and colours remain governed by the approved frames and the [written specification](https://pages.justinvisser.org/wolfskin/tv-spec). The revision-5 prototype delta remains `data/tv-v2/feedback-2026-11-06/`. 2026-11-07. Revision 6 adds the approved direction-A motion and loading states from [the motion reference](https://pages.justinvisser.org/wolfskin/tv-motion-speed.html), frozen with SHA-256 checksums at `data/tv-v2/motion-2026-11-06/`, and the subsequently approved **option B** [card metadata frames](https://pages.justinvisser.org/wolfskin/tv-card-meta-frames.html). Revision-5 feedback below still applies except where the card amendment explicitly supersedes it. Unchanged navigation, typography and colours remain governed by the [written specification](https://pages.justinvisser.org/wolfskin/tv-spec). The revision-5 prototype delta remains `data/tv-v2/feedback-2026-11-06/`.
## Who’s watching ## Who’s watching
@ -83,6 +83,29 @@ Discover key traversal skips pending lanes in both directions. If every remainin
Native **1920×1080** before/after frames and the frame-budget inventory are published at [the motion build proof](https://pages.justinvisser.org/wolfskin/tv-motion-build/). Before frames use the pre-change surface sources in this same isolated worktree; after frames use the shipped Compose surfaces. No device access, installation or optimized APK is part of this lane. On-box acceptance remains: no series layout change after first content, Home scrolling no worse than **39.6% janky / p90 65 ms**. Native JVM rendering does not measure that GPU/frame-timing target. Native **1920×1080** before/after frames and the frame-budget inventory are published at [the motion build proof](https://pages.justinvisser.org/wolfskin/tv-motion-build/). Before frames use the pre-change surface sources in this same isolated worktree; after frames use the shipped Compose surfaces. No device access, installation or optimized APK is part of this lane. On-box acceptance remains: no series layout change after first content, Home scrolling no worse than **39.6% janky / p90 65 ms**. Native JVM rendering does not measure that GPU/frame-timing target.
## Card metadata and bottom state line — revision 6, option B
Film cards show year, duration and a **24 px watched check**. Episodes show their own duration and check, without a year; series show year and check, never an aggregate runtime. Resuming and unwatched cards have no check. Compact metadata omits media-type words, season counts, character roles, ownership and remaining-time prose. Duration rounds total minutes before formatting hours/minutes; narrow layouts tighten metadata gaps from **12 → 8 → 4 px**, never shrink text or ellipsise duration.
Movies, Shows, Anime, Home See all and person filmography always show their card-width title/subtitle. Titles are one-line ellipses: **28 px / 32 px line**, medium muted at rest, bold foreground when focused. Metadata is **24 px / 26 px line**. The **96 px** poster caption band starts its text **38 px** below unscaled art. Home poster captions remain focus-only; Home landscape titles stay on the art with focus-only metadata below it. Landscape shelves reserve **64 px** below art. Loading placeholders reserve identical **blank** caption bands: only art-sized blocks are painted, with no title/subtitle pills. Each shelf still shares the motion lane's single pulse layer.
| Geometry at 1920×1080 | Pixels |
|---|---:|
| Library row pitch | 524 |
| Home poster shelf pitch | 632 |
| Home wide / tile shelf pitch | 522 / 441 |
| Initial library art row tops | 372 / 896 |
| Pinned library art row tops / second-row bottom | 168 / 692 / 1076 |
| Focus ring to caption / caption to next focused row ring | 20.48 / 26.48 |
One bottom state line replaces the independent resume bar. Unwatched and Discover have no state line; watched has a full-width **3 px accent** line; resume has proportional **6 px accent** fill on a full-width `color.progressTrack` track. Left/right are zero, with **2 px bottom backing** and **2 px opaque separator** above the fill, clipped by the art's **16 px** radius. Focus scales the art/line by **1.08**, with its **4 px** straddle ring drawn independently above them.
Completed Home progress (`resumeSeconds / runtimeSeconds >= 1`) follows the same state precedence as library/title cards: it is no longer resuming, so a played title uses the 3 px watched line and an unplayed title has no state line.
Justin's additional correction is binding even where a reference frame leaks: **the lowest card row must contain card/edge/fill colour, never art**. Clip artwork out of the backing band before painting it; merely overdrawing after a rounded clip can retain poster colours in antialiased corners. The backing is opaque `color.bg` (**#07070a**). Precompose and quantise the track over that backing once to preserve the approved **#3d3d3f** browser pixels instead of native alpha-rounding differences; fill is **#cc9fff**. The approved Annie Hall and Locke treatment frames themselves expose artwork below their inset state line; the native build deliberately makes that row solid.
Native same-fixture comparisons and the exhaustive real-art bottom-row proof are published in [the card metadata build gallery](https://pages.justinvisser.org/wolfskin/tv-card-meta-build/). `CardBottomPixelTest` checks white Annie Hall, dark Locke, colourful Apocalypse Now and Avatar Aang at rest and individually focused; every pixel across each full bottom row must equal an art-free rendering of the same clipped backing/ring, while straight-edge and fill/track pixels additionally match exact state colours. It also checks Home/library parity when progress reaches or exceeds completion. `CardGeometryTest` measures narrow fitting, the 24 px check, grid/shelf pitches, pinned rows and ring clearances to 0.1 px, and asserts every pixel in reserved loading caption bands remains background-coloured. `CardCaptionMotionTest` compares native glyph pixels after changing a focused title and moving across a focused Home shelf. Reference comparisons retain documented renderer/fixture artifacts; unapproved visible design changes remain merge blockers.
## Sources and verification ## Sources and verification
Authoritative changed values live in `wolfskin-design/tokens.json`, `title-tokens.json` and `discover-tokens.json`; regenerate Kotlin, CSS and the drift manifest with `python3 wolfskin-design/generate_tokens.py`. Authoritative changed values live in `wolfskin-design/tokens.json`, `title-tokens.json` and `discover-tokens.json`; regenerate Kotlin, CSS and the drift manifest with `python3 wolfskin-design/generate_tokens.py`.

View file

@ -185,6 +185,15 @@ not the working tree. An uncommitted merge still builds with the old version nam
Neither an upstream merge nor its validation build authorizes a release or TV Neither an upstream merge nor its validation build authorizes a release or TV
installation. installation.
## Upstream v1.1.1 integration
The patch release makes manual Seerr setup available when the optional companion
plugin is absent: setup is marked ready immediately rather than remaining pending.
With the plugin installed, an empty settings response or a failed settings read
still reaches the existing ready-state fallback; plugin URL prefill is unchanged.
There are no new Room migrations (schema remains 41), player, layout, session,
DNS/client, dependency, branding or signing changes relative to v1.1.0.
## Wolfskin shell ## Wolfskin shell
Wolfskin UI lives in `app/src/wolfskin/java`, beside upstream source. The signed-in Wolfskin UI lives in `app/src/wolfskin/java`, beside upstream source. The signed-in
@ -520,9 +529,9 @@ Down clamps a short final title row before entering the separate Try again / Sho
more row; an empty slot cannot activate recovery. An unavailable library hides more row; an empty slot cannot activate recovery. An unavailable library hides
its unknown item count. its unknown item count.
Caption separators use 40% of the existing muted colour's alpha. The rail follows Library titles and compact subtitles remain visible at rest, with one-line,
the locked prototype CSS: bold 24 px letters, with the focused letter bold 28 px, card-width ellipsis rather than a 640 px focused caption. The letter rail uses
overriding the spec table's generic caption style for this component. bold 24 px letters, with the focused letter bold 28 px.
Library and Settings header initials use the muted colour over the person's fill; Library and Settings header initials use the muted colour over the person's fill;
navigation and picker avatars retain their dark initials. navigation and picker avatars retain their dark initials.
@ -566,12 +575,35 @@ Title shelves reserve vertical drawing room for the scaled ring.
Before scaling the stroke, ring-to-neighbour clearances round to 28/22/16 px Before scaling the stroke, ring-to-neighbour clearances round to 28/22/16 px
for poster/tile/wide cards; including stroke scale they are 27.6/21.8/16.1 px. for poster/tile/wide cards; including stroke scale they are 27.6/21.8/16.1 px.
Poster progress uses the independent 18 px title-card inset, not caption spacing: Cards use the approved option-B bottom state line instead of a separate inset
the track remains 220 px wide on a 256 px poster. Library focus-follow restarts resume bar: watched is 3 px accent; resume is 6 px proportional accent over
when the effective column count, focused row or header geometry changes, retaining `color.progressTrack`; unwatched and Discover have no line. Opaque background
deep focus across live snapshots that add or remove the letter rail. backs the entire strip, including its bottom 2 px and the 2 px separator above
`LibrarySpacingRegressionTest` renders both progress geometry and the unchanged the fill. Artwork is clipped out of the backing band before painting it so
focused index across the 199↔200-title boundary. rounded-corner antialiasing cannot leak poster pixels into the lowest row.
The separately drawn focus ring stays above the line.
Film subtitles are year + duration + a 24 px watched check, episode subtitles
are their own duration + check, and series subtitles are year + check with no
single runtime. Resuming cards have no check. The poster caption band remains
96 px; Home poster captions are focus-only. Home landscapes retain titles on
the art and reserve 64 px for focused metadata below it, making wide/tile shelf
pitches 522/441 px; poster shelves remain 632 px and grids remain 524 px.
Loading rows reserve the same blank caption bands and share one pulse layer;
only art-sized blocks are painted, with no title/subtitle placeholder pills.
`CardBottomPixelTest` samples full native bottom rows on four real posters at
rest and individually focused, including white Annie Hall and dark Locke.
`CardGeometryTest` measures 210/327 px caption fitting, card-width titles,
24 px checks, row pitches, pinned rows and ring clearances to 0.1 px, and checks
that every reserved loading-caption pixel remains background-coloured.
`CardCaptionMotionTest` checks visible native glyph pixels when a still-focused
caption changes title directly or while moving across a Home shelf.
The [native option-B proof gallery](https://pages.justinvisser.org/wolfskin/tv-card-meta-build/)
contains the same illustrative watch-state data as the approved frames and
records renderer/fixture artifacts; unapproved design changes block merge.
`LibrarySpacingRegressionTest` retains the deep focused index across the
199↔200-title letter-rail boundary.
`LibraryCaptureTest.spacingShowsFirstFocused`, `LibraryCaptureTest.spacingShowsFirstFocused`,
`HomeCaptureTest.spacingNextUpFirstFocused` and `HomeCaptureTest.spacingNextUpFirstFocused` and

View file

@ -114,12 +114,6 @@
"dp": 18.0, "dp": 18.0,
"source": "locked index.html:94-165,180-198,1014-1029,1276-1289" "source": "locked index.html:94-165,180-198,1014-1029,1276-1289"
}, },
"captionWidth": {
"$type": "dimension",
"px": 640,
"dp": 320.0,
"source": "locked index.html:94-165,180-198,1014-1029,1276-1289"
},
"plateTop": { "plateTop": {
"$type": "dimension", "$type": "dimension",
"px": 14, "px": 14,
@ -240,18 +234,6 @@
"dp": 30.0, "dp": 30.0,
"source": "locked index.html:94-165,180-198,1014-1029,1276-1289" "source": "locked index.html:94-165,180-198,1014-1029,1276-1289"
}, },
"progressInset": {
"$type": "dimension",
"px": 18,
"dp": 9.0,
"source": "locked index.html:94-165,180-198,1014-1029,1276-1289"
},
"progressBottom": {
"$type": "dimension",
"px": 10,
"dp": 5.0,
"source": "locked index.html:94-165,180-198,1014-1029,1276-1289"
},
"endTop": { "endTop": {
"$type": "dimension", "$type": "dimension",
"px": 40, "px": 40,
@ -316,7 +298,6 @@
"savedPadding": { "$type": "dimension", "px": 20, "dp": 10.0, "source": "locked index.html:87" }, "savedPadding": { "$type": "dimension", "px": 20, "dp": 10.0, "source": "locked index.html:87" },
"savedGap": { "$type": "dimension", "px": 10, "dp": 5.0, "source": "locked index.html:87" }, "savedGap": { "$type": "dimension", "px": 10, "dp": 5.0, "source": "locked index.html:87" },
"savedIcon": { "$type": "dimension", "px": 28, "dp": 14.0, "source": "locked index.html:88" }, "savedIcon": { "$type": "dimension", "px": 28, "dp": 14.0, "source": "locked index.html:88" },
"savedRadius": { "$type": "dimension", "px": 28, "dp": 14.0, "source": "locked index.html:87" }, "savedRadius": { "$type": "dimension", "px": 28, "dp": 14.0, "source": "locked index.html:87" }
"posterProgressBottom": { "$type": "dimension", "px": 12, "dp": 6.0, "source": "locked index.html:138-140" }
} }
} }

View file

@ -367,9 +367,6 @@
"chipGap": {"$type":"dimension","px":16,"dp":8,"source":"index.html:184-185"}, "chipGap": {"$type":"dimension","px":16,"dp":8,"source":"index.html:184-185"},
"chipTop": {"$type":"dimension","px":16,"dp":8,"source":"index.html:184"}, "chipTop": {"$type":"dimension","px":16,"dp":8,"source":"index.html:184"},
"chipIcon": {"$type":"dimension","px":26,"dp":13,"source":"index.html:185"}, "chipIcon": {"$type":"dimension","px":26,"dp":13,"source":"index.html:185"},
"captionWidth": {"$type":"dimension","px":640,"dp":320,"source":"index.html:151"},
"captionSeparatorOpacity": {"$type":"number","$value":0.4,"source":"index.html:1207; spec 4.4"},
"captionFactsGap": {"$type":"dimension","px":18,"dp":9,"source":"index.html:154"},
"badgeIcon": {"$type":"dimension","px":28,"dp":14,"source":"index.html:142"}, "badgeIcon": {"$type":"dimension","px":28,"dp":14,"source":"index.html:142"},
"plateTop": {"$type":"dimension","px":14,"dp":7,"source":"index.html:162"}, "plateTop": {"$type":"dimension","px":14,"dp":7,"source":"index.html:162"},
"plateSide": {"$type":"dimension","px":16,"dp":8,"source":"index.html:162"}, "plateSide": {"$type":"dimension","px":16,"dp":8,"source":"index.html:162"},

View file

@ -10,18 +10,11 @@ import androidx.compose.runtime.remember
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.graphicsLayer import androidx.compose.ui.graphics.graphicsLayer
import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.text.TextStyle
import androidx.tv.material3.Text import androidx.tv.material3.Text
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.ui.draw.clipToBounds
import androidx.compose.ui.layout.Layout
import androidx.compose.ui.unit.constrainHeight
/** Captions rise inside the reserved band without changing measured geometry. */ /** Captions rise inside the reserved band without changing measured geometry. */
@Composable @Composable
fun Caption(title: String, facts: String, visible: Boolean, modifier: Modifier = Modifier, accentLastFact: Boolean = false, accentFirstFact: Boolean = false, fun Caption(title: String, facts: String, visible: Boolean, modifier: Modifier = Modifier) {
titleStyle: TextStyle = WolfskinTokens.Type.button, factsStyle: TextStyle = WolfskinTokens.Type.caption,
titleOverflow: TextOverflow = TextOverflow.Ellipsis) {
val alpha = remember(title, facts) { Animatable(0f) } val alpha = remember(title, facts) { Animatable(0f) }
val entryProgress = remember(title, facts) { mutableFloatStateOf(0f) } val entryProgress = remember(title, facts) { mutableFloatStateOf(0f) }
val reduced = reducedMotion() val reduced = reducedMotion()
@ -32,47 +25,10 @@ fun Caption(title: String, facts: String, visible: Boolean, modifier: Modifier =
if (visible) entryProgress.floatValue = value if (visible) entryProgress.floatValue = value
} }
} }
val titleStyle = WolfskinTokens.Type.button
val factsStyle = WolfskinTokens.Type.caption
Column(modifier.graphicsLayer { this.alpha = alpha.value; translationY = if (reduced) 0f else rise * (1f - entryProgress.floatValue) }) { Column(modifier.graphicsLayer { this.alpha = alpha.value; translationY = if (reduced) 0f else rise * (1f - entryProgress.floatValue) }) {
Text(title, color = WolfskinTokens.Color.fg, style = titleStyle, modifier = Modifier.exactLineBox(titleStyle), maxLines = 1, overflow = titleOverflow) Text(title, color = WolfskinTokens.Color.fg, style = titleStyle, modifier = Modifier.exactLineBox(titleStyle), maxLines = 1, overflow = TextOverflow.Ellipsis)
CaptionFacts(facts, accentFirstFact, accentLastFact, factsStyle) Text(facts, color = WolfskinTokens.Color.muted, style = factsStyle, modifier = Modifier.exactLineBox(factsStyle), maxLines = 1, overflow = TextOverflow.Ellipsis)
}
}
/** CSS flex facts: only coloured role/ownership spans shrink; ordinary facts and dots keep their width. */
@Composable
private fun CaptionFacts(facts: String, accentFirst: Boolean, accentLast: Boolean, style: TextStyle) {
val parts = remember(facts) { facts.split(" · ") }
val muted = WolfskinTokens.Color.muted
val colour = WolfskinTokens.Color.accentText
Layout(
content = {
parts.forEachIndexed { index, part ->
val accented = (index == 0 && accentFirst) || (index == parts.lastIndex && accentLast)
Text(part, color = if (accented) colour else muted, style = style, modifier = Modifier.exactLineBox(style), maxLines = 1, overflow = TextOverflow.Clip)
if (index != parts.lastIndex) Text("·", color = muted.copy(alpha = muted.alpha * WolfskinTokens.Library.captionSeparatorOpacity), style = style, modifier = Modifier.exactLineBox(style))
}
},
modifier = Modifier.fillMaxWidth().clipToBounds(),
) { children, constraints ->
val gap = WolfskinTokens.Library.captionFactsGap.roundToPx()
val basis = IntArray(children.size)
var fixed = gap * (children.size - 1).coerceAtLeast(0)
var flexible = 0
fun shrinks(index: Int) = (index == 0 && accentFirst) || (index == children.lastIndex && accentLast)
children.forEachIndexed { index, child ->
val width = child.maxIntrinsicWidth(constraints.maxHeight)
basis[index] = width
if (shrinks(index)) flexible += width else fixed += width
}
val available = (constraints.maxWidth - fixed).coerceAtLeast(0)
val ratio = if (flexible > available) available.toFloat() / flexible else 1f
val measured = children.mapIndexed { index, child ->
val width = if (shrinks(index)) (basis[index] * ratio).toInt() else basis[index]
child.measure(constraints.copy(minWidth = 0, maxWidth = width, minHeight = 0))
}
layout(constraints.maxWidth, constraints.constrainHeight(measured.maxOfOrNull { it.height } ?: 0)) {
var x = 0
measured.forEach { child -> child.placeRelative(x, 0); x += child.width + gap }
}
} }
} }

View file

@ -0,0 +1,81 @@
package org.justinvisser.wolfskin.design
import androidx.compose.animation.core.Animatable
import androidx.compose.animation.core.tween
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.size
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.remember
import androidx.compose.runtime.mutableFloatStateOf
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clipToBounds
import androidx.compose.ui.graphics.graphicsLayer
import androidx.compose.ui.layout.Layout
import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.Constraints
import androidx.compose.ui.res.painterResource
import androidx.tv.material3.Icon
import androidx.tv.material3.Text
import org.justinvisser.wolfskin.design.WolfskinTokens.CardMetadata as M
import org.justinvisser.wolfskin.design.WolfskinTokens.Color as C
/** Card-width titles never displace runtime or watched metadata into a neighbouring slot. */
@Composable
fun CardCaption(title: String, year: Int?, duration: String?, watched: Boolean, focused: Boolean,
modifier: Modifier = Modifier, focusOnly: Boolean = false, showTitle: Boolean = true) {
val transition = if (focusOnly) {
val alpha = remember(title) { Animatable(0f) }
val entryProgress = remember(title) { mutableFloatStateOf(0f) }
val reduced = reducedMotion()
val rise = with(LocalDensity.current) { WolfskinTokens.Motion.captionRise.toPx() }
LaunchedEffect(alpha, focused, reduced) {
if (focused && !reduced) kotlinx.coroutines.delay(WolfskinTokens.Motion.captionDelay.toLong())
alpha.animateTo(if (focused) 1f else 0f, tween(motionDuration(if (focused) WolfskinTokens.Motion.focusMove else WolfskinTokens.Motion.captionOut, reduced), easing = WolfskinTokens.Motion.easeOut)) {
if (focused) entryProgress.floatValue = value
}
}
Modifier.graphicsLayer {
this.alpha = alpha.value
translationY = if (reduced) 0f else rise * (1f - entryProgress.floatValue)
}
} else Modifier
Column(modifier.then(transition)) {
if (showTitle) {
val style = if (focused || focusOnly) M.titleFocus else M.titleRest
Text(title, color = if (focused || focusOnly) C.fg else C.muted, style = style,
modifier = Modifier.fillMaxWidth().exactLineBox(style), maxLines = 1, overflow = TextOverflow.Ellipsis)
}
CardMetadata(year, duration, watched)
}
}
/** Measured gaps tighten before any fixed-size year, runtime or check can be clipped. */
@Composable
fun CardMetadata(year: Int?, duration: String?, watched: Boolean, modifier: Modifier = Modifier) {
Layout(content = {
if (year != null) Text(year.toString(), color = C.muted, style = M.subtitle, modifier = Modifier.exactLineBox(M.subtitle), maxLines = 1)
if (duration != null) Text(duration, color = C.muted, style = M.subtitle, modifier = Modifier.exactLineBox(M.subtitle), maxLines = 1)
if (watched) Icon(painterResource(R.drawable.card_watched_check), "Watched", Modifier.size(M.check), tint = C.muted)
}, modifier = modifier.fillMaxWidth().clipToBounds()) { children, constraints ->
val sizes = IntArray(children.size) { children[it].maxIntrinsicWidth(constraints.maxHeight) }
val gaps = (children.size - 1).coerceAtLeast(0)
val textWidth = sizes.sum()
val gap = when {
textWidth + gaps * M.gap.roundToPx() <= constraints.maxWidth -> M.gap.roundToPx()
textWidth + gaps * M.compactGap.roundToPx() <= constraints.maxWidth -> M.compactGap.roundToPx()
else -> M.minimumGap.roundToPx()
}
val measured = Array(children.size) { children[it].measure(Constraints.fixedWidth(sizes[it])) }
val height = with(this) { M.subtitle.lineHeight.toPx().toInt() }
layout(constraints.maxWidth, height) {
var left = 0
measured.forEach { child ->
child.placeRelative(left, (height - child.height) / 2)
left += child.width + gap
}
}
}
}

View file

@ -55,7 +55,7 @@ fun DiscoverShelf(state: DiscoverState, lane: DiscoverLane, row: Int) {
LazyRow(state = cards, userScrollEnabled = false, LazyRow(state = cards, userScrollEnabled = false,
contentPadding = PaddingValues(start = L.safeSide, end = WolfskinTokens.Shell.stageWidth - L.safeSide - (K.posterW * slots + K.posterGap * (slots - 1)), top = K.posterGap, bottom = K.posterGap), contentPadding = PaddingValues(start = L.safeSide, end = WolfskinTokens.Shell.stageWidth - L.safeSide - (K.posterW * slots + K.posterGap * (slots - 1)), top = K.posterGap, bottom = K.posterGap),
horizontalArrangement = Arrangement.spacedBy(K.posterGap), horizontalArrangement = Arrangement.spacedBy(K.posterGap),
modifier = Modifier.offset(y = D.shelfHeadingBand - K.posterGap).height(height + K.posterGap * 2).skeletonPulse(lane.pending).rowReveal(lane.id, !lane.pending)) { modifier = Modifier.offset(y = D.shelfHeadingBand - K.posterGap).height(height + (if (services) WolfskinTokens.Shell.zero else K.posterCaptionBand) + K.posterGap * 2).skeletonPulse(lane.pending).rowReveal(lane.id, !lane.pending)) {
if (services) itemsIndexed(state.snapshot.services, key = { _, service -> "service:${service.id}" }, contentType = { _, _ -> "entry" }) { index, service -> if (services) itemsIndexed(state.snapshot.services, key = { _, service -> "service:${service.id}" }, contentType = { _, _ -> "entry" }) { index, service ->
Box(Modifier.size(K.entryW, K.entryH).focusScale(active && index == column, WolfskinTokens.Focus.scaleCard) Box(Modifier.size(K.entryW, K.entryH).focusScale(active && index == column, WolfskinTokens.Focus.scaleCard)
.focusRing(active && index == column, WolfskinTokens.Radius.card, WolfskinTokens.Focus.ringCardRing, FocusLift.Card) .focusRing(active && index == column, WolfskinTokens.Radius.card, WolfskinTokens.Focus.ringCardRing, FocusLift.Card)
@ -66,7 +66,7 @@ fun DiscoverShelf(state: DiscoverState, lane: DiscoverLane, row: Int) {
} }
} else if (lane.pending) { } else if (lane.pending) {
items(slots, key = { "pending:$it" }) { items(slots, key = { "pending:$it" }) {
Box(Modifier.size(K.posterW, K.posterH).background(C.surface2, RoundedCornerShape(WolfskinTokens.Radius.card))) PosterCaptionSkeleton()
} }
} else { } else {
itemsIndexed(lane.titles, key = { _, item -> item.key }, contentType = { _, _ -> "poster" }) { index, item -> itemsIndexed(lane.titles, key = { _, item -> item.key }, contentType = { _, _ -> "poster" }) { index, item ->
@ -80,15 +80,16 @@ fun DiscoverShelf(state: DiscoverState, lane: DiscoverLane, row: Int) {
} }
} }
} }
if (!services && active && !lane.pending) { if (!services && !lane.pending) {
val item = lane.titles.getOrNull(column) val item = lane.titles.getOrNull(column)
Caption( val captionModifier = Modifier.offset(L.safeSide + K.posterPitch * (column - first), D.shelfHeadingBand + K.posterH + L.captionOffset).width(K.posterW)
item?.title ?: if (lane.tail == DiscoverTail.SeeAll) "See all ${lane.title}" else if (lane.tail == DiscoverTail.Retry) "Try again" else "Show more", if (item != null) {
if (item != null) buildDiscoverCaptionFacts(item, state, lane.hidden) else if (lane.tail == DiscoverTail.SeeAll) "${lane.total} titles" else if (lane.tail == DiscoverTail.Retry) "${lane.title} stopped loading" else "The last read only repeated titles", val card = item.libraryCard(state)
true, Modifier.offset(L.safeSide + K.posterPitch * (column - first), D.shelfHeadingBand + K.posterH + L.captionOffset).width(WolfskinTokens.Library.captionWidth), CardCaption(item.title, card.year, card.duration, false, active, captionModifier, focusOnly = true)
accentFirstFact = lane.hidden, } else Caption(
accentLastFact = item != null && (item.heldBy.isNotEmpty() || state.requests[item.key].orEmpty().isNotEmpty()), if (lane.tail == DiscoverTail.SeeAll) "See all ${lane.title}" else if (lane.tail == DiscoverTail.Retry) "Try again" else "Show more",
) if (lane.tail == DiscoverTail.SeeAll) "${lane.total} titles" else if (lane.tail == DiscoverTail.Retry) "${lane.title} stopped loading" else "The last read only repeated titles",
active, captionModifier)
} }
} }
} }
@ -108,7 +109,3 @@ internal fun DiscoverTailCard(lane: DiscoverLane, focused: Boolean, onClick: ()
else CatalogueRecoveryCard(lane.tail == DiscoverTail.Retry, focused, onClick) else CatalogueRecoveryCard(lane.tail == DiscoverTail.Retry, focused, onClick)
} }
internal fun buildDiscoverCaptionFacts(title: DiscoverTitle, state: DiscoverState, hidden: Boolean): String {
val card = title.libraryCard(state)
return (if (hidden) "Hidden from your picks · " else "") + card.facts(true)
}

View file

@ -38,7 +38,6 @@ data class HomeTitle(
} }
val remaining: String get() = "${(((runtimeSeconds ?: 0) - resumeSeconds) / 60).roundToInt().coerceAtLeast(1)} min left" val remaining: String get() = "${(((runtimeSeconds ?: 0) - resumeSeconds) / 60).roundToInt().coerceAtLeast(1)} min left"
val subtitle: String get() = if (isEpisode) "$episodeLabel · $title" else "${if (type == "movie") "Movie" else "Series"} · ${year ?: ""}" val subtitle: String get() = if (isEpisode) "$episodeLabel · $title" else "${if (type == "movie") "Movie" else "Series"} · ${year ?: ""}"
val facts: String get() = listOfNotNull(year?.toString(), if (type == "movie") runtimeSeconds?.let { runtime } ?: "Movie" else seasons?.let { "$it ${if (it == 1) "season" else "seasons"}" } ?: "Series").joinToString(" · ")
} }
/** Only observed, range-checked scores reach this model; formatting follows the TV locale. */ /** Only observed, range-checked scores reach this model; formatting follows the TV locale. */

View file

@ -119,7 +119,7 @@ fun HomeScreen(
private fun rowHeight(row: HomeRow) = when (row.kind) { private fun rowHeight(row: HomeRow) = when (row.kind) {
HomeRowKind.Hero -> L.row_hero HomeRowKind.Hero -> L.row_hero
HomeRowKind.Shelf -> L.rowHeadingBand + row.cardHeight + (if (row.cardKind == HomeCardKind.Poster) WolfskinTokens.Card.posterCaptionBand else WolfskinTokens.Shell.zero) + L.rowGapAfter HomeRowKind.Shelf -> L.rowHeadingBand + row.cardHeight + (if (row.cardKind == HomeCardKind.Poster) WolfskinTokens.Card.posterCaptionBand else WolfskinTokens.CardMetadata.landscapeBand) + L.rowGapAfter
HomeRowKind.Section -> L.row_section HomeRowKind.Section -> L.row_section
HomeRowKind.State -> L.row_state HomeRowKind.State -> L.row_state
HomeRowKind.Note -> L.row_note HomeRowKind.Note -> L.row_note

View file

@ -8,7 +8,6 @@ import androidx.compose.foundation.lazy.LazyRow
import androidx.compose.foundation.lazy.itemsIndexed import androidx.compose.foundation.lazy.itemsIndexed
import androidx.compose.foundation.lazy.LazyListState import androidx.compose.foundation.lazy.LazyListState
import androidx.compose.foundation.shape.CircleShape import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.SideEffect import androidx.compose.runtime.SideEffect
@ -20,7 +19,6 @@ import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Brush import androidx.compose.ui.graphics.Brush
import androidx.compose.ui.platform.testTag import androidx.compose.ui.platform.testTag
import androidx.compose.ui.draw.clip
import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.Dp import androidx.compose.ui.unit.Dp
import androidx.compose.ui.zIndex import androidx.compose.ui.zIndex
@ -36,7 +34,7 @@ import org.justinvisser.wolfskin.design.HomeType as T
fun HomeShelf(row: HomeRow, memory: HomeViewMemory, focused: Boolean, column: Int, ratings: List<HomeRating>, onCard: (Int) -> Unit, onHover: (Int) -> Unit) { fun HomeShelf(row: HomeRow, memory: HomeViewMemory, focused: Boolean, column: Int, ratings: List<HomeRating>, onCard: (Int) -> Unit, onHover: (Int) -> Unit) {
val width = row.cardWidth val width = row.cardWidth
val height = row.cardHeight val height = row.cardHeight
val caption = if (row.cardKind == HomeCardKind.Poster) K.posterCaptionBand else WolfskinTokens.Shell.zero val caption = if (row.cardKind == HomeCardKind.Poster) K.posterCaptionBand else WolfskinTokens.CardMetadata.landscapeBand
val first = row.adjustedFirst(column, memory.firstCards[row.id] ?: 0) val first = row.adjustedFirst(column, memory.firstCards[row.id] ?: 0)
SideEffect { if (memory.firstCards[row.id] != first) memory.firstCards[row.id] = first } SideEffect { if (memory.firstCards[row.id] != first) memory.firstCards[row.id] = first }
val track = remember(memory, row.id) { memory.tracks.getOrPut(row.id) { LazyListState(first) } } val track = remember(memory, row.id) { memory.tracks.getOrPut(row.id) { LazyListState(first) } }
@ -50,39 +48,36 @@ fun HomeShelf(row: HomeRow, memory: HomeViewMemory, focused: Boolean, column: In
Box(Modifier.fillMaxWidth().height(L.rowHeadingBand + height + caption + L.rowGapAfter)) { Box(Modifier.fillMaxWidth().height(L.rowHeadingBand + height + caption + L.rowGapAfter)) {
if (!row.skeleton) Text(row.title, color = C.fg, style = T.rowHeading, maxLines = 1, modifier = Modifier.offset(x = L.safeSide)) if (!row.skeleton) Text(row.title, color = C.fg, style = T.rowHeading, maxLines = 1, modifier = Modifier.offset(x = L.safeSide))
val cards = if (row.skeleton) List(if (row.cardKind == HomeCardKind.Poster) 8 else 5) { null } else row.cards + if (row.hasSeeAll) listOf(null) else emptyList() val cards = if (row.skeleton) List(if (row.cardKind == HomeCardKind.Poster) 8 else 5) { null } else row.cards + if (row.hasSeeAll) listOf(null) else emptyList()
LazyRow(state = track, modifier = Modifier.fillMaxWidth().offset(y = L.rowHeadingBand - L.rowGapAfter).height(height + L.rowGapAfter * 2).skeletonPulse(row.skeleton).rowReveal(row.id, !row.skeleton), contentPadding = PaddingValues(horizontal = L.safeSide, vertical = L.rowGapAfter), horizontalArrangement = Arrangement.spacedBy(K.posterGap), userScrollEnabled = !row.skeleton) { LazyRow(state = track, modifier = Modifier.fillMaxWidth().offset(y = L.rowHeadingBand - L.rowGapAfter).height(height + caption + L.rowGapAfter * 2).skeletonPulse(row.skeleton).rowReveal(row.id, !row.skeleton), contentPadding = PaddingValues(horizontal = L.safeSide, vertical = L.rowGapAfter), horizontalArrangement = Arrangement.spacedBy(K.posterGap), userScrollEnabled = !row.skeleton) {
itemsIndexed(cards, key = { index, title -> title?.id ?: "${row.id}:tail:$index" }, contentType = { _, title -> if (row.skeleton) "skeleton" else if (title == null) "seeAll" else row.cardKind.name.lowercase() }) { index, title -> itemsIndexed(cards, key = { index, title -> title?.id ?: "${row.id}:tail:$index" }, contentType = { _, title -> if (row.skeleton) "skeleton" else if (title == null) "seeAll" else row.cardKind.name.lowercase() }) { index, title ->
val selected = !row.skeleton && focused && column == index val selected = !row.skeleton && focused && column == index
val cardModifier = Modifier.zIndex(if (selected) 2f else 0f).testTag("home-card:${row.id}:$index") val cardModifier = Modifier.zIndex(if (selected) 2f else 0f).testTag("home-card:${row.id}:$index")
.then(if (!row.skeleton) Modifier.focusOnPointerEnter { onHover(index) } else Modifier) .then(if (!row.skeleton) Modifier.focusOnPointerEnter { onHover(index) } else Modifier)
if (title == null && !row.skeleton) { if (row.skeleton) {
PosterCaptionSkeleton(width, height, caption)
} else if (title == null) {
LibrarySeeAllCard(row.total, selected, { onCard(index) }, cardModifier, width, height, T.listTitle, T.label) LibrarySeeAllCard(row.total, selected, { onCard(index) }, cardModifier, width, height, T.listTitle, T.label)
} else TitleCard(selected, cardModifier.then(if (!row.skeleton) Modifier.clickable { onCard(index) } else Modifier), width, height) { } else {
if (title == null) Box(Modifier.fillMaxSize().background(C.surface2)) else { val progress = if (row.catalogue || title.runtimeSeconds == null || title.runtimeSeconds <= 0 || title.resumeSeconds <= 0) 0f else (title.resumeSeconds / title.runtimeSeconds).toFloat()
TitleCard(selected, cardModifier.clickable { onCard(index) }, width, height,
watched = !row.catalogue && title.played, progress = if (progress >= 1f) 0f else progress) {
TitleArtwork(title.title, title.type, title.poster, width, height, Modifier.fillMaxSize(), backdrop = title.backdrop, posterOnly = title.posterOnly, landscape = row.cardKind != HomeCardKind.Poster) TitleArtwork(title.title, title.type, title.poster, width, height, Modifier.fillMaxSize(), backdrop = title.backdrop, posterOnly = title.posterOnly, landscape = row.cardKind != HomeCardKind.Poster)
if (row.cardKind != HomeCardKind.Poster) TileOverlay(title) if (row.cardKind != HomeCardKind.Poster) TileOverlay(title)
if (row.catalogue && title.held) Box(Modifier.align(Alignment.BottomEnd).padding(L.badgeInset).size(L.badge).background(C.ok, CircleShape), contentAlignment = Alignment.Center) { Text("✓", color = C.onFocus, style = T.button) } if (row.catalogue && title.held) Box(Modifier.align(Alignment.BottomEnd).padding(L.badgeInset).size(L.badge).background(C.ok, CircleShape), contentAlignment = Alignment.Center) { Text("✓", color = C.onFocus, style = T.button) }
if (title.resumeSeconds > 0 && title.runtimeSeconds != null) Box(Modifier.align(Alignment.BottomCenter).padding(horizontal = H.progressInset).padding(bottom = if (row.cardKind == HomeCardKind.Poster) H.posterProgressBottom else H.progressBottom).fillMaxWidth().height(L.progressBar).clip(RoundedCornerShape(WolfskinTokens.Radius.progress)).background(C.progressTrack)) {
Box(Modifier.fillMaxHeight().fillMaxWidth((title.resumeSeconds / title.runtimeSeconds).toFloat().coerceIn(0f, 1f)).background(C.accent))
}
HomeRatingPlate(if (selected) ratings else emptyList(), selected, Modifier.fillMaxWidth()) HomeRatingPlate(if (selected) ratings else emptyList(), selected, Modifier.fillMaxWidth())
} }
} }
} }
} }
if (row.cardKind == HomeCardKind.Poster && focused && !row.skeleton) { if (!row.skeleton) {
val title = row.cards.getOrNull(column) val title = row.cards.getOrNull(column)
val availability = when { val captionModifier = Modifier.offset(L.safeSide + (width + K.posterGap) * (column - first), L.rowHeadingBand + height + L.captionOffset).width(width)
title == null -> null if (title == null) {
row.catalogue -> if (title.held) "In your library" else title.otherOwner?.let { "In $it’s library" } if (row.cardKind == HomeCardKind.Poster) Caption("See all ${row.title}", "${row.total} titles", focused, captionModifier)
title.played -> "Watched" } else CardCaption(title.displayTitle, if (title.isEpisode) null else title.year,
title.resumeSeconds > 0 -> title.remaining title.takeIf { it.type == "movie" || it.isEpisode }?.runtimeSeconds?.takeIf { it > 0 }?.let { title.runtime },
else -> null !row.catalogue && title.played && title.resumeSeconds <= 0, focused, captionModifier,
} focusOnly = true, showTitle = row.cardKind == HomeCardKind.Poster)
val facts = remember(title, row.total, availability) {
(title?.facts ?: "${row.total} titles").replace(" · ", " · ") + availability?.let { " · $it" }.orEmpty()
}
Caption(title?.title ?: "See all ${row.title}", facts, true, Modifier.offset(L.safeSide + (width + K.posterGap) * (column - first), L.rowHeadingBand + height + L.captionOffset).width(H.captionWidth), accentLastFact = availability != null, titleStyle = T.button, factsStyle = T.caption, titleOverflow = TextOverflow.Ellipsis)
} }
} }
} }
@ -90,9 +85,11 @@ fun HomeShelf(row: HomeRow, memory: HomeViewMemory, focused: Boolean, column: In
@Composable @Composable
private fun TileOverlay(title: HomeTitle) { private fun TileOverlay(title: HomeTitle) {
Box(Modifier.fillMaxSize(), contentAlignment = Alignment.BottomStart) { Box(Modifier.fillMaxSize(), contentAlignment = Alignment.BottomStart) {
Column(Modifier.fillMaxWidth().background(Brush.verticalGradient(listOf(C.bg.copy(alpha = 0f), C.bg.copy(alpha = .92f)))).padding(start = H.cardPadding, end = H.cardPadding, top = H.cardOverlayTop, bottom = H.cardOverlayBottom)) { val metadata = WolfskinTokens.CardMetadata
Text(title.displayTitle, modifier = Modifier.exactLineBox(T.button), color = C.fg, style = T.button, maxLines = 1, overflow = TextOverflow.Ellipsis) Column(Modifier.padding(bottom = metadata.bottom + metadata.separator + metadata.resumeLine).fillMaxWidth()
Text(title.subtitle, modifier = Modifier.exactLineBox(T.caption), color = C.muted, style = T.caption, maxLines = 1, overflow = TextOverflow.Ellipsis) .background(Brush.verticalGradient(listOf(C.bg.copy(alpha = 0f), C.bg.copy(alpha = .92f))))
.padding(start = H.cardPadding, end = H.cardPadding, top = H.cardOverlayTop, bottom = H.cardOverlayBottom)) {
Text(title.displayTitle, modifier = Modifier.exactLineBox(metadata.titleFocus), color = C.fg, style = metadata.titleFocus, maxLines = 1, overflow = TextOverflow.Ellipsis)
} }
} }
} }

View file

@ -18,6 +18,7 @@ interface PosterContent {
val poster: String? val poster: String?
val ratings: List<LibraryRating> val ratings: List<LibraryRating>
val progress: Float val progress: Float
val posterWatched: Boolean
val badge: String? val badge: String?
} }
@ -40,18 +41,15 @@ data class LibraryTitle(
) : PosterContent { ) : PosterContent {
override val posterTitle: String get() = title override val posterTitle: String get() = title
override val posterIsMovie: Boolean get() = type == "movie" override val posterIsMovie: Boolean get() = type == "movie"
override val posterWatched: Boolean get() = played
val duration: String? get() = if (type == "movie" || type == "episode") runtimeSeconds?.takeIf { it > 0 }?.let {
val minutes = (it + 30) / 60
if (minutes >= 60) "${minutes / 60}h ${minutes % 60}m" else "${minutes}m"
} else null
val sortName: String = title.replace(leadingArticle, "").lowercase(Locale.ROOT) val sortName: String = title.replace(leadingArticle, "").lowercase(Locale.ROOT)
val catalogueKey: String = "$type:${tmdbId ?: id}" val catalogueKey: String = "$type:${tmdbId ?: id}"
val letter: Char = sortName.firstOrNull()?.uppercaseChar()?.takeIf { it in 'A'..'Z' } ?: '#' val letter: Char = sortName.firstOrNull()?.uppercaseChar()?.takeIf { it in 'A'..'Z' } ?: '#'
val leadScore: Double get() = ratings.firstOrNull()?.let { if (it.source.startsWith("rt")) it.score else it.score * 10 } ?: -1.0 val leadScore: Double get() = ratings.firstOrNull()?.let { if (it.source.startsWith("rt")) it.score else it.score * 10 } ?: -1.0
fun facts(catalogue: Boolean): String = buildList {
year?.let { add(it.toString()) }
add(if (type == "movie") runtimeSeconds?.let { val minutes = (it + 30) / 60; if (minutes >= 60) "${minutes / 60}h ${minutes % 60}m" else "${minutes}m" } ?: "Movie" else seasons?.let { "$it season${if (it == 1) "" else "s"}" } ?: "Series")
if (catalogue) { if (availability.isNotEmpty()) add(availability) }
else if (played) add("Watched")
else if (progress > 0f && runtimeSeconds != null) add("${((runtimeSeconds * (1 - progress) + 30) / 60).toInt().coerceAtLeast(1)} min left")
}.joinToString(" · ")
} }
/** Ordered rating entries are formatted after the active TV locale is known. */ /** Ordered rating entries are formatted after the active TV locale is known. */

View file

@ -25,9 +25,12 @@ fun LibraryPoster(
catalogue: Boolean, catalogue: Boolean,
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
offlineArt: ((String) -> ImageBitmap?)? = null, offlineArt: ((String) -> ImageBitmap?)? = null,
showWatchState: Boolean = !catalogue,
) { ) {
val poster = title.poster val poster = title.poster
TitleCard(focused, modifier.testTag("poster-${title.id}")) { val progress = if (showWatchState) title.progress else 0f
TitleCard(focused, modifier.testTag("poster-${title.id}"), watched = showWatchState && title.posterWatched,
progress = if (progress >= 1f) 0f else progress) {
val bitmap = remember(poster, offlineArt) { poster?.let { offlineArt?.invoke(it) } } val bitmap = remember(poster, offlineArt) { poster?.let { offlineArt?.invoke(it) } }
if (bitmap != null) Image(bitmap, title.posterTitle, Modifier.fillMaxSize(), contentScale = ContentScale.Crop) if (bitmap != null) Image(bitmap, title.posterTitle, Modifier.fillMaxSize(), contentScale = ContentScale.Crop)
else if (poster != null && offlineArt == null) { else if (poster != null && offlineArt == null) {
@ -39,13 +42,6 @@ fun LibraryPoster(
onSuccess = { ready = true }, onError = { ready = false }) onSuccess = { ready = true }, onError = { ready = false })
} else MediaArtworkFallback(title.posterTitle, title.posterIsMovie) } else MediaArtworkFallback(title.posterTitle, title.posterIsMovie)
if (title.ratings.isNotEmpty()) LibraryRatingPlate(title.ratings, visible = ratingsVisible) if (title.ratings.isNotEmpty()) LibraryRatingPlate(title.ratings, visible = ratingsVisible)
val progress = title.progress
if (progress > 0f && progress < 1f) {
Box(Modifier.align(Alignment.BottomCenter).padding(horizontal = WolfskinTokens.Title.cardProgressInset, vertical = WolfskinTokens.Layout.navItemGap)
.fillMaxWidth().height(WolfskinTokens.Layout.progressBar).testTag("poster-progress-${title.id}").background(C.progressTrack, CircleShape)) {
Box(Modifier.fillMaxHeight().fillMaxWidth(progress).background(C.accent, CircleShape))
}
}
if (catalogue && title.badge != null) { if (catalogue && title.badge != null) {
val held = title.badge == "held" val held = title.badge == "held"
val color = if (title.badge == "failed") C.fail else C.accent val color = if (title.badge == "failed") C.fail else C.accent

View file

@ -121,7 +121,7 @@ fun LibraryScreen(
LibraryStatus.Loading -> items(3, key = { "skeleton-$it" }, contentType = { "grid" }) { LibraryStatus.Loading -> items(3, key = { "skeleton-$it" }, contentType = { "grid" }) {
Box(Modifier.fillMaxWidth().height(rowHeight)) { Box(Modifier.fillMaxWidth().height(rowHeight)) {
Row(Modifier.padding(start = L.safeSide).skeletonPulse(), horizontalArrangement = Arrangement.spacedBy(K.posterGap)) { Row(Modifier.padding(start = L.safeSide).skeletonPulse(), horizontalArrangement = Arrangement.spacedBy(K.posterGap)) {
repeat(grid.columns) { Box(Modifier.size(K.posterW, K.posterH).background(C.surface2, RoundedCornerShape(WolfskinTokens.Radius.card))) } repeat(grid.columns) { PosterCaptionSkeleton() }
} }
} }
} }
@ -139,6 +139,7 @@ fun LibraryScreen(
val title = items.getOrNull(index) ?: break val title = items.getOrNull(index) ?: break
key(if (grid.catalogue) title.catalogueKey else title.id) { key(if (grid.catalogue) title.catalogueKey else title.id) {
val isFocused = active && !grid.toolbar && !grid.inRail && grid.index == index val isFocused = active && !grid.toolbar && !grid.inRail && grid.index == index
Box(Modifier.width(K.posterW)) {
LibraryPoster(title, isFocused, plateKey == title.id && isFocused, grid.catalogue, LibraryPoster(title, isFocused, plateKey == title.id && isFocused, grid.catalogue,
Modifier.zIndex(if (isFocused) 1f else 0f).pointerInput(index, grid) { Modifier.zIndex(if (isFocused) 1f else 0f).pointerInput(index, grid) {
awaitPointerEventScope { awaitPointerEventScope {
@ -150,14 +151,12 @@ fun LibraryScreen(
} }
} }
}.clickable { grid.index = index; grid.toolbar = false; shell.navFocused = false; grid.onTitle(title) }, offlineArt) }.clickable { grid.index = index; grid.toolbar = false; shell.navFocused = false; grid.onTitle(title) }, offlineArt)
CardCaption(title.title, if (title.type == "episode") null else title.year, title.duration,
!grid.catalogue && title.played && !(title.progress > 0f && title.progress < 1f), isFocused,
Modifier.offset(y = K.posterH + L.captionOffset).width(K.posterW))
} }
} }
} }
if (active && !grid.toolbar && !grid.inRail && grid.index / grid.columns == row) {
val title = items.getOrNull(grid.index)
if (title != null) Caption(title.title, title.facts(grid.catalogue), true,
Modifier.offset(L.safeSide + K.posterPitch * (grid.index % grid.columns), K.posterH + L.captionOffset).width(G.captionWidth),
accentLastFact = if (grid.catalogue) title.availability.isNotEmpty() else title.played || title.progress > 0f)
} }
} }
} }
@ -240,7 +239,7 @@ private fun LibraryTail(grid: LibraryGridState, focused: Boolean) {
Box(Modifier.fillMaxWidth().height(K.posterH + K.posterCaptionBand + L.gridRowGap)) { Box(Modifier.fillMaxWidth().height(K.posterH + K.posterCaptionBand + L.gridRowGap)) {
CatalogueRecoveryCard(retry, focused, { grid.index = grid.tailIndex; grid.key(ShellKey.Ok) }, Modifier.offset(L.safeSide)) CatalogueRecoveryCard(retry, focused, { grid.index = grid.tailIndex; grid.key(ShellKey.Ok) }, Modifier.offset(L.safeSide))
Caption(if (retry) "Try again" else "Show more", if (retry) "${grid.title} stopped loading" else "The last read only repeated titles", focused, Caption(if (retry) "Try again" else "Show more", if (retry) "${grid.title} stopped loading" else "The last read only repeated titles", focused,
Modifier.offset(L.safeSide, K.posterH + L.captionOffset).width(G.captionWidth)) Modifier.offset(L.safeSide, K.posterH + L.captionOffset).width(K.posterW))
} }
} }

View file

@ -46,7 +46,7 @@ private fun PosterSkeletonRow(heading: androidx.compose.ui.unit.Dp) {
Column { Column {
Spacer(Modifier.height(heading)) Spacer(Modifier.height(heading))
Row(Modifier.padding(start = L.safeSide).skeletonPulse(), horizontalArrangement = Arrangement.spacedBy(K.posterGap)) { Row(Modifier.padding(start = L.safeSide).skeletonPulse(), horizontalArrangement = Arrangement.spacedBy(K.posterGap)) {
repeat(K.posterSlots) { SkeletonBlock(K.posterW, K.posterH) } repeat(K.posterSlots) { PosterCaptionSkeleton() }
} }
} }
} }
@ -55,3 +55,13 @@ private fun PosterSkeletonRow(heading: androidx.compose.ui.unit.Dp) {
private fun SkeletonBlock(width: androidx.compose.ui.unit.Dp, height: androidx.compose.ui.unit.Dp) { private fun SkeletonBlock(width: androidx.compose.ui.unit.Dp, height: androidx.compose.ui.unit.Dp) {
Box(Modifier.size(width, height).background(C.surface2, RoundedCornerShape(WolfskinTokens.Radius.card))) Box(Modifier.size(width, height).background(C.surface2, RoundedCornerShape(WolfskinTokens.Radius.card)))
} }
/** Loading reserves the ready caption band without painting unapproved text placeholders. */
@Composable
internal fun PosterCaptionSkeleton(width: androidx.compose.ui.unit.Dp = K.posterW,
height: androidx.compose.ui.unit.Dp = K.posterH, band: androidx.compose.ui.unit.Dp = K.posterCaptionBand) {
Column(Modifier.width(width)) {
SkeletonBlock(width, height)
Spacer(Modifier.height(band))
}
}

View file

@ -148,7 +148,7 @@ private fun PersonPage(state: PersonState, shell: ShellState, modifier: Modifier
Row(Modifier.height(D.gridHeight).padding(start = L.safeSide), horizontalArrangement = Arrangement.spacedBy(WolfskinTokens.Card.posterGap)) { Row(Modifier.height(D.gridHeight).padding(start = L.safeSide), horizontalArrangement = Arrangement.spacedBy(WolfskinTokens.Card.posterGap)) {
row.forEachIndexed { column, item -> row.forEachIndexed { column, item ->
val index = rowIndex * columns + column val index = rowIndex * columns + column
TitlePoster(item, index == state.index && !shell.navFocused, Modifier.width(WolfskinTokens.Card.posterW), catalogue = true, onFocus = { state.index = index }) { state.click(index) } TitlePoster(item, index == state.index && !shell.navFocused, Modifier.width(WolfskinTokens.Card.posterW), alwaysShowCaption = true, onFocus = { state.index = index }) { state.click(index) }
} }
} }
} }

View file

@ -9,15 +9,51 @@ import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip import androidx.compose.ui.draw.clip
import androidx.compose.ui.draw.drawWithCache
import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.geometry.Size
import androidx.compose.ui.graphics.drawscope.clipRect
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.unit.Dp import androidx.compose.ui.unit.Dp
// Precompose before packing into Color: packing first loses the browser's truncated channel fractions.
private val opaqueProgressTrack = WolfskinTokens.Color.progressTrack.let { track ->
val backing = WolfskinTokens.Color.bg
val inverse = 1f - track.alpha
Color(
((track.red * track.alpha + backing.red * inverse) * 255).toInt(),
((track.green * track.alpha + backing.green * inverse) * 255).toInt(),
((track.blue * track.alpha + backing.blue * inverse) * 255).toInt(),
)
}
/** Shelf, grid and continuation cards share one fixed slot and draw-only focus surface. */ /** Shelf, grid and continuation cards share one fixed slot and draw-only focus surface. */
@Composable @Composable
fun TitleCard(focused: Boolean, modifier: Modifier = Modifier, width: Dp = WolfskinTokens.Card.posterW, fun TitleCard(focused: Boolean, modifier: Modifier = Modifier, width: Dp = WolfskinTokens.Card.posterW,
height: Dp = WolfskinTokens.Card.posterH, content: @Composable BoxScope.() -> Unit) { height: Dp = WolfskinTokens.Card.posterH, watched: Boolean = false, progress: Float = 0f,
content: @Composable BoxScope.() -> Unit) {
val shape = RoundedCornerShape(WolfskinTokens.Radius.card) val shape = RoundedCornerShape(WolfskinTokens.Radius.card)
Box(modifier.size(width, height).focusScale(focused, WolfskinTokens.Focus.scaleCard) Box(modifier.size(width, height).focusScale(focused, WolfskinTokens.Focus.scaleCard)
.focusRing(focused, WolfskinTokens.Radius.card, WolfskinTokens.Focus.ringCardRing, FocusLift.Card)) { .focusRing(focused, WolfskinTokens.Radius.card, WolfskinTokens.Focus.ringCardRing, FocusLift.Card)) {
Box(Modifier.fillMaxSize().clip(shape).background(WolfskinTokens.Color.surface2), content = content) Box(Modifier.fillMaxSize().clip(shape).background(WolfskinTokens.Color.surface2)
.drawWithCache {
val edge = WolfskinTokens.CardMetadata.bottom.toPx()
val fill = when {
progress > 0f -> WolfskinTokens.CardMetadata.resumeLine.toPx()
watched -> WolfskinTokens.CardMetadata.watchedLine.toPx()
else -> 0f
}
val backing = edge + if (fill > 0f) fill + WolfskinTokens.CardMetadata.separator.toPx() else 0f
onDrawWithContent {
clipRect(bottom = size.height - backing) { this@onDrawWithContent.drawContent() }
drawRect(WolfskinTokens.Color.bg, Offset(0f, size.height - backing), Size(size.width, backing))
if (fill > 0f) {
val top = Offset(0f, size.height - edge - fill)
if (progress > 0f) drawRect(opaqueProgressTrack, top, Size(size.width, fill))
drawRect(WolfskinTokens.Color.accent, top,
Size(size.width * if (progress > 0f) progress.coerceIn(0f, 1f) else 1f, fill))
}
}
}, content = content)
} }
} }

View file

@ -30,6 +30,7 @@ data class TitleItem(
) : PosterContent { ) : PosterContent {
override val posterTitle: String get() = name override val posterTitle: String get() = name
override val posterIsMovie: Boolean get() = kind == "movie" override val posterIsMovie: Boolean get() = kind == "movie"
override val posterWatched: Boolean get() = watched
override val progress: Float get() = if (runtimeSeconds > 0) resumeSeconds.toFloat() / runtimeSeconds else 0f override val progress: Float get() = if (runtimeSeconds > 0) resumeSeconds.toFloat() / runtimeSeconds else 0f
override val badge: String? get() = if (held) "held" else null override val badge: String? get() = if (held) "held" else null
val episodeLabel: String get() = "S$season E$episode" val episodeLabel: String get() = "S$season E$episode"

View file

@ -4,7 +4,6 @@ import androidx.compose.animation.fadeIn
import androidx.compose.animation.fadeOut import androidx.compose.animation.fadeOut
import androidx.compose.animation.core.tween import androidx.compose.animation.core.tween
import androidx.compose.ui.platform.LocalDensity import androidx.compose.ui.platform.LocalDensity
import androidx.compose.foundation.layout.fillMaxHeight
import androidx.compose.foundation.background import androidx.compose.foundation.background
import androidx.compose.foundation.clickable import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Arrangement
@ -107,53 +106,36 @@ fun TitleMediaShelf(state: TitleState, episodes: Boolean, active: Boolean) {
} }
} }
/** Posters draw art at rest, with facts only in the reserved focused-caption band. */ /** Shelf captions are focus-only; filmography keeps card-width metadata visible at rest. */
@Composable @Composable
fun TitlePoster(item: TitleItem, focused: Boolean, modifier: Modifier = Modifier, tile: Boolean = false, ratingsVisible: Boolean = true, catalogue: Boolean = false, onFocus: (() -> Unit)? = null, onClick: () -> Unit) { fun TitlePoster(item: TitleItem, focused: Boolean, modifier: Modifier = Modifier, tile: Boolean = false, ratingsVisible: Boolean = true, alwaysShowCaption: Boolean = false, onFocus: (() -> Unit)? = null, onClick: () -> Unit) {
val focusOwner = LocalTitleFocusOwner.current val focusOwner = LocalTitleFocusOwner.current
Column(modifier.zIndex(if (focused) 1f else 0f)) { Column(modifier.zIndex(if (focused) 1f else 0f)) {
if (!tile) { if (!tile) {
val bitmaps = LocalTitleBitmaps.current val bitmaps = LocalTitleBitmaps.current
val offlineArt = androidx.compose.runtime.remember(bitmaps) { if (bitmaps.isEmpty()) null else { path: String -> bitmaps[path] } } val offlineArt = androidx.compose.runtime.remember(bitmaps) { if (bitmaps.isEmpty()) null else { path: String -> bitmaps[path] } }
LibraryPoster(item, focused, focused && ratingsVisible, catalogue = catalogue, LibraryPoster(item, focused, focused && ratingsVisible, catalogue = alwaysShowCaption, showWatchState = true,
modifier = Modifier.titlePointerFocus(onFocus).clickable { focusOwner?.invoke(); onClick() }, offlineArt = offlineArt) modifier = Modifier.titlePointerFocus(onFocus).clickable { focusOwner?.invoke(); onClick() }, offlineArt = offlineArt)
} else Box(Modifier.size(WolfskinTokens.Card.tileW, D.episodeCardHeight).titlePointerFocus(onFocus).focusScale(focused, WolfskinTokens.Focus.scaleCard) } else TitleCard(focused, Modifier.titlePointerFocus(onFocus).clickable { focusOwner?.invoke(); onClick() },
.focusRing(focused, WolfskinTokens.Radius.card, WolfskinTokens.Focus.ringCardRing, FocusLift.Card) WolfskinTokens.Card.tileW, D.episodeCardHeight, watched = item.watched,
.clip(RoundedCornerShape(WolfskinTokens.Radius.card)).background(C.surface2).clickable { focusOwner?.invoke(); onClick() }) { progress = item.progress.takeIf { it < 1f } ?: 0f) {
TitleTileArtwork(item) TitleTileArtwork(item)
Column(Modifier.align(Alignment.BottomStart).fillMaxWidth().background(Brush.verticalGradient(listOf(C.bg.copy(alpha = 0f), C.bg.copy(alpha = D.cardOverlayOpacity)))) 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)) { .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.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) 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 && 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)
val progress = item.progress
if (progress > 0f && progress < 1f) {
Box(Modifier.align(Alignment.BottomCenter).padding(start = D.cardProgressInset, end = D.cardProgressInset, bottom = D.tileProgressBottom)
.fillMaxWidth().height(L.progressBar).clip(RoundedCornerShape(WolfskinTokens.Radius.progress)).background(C.progressTrack)) {
Box(Modifier.fillMaxWidth(progress).fillMaxHeight().background(C.accent))
}
} }
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()))), exit = fadeOut(tween(motionDuration(WolfskinTokens.Motion.base, reducedMotion())))) {
LibraryRatingPlate(item.ratings, Modifier.fillMaxWidth()) LibraryRatingPlate(item.ratings, Modifier.fillMaxWidth())
} }
} }
if (!tile) { if (!tile) {
val availability = androidx.compose.runtime.remember(item, catalogue) { Box(Modifier.height(WolfskinTokens.Card.posterCaptionBand).width(WolfskinTokens.Card.posterW)) {
if (catalogue) { CardCaption(item.name, item.year, if (item.kind == "movie" && item.runtimeSeconds > 0) item.runtime else null,
if (item.held) "In your library" else item.otherOwners.takeIf { it.isNotEmpty() }?.let { "In ${it.joinToString(" and ")}’s library" } item.watched && item.resumeSeconds <= 0, focused,
} else if (item.watched) "Watched" Modifier.offset(y = L.captionOffset).width(WolfskinTokens.Card.posterW), focusOnly = !alwaysShowCaption)
else if (item.resumeSeconds > 0) "${((item.runtimeSeconds - item.resumeSeconds + 30) / 60).coerceAtLeast(1)} min left"
else null
}
val facts = androidx.compose.runtime.remember(item, availability) {
listOfNotNull(item.character?.takeIf { it.isNotBlank() }?.let { "as $it" }, item.year?.toString(),
if (item.kind == "movie") { if (item.runtimeSeconds > 0) item.runtime else "Movie" } else if (item.seasons > 0) "${item.seasons} season${if (item.seasons == 1) "" else "s"}" else "Series",
availability).joinToString(" · ")
}
Box(Modifier.height(WolfskinTokens.Card.posterCaptionBand)) {
Caption(item.name, facts, focused, Modifier.offset(y = L.captionOffset).wrapContentWidth(Alignment.Start, unbounded = true).width(D.captionWidth),
accentLastFact = availability != null, accentFirstFact = !item.character.isNullOrBlank())
} }
} }
} }

View file

@ -11,6 +11,20 @@ import androidx.compose.ui.unit.sp
/** The locked TV design, converted at the source density. */ /** The locked TV design, converted at the source density. */
object WolfskinTokens { object WolfskinTokens {
object CardMetadata {
val titleRest = TextStyle(fontSize = 14.0.sp, fontWeight = FontWeight(500), lineHeight = 16.0.sp, letterSpacing = 0.0.em)
val titleFocus = TextStyle(fontSize = 14.0.sp, fontWeight = FontWeight.Bold, lineHeight = 16.0.sp, letterSpacing = 0.0.em)
val subtitle = TextStyle(fontSize = 12.0.sp, fontWeight = FontWeight(400), lineHeight = 13.0.sp, letterSpacing = 0.0.em)
val check = 12.0.dp
val gap = 6.0.dp
val compactGap = 4.0.dp
val minimumGap = 2.0.dp
val bottom = 1.0.dp
val separator = 1.0.dp
val watchedLine = 1.5.dp
val resumeLine = 3.0.dp
val landscapeBand = 32.0.dp
}
object Color { object Color {
val bg = ComposeColor(0xFF07070A) val bg = ComposeColor(0xFF07070A)
val surface = ComposeColor(0xFF13131A) val surface = ComposeColor(0xFF13131A)
@ -173,13 +187,11 @@ object WolfskinTokens {
val tileH = 112.5.dp val tileH = 112.5.dp
val tileGap = 20.0.dp val tileGap = 20.0.dp
val tilePitch = 220.0.dp val tilePitch = 220.0.dp
val tileCaptionBand = 0.0.dp
const val tileSlots = 4 const val tileSlots = 4
val wideW = 272.0.dp val wideW = 272.0.dp
val wideH = 153.0.dp val wideH = 153.0.dp
val wideGap = 20.0.dp val wideGap = 20.0.dp
val widePitch = 292.0.dp val widePitch = 292.0.dp
val wideCaptionBand = 0.0.dp
const val wideSlots = 3 const val wideSlots = 3
val entryW = 128.0.dp val entryW = 128.0.dp
val entryH = 72.0.dp val entryH = 72.0.dp
@ -379,9 +391,6 @@ object WolfskinTokens {
val chipGap = 8.0.dp val chipGap = 8.0.dp
val chipTop = 8.0.dp val chipTop = 8.0.dp
val chipIcon = 13.0.dp val chipIcon = 13.0.dp
val captionWidth = 320.0.dp
const val captionSeparatorOpacity = 0.4f
val captionFactsGap = 9.0.dp
val badgeIcon = 14.0.dp val badgeIcon = 14.0.dp
val plateTop = 7.0.dp val plateTop = 7.0.dp
val plateSide = 8.0.dp val plateSide = 8.0.dp
@ -465,7 +474,6 @@ object WolfskinTokens {
val personGap = 24.0.dp val personGap = 24.0.dp
val personNameBottom = 5.0.dp val personNameBottom = 5.0.dp
val personNoteBottom = 3.0.dp val personNoteBottom = 3.0.dp
val captionWidth = 320.0.dp
val sheetPaddingTop = 40.0.dp val sheetPaddingTop = 40.0.dp
val sheetPaddingSide = 32.0.dp val sheetPaddingSide = 32.0.dp
val sheetGap = 9.0.dp val sheetGap = 9.0.dp
@ -552,8 +560,6 @@ object WolfskinTokens {
val tileOverlaySide = 11.0.dp val tileOverlaySide = 11.0.dp
val tileOverlayBottom = 9.0.dp val tileOverlayBottom = 9.0.dp
const val cardOverlayOpacity = 0.92f const val cardOverlayOpacity = 0.92f
val cardProgressInset = 9.0.dp
val tileProgressBottom = 5.0.dp
val ambientInset = 40.0.dp val ambientInset = 40.0.dp
const val ambientScale = 1.1f const val ambientScale = 1.1f
val posterShadowRadius = 40.0.dp val posterShadowRadius = 40.0.dp
@ -591,7 +597,6 @@ object WolfskinTokens {
val statePaddingY = 17.0.dp val statePaddingY = 17.0.dp
val stateGap = 4.0.dp val stateGap = 4.0.dp
val stateIcon = 18.0.dp val stateIcon = 18.0.dp
val captionWidth = 320.0.dp
val plateTop = 7.0.dp val plateTop = 7.0.dp
val plateSide = 8.0.dp val plateSide = 8.0.dp
val plateFade = 23.0.dp val plateFade = 23.0.dp
@ -612,8 +617,6 @@ object WolfskinTokens {
val heroSheetTop = 75.0.dp val heroSheetTop = 75.0.dp
val cardBlur = 13.0.dp val cardBlur = 13.0.dp
val heroBlur = 30.0.dp val heroBlur = 30.0.dp
val progressInset = 9.0.dp
val progressBottom = 5.0.dp
val endTop = 20.0.dp val endTop = 20.0.dp
val frameBottom = 40.0.dp val frameBottom = 40.0.dp
val toastPadding = 14.0.dp val toastPadding = 14.0.dp
@ -634,7 +637,6 @@ object WolfskinTokens {
val savedGap = 5.0.dp val savedGap = 5.0.dp
val savedIcon = 14.0.dp val savedIcon = 14.0.dp
val savedRadius = 14.0.dp val savedRadius = 14.0.dp
val posterProgressBottom = 6.0.dp
} }
object Discover { object Discover {
val shelfHeadingBand = 52.0.dp val shelfHeadingBand = 52.0.dp

View file

@ -0,0 +1,4 @@
<!-- SPDX-License-Identifier: GPL-2.0-only -->
<vector xmlns:android="http://schemas.android.com/apk/res/android" android:width="24dp" android:height="24dp" android:viewportWidth="24" android:viewportHeight="24">
<path android:pathData="M5 12l4 4l10 -10" android:fillColor="#00000000" android:strokeColor="#FFFFFFFF" android:strokeWidth="2.5" android:strokeLineCap="round" android:strokeLineJoin="round" />
</vector>

View file

@ -0,0 +1,135 @@
package org.justinvisser.wolfskin.design
import android.app.Application
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.*
import androidx.compose.runtime.mutableStateOf
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.toArgb
import androidx.compose.ui.test.junit4.createAndroidComposeRule
import androidx.compose.ui.test.onRoot
import androidx.compose.ui.test.onNodeWithTag
import androidx.compose.ui.unit.dp
import com.github.takahirom.roborazzi.captureRoboImage
import java.io.File
import org.junit.Assert.assertEquals
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
/** Real white, dark and colourful art cannot leak through the card's opaque bottom edge. */
@RunWith(RobolectricTestRunner::class)
@Config(sdk = [34], application = Application::class, qualifiers = "w960dp-h540dp-xhdpi")
@GraphicsMode(GraphicsMode.Mode.NATIVE)
class CardBottomPixelTest {
@get:Rule val rule = createAndroidComposeRule<ComponentActivity>()
@Test fun realPosterBottomRowsStayOpaqueAtRestAndFocused() {
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
val focused = mutableStateOf(-1)
val showArt = mutableStateOf(true)
val state = mutableStateOf("unwatched")
val names = listOf("annie-hall-1977", "locke-2014", "apocalypse-now-1979", "avatar-aang-the-last-airbender-2026")
rule.setContent { WolfskinTheme {
Box(Modifier.requiredSize(960.dp, 540.dp).background(WolfskinTokens.Color.bg)) {
names.forEachIndexed { index, name ->
LibraryPoster(LibraryTitle(name, name, "movie", poster = if (showArt.value) "/library/assets/movie-$name-poster.jpg" else null,
played = state.value == "watched", progress = if (state.value == "resume") .43f else 0f),
focused.value == index, false, false, Modifier.offset((48 + 148 * index).dp, 130.dp), ::fixtureArt)
}
}
} }
val directory = File(System.getProperty("wolfskin.captureDirectory"), "card-pixels").apply { mkdirs() }
for (watchState in listOf("unwatched", "watched", "resume")) for (selection in -1..3) {
rule.runOnIdle { state.value = watchState; focused.value = selection; showArt.value = false }
rule.mainClock.advanceTimeBy(1000)
rule.waitForIdle()
val referenceFile = File(directory, "reference.png")
rule.onRoot().captureRoboImage(referenceFile.absolutePath)
val reference = BitmapFactory.decodeFile(referenceFile.absolutePath)
rule.runOnIdle { showArt.value = true }
rule.waitForIdle()
val file = File(directory, "$watchState-focus-$selection.png")
rule.onRoot().captureRoboImage(file.absolutePath)
val bitmap = BitmapFactory.decodeFile(file.absolutePath)
names.forEachIndexed { index, name ->
val focus = selection == index
val left = 96 + 296 * index
val bottom = if (focus) 659 else 643
val edgeLeft = if (focus) left - 10 else left
val edgeRight = if (focus) left + 266 else left + 256
// The reference supplies only rounded-corner/ring coverage, never poster colours.
for (x in edgeLeft until edgeRight) assertEquals("$name $watchState focus=$focus full bottom row ($x,$bottom)",
reference.getPixel(x, bottom), bitmap.getPixel(x, bottom))
val expected = if (focus) WolfskinTokens.Color.accent.toArgb() else WolfskinTokens.Color.bg.toArgb()
for (x in left + 32 until left + 224) assertEquals("$name $watchState focus=$focus straight edge ($x,$bottom)",
expected, bitmap.getPixel(x, bottom))
if (watchState != "unwatched") {
val fillY = if (focus) 655 else 640
assertEquals("$name $watchState fill", WolfskinTokens.Color.accent.toArgb(), bitmap.getPixel(left + 32, fillY))
val right = if (watchState == "resume") androidx.compose.ui.graphics.Color(0xFF3D3D3F) else WolfskinTokens.Color.accent
assertEquals("$name $watchState track", right.toArgb(), bitmap.getPixel(left + 224, fillY))
}
}
reference.recycle()
bitmap.recycle()
}
}
@Test fun completedHomeProgressUsesTheSameWatchedStripAsLibraryCards() {
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
val path = "/library/assets/movie-annie-hall-1977-poster.jpg"
val fraction = mutableStateOf(.5f)
val art = mapOf(path to fixtureArt(path)!!)
val memory = HomeViewMemory()
rule.setContent { WolfskinTheme {
androidx.compose.runtime.CompositionLocalProvider(LocalTitleBitmaps provides art) {
Box(Modifier.requiredSize(960.dp, 540.dp).background(WolfskinTokens.Color.bg)) {
val title = HomeTitle("home", "Home film", "movie", runtimeSeconds = 100,
poster = path, played = true, resumeSeconds = fraction.value * 100.0)
HomeShelf(HomeRow("progress", HomeRowKind.Shelf, "Progress", listOf(title)),
memory, false, 0, emptyList(), {}, {})
LibraryPoster(LibraryTitle("reference", "Reference", "movie", poster = path,
played = true, progress = fraction.value), false, false, false,
Modifier.offset(240.dp, 42.dp), ::fixtureArt)
}
}
} }
val directory = File(System.getProperty("wolfskin.captureDirectory"), "card-pixels").apply { mkdirs() }
for (progress in listOf(.5f, 1f, 1.5f)) {
rule.runOnIdle { fraction.value = progress }
rule.mainClock.advanceTimeBy(1000)
rule.waitForIdle()
val home = rule.onNodeWithTag("home-card:progress:0").fetchSemanticsNode().boundsInRoot
val reference = rule.onNodeWithTag("poster-reference").fetchSemanticsNode().boundsInRoot
assertEquals(reference.width, home.width, .1f)
assertEquals(reference.bottom, home.bottom, .1f)
val file = File(directory, "home-progress-${(progress * 100).toInt()}.png")
rule.onRoot().captureRoboImage(file.absolutePath)
val bitmap = BitmapFactory.decodeFile(file.absolutePath)
try {
val bottom = home.bottom.toInt()
val band = if (progress >= 1f) 7 else 10
for (y in bottom - band until bottom) for (x in 0 until home.width.toInt()) {
assertEquals("Home/library strip progress=$progress ($x,$y)",
bitmap.getPixel(reference.left.toInt() + x, y), bitmap.getPixel(home.left.toInt() + x, y))
}
val separator = if (progress >= 1f) WolfskinTokens.Color.bg else WolfskinTokens.Color.accent
assertEquals("Completion changes the resume strip to the 3px watched strip",
separator.toArgb(), bitmap.getPixel(home.left.toInt() + 32, bottom - 7))
assertEquals(WolfskinTokens.Color.accent.toArgb(), bitmap.getPixel(home.left.toInt() + 32, bottom - 4))
} finally { bitmap.recycle() }
}
}
}

View file

@ -0,0 +1,121 @@
package org.justinvisser.wolfskin.design
import android.app.Application
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.*
import androidx.compose.runtime.CompositionLocalProvider
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.snapshots.Snapshot
import androidx.compose.ui.Modifier
import androidx.compose.ui.geometry.Rect
import androidx.compose.ui.graphics.toArgb
import androidx.compose.ui.platform.testTag
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 java.io.File
import org.junit.Assert.assertEquals
import org.junit.Assert.assertTrue
import org.junit.Before
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
/** Changing a shelf's selected identity must restart its focus-only caption reveal. */
@RunWith(RobolectricTestRunner::class)
@Config(sdk = [34], application = Application::class, qualifiers = "w960dp-h540dp-xhdpi")
@GraphicsMode(GraphicsMode.Mode.NATIVE)
class CardCaptionMotionTest {
@get:Rule val rule = createAndroidComposeRule<ComponentActivity>()
@Before fun fullscreen() {
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
}
@Test fun changingTitleWhileStillFocusedRestoresOpaqueCaptionPixels() {
val title = mutableStateOf("Initial caption")
rule.setContent { WolfskinTheme {
Box(Modifier.requiredSize(960.dp, 540.dp).background(WolfskinTokens.Color.bg)) {
CardCaption(title.value, 2007, "2h 56m", true, true,
Modifier.offset(40.dp, 80.dp).width(128.dp).testTag("animated"), focusOnly = true)
CardCaption(title.value, 2007, "2h 56m", true, true,
Modifier.offset(40.dp, 180.dp).width(128.dp).testTag("reference"))
}
} }
for ((index, selected) in listOf("Initial caption", "Second caption", "Third caption").withIndex()) {
update { title.value = selected }
rule.mainClock.advanceTimeBy(400)
assertCaptionPixels("component-$index",
rule.onNodeWithTag("animated").fetchSemanticsNode().boundsInRoot,
rule.onNodeWithTag("reference").fetchSemanticsNode().boundsInRoot)
}
}
@Test fun movingAcrossAFocusedHomeShelfKeepsEachSelectedCaptionVisible() {
val path = "/library/assets/movie-annie-hall-1977-poster.jpg"
val cards = listOf(HomeTitle("first", "First shelf caption", "movie", 2007, 10560, poster = path),
HomeTitle("second", "Second shelf caption", "movie", 2008, 11520, poster = path))
val row = HomeRow("captions", HomeRowKind.Shelf, "Shelf", cards)
val column = mutableStateOf(0)
val memory = HomeViewMemory()
val art = mapOf(path to fixtureArt(path)!!)
rule.setContent { WolfskinTheme {
CompositionLocalProvider(LocalTitleBitmaps provides art) {
Box(Modifier.requiredSize(960.dp, 540.dp).background(WolfskinTokens.Color.bg)) {
Box(Modifier.testTag("home-shelf")) {
HomeShelf(row, memory, true, column.value, emptyList(), {}, {})
}
val selected = cards[column.value]
CardCaption(selected.title, selected.year, selected.runtime, false, true,
Modifier.offset(48.dp, 400.dp).width(128.dp).testTag("reference"))
}
}
} }
for ((step, selected) in listOf(0, 1, 0).withIndex()) {
update { column.value = selected }
rule.mainClock.advanceTimeBy(400)
val title = cards[selected].title
val actual = rule.onNode(hasText(title) and hasAnyAncestor(hasTestTag("home-shelf")), useUnmergedTree = true).fetchSemanticsNode().boundsInRoot
val reference = rule.onNode(hasText(title) and hasAnyAncestor(hasTestTag("reference")), useUnmergedTree = true).fetchSemanticsNode().boundsInRoot
assertCaptionPixels("home-$step", actual, reference)
}
}
private fun update(block: () -> Unit) {
rule.runOnIdle { block(); Snapshot.sendApplyNotifications() }
rule.mainClock.advanceTimeByFrame()
}
private fun assertCaptionPixels(name: String, actual: Rect, reference: Rect) {
assertEquals(reference.width, actual.width, .1f)
assertEquals(reference.height, actual.height, .1f)
rule.waitForIdle()
val directory = File(System.getProperty("wolfskin.captureDirectory"), "caption-followup").apply { mkdirs() }
val file = File(directory, "$name.png")
rule.onRoot().captureRoboImage(file.absolutePath)
val bitmap = BitmapFactory.decodeFile(file.absolutePath)
try {
val foreground = WolfskinTokens.Color.fg.toArgb()
var compared = 0
for (y in 0 until reference.height.toInt()) for (x in 0 until reference.width.toInt()) {
if (bitmap.getPixel(reference.left.toInt() + x, reference.top.toInt() + y) == foreground) {
assertEquals("$name opaque title pixel ($x,$y)", foreground,
bitmap.getPixel(actual.left.toInt() + x, actual.top.toInt() + y))
compared++
}
}
assertTrue("The reference must contain opaque title glyphs", compared >= 24)
} finally { bitmap.recycle() }
}
}

View file

@ -0,0 +1,169 @@
package org.justinvisser.wolfskin.design
import android.app.Application
import android.graphics.BitmapFactory
import androidx.compose.foundation.background
import androidx.compose.ui.graphics.toArgb
import com.github.takahirom.roborazzi.captureRoboImage
import java.io.File
import androidx.compose.foundation.layout.*
import androidx.compose.runtime.mutableStateOf
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.testTag
import androidx.compose.ui.semantics.SemanticsActions
import androidx.compose.ui.test.*
import androidx.compose.ui.test.junit4.createComposeRule
import androidx.compose.ui.text.TextLayoutResult
import androidx.compose.ui.unit.dp
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
/** Caption fitting and row pitches are measured from the same surfaces used by the TV. */
@RunWith(RobolectricTestRunner::class)
@Config(sdk = [34], application = Application::class, qualifiers = "w960dp-h540dp-xhdpi")
class CardGeometryTest {
@get:Rule val rule = createComposeRule()
@Test fun narrowCaptionsEllipsiseOnlyTheTitleAndKeepRuntimeAndCheck() {
val width = mutableStateOf(105.dp)
rule.setContent { WolfskinTheme {
CardCaption("Steins;Gate: The Movie - Load Region of Déjà Vu", 2007, "999h 59m", true, true,
Modifier.width(width.value).testTag("caption"))
} }
for (pixels in listOf(210, 327)) {
rule.runOnIdle { width.value = (pixels / 2f).dp }
rule.waitForIdle()
val layouts = rule.onAllNodes(SemanticsMatcher.keyIsDefined(SemanticsActions.GetTextLayoutResult), useUnmergedTree = true).fetchSemanticsNodes().map { node ->
val results = mutableListOf<TextLayoutResult>()
node.config[SemanticsActions.GetTextLayoutResult].action!!.invoke(results)
results.single()
}
val title = layouts.single { it.layoutInput.text.text.startsWith("Steins;") }
assertEquals(1, title.lineCount)
assertTrue("Only the long title ellipsises at $pixels", title.isLineEllipsized(0))
for (facts in layouts.filter { it !== title }) {
assertFalse("Metadata must not overflow at $pixels", facts.hasVisualOverflow)
assertFalse(facts.isLineEllipsized(0))
}
val caption = rule.onNodeWithTag("caption").fetchSemanticsNode().boundsInRoot
assertEquals(58f, caption.height, .1f)
assertEquals(pixels.toFloat(), caption.width, .1f)
val check = rule.onNodeWithContentDescription("Watched").fetchSemanticsNode().boundsInRoot
assertEquals(24f, check.width, .1f)
assertEquals(24f, check.height, .1f)
assertTrue(check.right <= caption.right)
}
}
@Test fun unfocusedLibraryTitlesKeepCardWidthAndGridPitch() {
val titles = List(12) { LibraryTitle("$it", "A long title occupying only one card column ${it.toString().padStart(2, '0')}", "movie", 2007, 10560, poster = "/library/assets/movie-annie-hall-1977-poster.jpg") }
val shell = ShellState(fixturePeople(), "shared").apply { pickerVisible = false; enterDestination("movies") }
val grid = LibraryGridState("Movies", titles)
rule.setContent { WolfskinTheme { ShellSurface(shell) { LibraryScreen(shell, grid, it, offlineArt = ::fixtureArt) } } }
rule.mainClock.advanceTimeBy(1000)
rule.waitForIdle()
val first = rule.onNodeWithTag("poster-0").fetchSemanticsNode().boundsInRoot
val second = rule.onNodeWithTag("poster-6").fetchSemanticsNode().boundsInRoot
// Focus scales around the unchanged slot centre.
assertEquals(372f, first.top + (first.height - 384f) / 2, .1f)
assertEquals(524f, second.top - (first.top + (first.height - 384f) / 2), .1f)
for (index in 0..5) {
val caption = rule.onNodeWithText(titles[index].title, useUnmergedTree = true).fetchSemanticsNode()
assertEquals(256f, caption.boundsInRoot.width, .1f)
assertEquals(794f, caption.boundsInRoot.top, .1f)
}
val focusedCaption = rule.onNodeWithText(titles[0].title, useUnmergedTree = true).fetchSemanticsNode().boundsInRoot
assertEquals(20.48f, focusedCaption.top - first.bottom - first.height * .04f - 2f * 1.08f, .1f)
assertEquals(26.48f, second.top - first.height * .04f - 2f * 1.08f - (focusedCaption.top + 58f), .1f)
rule.runOnIdle { kotlinx.coroutines.runBlocking { shell.view.listState.scrollToItem(0, 204) } }
rule.waitForIdle()
val pinnedFirst = rule.onNodeWithTag("poster-0").fetchSemanticsNode().boundsInRoot
val pinnedSecond = rule.onNodeWithTag("poster-6").fetchSemanticsNode().boundsInRoot
assertEquals(168f, pinnedFirst.top, .1f)
assertEquals(692f, pinnedSecond.top, .1f)
assertEquals(1076f, pinnedSecond.bottom, .1f)
}
@Test fun homePosterWideAndTileShelvesReserveTheirCaptionBands() {
val kind = mutableStateOf(HomeCardKind.Poster)
val loading = mutableStateOf(false)
val memory = HomeViewMemory()
rule.setContent { WolfskinTheme {
Column {
Box(Modifier.testTag("shelf")) {
HomeShelf(HomeRow("fixture", HomeRowKind.Shelf, "Fixture", listOf(HomeTitle("one", "One", "movie", 2007, 10560)),
kind.value, skeleton = loading.value), memory, false, 0, emptyList(), {}, {})
}
Box(Modifier.size(1.dp).testTag("next"))
}
} }
for ((cardKind, expected) in listOf(HomeCardKind.Poster to 632f, HomeCardKind.Wide to 522f, HomeCardKind.Tile to 441f)) {
for (pending in listOf(false, true)) {
rule.runOnIdle { kind.value = cardKind; loading.value = pending }
rule.waitForIdle()
assertEquals("$cardKind loading=$pending", expected, rule.onNodeWithTag("shelf").fetchSemanticsNode().boundsInRoot.height, .1f)
assertEquals(expected, rule.onNodeWithTag("next").fetchSemanticsNode().boundsInRoot.top, .1f)
}
}
}
@Test fun filmographyKeepsUnfocusedMetadataAndDistinguishesSeriesFromFilmRuntime() {
val path = "/library/assets/movie-annie-hall-1977-poster.jpg"
val item = mutableStateOf(TitleItem("credit", "A filmography credit", "movie", 2007, 10560,
poster = path, held = true, watched = true))
val art = mapOf(path to fixtureArt(path)!!)
rule.setContent { WolfskinTheme {
androidx.compose.runtime.CompositionLocalProvider(LocalTitleBitmaps provides art) {
TitlePoster(item.value, false, alwaysShowCaption = true) {}
}
} }
rule.onNodeWithText("A filmography credit").assertIsDisplayed()
rule.onNodeWithText("2007").assertIsDisplayed()
rule.onNodeWithText("2h 56m").assertIsDisplayed()
rule.onNodeWithContentDescription("Watched").assertIsDisplayed()
rule.runOnIdle { item.value = item.value.copy(kind = "tv", year = 2016, runtimeSeconds = 123456) }
rule.onNodeWithText("2016").assertIsDisplayed()
rule.onNodeWithText("2h 56m").assertDoesNotExist()
rule.onNodeWithContentDescription("Watched").assertIsDisplayed()
rule.runOnIdle { item.value = item.value.copy(resumeSeconds = 10) }
rule.onNodeWithContentDescription("Watched").assertDoesNotExist()
rule.onNodeWithText("A filmography credit").assertIsDisplayed()
}
@Test fun loadingCaptionBandsReserveSpaceWithoutPaintingBars() {
val slots = listOf("poster" to Triple(128.dp, 192.dp, 48.dp),
"wide" to Triple(272.dp, 153.dp, 32.dp), "tile" to Triple(200.dp, 112.5.dp, 32.dp))
rule.setContent { WolfskinTheme {
Box(Modifier.requiredSize(960.dp, 540.dp).background(WolfskinTokens.Color.bg)) {
Row(Modifier.offset(48.dp, 42.dp), horizontalArrangement = Arrangement.spacedBy(20.dp)) {
slots.forEach { (name, geometry) ->
Box(Modifier.testTag("loading-$name")) {
PosterCaptionSkeleton(geometry.first, geometry.second, geometry.third)
}
}
}
}
} }
rule.waitForIdle()
val directory = File(System.getProperty("wolfskin.captureDirectory"), "caption-followup").apply { mkdirs() }
val file = File(directory, "loading-bands.png")
rule.onRoot().captureRoboImage(file.absolutePath)
val bitmap = BitmapFactory.decodeFile(file.absolutePath)
try {
for ((name, geometry) in slots) {
val bounds = rule.onNodeWithTag("loading-$name").fetchSemanticsNode().boundsInRoot
val band = geometry.third.value * 2
assertEquals((geometry.second.value + geometry.third.value) * 2, bounds.height, .1f)
for (y in (bounds.bottom - band).toInt() until bounds.bottom.toInt()) {
for (x in bounds.left.toInt() until bounds.right.toInt()) {
assertEquals("$name reserved caption band ($x,$y)", WolfskinTokens.Color.bg.toArgb(), bitmap.getPixel(x, y))
}
}
}
} finally { bitmap.recycle() }
}
}

View file

@ -11,23 +11,12 @@ import org.junit.runner.RunWith
import org.robolectric.RobolectricTestRunner import org.robolectric.RobolectricTestRunner
import org.robolectric.annotation.Config import org.robolectric.annotation.Config
/** Caption spacing and live rail changes must preserve poster geometry and retained focus. */ /** Live letter-rail changes preserve deep selection and poster geometry. */
@RunWith(RobolectricTestRunner::class) @RunWith(RobolectricTestRunner::class)
@Config(sdk = [34], application = Application::class, qualifiers = "w960dp-h540dp-xhdpi") @Config(sdk = [34], application = Application::class, qualifiers = "w960dp-h540dp-xhdpi")
class LibrarySpacingRegressionTest { class LibrarySpacingRegressionTest {
@get:Rule val rule = createComposeRule() @get:Rule val rule = createComposeRule()
@Test fun partiallyWatchedPosterKeepsItsOriginalProgressInsets() {
val title = LibraryTitle("partial", "Partially watched", "movie", progress = .5f)
rule.setContent { WolfskinTheme { LibraryPoster(title, false, false, false) } }
val poster = rule.onNodeWithTag("poster-partial").fetchSemanticsNode().boundsInRoot
val track = rule.onNodeWithTag("poster-progress-partial").fetchSemanticsNode().boundsInRoot
assertEquals(256f, poster.width, .1f)
assertEquals(220f, track.width, .1f)
assertEquals(18f, track.left - poster.left, .1f)
assertEquals(18f, poster.right - track.right, .1f)
}
@Test fun crossingRailThresholdKeepsTheSameDeepFocusedPosterVisible() { @Test fun crossingRailThresholdKeepsTheSameDeepFocusedPosterVisible() {
val titles = List(200) { LibraryTitle("$it", "Title ${it.toString().padStart(3, '0')}", "tv") } val titles = List(200) { LibraryTitle("$it", "Title ${it.toString().padStart(3, '0')}", "tv") }
val shell = ShellState(fixturePeople(), "justin").apply { pickerVisible = false; enterDestination("shows") } val shell = ShellState(fixturePeople(), "justin").apply { pickerVisible = false; enterDestination("shows") }

View file

@ -23,7 +23,6 @@
"personGap": {"dp": 24, "px": 48, "source": "index.html:293"}, "personGap": {"dp": 24, "px": 48, "source": "index.html:293"},
"personNameBottom": {"dp": 5, "px": 10, "source": "index.html:296"}, "personNameBottom": {"dp": 5, "px": 10, "source": "index.html:296"},
"personNoteBottom": {"dp": 3, "px": 6, "source": "index.html:297"}, "personNoteBottom": {"dp": 3, "px": 6, "source": "index.html:297"},
"captionWidth": {"dp": 320, "px": 640, "source": "index.html:1242"},
"sheetPaddingTop": {"dp": 40, "px": 80, "source": "index.html:227"}, "sheetPaddingTop": {"dp": 40, "px": 80, "source": "index.html:227"},
"sheetPaddingSide": {"dp": 32, "px": 64, "source": "index.html:227"}, "sheetPaddingSide": {"dp": 32, "px": 64, "source": "index.html:227"},
"sheetGap": {"dp": 9, "px": 18, "source": "index.html:227"}, "sheetGap": {"dp": 9, "px": 18, "source": "index.html:227"},
@ -110,8 +109,6 @@
"tileOverlaySide": {"dp": 11, "px": 22, "source": "index.html:135"}, "tileOverlaySide": {"dp": 11, "px": 22, "source": "index.html:135"},
"tileOverlayBottom": {"dp": 9, "px": 18, "source": "index.html:135"}, "tileOverlayBottom": {"dp": 9, "px": 18, "source": "index.html:135"},
"cardOverlayOpacity": {"$value": 0.92, "source": "index.html:135"}, "cardOverlayOpacity": {"$value": 0.92, "source": "index.html:135"},
"cardProgressInset": {"dp": 9, "px": 18, "source": "index.html:138"},
"tileProgressBottom": {"dp": 5, "px": 10, "source": "index.html:140"},
"ambientInset": {"dp": 40, "px": 80, "source": "index.html:96"}, "ambientInset": {"dp": 40, "px": 80, "source": "index.html:96"},
"ambientScale": {"$value": 1.1, "source": "index.html:96"}, "ambientScale": {"$value": 1.1, "source": "index.html:96"},
"posterShadowRadius": {"dp": 40, "px": 80, "source": "index.html:97"}, "posterShadowRadius": {"dp": 40, "px": 80, "source": "index.html:97"},

View file

@ -1,5 +1,17 @@
/* Generated from the locked TV token sources. */ /* Generated from the locked TV token sources. */
:root { :root {
--cardMetadata-titleRest: 28.0px;
--cardMetadata-titleFocus: 28.0px;
--cardMetadata-subtitle: 24.0px;
--cardMetadata-check: 24.0px;
--cardMetadata-gap: 12.0px;
--cardMetadata-compactGap: 8.0px;
--cardMetadata-minimumGap: 4.0px;
--cardMetadata-bottom: 2.0px;
--cardMetadata-separator: 2.0px;
--cardMetadata-watchedLine: 3.0px;
--cardMetadata-resumeLine: 6.0px;
--cardMetadata-landscapeBand: 64.0px;
--color-bg: #07070a; --color-bg: #07070a;
--color-surface: #13131a; --color-surface: #13131a;
--color-surface2: #1b1b25; --color-surface2: #1b1b25;
@ -242,8 +254,6 @@
--library-chipGap: 16.0px; --library-chipGap: 16.0px;
--library-chipTop: 16.0px; --library-chipTop: 16.0px;
--library-chipIcon: 26.0px; --library-chipIcon: 26.0px;
--library-captionWidth: 640.0px;
--library-captionFactsGap: 18.0px;
--library-badgeIcon: 28.0px; --library-badgeIcon: 28.0px;
--library-plateTop: 14.0px; --library-plateTop: 14.0px;
--library-plateSide: 16.0px; --library-plateSide: 16.0px;
@ -303,7 +313,6 @@
--title-personGap: 48.0px; --title-personGap: 48.0px;
--title-personNameBottom: 10.0px; --title-personNameBottom: 10.0px;
--title-personNoteBottom: 6.0px; --title-personNoteBottom: 6.0px;
--title-captionWidth: 640.0px;
--title-sheetPaddingTop: 80.0px; --title-sheetPaddingTop: 80.0px;
--title-sheetPaddingSide: 64.0px; --title-sheetPaddingSide: 64.0px;
--title-sheetGap: 18.0px; --title-sheetGap: 18.0px;
@ -384,8 +393,6 @@
--title-tileOverlayTop: 54.0px; --title-tileOverlayTop: 54.0px;
--title-tileOverlaySide: 22.0px; --title-tileOverlaySide: 22.0px;
--title-tileOverlayBottom: 18.0px; --title-tileOverlayBottom: 18.0px;
--title-cardProgressInset: 18.0px;
--title-tileProgressBottom: 10.0px;
--title-ambientInset: 80.0px; --title-ambientInset: 80.0px;
--title-posterShadowRadius: 80.0px; --title-posterShadowRadius: 80.0px;
--title-posterShadowY: 30.0px; --title-posterShadowY: 30.0px;
@ -415,7 +422,6 @@
--home-statePaddingY: 34.0px; --home-statePaddingY: 34.0px;
--home-stateGap: 8.0px; --home-stateGap: 8.0px;
--home-stateIcon: 36.0px; --home-stateIcon: 36.0px;
--home-captionWidth: 640.0px;
--home-plateTop: 14.0px; --home-plateTop: 14.0px;
--home-plateSide: 16.0px; --home-plateSide: 16.0px;
--home-plateFade: 46.0px; --home-plateFade: 46.0px;
@ -436,8 +442,6 @@
--home-heroSheetTop: 150.0px; --home-heroSheetTop: 150.0px;
--home-cardBlur: 26.0px; --home-cardBlur: 26.0px;
--home-heroBlur: 60.0px; --home-heroBlur: 60.0px;
--home-progressInset: 18.0px;
--home-progressBottom: 10.0px;
--home-endTop: 40.0px; --home-endTop: 40.0px;
--home-frameBottom: 80.0px; --home-frameBottom: 80.0px;
--home-toastPadding: 28.0px; --home-toastPadding: 28.0px;
@ -457,7 +461,6 @@
--home-savedGap: 10.0px; --home-savedGap: 10.0px;
--home-savedIcon: 28.0px; --home-savedIcon: 28.0px;
--home-savedRadius: 28.0px; --home-savedRadius: 28.0px;
--home-posterProgressBottom: 12.0px;
--discover-shelfHeadingBand: 104.0px; --discover-shelfHeadingBand: 104.0px;
--discover-sheetPaddingX: 64.0px; --discover-sheetPaddingX: 64.0px;
--discover-sheetPaddingY: 80.0px; --discover-sheetPaddingY: 80.0px;

View file

@ -1,5 +1,5 @@
{ {
"$description": "Wolfskin TV design tokens, spec revision 5: approved focus and Level 2 spacing, taller episode cards, roomier Discover headings and panel-resolution hero artwork. CSS px on a 1920x1080 stage at density 2.0; dp/sp = px / 2.", "$description": "Wolfskin TV design tokens, spec revision 6: approved loading/motion and option-B card metadata/state lines. CSS px on a 1920x1080 stage at density 2.0; dp/sp = px / 2.",
"meta": { "meta": {
"density": 2.0, "density": 2.0,
"stagePx": [ "stagePx": [
@ -15,6 +15,20 @@
"generator": "data/tv-spec-lane/scratch/extract.py + build_tokens.py", "generator": "data/tv-spec-lane/scratch/extract.py + build_tokens.py",
"feedbackDelta": "data/tv-v2/feedback-2026-11-06/delta.json" "feedbackDelta": "data/tv-v2/feedback-2026-11-06/delta.json"
}, },
"cardMetadata": {
"titleRest": {"$type":"typography","px":28,"sp":14,"lineHeight":1.1428571428571428,"weight":500,"$value":{"fontWeight":500}},
"titleFocus": {"$type":"typography","px":28,"sp":14,"lineHeight":1.1428571428571428,"weight":700,"$value":{"fontWeight":700}},
"subtitle": {"$type":"typography","px":24,"sp":12,"lineHeight":1.0833333333333333,"weight":400,"$value":{"fontWeight":400}},
"check": {"$type":"dimension","px":24,"dp":12},
"gap": {"$type":"dimension","px":12,"dp":6},
"compactGap": {"$type":"dimension","px":8,"dp":4},
"minimumGap": {"$type":"dimension","px":4,"dp":2},
"bottom": {"$type":"dimension","px":2,"dp":1},
"separator": {"$type":"dimension","px":2,"dp":1},
"watchedLine": {"$type":"dimension","px":3,"dp":1.5},
"resumeLine": {"$type":"dimension","px":6,"dp":3},
"landscapeBand": {"$type":"dimension","px":64,"dp":32}
},
"color": { "color": {
"bg": { "bg": {
"$type": "color", "$type": "color",
@ -1045,7 +1059,7 @@
"px": 38, "px": 38,
"dp": 19, "dp": 19,
"source": "index.html:1242", "source": "index.html:1242",
"usedBy": "caption under a focused poster starts 38 px below the card bottom (capHtml top = headH + h + CAP_OFF; was 18, amended 2026-11-06); width 640" "usedBy": "card-width captions start 38 px below unscaled art; library titles and subtitles remain visible, Home poster captions are focus-only"
}, },
"plateInset": { "plateInset": {
"$type": "dimension", "$type": "dimension",
@ -1202,7 +1216,7 @@
"ratio": "2:3", "ratio": "2:3",
"fullSlotsPerRow": 6, "fullSlotsPerRow": 6,
"source": "index.html:656", "source": "index.html:656",
"usedBy": "every title shelf, library grid, See all grid, More like this, person filmography; art only at rest" "usedBy": "every poster shelf and six-column library, See all and filmography grid; library captions always visible, shelf captions focus-only"
}, },
"tile": { "tile": {
"$type": "card", "$type": "card",
@ -1210,15 +1224,13 @@
"w": 400, "w": 400,
"h": 225, "h": 225,
"gap": 40, "gap": 40,
"pitch": 440, "pitch": 440
"captionBand": 0
}, },
"dp": { "dp": {
"w": 200.0, "w": 200.0,
"h": 112.5, "h": 112.5,
"gap": 20.0, "gap": 20.0,
"pitch": 220.0, "pitch": 220.0
"captionBand": 0.0
}, },
"ratio": "16:9", "ratio": "16:9",
"fullSlotsPerRow": 4, "fullSlotsPerRow": 4,
@ -1231,15 +1243,13 @@
"w": 544, "w": 544,
"h": 306, "h": 306,
"gap": 40, "gap": 40,
"pitch": 584, "pitch": 584
"captionBand": 0
}, },
"dp": { "dp": {
"w": 272.0, "w": 272.0,
"h": 153.0, "h": 153.0,
"gap": 20.0, "gap": 20.0,
"pitch": 292.0, "pitch": 292.0
"captionBand": 0.0
}, },
"ratio": "16:9", "ratio": "16:9",
"fullSlotsPerRow": 3, "fullSlotsPerRow": 3,

View file

@ -1,8 +1,8 @@
b3f096b692c395534177cc82ac68f5b801870bc7e28f0dd5cd7c17e2103920a3 discover-tokens.json b3f096b692c395534177cc82ac68f5b801870bc7e28f0dd5cd7c17e2103920a3 discover-tokens.json
c29dc64f3c3ca900c747b3db8b270e25908948bf5f76388d2c627a612fc7c99d generate_tokens.py c29dc64f3c3ca900c747b3db8b270e25908948bf5f76388d2c627a612fc7c99d generate_tokens.py
25985087986a46f00e2819917d0421ff2b437ec21c54f6ce6412b84c0eb05302 home-tokens.json eef4e9f8c91306362540eda617c9efad4e19ed688f205f6f486625a5918d8105 home-tokens.json
be3af0ddcb0d67abef0805d19d7a33cdb55428bcace656b50f0a38935f212028 shell-tokens.json 192beb887fc34005db99c87ffbcbaf4a057ee2728475fc96224cf89445019b51 shell-tokens.json
0446c2e85bbda72adde76329958931216fe81c43cebd47bbdb885f5dd59d87f8 src/main/java/org/justinvisser/wolfskin/design/WolfskinTokens.kt 3e2ad9a57c15c2ed4713abf383807bb567b27b38eddaaa36c00025e078864e12 src/main/java/org/justinvisser/wolfskin/design/WolfskinTokens.kt
e0a2f5df2f249bce3f6e13f4a0320c7b18b85777eaf04a27a428d6683a6aad28 title-tokens.json 062597ea50dff9e6987485f9a83d412918dba3e10109524c26d3c2332a2f5b73 title-tokens.json
faa4e0c5398d35ecab9b1088815b79c8de26a44d403c4cafae59c7c341992da7 tokens.css d7d6b17f8258083c879365f01e06729aa0fa12b69b7cd81e1bceabbbe6ab2a7d tokens.css
f4424475291a745400a5daa071cb846838fb5e9ce889022288bcc6a7be758fe0 tokens.json ba0da02b03047fbc2f29d538d980e87aa237f8ae36545aa4234600243fe14929 tokens.json