diff --git a/app/src/test/java/org/justinvisser/wolfskin/tv/TitleKeysTest.kt b/app/src/test/java/org/justinvisser/wolfskin/tv/TitleKeysTest.kt index 6df8426d..7aa9bfd7 100644 --- a/app/src/test/java/org/justinvisser/wolfskin/tv/TitleKeysTest.kt +++ b/app/src/test/java/org/justinvisser/wolfskin/tv/TitleKeysTest.kt @@ -312,11 +312,6 @@ class TitleKeysTest { assertEquals(553f, rule.onNodeWithTag("title-actions").fetchSemanticsNode().boundsInRoot.top, 1f) } - @GraphicsMode(GraphicsMode.Mode.NATIVE) - @Test fun episodeProgressReclaimsItsNegativeMarginBeforeTheActionRow() { - render("Andor") - assertEquals(603f, rule.onNodeWithTag("title-actions").fetchSemanticsNode().boundsInRoot.top, 1f) - } @Test fun everyLongTrackChoiceAndDoneRemainInsideTheVisibleSheet() { render(transform = { data -> diff --git a/app/src/wolfskin/java/org/justinvisser/wolfskin/tv/read/TitleReadFacade.kt b/app/src/wolfskin/java/org/justinvisser/wolfskin/tv/read/TitleReadFacade.kt index 4b3a3a4a..13676b12 100644 --- a/app/src/wolfskin/java/org/justinvisser/wolfskin/tv/read/TitleReadFacade.kt +++ b/app/src/wolfskin/java/org/justinvisser/wolfskin/tv/read/TitleReadFacade.kt @@ -35,6 +35,7 @@ import org.justinvisser.wolfskin.design.TitlePageData import org.justinvisser.wolfskin.design.TitlePerson import org.justinvisser.wolfskin.design.TitleTrack import org.justinvisser.wolfskin.design.TitleVersion +import org.justinvisser.wolfskin.design.WolfskinTokens import java.io.File import java.util.Locale import java.util.UUID @@ -189,27 +190,44 @@ class TitleReadFacade @Inject constructor( streams.filter { it.type == MediaStreamType.AUDIO }.map { TitleTrack(it.index, it.displayTitle ?: it.language ?: it.codec.orEmpty(), it.isDefault) }, streams.filter { it.type == MediaStreamType.SUBTITLE }.map { TitleTrack(it.index, it.displayTitle ?: it.language ?: it.codec.orEmpty(), it.isDefault) }) } - return TitlePageData(mapItem(item).copy(seasons = seasonNumbers.size), target?.takeIf { it.type == BaseItemKind.EPISODE || (it.userData?.playbackPositionTicks ?: 0) > 0 }?.let { mapItem(it) }, - rows.first { it.id.toString() == watchId }.let { mapItem(it) }, versions, chosen?.source?.id, chosen?.audioStream?.stream?.index, + val pageItem = mapItem(item).copy(seasons = seasonNumbers.size) + return TitlePageData(pageItem, target?.takeIf { it.type == BaseItemKind.EPISODE || (it.userData?.playbackPositionTicks ?: 0) > 0 }?.let { mapItem(it, pageItem.backdrop) }, + rows.first { it.id.toString() == watchId }.let { mapItem(it, pageItem.backdrop) }, versions, chosen?.source?.id, chosen?.audioStream?.stream?.index, if (chosen?.subtitlesDisabled == true) -1 else chosen?.subtitleStream?.stream?.index ?: -1, item.remoteTrailers.orEmpty().firstNotNullOfOrNull { it.url }, cast = item.people.orEmpty().filter { it.type.toString().equals("Actor", true) }.map { person -> TitlePerson(person.id.toString(), person.name.orEmpty(), person.role.orEmpty(), person.primaryImageTag?.let { images.getItemImageUrl(person.id, ImageType.PRIMARY, maxWidth = 200, maxHeight = 200, tag = it) }) }, - episodes = rows.filter { it.type == BaseItemKind.EPISODE && it.parentIndexNumber == selectedSeason }.map { mapItem(it) }, + episodes = rows.filter { it.type == BaseItemKind.EPISODE && it.parentIndexNumber == selectedSeason }.map { mapItem(it, pageItem.backdrop) }, connection = TitleConnection.SignedOut, seasonNumbers = seasonNumbers, selectedSeason = selectedSeason) } - private fun mapItem(item: BaseItemDto): TitleItem = TitleItem( + private fun mapItem(item: BaseItemDto, seriesBackdrop: String? = null): TitleItem = TitleItem( item.id.toString(), item.name.orEmpty(), when (item.type) { BaseItemKind.MOVIE -> "movie"; BaseItemKind.EPISODE -> "episode"; else -> "tv" }, item.productionYear, (item.runTimeTicks ?: 0) / 10_000_000, overview = item.overview.orEmpty(), genres = item.genres.orEmpty(), poster = item.imageTags?.get(ImageType.PRIMARY)?.let { images.getItemImageUrl(item.id, ImageType.PRIMARY, maxWidth = 256, maxHeight = 384, tag = it) }, - backdrop = item.backdropImageTags?.firstOrNull()?.let { images.getItemImageUrl(item.id, ImageType.BACKDROP, maxWidth = 1280, maxHeight = 720, tag = it) }, + backdrop = if (item.type == BaseItemKind.EPISODE) episodeArtwork(item, seriesBackdrop) else item.backdropImageTags?.firstOrNull()?.let { images.getItemImageUrl(item.id, ImageType.BACKDROP, maxWidth = WolfskinTokens.Image.heroRequestWidth, maxHeight = WolfskinTokens.Image.heroRequestHeight, tag = it) }, held = true, watched = item.userData?.played == true, resumeSeconds = (item.userData?.playbackPositionTicks ?: 0) / 10_000_000, seriesId = item.seriesId?.toString(), seriesName = item.seriesName, season = item.parentIndexNumber ?: 0, episode = item.indexNumber ?: 0, tmdbId = item.providerIds?.entries?.firstOrNull { it.key.equals("Tmdb", true) }?.value?.toLongOrNull(), ) + private fun episodeArtwork(item: BaseItemDto, seriesBackdrop: String?): String? { + val type = when { + item.imageTags?.get(ImageType.PRIMARY) != null -> ImageType.PRIMARY + item.imageTags?.get(ImageType.THUMB) != null -> ImageType.THUMB + else -> null + } + if (type != null) return images.getItemImageUrl(item.id, type, + maxWidth = WolfskinTokens.Image.episodeRequestWidth, maxHeight = WolfskinTokens.Image.episodeRequestHeight, tag = item.imageTags?.get(type)) + return seriesBackdrop ?: item.parentBackdropItemId?.let { parent -> + item.parentBackdropImageTags?.firstOrNull()?.let { tag -> + images.getItemImageUrl(parent, ImageType.BACKDROP, + maxWidth = WolfskinTokens.Image.episodeRequestWidth, maxHeight = WolfskinTokens.Image.episodeRequestHeight, tag = tag) + } + } + } + private fun cacheFile(id: UUID): File = File(context.filesDir, "wolfskin-title/${repository.currentServer?.id}/${repository.currentUser?.id}/$id.json") private fun personCacheFile(id: UUID): File = File(cacheFile(id).parentFile, "person-$id.json") private fun savedTime(file: File): String = java.text.SimpleDateFormat("HH:mm", Locale.getDefault()).format(java.util.Date(file.lastModified())) diff --git a/data/tv-spec/spec.md b/data/tv-spec/spec.md new file mode 100644 index 00000000..4fb76581 --- /dev/null +++ b/data/tv-spec/spec.md @@ -0,0 +1,61 @@ +# Wolfskin TV specification — revision 5 + +2026-11-06. This feedback amendment applies to the approved revision-4 specification and frozen frames at `data/tv-v2/focus-2026-11-06/` (prototype SHA-256 `1bb7f4097a5303a58e45ec5e61cf867fa6cfdcbfe12fc7a196f00acacae5e48d`). Unchanged geometry, navigation, typography, colours and motion remain governed by those frames and the [written specification](https://pages.justinvisser.org/wolfskin/tv-spec). The amendment's prototype delta is `data/tv-v2/feedback-2026-11-06/`. + +## Who’s watching + +The Settings gear is horizontally centred beneath the complete people row, independent of person count or selection. Justin confirmed this placement. It uses the same 4 px accent straddle ring as the people, with the existing 1.08 scale and focus-only Settings caption. Down still reaches it only from the final person; Up returns to that person. + +## Focus visibility + +“Centered” means consistent visibility, not geometric centering. Keep the approved shelf rule: one-card lookahead, shifting after the second-last visible slot, with first/last positions clamped to the actual contents. Short rows do not manufacture empty scroll space. Poster, tile, wide, service, episode, cast and related shelves follow the same rule. + +The focused card and its ring remain fully inside the viewport, with at least the Level 2 gap (40 px) of horizontal clearance at each screen edge. Focused rows clear the nav band and the bottom edge. Preserve the approved 168 px heading pin and initial fully-visible-row exceptions; hero/header anchors remain zero. First entry, row changes, row ends and Back from a title must reconcile retained logical selection with the actual scroll position. A frame is recorded as positioned only after its scroll finishes. Discover uses the approved six poster slots, not a rounded-down five-slot approximation. + +Library and Person grids retain six columns and the existing Level 2 row spacing. The final Person column keeps the full poster width; horizontal padding must not shrink it. + +## Episode artwork and dimensions + +Episode cards use **400×280 px (200×140 dp)** art: 55 px / 24.4% taller than the previous 400×225 px tile. Other tile and wide-card sizes are unchanged. The episode shelf is `84 + 280 + 68 = 432 px`; its heading band and 68 px clearance before Cast remain Level 2. Cast moves down by the additional card height rather than overlapping it. + +Choose available art in this order: + +1. Episode `Primary` image — Jellyfin stores episode stills under this type. +2. Episode `Thumb` image. +3. Series `Backdrop`, centre-cropped to the episode slot. +4. The existing missing-art treatment when none is available. + +A series-page read supplies its already-read backdrop to the episode projection; it does not make one series request per episode. Saved pages rebuild the same projection from cached metadata. Thumbnail reads and prefetch use the 400×280 px episode request, not the unrelated poster dimensions. + +Read-only Jellyfin evidence: all ten The Bear season-three episodes have Primary tags and no episode Backdrop tags. Tomorrow’s Primary source is 2100×1182; the series Backdrop is 3840×2160. These are existing aired files, not missing/unaired episode placeholders. + +## Episode summaries and series header spacing + +In episode context, the summary belongs to the episode: focused episode first, explicitly opened episode next, otherwise the Up-next episode. Use the show overview only when no episode context is selected. An absent episode overview stays absent instead of substituting the show's description. The focused episode card can show two ellipsised lines of its own overview below its episode label. + +A held series header measures its actual copy, actions and action-caption band, then leaves **40 px** before the Seasons row. Remove the unconditional 900 px series-header spacer. Films, non-held titles and loading/error layouts retain their approved fixed header geometry. Measured series geometry also drives the frame offsets and focus-follow calculation, preventing season/episode rows from scrolling to stale offsets. Long titles or summaries may grow the header; controls must never overlap the seasons. + +## Hero image quality and memory + +| Surface | Requested/decode bound | Format policy | +|---|---|---| +| Home and Discover hero; title backdrop | 1920×1080 | ARGB, 7.91 MiB at the bound | +| Upright hero poster | 420×630 | RGB565 where supported, 0.50 MiB | +| Episode card | 400×280 | ARGB, 0.43 MiB | +| Library poster, ordinary tile/wide, cast | Existing slot dimensions | Existing RGB565/ARGB policy | + +Jellyfin image URLs discard conflicting width/height/max/fill caps and request the consuming slot while preserving image tags, quality and other query parameters. Jellyfin quality remains **96**. TMDB heroes use the original source rather than the previous 780 px source; Coil constrains decoding to the panel. Upright TMDB sheets use a suitable poster tier. Intentionally blurred poster-only washes remain bounded to the upright source, not an oversized portrait bitmap. Decode fitting prevents allocation of an oversized off-ratio source merely to fill a crop. + +The shared memory cache remains **64 MiB**, disk cache **128 MiB**, image concurrency **4**. Three 1080p hero bitmaps consume 23.73 MiB, leaving 40.27 MiB of the cache for other art. These are allocation bounds, not measured TV resident-memory claims; do not request or decode 4K hero bitmaps on the 2 GB armv7 box. + +Read-only source measurement: Desperate Housewives has a 2732×1536 Backdrop. With quality 96, Jellyfin returned a 1281×720 JPEG of 438,329 bytes for the old fill request and a 1921×1080 JPEG of 982,435 bytes for the new request; the extra source pixel is Jellyfin aspect-ratio rounding. The display decode stays bounded to 1920×1080. + +## Discover lane spacing + +Every Discover lane, including Movies for you, Series for you, service entries, service-specific lanes and Hidden picks, uses a **104 px heading band**: heading line plus **60 px heading-to-card gap**, up from 40 px. This is a consistent Level 2 extension, not a Series-only exception. Poster shelves become 652 px high and service-entry shelves 316 px. Captions and frame scroll offsets use the same heading band. Home headings remain unchanged. + +## 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`. + +The feedback prototype delta links the native 1920×1080 before/after frames. `HomeViewportTest` exercises short and long poster/tile/wide rows, first focus, both row directions and native Back from the shipped title surface. The real-Bear capture harness reads recorded Jellyfin metadata through `TitleReadFacade`; it is a throwaway proof and is not shipped. The final gates are `make test` and `make apk-debug`. No TV access or optimized-APK build is part of this worker's validation. diff --git a/data/tv-v2/feedback-2026-11-06/delta.json b/data/tv-v2/feedback-2026-11-06/delta.json new file mode 100644 index 00000000..5e732f73 --- /dev/null +++ b/data/tv-v2/feedback-2026-11-06/delta.json @@ -0,0 +1,50 @@ +{ + "specRevision": 5, + "base": "data/tv-v2/focus-2026-11-06/index.html", + "baseSha256": "1bb7f4097a5303a58e45ec5e61cf867fa6cfdcbfe12fc7a196f00acacae5e48d", + "stagePx": [1920, 1080], + "density": 2, + "picker": { + "gearAlignment": "center of the complete people row", + "gearRing": "same accent straddle ring as picker people", + "gearFocusScale": 1.08, + "navigation": "unchanged: Down from the final person only" + }, + "focus": { + "horizontalRule": "second-last-slot lookahead; clamp first/last to real contents", + "minimumScreenSideMarginPx": 40, + "headingPinPx": 168, + "geometricCentering": false, + "discoverPosterSlots": 6 + }, + "episode": { + "artPx": [400, 280], + "shelfHeightPx": 432, + "gapBeforeCastPx": 68, + "artFallback": ["episode Primary", "episode Thumb", "series Backdrop crop", "missing art"], + "summaryContext": ["focused episode", "explicitly opened episode", "up-next episode", "show when no episode context"], + "focusedCardSummaryLines": 2 + }, + "seriesHeader": { + "height": "measured copy, actions and caption band plus gap", + "gapBeforeSeasonsPx": 40, + "otherTitleHeaders": "unchanged" + }, + "discover": { + "allLaneHeadingBandPx": 104, + "allLaneHeadingToCardGapPx": 60, + "posterShelfHeightPx": 652, + "serviceShelfHeightPx": 316, + "homeHeadingBand": "unchanged" + }, + "images": { + "heroDecodeBoundPx": [1920, 1080], + "heroPosterDecodeBoundPx": [420, 630], + "jellyfinQuality": 96, + "memoryCacheMiB": 64, + "diskCacheMiB": 128, + "concurrentReads": 4 + }, + "nativeFrames": "https://pages.justinvisser.org/wolfskin/tv-fb1-visual/", + "specification": "../../tv-spec/spec.md" +} diff --git a/data/tv-v2/feedback-2026-11-06/index.html b/data/tv-v2/feedback-2026-11-06/index.html new file mode 100644 index 00000000..7b715570 --- /dev/null +++ b/data/tv-v2/feedback-2026-11-06/index.html @@ -0,0 +1,36 @@ + + + + +Wolfskin TV · feedback delta · revision 5 + +

Wolfskin TV · feedback delta

+

Specification revision 5. The approved frames remain the baseline; these native 1920×1080 frames show only the requested feedback changes. This is a frame prototype, not a simulated Jellyfin client.

+ +

Who’s watching

Gear centred beneath everyone, with the person-style focus ring.

+Native picker after: Settings centred below the complete people row +
BeforeNative picker before: gear below the final person
+

Home

Approved edge scrolling retained. Short/long poster, tile and wide rows, row changes and native title Back exercise the same visibility contract.

+Native Home after: focused card safely inside a long shelf +
BeforeNative Home baseline
+
Real-source hero request comparison

Same native renderer and source artwork: old 720p response, then panel-sized response. Intentionally blurred poster washes are unchanged.

Native hero using the old 720p source responseNative hero using the panel-resolution source response
+

Discover

60 px from lane title to cards; all lanes use the same 104 px heading band.

+Native Discover after: more space above every lane's cards +
BeforeNative Discover before: smaller heading gap
+

Series header

Episode-context summary and a measured header instead of the empty fixed-height spacer. Season chips and thumbnail artwork enter the initial view.

+Native Bear series header after: episode overview and visible thumbnail row +
BeforeNative Bear series header before: show overview and empty space before seasons
+

Episode shelf

Real episode Primary artwork, 400×280 px cards and the focused episode's own overview. Cast retains its Level 2 clearance.

+Native Bear episodes after: taller thumbnail cards, focused overview and Cast below +
BeforeNative Bear episodes before: monitor placeholders
+

Person

Six full-width poster columns; no shrinking of the final column.

+Native Person after +
BeforeNative Person before
+ diff --git a/docker/README.md b/docker/README.md index f2fd9935..b68d58c6 100644 --- a/docker/README.md +++ b/docker/README.md @@ -169,7 +169,7 @@ The stroke is inset within the fixed 20 dp / 40 px height; horizontal border-box padding includes it. The 6 px dash / 4 px gap follows the browser's 3:2 stroke-width ratios. Resting borders are white at 22% opacity; focused borders use ink at 40%. -The small gear under the final person opens Settings with Requests focused. +The small gear centred beneath the complete people row opens Settings with Requests focused. Settings groups hand off to existing upstream pages; Requests opens the household Coming-in sheet described below. **Advanced settings → Wolfskin screens** is the persistent rollout switch: turning it off renders upstream Home and library destinations with @@ -537,6 +537,36 @@ nav avatar, picker, Discover chip, Library toolbar chip and title Request captures exercise the actual screens at 1920×1080. Browser and native glyph metrics differ; their ring offsets are measured relative to each surface edge. +### TV feedback corrections (spec revision 5) + +The [revision-5 amendment](../data/tv-spec/spec.md) and +[native frame prototype delta](../data/tv-v2/feedback-2026-11-06/index.html) +retain the approved frames except for the requested feedback changes: + +- The picker gear is centred under everyone and uses the picker-person ring. +- Shelf tracks share second-last-slot lookahead, count clamping and settled + offsets. Home records completed frame positioning rather than an animation + that may still be cancelled; first mount restores the intended shelf item. + Discover uses all six approved poster slots. Person keeps the final column + at full poster width. +- Episode images choose episode Primary, then Thumb, then the already-read + series Backdrop. Episode cards are 400×280 px; cast retains its 68 px clearance. + Episode context uses the episode overview, with two summary lines on the + focused card. Held-series headers measure their copy/actions and leave 40 px + before seasons instead of reserving a fixed empty 900 px header. +- Every Discover heading uses a 104 px band, including a 60 px title-to-card + gap. Home heading geometry is unchanged. +- Hero/backdrop decoding is bounded to 1920×1080; upright hero sheets request + 420×630. The shared request builder removes conflicting Jellyfin size caps, + preserves tags and quality 96, and selects a suitable TMDB tier. The 64 MiB + memory cache, 128 MiB disk cache and four image-read limit remain unchanged. + +`HomeViewportTest` covers short/long poster, tile and wide rows, both row +directions, first focus and native Back from the actual title surface. Native +proof records real Bear season-three metadata through the title read facade; +the proof-only harness is not part of the shipped tests or APK. + + ## Title pages and people `TitleSurface` owns the film/series/episode header, season chips, episode tiles, diff --git a/wolfskin-design/discover-tokens.json b/wolfskin-design/discover-tokens.json index 83266ff5..bf45e265 100644 --- a/wolfskin-design/discover-tokens.json +++ b/wolfskin-design/discover-tokens.json @@ -1,5 +1,6 @@ { "discover": { + "shelfHeadingBand": {"dp": 52, "px": 104, "source": "feedback-2026-11-06: heading-to-card gap 60 px on every Discover lane"}, "sheetPaddingX": { "$type": "dimension", "px": 64, diff --git a/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/DiscoverArtwork.kt b/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/DiscoverArtwork.kt index 92e63ad9..77e26655 100644 --- a/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/DiscoverArtwork.kt +++ b/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/DiscoverArtwork.kt @@ -50,8 +50,11 @@ fun DiscoverArtwork(path: String?, modifier: Modifier, poster: Boolean = false, this *= ColorMatrix().apply { setToScale(D.ambientBrightness, D.ambientBrightness, D.ambientBrightness, 1f) } }) else null } val context = LocalContext.current - val request = remember(context, path, poster) { - WolfskinImages.request(context, path, if (poster) 256 else 1280, if (poster) 384 else 720, poster) + val request = remember(context, path, poster, dim) { + val upright = poster || dim + WolfskinImages.request(context, path, + if (upright) WolfskinTokens.Image.heroPosterRequestWidth else WolfskinTokens.Image.heroRequestWidth, + if (upright) WolfskinTokens.Image.heroPosterRequestHeight else WolfskinTokens.Image.heroRequestHeight, upright) } if (painter != null) Image(painter, null, modifier, contentScale = ContentScale.Crop, colorFilter = filter) else AsyncImage( diff --git a/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/DiscoverScreen.kt b/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/DiscoverScreen.kt index bd6c40ef..fcc891ff 100644 --- a/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/DiscoverScreen.kt +++ b/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/DiscoverScreen.kt @@ -86,7 +86,7 @@ fun DiscoverScreen(state: DiscoverState, modifier: Modifier = Modifier) { add(if (state.hasCatalogue) L.row_chips else WolfskinTokens.Shell.zero) if (!state.hasCatalogue) add(L.row_state) else { if (note) add(L.row_note) - state.lanes.forEach { lane -> add(L.rowHeadingBand + (if (lane.id == "svc") WolfskinTokens.Card.entryH else WolfskinTokens.Card.posterH + WolfskinTokens.Card.posterCaptionBand) + L.rowGapAfter) } + state.lanes.forEach { lane -> add(D.shelfHeadingBand + (if (lane.id == "svc") WolfskinTokens.Card.entryH else WolfskinTokens.Card.posterH + WolfskinTokens.Card.posterCaptionBand) + L.rowGapAfter) } } } } diff --git a/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/DiscoverShelf.kt b/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/DiscoverShelf.kt index 92e8317e..e6ded43c 100644 --- a/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/DiscoverShelf.kt +++ b/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/DiscoverShelf.kt @@ -1,9 +1,6 @@ // SPDX-License-Identifier: GPL-2.0-only package org.justinvisser.wolfskin.design -import androidx.compose.animation.core.tween -import androidx.compose.animation.core.CubicBezierEasing -import androidx.compose.foundation.gestures.animateScrollBy import androidx.compose.foundation.background import androidx.compose.foundation.border import androidx.compose.foundation.clickable @@ -22,15 +19,13 @@ import androidx.compose.foundation.lazy.rememberLazyListState import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect -import androidx.compose.runtime.getValue -import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember -import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.Brush import androidx.compose.ui.graphics.Color import androidx.compose.ui.platform.testTag +import androidx.compose.ui.platform.LocalDensity import androidx.compose.ui.zIndex import androidx.tv.material3.Text import org.justinvisser.wolfskin.design.WolfskinTokens.Card as K @@ -47,27 +42,20 @@ fun DiscoverShelf(state: DiscoverState, lane: DiscoverLane, row: Int) { val count = if (services) state.snapshot.services.size else lane.titles.size + if (lane.tail == DiscoverTail.None) 0 else 1 val column = (state.shell.view.cards[lane.id] ?: 0).coerceIn(0, (count - 1).coerceAtLeast(0)) val active = state.contentFocused && state.focusedRow == row - val slots = ((L.contentWidth + K.posterGap) / K.posterPitch).toInt() + val slots = K.posterSlots val first = state.shell.view.cards["first:${lane.id}"] ?: 0 - var initialized by remember { mutableStateOf(false) } - LaunchedEffect(column, count) { - var target = first - if (column > target + slots - 2) target = column - (slots - 2) - if (column < target + 1) target = (column - 1).coerceAtLeast(0) - target = target.coerceIn(0, (count - slots).coerceAtLeast(0)) + val density = LocalDensity.current + LaunchedEffect(column, count, active) { + val target = shelfFirst(column, first, slots, count) state.shell.view.cards["first:${lane.id}"] = target - val offset = cards.layoutInfo.visibleItemsInfo.firstOrNull { it.index == target }?.offset - if (initialized && offset != null) { - if (offset != 0) cards.animateScrollBy(offset.toFloat(), tween(WolfskinTokens.Motion.base, easing = CubicBezierEasing(.25f, .1f, .25f, 1f))) - } else cards.scrollToItem(target) - initialized = true + cards.scrollShelfTo(target, with(density) { K.posterPitch.toPx() }) } - Box(Modifier.fillMaxWidth().height(L.rowHeadingBand + height + (if (services) WolfskinTokens.Shell.zero else K.posterCaptionBand) + L.rowGapAfter).testTag("discover-lane-${lane.id}")) { + Box(Modifier.fillMaxWidth().height(D.shelfHeadingBand + height + (if (services) WolfskinTokens.Shell.zero else K.posterCaptionBand) + L.rowGapAfter).testTag("discover-lane-${lane.id}")) { Text(lane.title, Modifier.offset(L.safeSide), color = C.fg, style = DiscoverType.rowHeading, maxLines = 1) 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), horizontalArrangement = Arrangement.spacedBy(K.posterGap), - modifier = Modifier.offset(y = L.rowHeadingBand - K.posterGap).height(height + K.posterGap * 2)) { + modifier = Modifier.offset(y = D.shelfHeadingBand - K.posterGap).height(height + K.posterGap * 2)) { 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) .focusRing(active && index == column, WolfskinTokens.Radius.card, WolfskinTokens.Focus.ringCardRing, FocusLift.Card) @@ -97,7 +85,7 @@ fun DiscoverShelf(state: DiscoverState, lane: DiscoverLane, row: Int) { Caption( 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) 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", - true, Modifier.offset(L.safeSide + K.posterPitch * (column - first), L.rowHeadingBand + K.posterH + L.captionOffset).width(WolfskinTokens.Library.captionWidth), + true, Modifier.offset(L.safeSide + K.posterPitch * (column - first), D.shelfHeadingBand + K.posterH + L.captionOffset).width(WolfskinTokens.Library.captionWidth), accentFirstFact = lane.hidden, accentLastFact = item != null && (item.heldBy.isNotEmpty() || state.requests[item.key].orEmpty().isNotEmpty()), ) diff --git a/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/HomeFocus.kt b/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/HomeFocus.kt index e91b22cd..eefd5945 100644 --- a/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/HomeFocus.kt +++ b/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/HomeFocus.kt @@ -107,9 +107,5 @@ internal val HomeRow.focusSlots: Int get() = when (cardKind) { } /** Keep one card behind focus, but do not shift again while focus stays in the visible middle. */ -internal fun HomeRow.adjustedFirst(column: Int, previous: Int): Int { - var first = previous - if (column > first + focusSlots - 2) first = column - (focusSlots - 2) - if (column < first + 1) first = (column - 1).coerceAtLeast(0) - return first.coerceIn(0, (count - focusSlots).coerceAtLeast(0)) -} +internal fun HomeRow.adjustedFirst(column: Int, previous: Int): Int = + shelfFirst(column, previous, focusSlots, count) diff --git a/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/HomeScreen.kt b/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/HomeScreen.kt index 84766c0e..10090be2 100644 --- a/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/HomeScreen.kt +++ b/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/HomeScreen.kt @@ -61,25 +61,17 @@ fun HomeScreen( LaunchedEffect(view.row, snapshot.rows) { snapshot.rows.drop(view.row + 1).firstOrNull { it.kind == HomeRowKind.Shelf && !it.skeleton }?.let { WolfskinImages.prefetch(context, it) } } - LaunchedEffect(view) { - if (view.home.positionedKey != null) { - view.home.focusedKey?.let { view.home.tracks[it] }?.let { track -> - track.scrollToItem(track.firstVisibleItemIndex, track.firstVisibleItemScrollOffset) - } - view.listState.scrollToItem(view.listState.firstVisibleItemIndex, view.listState.firstVisibleItemScrollOffset) - } - } - LaunchedEffect(view, view.row, view.home.resetScroll, snapshot.rows) { + LaunchedEffect(view, view.row, view.home.resetScroll, heights) { val key = snapshot.rows.getOrNull(view.row)?.id if (view.home.positionedKey == key && view.home.positionedReset == view.home.resetScroll) return@LaunchedEffect val topRequested = shell.navFocused && view.home.resetScroll > 0 && view.home.positionedReset != view.home.resetScroll val target = if (topRequested) 0f else with(density) { (heights.take(view.row).fold(topInset) { total, height -> total + height } - if (snapshot.rows.getOrNull(view.row)?.kind == HomeRowKind.Hero) WolfskinTokens.Shell.zero else L.pinnedLine).toPx() }.coerceAtLeast(0f) - view.home.positionedKey = key - view.home.positionedReset = view.home.resetScroll val current = with(density) { heights.take(view.listState.firstVisibleItemIndex).fold(WolfskinTokens.Shell.zero) { total, height -> total + height }.toPx() } + view.listState.firstVisibleItemScrollOffset view.listState.animateScrollBy(target - current, tween(WolfskinTokens.Motion.scroll, easing = CubicBezierEasing(.33f, 1f, .68f, 1f))) + view.home.positionedKey = key + view.home.positionedReset = view.home.resetScroll } val cache = view.home.ratings val ratingsReader = LocalScreenRatings.current diff --git a/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/HomeShelf.kt b/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/HomeShelf.kt index 9b97fe8b..069851f1 100644 --- a/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/HomeShelf.kt +++ b/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/HomeShelf.kt @@ -2,8 +2,6 @@ package org.justinvisser.wolfskin.design import androidx.compose.foundation.background import androidx.compose.foundation.clickable -import androidx.compose.animation.core.tween -import androidx.compose.foundation.gestures.animateScrollBy import androidx.compose.ui.platform.LocalDensity import androidx.compose.foundation.layout.* import androidx.compose.foundation.lazy.LazyRow @@ -16,9 +14,8 @@ import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.SideEffect import androidx.compose.runtime.remember import androidx.compose.runtime.getValue -import androidx.compose.runtime.rememberUpdatedState -import androidx.compose.runtime.snapshotFlow -import kotlinx.coroutines.flow.collectLatest +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.Brush @@ -43,18 +40,12 @@ fun HomeShelf(row: HomeRow, memory: HomeViewMemory, focused: Boolean, column: In val first = row.adjustedFirst(column, memory.firstCards[row.id] ?: 0) 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 targetFirst by rememberUpdatedState(first) val density = LocalDensity.current - LaunchedEffect(track) { - track.scrollToItem(track.firstVisibleItemIndex, track.firstVisibleItemScrollOffset) - var positionedFirst = targetFirst - snapshotFlow { targetFirst }.collectLatest { target -> - if (target != positionedFirst) { - positionedFirst = target - val pitch = with(density) { (width + K.posterGap).toPx() } - track.animateScrollBy((target - track.firstVisibleItemIndex) * pitch - track.firstVisibleItemScrollOffset, tween(WolfskinTokens.Motion.base)) - } - } + var positioned by remember(track) { mutableStateOf(false) } + LaunchedEffect(track, first, focused, width, density) { + if (!positioned) track.scrollToItem(first) + else track.scrollShelfTo(first, with(density) { (width + K.posterGap).toPx() }) + positioned = true } 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)) diff --git a/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/PersonPage.kt b/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/PersonPage.kt index 011a1d70..ea95f24b 100644 --- a/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/PersonPage.kt +++ b/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/PersonPage.kt @@ -145,7 +145,7 @@ private fun PersonPage(state: PersonState, shell: ShellState, modifier: Modifier Text("Filmography", color = C.fg, style = TitleTextStyles.rowHeading, modifier = Modifier.height(L.row_gh).padding(start = L.safeSide, top = D.gridHeadingTop)) } itemsIndexed(state.credits.chunked(columns), key = { _, row -> row.first().id }, contentType = { _, _ -> "grid" }) { rowIndex, row -> - Row(Modifier.height(D.gridHeight).padding(horizontal = 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 -> 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) } diff --git a/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/ShelfScroll.kt b/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/ShelfScroll.kt new file mode 100644 index 00000000..fc84ed7f --- /dev/null +++ b/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/ShelfScroll.kt @@ -0,0 +1,22 @@ +package org.justinvisser.wolfskin.design + +import androidx.compose.animation.core.CubicBezierEasing +import androidx.compose.animation.core.tween +import androidx.compose.foundation.gestures.animateScrollBy +import androidx.compose.foundation.lazy.LazyListState + +/** Preserve one lookahead card and clamp short shelves and row ends to their real contents. */ +internal fun shelfFirst(column: Int, previous: Int, slots: Int, count: Int): Int { + var first = previous + if (column > first + slots - 2) first = column - (slots - 2) + if (column < first + 1) first = (column - 1).coerceAtLeast(0) + return first.coerceIn(0, (count - slots).coerceAtLeast(0)) +} + +/** Reconcile the actual track on every focus transition, including interrupted or restored offsets. */ +internal suspend fun LazyListState.scrollShelfTo(first: Int, pitch: Float) { + val distance = (first - firstVisibleItemIndex) * pitch - firstVisibleItemScrollOffset + if (distance != 0f) animateScrollBy(distance, tween(WolfskinTokens.Motion.base, + easing = CubicBezierEasing(.25f, .1f, .25f, 1f))) + if (firstVisibleItemIndex != first || firstVisibleItemScrollOffset != 0) scrollToItem(first) +} diff --git a/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/TitleArtwork.kt b/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/TitleArtwork.kt index 1ea37eb4..cc2696d6 100644 --- a/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/TitleArtwork.kt +++ b/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/TitleArtwork.kt @@ -98,14 +98,14 @@ fun TitleBackdrop(item: TitleItem, modifier: Modifier = Modifier) { for (index in 0 until 15) matrix.values[index] *= filter[3].toFloat() ColorFilter.colorMatrix(matrix) } - TitleArtwork(item.poster, WolfskinTokens.Image.heroRequestWidth, WolfskinTokens.Image.heroRequestHeight, + TitleArtwork(item.poster, WolfskinTokens.Image.heroPosterRequestWidth, WolfskinTokens.Image.heroPosterRequestHeight, Modifier.fillMaxSize().artOverscan(D.ambientInset).graphicsLayer { scaleX = D.ambientScale; scaleY = D.ambientScale } .blur(Dp(filter[1].toFloat() / 2)), colorFilter = colorFilter) val posterShape = remember { RoundedCornerShape(WolfskinTokens.Radius.heroSheet) } val posterShadow = remember { Shadow(radius = D.posterShadowRadius, offset = DpOffset.Zero.copy(y = D.posterShadowY), color = Color.Black.copy(alpha = D.posterShadowOpacity)) } TitleArtwork(item.poster, (L.heroSheetWidth.value * 2).toInt(), (L.heroSheetHeight.value * 2).toInt(), Modifier.offset(WolfskinTokens.Shell.stageWidth - L.heroSheetWidth - D.heroSheetRight, D.heroSheetTop) - .size(L.heroSheetWidth, L.heroSheetHeight).dropShadow(posterShape, posterShadow).clip(posterShape)) + .size(L.heroSheetWidth, L.heroSheetHeight).dropShadow(posterShape, posterShadow).clip(posterShape), poster = true) } Box(Modifier.fillMaxSize().background(horizontal)) Box(Modifier.fillMaxSize().background(vertical)) @@ -131,13 +131,13 @@ fun TitleTileArtwork(item: TitleItem) { val filter = remember { ColorFilter.colorMatrix(ColorMatrix().apply { for (index in 0 until 15) values[index] *= D.tileAmbientBrightness }) } - TitleArtwork(item.poster, WolfskinTokens.Image.tileRequestWidth, WolfskinTokens.Image.tileRequestHeight, Modifier.fillMaxSize().artOverscan(D.tileAmbientInset).blur(D.tileAmbientBlur), colorFilter = filter) - val height = WolfskinTokens.Card.tileH - D.tileSheetInset * 2 + TitleArtwork(item.poster, WolfskinTokens.Image.episodeRequestWidth, WolfskinTokens.Image.episodeRequestHeight, Modifier.fillMaxSize().artOverscan(D.tileAmbientInset).blur(D.tileAmbientBlur), colorFilter = filter) + val height = D.episodeCardHeight - D.tileSheetInset * 2 val shape = remember { RoundedCornerShape(D.tileSheetRadius) } val shadow = remember { Shadow(radius = D.tileShadowRadius, offset = DpOffset.Zero.copy(y = D.tileShadowY), color = Color.Black.copy(alpha = D.tileShadowOpacity)) } TitleArtwork(item.poster, (height.value * 2 * 2 / 3).toInt(), (height.value * 2).toInt(), Modifier.offset(D.tileSheetLeft, D.tileSheetInset).size(height * (2f / 3), height).dropShadow(shape, shadow).clip(shape), poster = true) - } else TitleArtwork(item.backdrop, WolfskinTokens.Image.tileRequestWidth, WolfskinTokens.Image.tileRequestHeight, Modifier.fillMaxSize()) + } else TitleArtwork(item.backdrop, WolfskinTokens.Image.episodeRequestWidth, WolfskinTokens.Image.episodeRequestHeight, Modifier.fillMaxSize()) } } diff --git a/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/TitleHeader.kt b/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/TitleHeader.kt index 903bf508..b5d35264 100644 --- a/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/TitleHeader.kt +++ b/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/TitleHeader.kt @@ -36,6 +36,11 @@ import org.justinvisser.wolfskin.design.WolfskinTokens.Title as D fun TitleHeader(state: TitleState, active: Boolean, modifier: Modifier = Modifier) { if (state.data.status == TitleReadStatus.Loading) { TitleHeaderSkeleton(modifier); return } val item = state.data.item + val episode = when { + state.row == "episodes" -> state.data.episodes.getOrNull(state.column) + state.data.watchTarget.kind == "episode" -> state.data.watchTarget + else -> state.data.playTarget?.takeIf { it.kind == "episode" } + } val type = if (item.kind == "movie") "Movie" else "Series" CssColumn(modifier.offset(L.safeSide, L.detailCopyTop + D.lineLift).width(L.detailCopyWidth)) { Row(Modifier.exactLineBox(TitleTextStyles.dLine, bottom = D.lineGap), horizontalArrangement = Arrangement.spacedBy(L.navItemGap), verticalAlignment = Alignment.CenterVertically) { @@ -63,7 +68,7 @@ fun TitleHeader(state: TitleState, active: Boolean, modifier: Modifier = Modifie Text("for ${state.data.requestFor ?: "you"}" + state.data.requestActor?.let { " · by $it" }.orEmpty(), color = C.muted, style = TitleTextStyles.dLine) } } - Text(item.overview, color = C.muted, style = TitleTextStyles.body, maxLines = 3, overflow = TextOverflow.Ellipsis, modifier = Modifier.exactLineBox(TitleTextStyles.body, bottom = D.overviewGap).testTag("title-overview")) + Text((episode ?: item).overview, color = C.muted, style = TitleTextStyles.body, maxLines = 3, overflow = TextOverflow.Ellipsis, modifier = Modifier.exactLineBox(TitleTextStyles.body, bottom = D.overviewGap).testTag("title-overview")) if (!item.held && state.data.connection != TitleConnection.SignedIn) { Text(if (state.data.connection == TitleConnection.SignedOut) "Requests need this TV to be signed in to Conductarr." else "Requests need Conductarr, and it can’t be reached right now.", color = C.warn, style = TitleTextStyles.body, modifier = Modifier.exactLineBox(TitleTextStyles.body, bottom = D.overviewGap)) diff --git a/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/TitlePage.kt b/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/TitlePage.kt index 85f847f5..e2deee4a 100644 --- a/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/TitlePage.kt +++ b/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/TitlePage.kt @@ -2,6 +2,7 @@ package org.justinvisser.wolfskin.design import androidx.compose.ui.layout.onGloballyPositioned import androidx.compose.ui.layout.boundsInRoot +import androidx.compose.ui.layout.onSizeChanged import androidx.compose.foundation.background import androidx.compose.foundation.border import androidx.compose.foundation.clickable @@ -90,6 +91,10 @@ fun TitlePage(state: TitleState, shell: ShellState, modifier: Modifier = Modifie val list = shell.view.listState var wheelPending by remember { mutableStateOf(false) } val focus = remember { FocusRequester() } + var measuredHeaderHeight by remember(state.data.item.id) { mutableStateOf(L.row_dhead) } + val compactSeries = state.data.status == TitleReadStatus.Ready && state.data.item.held && + state.data.item.kind == "tv" && state.data.item.seasons > 0 + val headerHeight = if (compactSeries) measuredHeaderHeight else L.row_dhead LaunchedEffect(state.sheet, shell.navFocused, shell.pickerVisible) { if (state.sheet == null && !shell.navFocused && !shell.pickerVisible) focus.requestFocus() } @@ -105,19 +110,19 @@ fun TitlePage(state: TitleState, shell: ShellState, modifier: Modifier = Modifie if (state.data.related.isNotEmpty()) add("related") } } - val rowOffsets = remember(displayedRows, density) { + val rowOffsets = remember(displayedRows, density, headerHeight) { FloatArray(displayedRows.size + 1).apply { - displayedRows.forEachIndexed { index, row -> this[index + 1] = this[index] + with(density) { titleRowHeight(row).toPx() } } + displayedRows.forEachIndexed { index, row -> this[index + 1] = this[index] + with(density) { titleRowHeight(row, headerHeight).toPx() } } } } val position by remember(list, rowOffsets) { derivedStateOf { rowOffsets[list.firstVisibleItemIndex.coerceAtMost(rowOffsets.lastIndex)] + list.firstVisibleItemScrollOffset } } - LaunchedEffect(state.row, shell.navFocused, state.data.status, displayedRows) { + LaunchedEffect(state.row, shell.navFocused, state.data.status, displayedRows, rowOffsets) { if (state.data.status == TitleReadStatus.Loading) shell.focusNav(shell.currentTab) val index = if (shell.navFocused) 0 else displayedRows.indexOf(state.row).coerceAtLeast(0) val y = rowOffsets[index] - val height = with(density) { titleRowHeight(state.row).toPx() } + val height = with(density) { titleRowHeight(state.row, headerHeight).toPx() } val target = if (index == 0 || (y < with(density) { L.windowAhead.toPx() } && y + height <= with(density) { (WolfskinTokens.Shell.stageHeight - L.safeTopPicker).toPx() })) 0f else y - with(density) { L.pinnedLine.toPx() } if (target != position) list.animateScrollBy(target - position, titleFrameScrollSpec) @@ -148,7 +153,11 @@ fun TitlePage(state: TitleState, shell: ShellState, modifier: Modifier = Modifie displayedRows.forEach { id -> item(key = id, contentType = if (id == "header") "header" else if (id == "seasons") "chips" else "shelf") { val active = state.row == id && !shell.navFocused && state.sheet == null when (id) { - "header" -> Box(Modifier.height(L.row_dhead)) { TitleHeader(state, active) } + "header" -> Box(Modifier.height(headerHeight)) { + TitleHeader(state, active, if (compactSeries) Modifier.onSizeChanged { size -> + measuredHeaderHeight = L.detailCopyTop + D.lineLift + with(density) { size.height.toDp() } + D.seriesHeaderGap + } else Modifier) + } "seasons" -> TitleSeasons(state, active) "episodes" -> TitleMediaShelf(state, true, active) "cast" -> TitleCast(state, active) @@ -156,16 +165,16 @@ fun TitlePage(state: TitleState, shell: ShellState, modifier: Modifier = Modifie } } } item(key = "end-padding", contentType = "spacer") { - Spacer(Modifier.height((WolfskinTokens.Shell.stageHeight - L.pinnedLine - titleRowHeight(displayedRows.last())).coerceAtLeast(D.minimumEndPadding))) + Spacer(Modifier.height((WolfskinTokens.Shell.stageHeight - L.pinnedLine - titleRowHeight(displayedRows.last(), headerHeight)).coerceAtLeast(D.minimumEndPadding))) } } } } -private fun titleRowHeight(row: String): Dp = when (row) { - "header" -> L.row_dhead +private fun titleRowHeight(row: String, headerHeight: Dp): Dp = when (row) { + "header" -> headerHeight "seasons" -> L.row_chips - "episodes" -> L.rowHeadingBand + WolfskinTokens.Card.tileH + L.rowGapAfter + "episodes" -> L.rowHeadingBand + D.episodeCardHeight + L.rowGapAfter "cast" -> D.castHeight else -> L.rowHeadingBand + WolfskinTokens.Card.posterH + WolfskinTokens.Card.posterCaptionBand + L.rowGapAfter } diff --git a/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/TitlePrefetch.kt b/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/TitlePrefetch.kt index 641fead0..d4a956fc 100644 --- a/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/TitlePrefetch.kt +++ b/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/TitlePrefetch.kt @@ -22,7 +22,7 @@ internal fun TitleRowPrefetch(data: TitlePageData, row: String?) { queue(data.cast[index].portrait, WolfskinTokens.Image.castRequestWidth, WolfskinTokens.Image.castRequestHeight) "episodes" -> for (index in 0 until minOf(data.episodes.size, WolfskinTokens.Card.tileSlots)) { val item = data.episodes[index] - queue(if (item.isPosterBackdrop) item.poster else item.backdrop, WolfskinTokens.Image.tileRequestWidth, WolfskinTokens.Image.tileRequestHeight) + queue(if (item.isPosterBackdrop) item.poster else item.backdrop, WolfskinTokens.Image.episodeRequestWidth, WolfskinTokens.Image.episodeRequestHeight) } "related" -> for (index in 0 until minOf(data.related.size, WolfskinTokens.Card.posterSlots)) queue(data.related[index].poster, WolfskinTokens.Image.posterRequestWidth, WolfskinTokens.Image.posterRequestHeight, true) diff --git a/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/TitleShelf.kt b/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/TitleShelf.kt index 15a74880..6d7752ae 100644 --- a/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/TitleShelf.kt +++ b/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/TitleShelf.kt @@ -3,8 +3,6 @@ package org.justinvisser.wolfskin.design import androidx.compose.animation.fadeIn import androidx.compose.animation.fadeOut import androidx.compose.animation.core.tween -import androidx.compose.animation.core.CubicBezierEasing -import androidx.compose.foundation.gestures.animateScrollBy import androidx.compose.ui.platform.LocalDensity import androidx.compose.foundation.layout.fillMaxHeight import androidx.compose.foundation.background @@ -40,6 +38,7 @@ import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.zIndex import androidx.tv.material3.Text +import kotlin.math.roundToInt import org.justinvisser.wolfskin.design.WolfskinTokens.Color as C import org.justinvisser.wolfskin.design.WolfskinTokens.Layout as L import org.justinvisser.wolfskin.design.WolfskinTokens.Title as D @@ -50,7 +49,9 @@ fun TitleCast(state: TitleState, active: Boolean) { val track = rememberLazyListState() val focusOwner = LocalTitleFocusOwner.current val pitch = with(LocalDensity.current) { WolfskinTokens.Card.personPitch.toPx() } - LaunchedEffect(state.column, active) { if (active) track.keepColumnInWindow(state.column, WolfskinTokens.Card.personSlots, pitch) } + LaunchedEffect(state.column, active, state.data.cast.size) { + if (active) track.keepColumnInWindow(state.column, WolfskinTokens.Card.personSlots, pitch, state.data.cast.size) + } Box(Modifier.height(D.castHeight)) { Text("Cast", color = C.fg, style = WolfskinTokens.Type.rowHeading, modifier = Modifier.padding(start = L.safeSide).height(L.rowHeadingBand)) LazyRow(state = track, modifier = Modifier.fillMaxWidth().offset(y = L.rowHeadingBand - L.rowGapAfter).height(WolfskinTokens.Card.personH + L.rowGapAfter * 2), @@ -81,11 +82,13 @@ fun TitleMediaShelf(state: TitleState, episodes: Boolean, active: Boolean) { val titles = if (episodes) state.data.episodes else state.data.related val id = if (episodes) "episodes" else "related" val width = if (episodes) WolfskinTokens.Card.tileW else WolfskinTokens.Card.posterW - val height = if (episodes) WolfskinTokens.Card.tileH else WolfskinTokens.Card.posterH + val height = if (episodes) D.episodeCardHeight else WolfskinTokens.Card.posterH val track = rememberLazyListState() val visible = if (episodes) WolfskinTokens.Card.tileSlots else WolfskinTokens.Card.posterSlots val pitch = with(LocalDensity.current) { (if (episodes) WolfskinTokens.Card.tilePitch else WolfskinTokens.Card.posterPitch).toPx() } - LaunchedEffect(state.column, active) { if (active) track.keepColumnInWindow(state.column, visible, pitch) } + LaunchedEffect(state.column, active, titles.size) { + if (active) track.keepColumnInWindow(state.column, visible, pitch, titles.size) + } Box(Modifier.height(L.rowHeadingBand + height + L.rowGapAfter + if (episodes) WolfskinTokens.Shell.zero else WolfskinTokens.Card.posterCaptionBand)) { Text(if (episodes) "Season ${state.season}" else "More like this", color = C.fg, style = WolfskinTokens.Type.rowHeading, modifier = Modifier.padding(start = L.safeSide).height(L.rowHeadingBand)) LazyRow(state = track, modifier = Modifier.fillMaxWidth().offset(y = L.rowHeadingBand - L.rowGapAfter) @@ -114,7 +117,7 @@ fun TitlePoster(item: TitleItem, focused: Boolean, modifier: Modifier = Modifier val offlineArt = androidx.compose.runtime.remember(bitmaps) { if (bitmaps.isEmpty()) null else { path: String -> bitmaps[path] } } LibraryPoster(item, focused, focused && ratingsVisible, catalogue = catalogue, modifier = Modifier.titlePointerFocus(onFocus).clickable { focusOwner?.invoke(); onClick() }, offlineArt = offlineArt) - } else Box(Modifier.size(WolfskinTokens.Card.tileW, WolfskinTokens.Card.tileH).titlePointerFocus(onFocus).focusScale(focused, WolfskinTokens.Focus.scaleCard) + } else Box(Modifier.size(WolfskinTokens.Card.tileW, D.episodeCardHeight).titlePointerFocus(onFocus).focusScale(focused, WolfskinTokens.Focus.scaleCard) .focusRing(focused, WolfskinTokens.Radius.card, WolfskinTokens.Focus.ringCardRing, FocusLift.Card) .clip(RoundedCornerShape(WolfskinTokens.Radius.card)).background(C.surface2).clickable { focusOwner?.invoke(); onClick() }) { TitleTileArtwork(item) @@ -122,6 +125,7 @@ fun TitlePoster(item: TitleItem, focused: Boolean, modifier: Modifier = Modifier .padding(start = D.tileOverlaySide, end = D.tileOverlaySide, top = D.tileOverlayTop, bottom = D.tileOverlayBottom)) { Text(item.seriesName ?: item.name, color = C.fg, style = WolfskinTokens.Type.button, maxLines = 1, overflow = TextOverflow.Ellipsis) Text("${item.episodeLabel} · ${item.name}", color = C.muted, style = WolfskinTokens.Type.caption, maxLines = 1, overflow = TextOverflow.Ellipsis) + if (focused && item.overview.isNotBlank()) Text(item.overview, color = C.muted, style = WolfskinTokens.Type.caption, maxLines = 2, overflow = TextOverflow.Ellipsis) } val progress = item.progress if (progress > 0f && progress < 1f) { @@ -155,15 +159,8 @@ fun TitlePoster(item: TitleItem, focused: Boolean, modifier: Modifier = Modifier } } -/** Keep a full lookahead card, but do not recenter a row when reversing direction. */ -private suspend fun LazyListState.keepColumnInWindow(column: Int, slots: Int, pitch: Float) { - val first = firstVisibleItemIndex - val next = when { - column < first -> column - column >= first + slots - 1 -> (column - slots + 2).coerceAtLeast(0) - else -> first - } - if (next != first) animateScrollBy((next - first) * pitch - firstVisibleItemScrollOffset, titleTrackScrollSpec) +/** Apply the same lookahead and settled-offset rule to cast, episodes and related shelves. */ +private suspend fun LazyListState.keepColumnInWindow(column: Int, slots: Int, pitch: Float, count: Int) { + val previous = firstVisibleItemIndex + (firstVisibleItemScrollOffset / pitch).roundToInt() + scrollShelfTo(shelfFirst(column, previous, slots, count), pitch) } - -private val titleTrackScrollSpec = tween(WolfskinTokens.Motion.base, easing = CubicBezierEasing(.25f, .1f, .25f, 1f)) diff --git a/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/TitleSlotArtwork.kt b/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/TitleSlotArtwork.kt index 242e0e6b..154c44a3 100644 --- a/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/TitleSlotArtwork.kt +++ b/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/TitleSlotArtwork.kt @@ -48,12 +48,12 @@ fun TitleArtwork(title: String, type: String, poster: String?, width: Dp, height .requiredSize(width + inset * 2, height + inset * 2) .blur(if (hero) H.heroBlur else H.cardBlur) .graphicsLayer { if (hero) { scaleX = 1.1f; scaleY = 1.1f } } - SlotImage(poster, width, height, washModifier, colorFilter = wash, onError = { failed.value = true }) + SlotImage(poster, if (hero) WolfskinTokens.Layout.heroSheetWidth else width, if (hero) WolfskinTokens.Layout.heroSheetHeight else height, washModifier, colorFilter = wash, onError = { failed.value = true }) val sheetHeight = if (hero) WolfskinTokens.Layout.heroSheetHeight else height - H.cardSheetInset val sheetWidth = if (hero) WolfskinTokens.Layout.heroSheetWidth else sheetHeight * (2f / 3f) val sheetModifier = if (hero) Modifier.align(Alignment.TopEnd).offset(-H.heroSheetRight, H.heroSheetTop) else Modifier.offset(H.cardSheetLeft, H.cardSheetTop) SlotImage(poster, sheetWidth, sheetHeight, sheetModifier.size(sheetWidth, sheetHeight).clip(RoundedCornerShape(if (hero) WolfskinTokens.Radius.heroSheet else WolfskinTokens.Radius.cardSheet)), poster = true, onError = { failed.value = true }) - } else SlotImage(source, if (hero) WolfskinTokens.Card.posterW * 5 else width, if (hero) WolfskinTokens.Card.posterW * 5 * (9f / 16f) else height, Modifier.fillMaxSize(), poster = !landscape, onError = { failed.value = true }) + } else SlotImage(source, width, height, Modifier.fillMaxSize(), poster = !landscape, onError = { failed.value = true }) } else if (!hero) MediaArtworkFallback(title, type == "movie", showTitle = !landscape) else HomeAmbient(Modifier.fillMaxSize()) } diff --git a/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/WhosWatching.kt b/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/WhosWatching.kt index 48d6c5e6..224469bc 100644 --- a/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/WhosWatching.kt +++ b/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/WhosWatching.kt @@ -52,10 +52,9 @@ private fun PeoplePicker(state: ShellState) { } } } - val total = L.pickerAvatar * state.people.size + L.pickerGap * (state.people.size - 1) - val left = (S.stageWidth - total) / 2 + (L.pickerAvatar + L.pickerGap) * state.people.lastIndex + (L.pickerAvatar - L.gear) / 2 + val left = (S.stageWidth - L.gear) / 2 Box(Modifier.offset(left, S.pickerGearTop).size(L.gear).focusScale(state.gearFocused, WolfskinTokens.Focus.scaleCard) - .focusRing(state.gearFocused, L.gear / 2, WolfskinTokens.Focus.ringAvatarRing) + .focusRing(state.gearFocused, L.gear / 2, WolfskinTokens.Focus.ringPickerRing) .clickable { state.gearFocused = true; state.key(ShellKey.Ok) }, contentAlignment = Alignment.Center) { Glyph("gear", if (state.gearFocused) C.fg else C.dim.copy(alpha = C.dim.alpha * S.gearOpacity), Modifier.size(L.buttonIcon)) if (state.gearFocused) { diff --git a/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/WolfskinImages.kt b/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/WolfskinImages.kt index 4e0822bf..349fd2d2 100644 --- a/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/WolfskinImages.kt +++ b/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/WolfskinImages.kt @@ -1,6 +1,7 @@ package org.justinvisser.wolfskin.design import android.content.Context +import android.net.Uri import coil3.ComponentRegistry import coil3.ImageLoader import coil3.disk.DiskCache @@ -10,6 +11,7 @@ import coil3.request.crossfade import coil3.memory.MemoryCache import coil3.request.ImageRequest import coil3.request.allowRgb565 +import coil3.size.Scale import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.sync.Semaphore import kotlinx.coroutines.sync.withPermit @@ -61,7 +63,7 @@ object WolfskinImages { } fun request(context: Context, source: Any, width: Int, height: Int, poster: Boolean): ImageRequest = - ImageRequest.Builder(context).data(source).size(width, height).allowRgb565(poster).build() + ImageRequest.Builder(context).data(sourceForSize(source, width, height)).size(width, height).scale(Scale.FIT).allowRgb565(poster).build() fun prefetch(context: Context, row: HomeRow) { val count = when (row.cardKind) { HomeCardKind.Poster -> 7; HomeCardKind.Tile -> 5; HomeCardKind.Wide -> 4 } @@ -73,4 +75,35 @@ object WolfskinImages { if (source != null) loader(context).enqueue(request(context, source, width, height, poster)) } } + + private val jellyfinImagePath = Regex("/Items/[^/]+/Images/[^/]+(?:/\\d+)?", RegexOption.IGNORE_CASE) + private val tmdbSizePath = Regex("/t/p/(?:w\\d+|original)/") + private val dimensionParameters = setOf("width", "height", "maxwidth", "maxheight", "fillwidth", "fillheight") + + private fun sourceForSize(source: Any, width: Int, height: Int): Any { + if (source !is String) return source + val uri = Uri.parse(source) + if (uri.scheme != "https" && uri.scheme != "http") return source + if (uri.host == "image.tmdb.org") { + val size = if (height > width) when { + width <= 500 -> "w500" + width <= 780 -> "w780" + else -> "original" + } else when { + width <= 780 -> "w780" + width <= 1280 -> "w1280" + else -> "original" + } + return uri.buildUpon().path(uri.path?.replace(tmdbSizePath, "/t/p/$size/")).build().toString() + } + if (!jellyfinImagePath.matches(uri.path.orEmpty())) return source + val builder = uri.buildUpon().clearQuery() + for (name in uri.queryParameterNames) { + if (name.lowercase() !in dimensionParameters) { + uri.getQueryParameters(name).forEach { value -> builder.appendQueryParameter(name, value) } + } + } + return builder.appendQueryParameter("fillWidth", width.toString()) + .appendQueryParameter("fillHeight", height.toString()).build().toString() + } } diff --git a/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/WolfskinTokens.kt b/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/WolfskinTokens.kt index 4a9ec01c..fa730cd2 100644 --- a/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/WolfskinTokens.kt +++ b/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/WolfskinTokens.kt @@ -224,8 +224,12 @@ object WolfskinTokens { const val tileRequestHeight = 234 const val wideRequestWidth = 560 const val wideRequestHeight = 315 - const val heroRequestWidth = 1280 - const val heroRequestHeight = 720 + const val heroRequestWidth = 1920 + const val heroRequestHeight = 1080 + const val heroPosterRequestWidth = 420 + const val heroPosterRequestHeight = 630 + const val episodeRequestWidth = 400 + const val episodeRequestHeight = 280 const val castRequestWidth = 200 const val castRequestHeight = 200 const val memoryCacheMB = 64.0f @@ -416,6 +420,8 @@ object WolfskinTokens { const val sheetOpacity = 0.5f } object Title { + val episodeCardHeight = 140.0.dp + val seriesHeaderGap = 20.0.dp val lineGap = 11.0.dp val lineLift = -2.0.dp val overviewGap = 17.0.dp @@ -609,6 +615,7 @@ object WolfskinTokens { val posterProgressBottom = 6.0.dp } object Discover { + val shelfHeadingBand = 52.0.dp val sheetPaddingX = 32.0.dp val sheetPaddingY = 40.0.dp val sheetGap = 9.0.dp diff --git a/wolfskin-design/src/test/java/org/justinvisser/wolfskin/design/HomeViewportTest.kt b/wolfskin-design/src/test/java/org/justinvisser/wolfskin/design/HomeViewportTest.kt new file mode 100644 index 00000000..fe86448f --- /dev/null +++ b/wolfskin-design/src/test/java/org/justinvisser/wolfskin/design/HomeViewportTest.kt @@ -0,0 +1,95 @@ +package org.justinvisser.wolfskin.design + +import android.app.Application +import androidx.activity.ComponentActivity +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.setValue +import androidx.compose.ui.input.key.Key +import androidx.compose.ui.test.assertIsDisplayed +import androidx.compose.ui.test.junit4.createAndroidComposeRule +import androidx.compose.ui.test.onNodeWithTag +import androidx.compose.ui.test.onRoot +import androidx.compose.ui.test.performKeyInput +import androidx.compose.ui.test.pressKey +import com.github.takahirom.roborazzi.captureRoboImage +import java.io.File +import org.junit.Assert.assertTrue +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 + +/** Row ends, mixed card widths and real title Back navigation keep the selected art inside the view. */ +@RunWith(RobolectricTestRunner::class) +@Config(sdk = [34], application = Application::class, qualifiers = "w960dp-h540dp-xhdpi") +@GraphicsMode(GraphicsMode.Mode.NATIVE) +class HomeViewportTest { + @get:Rule val rule = createAndroidComposeRule() + + @Test fun rowEndsRowChangesAndTitleBackPreserveTheFocusedCardMargin() { + val cards = HomeFixture(rule.activity).snapshot("justin").rows.flatMap { it.cards }.distinctBy { it.id }.take(12) + val rows = HomeCardKind.entries.flatMap { kind -> + val slots = when (kind) { HomeCardKind.Poster -> 6; HomeCardKind.Tile -> 4; HomeCardKind.Wide -> 3 } + listOf(slots, cards.size).map { count -> HomeRow("${kind.name}-$count", HomeRowKind.Shelf, + "${kind.name} shelf", cards.take(count), kind) } + } + val snapshot = HomeSnapshot(rows) + val shell = ShellState(fixturePeople(), "justin").apply { pickerVisible = false } + var title by mutableStateOf(null) + rule.setContent { WolfskinTheme { + val page = title + if (page != null) TitleSurface(page, shell) + else ShellSurface(shell) { modifier -> HomeScreen(shell, snapshot, modifier) { action -> + if (action is HomeAction.Details) { + shell.enterDestination("title/viewport") + title = TitleState(TitlePageData(TitleItem(action.title.id, action.title.title, "movie", held = true)), shell.person) { effect -> + if (effect == TitleAction.Pop) { shell.enterDestination("home"); title = null } + } + } + } } + } } + for ((index, row) in rows.withIndex()) { + if (index > 0) press(Key.DirectionDown) + assertVisible(shell, rows) + repeat(row.count) { press(Key.DirectionLeft) } + assertVisible(shell, rows) + repeat(row.count - 1) { press(Key.DirectionRight) } + assertVisible(shell, rows) + capture("viewport-${row.id}") + press(Key.DirectionCenter) + rule.onNodeWithTag("title-page").assertIsDisplayed() + press(Key.Back) + assertVisible(shell, rows) + } + repeat(rows.lastIndex) { press(Key.DirectionUp); assertVisible(shell, rows) } + } + + private fun press(key: Key) { + rule.onRoot().performKeyInput { pressKey(key) } + rule.mainClock.advanceTimeBy(1000) + rule.waitForIdle() + } + + private fun assertVisible(shell: ShellState, rows: List) { + rule.mainClock.advanceTimeBy(1000) + rule.waitForIdle() + val row = rows[shell.view.row] + val column = shell.view.cards[row.id] ?: 0 + val bounds = rule.onNodeWithTag("home-card:${row.id}:$column").fetchSemanticsNode().boundsInRoot + val density = rule.density.density + val margin = WolfskinTokens.Card.posterGap.value * density + val stage = WolfskinTokens.Shell.stageWidth.value * density + assertTrue("Focused card is clipped: ${row.id}:$column $bounds", bounds.width >= row.cardWidth.value * density) + assertTrue("Focused card needs side margins: ${row.id}:$column $bounds", bounds.left >= margin && bounds.right <= stage - margin) + assertTrue("Focused row must clear the nav: ${row.id}:$column $bounds", bounds.top >= WolfskinTokens.Layout.navBand.value * density) + assertTrue("Focused card must clear the bottom: ${row.id}:$column $bounds", bounds.bottom <= WolfskinTokens.Shell.stageHeight.value * density - margin) + } + + private fun capture(name: String) { + val directory = File(System.getProperty("wolfskin.captureDirectory"), "home").apply { mkdirs() } + rule.onRoot().captureRoboImage(File(directory, "$name.png").absolutePath) + } +} diff --git a/wolfskin-design/title-tokens.json b/wolfskin-design/title-tokens.json index 5138e020..156054d0 100644 --- a/wolfskin-design/title-tokens.json +++ b/wolfskin-design/title-tokens.json @@ -1,5 +1,7 @@ { "title": { + "episodeCardHeight": {"dp": 140, "px": 280, "source": "feedback-2026-11-06: episode art height +24.4%"}, + "seriesHeaderGap": {"dp": 20, "px": 40, "source": "feedback-2026-11-06: measured series header to seasons"}, "lineGap": {"dp": 11, "px": 22, "source": "index.html:214"}, "lineLift": {"dp": -2, "px": -4, "source": "index.html:214"}, "overviewGap": {"dp": 17, "px": 34, "source": "index.html:216"}, diff --git a/wolfskin-design/tokens.css b/wolfskin-design/tokens.css index 9c7f17f1..423a5e4c 100644 --- a/wolfskin-design/tokens.css +++ b/wolfskin-design/tokens.css @@ -260,6 +260,8 @@ --lift-heroY: 30.0px; --lift-sheetBlur: 80.0px; --lift-sheetX: -30.0px; + --title-episodeCardHeight: 280.0px; + --title-seriesHeaderGap: 40.0px; --title-lineGap: 22.0px; --title-lineLift: -4.0px; --title-overviewGap: 34.0px; @@ -436,6 +438,7 @@ --home-savedIcon: 28.0px; --home-savedRadius: 28.0px; --home-posterProgressBottom: 12.0px; + --discover-shelfHeadingBand: 104.0px; --discover-sheetPaddingX: 64.0px; --discover-sheetPaddingY: 80.0px; --discover-sheetGap: 18.0px; diff --git a/wolfskin-design/tokens.json b/wolfskin-design/tokens.json index f8121dc4..70571dc3 100644 --- a/wolfskin-design/tokens.json +++ b/wolfskin-design/tokens.json @@ -1,5 +1,5 @@ { - "$description": "Wolfskin TV design tokens, spec revision 4: approved Level 2 card spacing and one accent straddle focus ring for cards, controls, avatars and picker people. Source: data/tv-v2/focus-2026-11-06/index.html. CSS px on a 1920x1080 stage at density 2.0; dp/sp = px / 2. Earlier prototype source coordinates remain provenance; revision 4 focus geometry is recorded explicitly in each focus ring token.", + "$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.", "meta": { "density": 2.0, "stagePx": [ @@ -12,7 +12,8 @@ ], "source": "data/tv-v2/focus-2026-11-06/index.html", "sourceSha256": "1bb7f4097a5303a58e45ec5e61cf867fa6cfdcbfe12fc7a196f00acacae5e48d", - "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" }, "color": { "bg": { @@ -1509,13 +1510,23 @@ }, "heroRequest": { "px": [ - 1280, - 720 + 1920, + 1080 ], "decode": "ARGB_8888", - "bytes": 3686400, - "source": "index.html:549", - "usedBy": "Home hero, Discover hero, title backdrop: the 1280x720 source, never upscaled; released when scrolled past" + "bytes": 8294400, + "source": "feedback-2026-11-06: panel-resolution artwork", + "usedBy": "Home hero, Discover hero and title backdrop; bounded 1920x1080 decode" + }, + "heroPosterRequest": { + "px": [420, 630], + "decode": "RGB565", + "source": "feedback-2026-11-06: largest upright hero poster slot" + }, + "episodeRequest": { + "px": [400, 280], + "decode": "ARGB_8888", + "source": "feedback-2026-11-06: taller episode cards" }, "castRequest": { "px": [ diff --git a/wolfskin-design/tokens.sha256 b/wolfskin-design/tokens.sha256 index b3fabec1..713ae2f4 100644 --- a/wolfskin-design/tokens.sha256 +++ b/wolfskin-design/tokens.sha256 @@ -1,8 +1,8 @@ -1f22175c1ce3987c4efa14d573601641a61e1a915e2c464c943b6c400356adb4 discover-tokens.json +b3f096b692c395534177cc82ac68f5b801870bc7e28f0dd5cd7c17e2103920a3 discover-tokens.json 85109c394255f6120a621fbefe8e58df82832974ccd6a6a975a61567f641ea2a generate_tokens.py 25985087986a46f00e2819917d0421ff2b437ec21c54f6ce6412b84c0eb05302 home-tokens.json be3af0ddcb0d67abef0805d19d7a33cdb55428bcace656b50f0a38935f212028 shell-tokens.json -c5ca037f08a0ab36139bf3805f1e0305e4c8e31e0d198cf056c791476950b96b src/main/java/org/justinvisser/wolfskin/design/WolfskinTokens.kt -8ae28b149e38a00952c07a6c27bb5df667101acf09b7b571b658774f453b2063 title-tokens.json -b482e5c532b50cffae9923b86efd03d40f1a56300e1c3c6d969b965f41ea1670 tokens.css -3b8b2112917dbd9fb16030cc1f3de2c35b5b6775136d16600b55ffd83936da69 tokens.json +bb64e45654fce2cd3ba595b505fb8b5a540526cef696dfa974be25f028d3e119 src/main/java/org/justinvisser/wolfskin/design/WolfskinTokens.kt +e0a2f5df2f249bce3f6e13f4a0320c7b18b85777eaf04a27a428d6683a6aad28 title-tokens.json +36d5b2646dff2e4055d2683e4c284111a6886c311e9c924b73061fa785bc863d tokens.css +3226c6fb9c7e35c5bde90aaf01630b238cfcb9a768ff60c49461e19a200c9125 tokens.json