#!/usr/bin/env python3 """Build the Wolfskin TV load-states / motion / speed spike page (dark only). Inputs : page/proto.css, page/proto.js, page/tv-motion-speed/* (webm clips from the box, fixture art), numbers below (all from data/tv-spike-motion-speed/raw/ and report.md). Output : /opt/stacks/pages/store/wolfskin/tv-motion-speed.html + tv-motion-speed/ assets (mode 660). """ import os, shutil HERE = os.path.dirname(os.path.abspath(__file__)) STORE = '/opt/stacks/pages/store/wolfskin/' OUT = STORE + 'tv-motion-speed.html' ASSETS = STORE + 'tv-motion-speed/' CSS = open(os.path.join(HERE, 'proto.css')).read() JS = open(os.path.join(HERE, 'proto.js')).read() def table(head, rows, cls=''): h = ''.join(f'{c}' for c in head) b = ''.join('' + ''.join(f'{c}' for c in r) + '' for r in rows) return f'
{h}{b}
' def pair(pid, scene, before, after_title, lead, note='', variants=False, before2=None): vids = ''.join(f'

{lab}

' for src, lab in ([before] + ([before2] if before2 else []))) var = f'
' if variants else '' return f'''
{f'{note}' if note else ''}
{vids}

{after_title}

{var}
''' def opt(title, gain, effort, risk, see, where='TV'): return [title, gain, effort, risk, see, where] OPT_HEAD = ['Option', 'Expected gain (measured basis)', 'Effort', 'Risk', 'What you would see', 'Ships in'] page = f'''Wolfskin TV: faster, quieter, smoother

Wolfskin TV: faster, quieter, smoother

Measured on your Xiaomi box with Wolfskin (Opt) 1.1.0-133 and from tank, on 2026-11-06. This page has the options, the plan, and live prototypes of the loading and motion. Each prototype sits next to a recording of the real app doing the same thing today.

The short version
Prototypes loop. "Replay together" restarts the recording and the prototype in step. Counters are ms after the screen first reacts to the key.

1. Where the time goes today

Per screen, from the first frame that reacts to the key until the screen stops changing

Method: on-device screenrecord (it only emits frames on change), scene-change detection with ffmpeg, logcat (OkHttp and ActivityManager) and Conductarr's own request log for the same seconds. "Settled" is the last visible change. Raw clips and sheets are in data/tv-spike-motion-speed/.

{table(['Screen', 'First content', 'Settled', 'What arrives late', 'Repeat visit'], [ ['Cold start (am start -W TotalTime, 4 runs)', '583 / 538 / 438 / 453 ms to first frame', 'picker at +0.3–0.4 s (DNS warm) or +1.65 s (DNS cold)', 'Spinner while Users/Me then System/Info/Public run one after the other (ServerRepository.kt:104,107)', 'same, bimodal on the 60 s DNS cache'], ['Who\'s watching → Home', 'hero text 135 ms', '430 ms', 'shelf tiles flat grey until 400 ms, then all images pop', '370 ms'], ['Home → Discover tab', 'content 270 ms (dims first)', '700 ms', 'posters pop in over 0.4 s', '600 ms'], ['Movies grid', '230 ms (blank tiles)', '750 ms', 'posters fill row by row', '500 ms'], ['Shows grid', '280 ms', '640 ms', 'posters', '—'], ['Title, movie in library (American Gangster)', '430 ms (text)', '2.69 s', 'backdrop 470 ms; cast portraits 2.7 s (initials until then)', '450 ms total'], ['Title, Discover pick (Donnie Brasco)', '980 ms (blank skeleton until then)', '1.53 s', 'backdrop 1.37 s, cast 1.5 s', '—'], ['Title, series (Adults)', '620 ms', '3.42 s', 'ratings and "Ready for you · by Mindel" land at 3.4 s and push the overview down', '—'], ['Person page (Martin Sheen)', '620 ms', '720 ms', 'filmography posters', '—'], ['Back from title / series / person', '260 / 300 / 450 ms', '', '', ''], ['Settings', '220 ms', '220 ms', '—', ''], ['Coming in sheet', '74 ms', '74 ms (from cache)', 'posters refresh silently', ''], ], 'num')}

Frame timing per interaction (dumpsys gfxinfo, ART status speed)

{table(['Interaction', 'Frames', 'Janky', 'p50', 'p90', 'p99', 'Main thread slow / GPU slow'], [ ['Home rows scroll (down 7, up 6, right 5), run 1 / 2', '149 / 115', '38.9 % / 34.8 %', '44 / 46 ms', '69 / 65', '89 / 101', '43 / 52 · 27 / 35'], ['Movies grid scroll (down 8, up 8), run 1 / 2', '180 / 159', '43.9 % / 32.1 %', '30 / 30 ms', '53 / 46', '93 / 73', '30 / 69 · 20 / 48'], ['Discover scroll', '146', '30.1 %', '34 ms', '57', '73', '34 / 35'], ['Tab switch ×4', '158', '18.4 %', '28 ms', '65', '150', '15 / 29'], ['Title open + back ×3', '103', '33.0 %', '48 ms', '113', '150', '29 / 34'], ['Who\'s watching → Home', '15', '53.3 %', '61 ms', '117', '133', '7 / 8'], ['Open title (movie, first time)', '20', '60.0 %', '57 ms', '150', '200', '9 / 12'], ['Back from person', '12', '75.0 %', '93 ms', '150', '150', '8 / 9'], ], 'num')}

Frame phases on the Shows grid (framestats, 68 frames): record p50 0.5 / p90 10.3 ms, GPU issue p50 4.3 / p90 8.9 ms, GPU complete p50 14.3 / p90 21.2 ms, total p50 25.6 / p90 41.4 ms. The app renders at 1920×1080 and the box outputs 3840×2160 at 59.94 Hz. Today's grid is worse than the earlier Opt measurement (17.7 % janky), which was taken before the spacing and focus-ring builds.

Network, from tank (10 runs each; cold = new connection, warm = kept-alive)

{table(['Call', 'Bytes', 'Cold p50 / p95', 'Warm p50 / p95', 'Cache headers'], [ ['Jellyfin UserViews (called 4–5× per launch)', '2.3 KB', '65 / 166 ms', '46 / 51 ms', 'none'], ['Home Resume (20)', '1.4 KB', '58 / 66', '30 / 41', 'none'], ['Home NextUp (20)', '6.2 KB', '71 / 91', '47 / 52', 'none'], ['Home Items/{{id}} per series (sequential)', '4.0 KB', '43 / 60', '24 / 28', 'none'], ['Home library shelf (20)', '18.7 KB', '85 / 116', '74 / 84', 'none'], ['Movies grid page (200)', '51 KB', '68 / 148', '61 / 64', 'none'], ['Title episodes with MediaSources + People', '96.8 KB', '142 / 185', '122 / 169', 'none'], ['Title episodes, lean fields', '6.5 KB', '26 / 65', '11 / 12', 'none'], ['Grid poster, no size (today, LibraryReadFacade.kt:86)', '626 KB', '26 / 30', '8 / 9', 'max-age 1 y, immutable, ETag'], ['Grid poster fillWidth 256×384 q96', '31.6 KB', '19 / 131', '2 / 4', 'same'], ['Grid poster 256×384 q80 WebP', '11.2 KB', '19 / 118', '1 / 2', 'same'], ['Backdrop 1280 q96 / q80 WebP', '91 / 31 KB', '19 / 25', '2 / 2', 'same'], ['TMDB poster w780 (today, ConductarrProjection.kt:9)', '131 KB', '128 / 144', '49 / 70', 'max-age 1 y'], ['TMDB poster w342 / w185', '41 / 15 KB', '100 / 76', '31 / 30', 'max-age 1 y'], ], 'num')}

Box-side DNS (ping timing, 6 samples 70 s apart): uncached jelly.justinvisser.org 490–1390 ms, wolfskin-tv 460–490 ms, image.tmdb.org 60–1170 ms; cached 40–50 ms. The box uses Private DNS dns.adguard.com. The LAN resolver 192.168.1.2 answers uncached names in 136–160 ms and cached in 0 ms. The A record behind both hosts has a 60 s TTL. Cold launches 1 and 3 were more than 60 s after the previous lookup, and 2 and 4 were within it.

Conductarr TV routes, server-side (Conductarr request log, 22 h, 200/304)

{table(['Route', 'n', 'p50', 'p95', 'Body p50', 'Why'], [ ['activity/titles/{{tmdb}}/{{type}} (Ready for you)', '45', '2856 ms', '5002 ms', '1.7 KB', 'Re-reads the held title twice, the Seerr requests and a Jellyfin GET per episode; no cache (activity/detail.go:21, request_evidence.go:45, users.go:262). The 5002 ms p95 is most likely the TV giving up: its read timeout is 5 s (ConductarrSession.kt:53), so on the slowest titles the line never appears'], ['catalogue/titles/{{tmdb}}/{{type}} (detail)', '51', '1198 ms', '3217 ms', '11.9 KB', 'Held-title read lists every Arr library unpaged, then Seerr details, then ratings; sequential (catalogue/detail.go:14-17, library_detail.go:81)'], ['catalogue/tv/{{tmdb}}/seasons/{{n}}', '76 / 17 (304)', '1084 / 2282 ms', '2664 ms', '1 KB', 'Same held-title read; a 304 still renders and hashes the full body first (read_validators.go)'], ['…/ratings', '265', '147 ms', '449 ms', '0.5 KB', '6 h server cache, 300 titles'], ['…/enrichment · …/related · …/cast', '46 · 42 · 26', '103 · 124 · 33 ms', '465 · 261 · 78', '3 · 14 · 12.5 KB', 'enrichment uncached'], ['recommendations feed', '42', '16 ms', '40 ms', '204 KB, uncompressed', 'no gzip/br on the TV door (Caddyfile.base:316-333, serve.go:140)'], ['activity (polled every 30 s)', '713 × 304', '52 ms', '83 ms', '62.5 KB when 200', ''], ['operator · profiles · dismissals · options · display', '', '12–25 ms', '30–93 ms', '', 'cheap; display is called ≈1 400×/day, once per poster'], ], 'num')}

Every TV request also costs Conductarr an uncached Jellyfin /Users/Me to check the token (clients/jellyfin/token.go:42). Every authenticated response is Cache-Control: no-store (authority.go:32). The TV client revalidates with its own ETag store instead.

Caches the app has today

{table(['Cache', 'Where', 'Behaviour', 'Observed'], [ ['Wolfskin image loader (Coil 3)', 'WolfskinImages.kt:46-61', '64 MiB memory, 128 MiB disk, 4 parallel reads, crossfade off', 'repeat visits paint art from memory; first grid visit downloads 626 KB per poster (no size)'], ['Upstream Wholphin image loader', 'CoilConfig.kt:80-101', 'separate memory + 200 MB disk', 'a second copy of the same art in memory'], ['Conductarr JSON', 'FileCacheStore, noBackupFilesDir/conductarr', 'saved copy shown first, then revalidated with ETag; no memory layer', 'title re-open: every ON_OPEN route re-fetched (cast 304, detail 875 ms again)'], ['Home, library, title, person snapshots', 'filesDir/wolfskin-home, -library-cache, -title', 'disk JSON, painted first', 'repeat visits 370–500 ms instead of 430–750'], ['HTTP cache (OkHttp)', 'none', '—', 'Jellyfin JSON never cached; images rely on Coil only'], ['DNS', 'system only', '60 s TTL through Private DNS', '1.5 s launch spinner when expired'], ])}

2. Load states

Today: a spinner on launch, flat grey blocks on Home, the grids and title pages, and a blank title page until Conductarr answers on Discover picks. Images snap in because crossfade is off in both loaders. Nothing shows a "loading" sentence; that rule already holds.

{table(OPT_HEAD, [ opt('Picker from the saved people list, verify in the background', 'Removes the 1.5 s spinner on every DNS-cold launch (2 of 4 measured)', 'S', 'Low. A revoked session must still land on the recovery picker', 'The launcher fades straight into Who\'s watching'), opt('Shaped skeletons that breathe (opacity only, staggered)', 'No speed change; replaces the flat grey and spinner states', 'S', 'Low. One alpha layer per row', 'Card-shaped tiles gently pulsing until the art lands'), opt('Art fades in (120–160 ms) instead of popping', 'Removes the pop on every first visit', 'XS', 'Low. Keep it off for memory-cache hits so repeat visits stay instant', 'Posters settle instead of flickering in'), opt('Reserve space for late fields (ratings, Ready for you, genres, overview)', 'No layout jump at 3.4 s on series pages', 'S', 'Low', 'The line fades into a gap that was already there'), opt('Title page paints what the card already knew', 'First content 430–980 ms → about 60 ms', 'M', 'Medium. Card data must match detail (year, runtime, ratings)', 'Title, year, ratings and Play at once, over a colour wash of the poster'), opt('Shimmer sweep skeletons', 'None', 'S', 'High on this GPU: a moving gradient over every card, every frame', 'Light sweeps across the tiles. Not recommended'), ])}

See it: launch

{pair('p-launch', 'launch', ('before-launch-spinner', 'Today, DNS cold (2 of 4 launches): 1.5 s spinner'), 'After: no spinner, people from the saved list', 180, '', False, ('before-launch-fast', 'Today, DNS warm'))}

See it: skeleton styles

Pick a style. Content lands at 900 ms in each, about the Movies grid's first visit.

{pair('p-skel', 'skeleton', ('before-movies-grid', 'Today: Movies grid, first visit'), 'Options', 140, '', True)}

3. Motion and transitions

Today's motion tokens: focus scale 1.08 over 140 ms, ring and caption fades of 140 ms, a nav blob spring, and 160–180 ms row scrolls. There are no screen transitions or hero cross-fades, and reduced motion is defined but nothing reads it (WolfskinTokens.kt:218). At 30–46 ms per frame, a 140 ms tween is drawn in 3–4 frames. Tick box frame rate at the top to see that.

{table(OPT_HEAD, [ opt('Staged reveal on screen entry (hero copy rises 16 px, rows rise 24 px, staggered 30–40 ms, 180 ms ease-out)', 'Perceived: art arrives into motion instead of popping. Cost: translate + alpha on one layer per row', 'S', 'Low. Stagger must not delay focus', 'Home and Discover settle like a curtain rather than tiles snapping'), opt('Hero cross-fade (backdrop 320 ms, copy hands off)', 'Removes the hard cut when the hero changes', 'S', 'Medium. Two 1080p bitmaps for 320 ms; the GPU is already at 14–21 ms/frame, so fade the backdrop only and keep the scrim static', 'The next pick dissolves in'), opt('Focus: 180 ms ease-out, caption rises 10 px', 'Same frame cost as today; the curve hides dropped frames better', 'XS', 'Low', 'Focus glides rather than ticks'), opt('Title entry: grid fades 160 ms, page builds from the poster colour', 'Hides the 470 ms backdrop fetch', 'M', 'Medium (see load states)', 'No black gap between grid and title'), opt('Shared-element poster → title sheet', 'None measurable', 'L', 'High. Compose shared elements across NavHost destinations on this GPU; the movie page has no poster sheet when a backdrop exists', 'The card flies into place. Not recommended now'), opt('Honour reduced motion (system animator scale 0 or a Wolfskin toggle)', 'Accessibility', 'S', 'Low', 'Fades only, ≤100 ms; the toggle at the top shows it'), ])}

See it: Who's watching → Home

{pair('p-home', 'home', ('before-home-enter', 'Today: grey tiles, then art pops at 400 ms'), 'After', 200)}

See it: tab switch and row reveal (Home → Discover)

{pair('p-tab', 'tab', ('before-discover-tab', 'Today'), 'After', 150)}

See it: title page entry

{pair('p-title', 'title', ('before-title-movie', 'Today: American Gangster, first open (cast at 2.7 s)'), 'After: Apocalypse Now from the same grid', 170, '', False, ('before-title-discover', 'Today: Donnie Brasco from Discover, blank until 980 ms'))}

See it: a late line on a series page

{pair('p-series', 'series', ('before-title-series', 'Today: Adults, the readiness line lands at 3.4 s and pushes the text down'), 'After', 150, '', True)}

See it: focus move

{pair('p-focus', 'focus', ('before-movies-grid', 'Today, real grid'), 'Focus along a row', 0, 'Tick box frame rate to compare at 25 fps.', True)}

See it: hero cross-fade

{pair('p-hero', 'hero', ('before-discover-tab', 'Today (Discover hero)'), 'Hero advances', 0, '', True)}

4. Caching

{table(OPT_HEAD, [ opt('Persisted DNS answers (OkHttp Dns: serve the last address at once, refresh behind)', 'Removes 0.45–1.35 s on launch and on the first TMDB image after any 60 s pause', 'S', 'Low. Fall back to the system lookup if the cached address fails', 'No spinner on launch; first cast photos in ≈0.6 s instead of 2.7 s [inference: DNS is the cast delay]'), opt('Warm the connections at process start (Jellyfin, Conductarr door, TMDB) in parallel', 'Overlaps DNS + TLS with the 450 ms first frame', 'XS', 'Low', 'Same as above, smaller'), opt('Memory layer over the Conductarr disk cache', 'Skips disk read and JSON decode on back and re-open (title re-open settles at 450 ms today; ~250 expected) [inference]', 'S', 'Low', 'Back and re-open feel instant'), opt('Stale-while-revalidate for ON_OPEN title routes (skip refetch if validated in the last 5 min)', 'Re-opening a title stops costing the server 875–2 900 ms', 'S', 'Low. Requests and readiness may be 5 min old; keep requests on FORCE', 'Nothing visible; less churn'), opt('Prefetch the focused card\'s title page (detail JSON + backdrop) after 350 ms of dwell', 'Title first content 430 ms → about 60 ms; backdrop already decoded', 'M', 'Medium. Bandwidth: at most one title per dwell; cancel on move', 'Opening a title is instant'), opt('Pre-decode last-known Home art while Who\'s watching is up', 'Home settles at 430 ms → about 250 ms with no pops', 'S', 'Low. Memory: 6 backdrops ≈ 21 MiB of the 64 MiB', 'Home appears whole'), opt('One image loader instead of two', 'Frees a second memory cache (Coil default ≈ 25 % of the heap) [inference]', 'M', 'Medium. Upstream screens use their own', 'Fewer re-downloads after big screens'), opt('OkHttp HTTP cache for Jellyfin JSON', 'Small. Jellyfin sends no cache headers, so only conditional revalidation is possible', 'S', 'Low', 'Nothing visible'), ])}

5. Backend retrieval

{table(OPT_HEAD, [ opt('Cache the held-title read in Conductarr (Arr library list, queue, history) for 30–60 s, shared across routes', 'Title detail 1.2 s → ≈0.1 s, season 1.1 s → ≈0.1 s, readiness 2.9 s → <1 s [inference from the code path]', 'M', 'Medium. Freshness of "Ready" after an import; invalidate on Arr webhook (already received)', 'Series pages settle in under a second; no late line', 'Conductarr release'), opt('Readiness: drop the duplicate held-title read and the per-episode Jellyfin GETs', 'The 5 s tail on readiness', 'M', 'Medium', '"Ready for you" appears with the page', 'Conductarr release'), opt('Compress the TV door (Caddy encode zstd gzip)', 'Feed 204 KB → ~25–35 KB [inference: JSON typically 6–8×], activity 62 KB → ~8 KB', 'XS', 'Low', 'Discover opens a little faster on Wi-Fi', 'Caddy change (server-docs)'), opt('304 before rendering (keep a per-route version, not a hash of the full body)', 'Season 304 2.3 s → ms', 'M', 'Medium', 'Nothing visible except speed', 'Conductarr release'), opt('Cache the token check (/Users/Me) for 60 s per token', 'Removes a Jellyfin round trip from every TV request (~10–40 ms each, 6–10 per title page)', 'S', 'Low. Revocation lag 60 s', 'Nothing visible', 'Conductarr release'), opt('Batch display reads (one call for a row of posters)', '≈1 400 display calls/day → a few dozen', 'M', 'Low', 'Coming in and Discover rows fill together', 'Conductarr release + TV'), opt('Size the grid posters (fillWidth=256&fillHeight=384&quality=80&format=Webp)', '626 KB → 11 KB per poster (57×); 85 films 53 MB → 1 MB', 'XS', 'Low', 'Movies and Shows fill at once on first visit', 'TV'), opt('Ask TMDB for w342 posters, not w780', '131 KB → 41 KB per Discover poster', 'XS', 'Low. 342 px for a 256 px card', 'Discover rows fill sooner', 'TV'), opt('Lean episode fields (drop MediaSources/MediaStreams/People from the list; fetch them on Play)', '96.8 KB → 6.5 KB per season, 142 → 26 ms', 'S', 'Medium. The episode row shows file chips', 'Series pages settle sooner', 'TV'), opt('Fewer launch calls: UserViews 4–5× and LiveTv/Recordings/Folders 2× per launch', '≈250 ms of serial calls', 'S', 'Low', 'Nothing visible', 'TV'), ])}

6. Kotlin and Compose

{table(OPT_HEAD, [ opt('Library load re-emits the full list per 200-item page and re-sorts each time (LibraryReadFacade.kt:96, LibraryGridState.kt:99)', 'Grid first visit; matters more as the library grows', 'S', 'Low', 'Grid appears in one step', 'TV'), opt('Hoist the Regex in titleFallbackBackground / titleAmbientBackground (TitleArtwork.kt:152,167)', 'Draw-time cost per card', 'XS', 'Low', '—', 'TV'), opt('Discover reads firstVisibleItemIndex in composition (DiscoverScreen.kt:107) → derivedStateOf', 'Fewer recompositions while scrolling Discover (34 of 44 janky frames were UI-thread)', 'XS', 'Low', 'Smoother Discover scroll', 'TV'), opt('NavBar label colour read in composition (NavBar.kt:86) → draw-phase', 'Tab switch frames', 'XS', 'Low', '—', 'TV'), opt('WolfskinImages.loader() is @Synchronized and called in composition', 'Lock on every poster recomposition', 'XS', 'Low', '—', 'TV'), opt('Cut full-screen layers: hero blur (RenderEffect) + two full-screen gradient scrims + ambient', 'GPU 14–21 ms/frame is the floor today; pre-blurred tiny art and one combined scrim drawn once', 'M', 'Medium. Must match the approved frames', 'Smoother scroll on Home and Discover', 'TV'), opt('Keys and contentType', 'Already done everywhere', '—', '—', '—', ''), ])}

7. Baseline profile

Today: a hand seed of 6 lines (app/src/optimized/baselineProfiles/baseline-prof.txt), no generator and no Macrobenchmark. The box is at speed only because it was forced with cmd package compile. Every new install starts at verify (interpreted/JIT) until background dexopt runs.

{table(['State (same box, earlier tv-perf run)', 'Cold start', 'Home scroll janky / p50 / p90', 'Movies grid'], [ ['Opt just installed (verify)', '0.5–1.0 s', '51.4 % / 65 / 117 ms', '—'], ['Opt after forced AOT (speed)', '0.39–0.44 s', '29.2 % / 38 / 61 ms', '17.7 % / 19 / 36 ms'], ], 'num')}

Expected from a recorded profile (Baseline Profile Gradle plugin plus one UiAutomator journey through picker, Home, Discover, the grids, a title and a person page): a fresh install behaves like the AOT row from the first launch. That is roughly −40 % janky frames on Home scroll and up to half a second off cold start, for every build the second mate installs. It does not change steady state on a box already at speed. Effort M: the journey can run on an emulator in the build container; the box itself isn't needed. Risk low.

8. Recommended plan

  1. DNS and launch (TV, S, ~1 day). Persisted DNS answers plus parallel connection warm-up at process start. Show the picker from saved people and check the session in the background. Prove it: 6 cold launches spaced >60 s apart, picker at ≤0.5 s every time (today 1.65 s in half); first cast photos on a new title ≤1 s.
  2. Image sizes (TV, XS, hours). Sized WebP grid posters, TMDB w342, lean episode fields. Prove it: first Movies visit settles ≤400 ms (today 750); tank payload table.
  3. Load states and motion (TV, M, 2–3 days, against your pick on this page). Breathing shaped skeletons, art fade-in for network loads only, reserved late-line slots, staged reveal, hero cross-fade, reduced motion. Prove it: recordings like the ones above, no layout change after first content, gfxinfo janky no worse than today.
  4. Prefetch and memory cache (TV, M, 2 days). Dwell prefetch of title pages, a memory layer over the Conductarr cache, pre-decoded Home art behind the picker. Prove it: title first content ≤100 ms and Home settled ≤250 ms.
  5. Conductarr release (M–L, 2–4 days): held-title cache with webhook invalidation, readiness without duplicate reads or per-episode GETs, 304 before render, token-check cache. Caddy: compress the TV door. Prove it: the route table above for 24 h, with detail, season and readiness p50 ≤200 ms and p95 ≤600 ms, and the feed on the wire ≤40 KB.
  6. Baseline profile (TV build, M, 1–2 days). Recorded journey profile. Prove it: fresh install without a forced compile, Home scroll janky ≤30 %, cold start ≤0.5 s.
  7. Frame cost (TV, M, 2–3 days). The Compose fixes in section 6 and fewer full-screen layers. Prove it: Home and grid scroll janky ≤20 %, GPU p90 ≤16 ms in framestats.

Total: about 2 weeks of TV work and 3–4 days for a Conductarr release. Steps 1–2 alone remove the two waits you can feel today (the launch spinner and grids filling slowly), in about a day and a half.

A box-side alternative to step 1's DNS part: switch the box's Private DNS from dns.adguard.com to Automatic, so it uses the LAN resolver (136–160 ms uncached, 0 ms cached). That changes the whole box, not just Wolfskin, so it's your call; the TV-side fix works either way.

9. Your picks (all of them on one page)

Six decisions. Each locks the moment you send it and the build lanes start on it. Nothing here touches the TV while you are playing.

1. Load-state and motion direction

Decides plan step 3 (skeletons and motion). The prototypes above show each part.

2. The two quick speed fixes (about a day and a half, TV only)

(a) DNS cache with stale-serve plus a picker painted from saved people, so the launch spinner goes away; (b) sized WebP grid posters, smaller TMDB art, lean episode fields, so grids fill fast. Both ship in the next TV build.

3. Change the box Private DNS (your call, system setting)

Today the box uses dns.adguard.com: an uncached lookup costs 450 to 1350 ms. Automatic uses the LAN resolver (136 to 160 ms uncached, 0 ms cached). It changes DNS for the whole box, not just Wolfskin. I do it with ADB only when the TV is free and you say so; the TV-side fix in 2(a) works either way.

4. Later plan steps to queue (TV, after the build above)

Prefetch and memory cache (title pages, 2 days); recorded Baseline Profile (1 to 2 days); frame cost and Compose fixes (2 to 3 days). Each has its own proof target in section 8.

5. Conductarr title-read speed-up (needs a Conductarr release)

Held-title cache with Arr webhook invalidation, readiness without duplicate reads or per-episode calls, 304 before render, token-check cache, compression on the TV door. 3 to 4 days, plus a release and a server-docs update. Title pages are slow because of this (readiness p50 2.9 s).

6. When should these land on the TV?

You said you will not be using the TV during this session.

Method, raw numbers and files: data/tv-spike-motion-speed/report.md. Nothing was changed in the app or on the server. The box was left on the Movies tab.

''' os.makedirs(ASSETS, exist_ok=True) src = os.path.join(HERE, 'tv-motion-speed') for f in os.listdir(src): shutil.copyfile(os.path.join(src, f), ASSETS + f) os.chmod(ASSETS + f, 0o660) with open(OUT, 'w') as fh: fh.write(page) os.chmod(OUT, 0o660) print(OUT, len(page))