All checks were successful
review/code-claude Code review passed on 16684454, no blocking finding
review/design-claude Design review pass: motion numbers equal direction A; 6 non-blocking notes
298 lines
40 KiB
Python
Executable file
298 lines
40 KiB
Python
Executable file
#!/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'<th>{c}</th>' for c in head)
|
||
b = ''.join('<tr>' + ''.join(f'<td>{c}</td>' for c in r) + '</tr>' for r in rows)
|
||
return f'<div class="tw"><table class="{cls}"><thead><tr>{h}</tr></thead><tbody>{b}</tbody></table></div>'
|
||
|
||
|
||
def pair(pid, scene, before, after_title, lead, note='', variants=False, before2=None):
|
||
vids = ''.join(f'<div class="pane"><p class="lab">{lab}</p><div class="sbox"><video src="tv-motion-speed/{src}.webm" muted loop playsinline autoplay preload="auto"></video></div></div>'
|
||
for src, lab in ([before] + ([before2] if before2 else [])))
|
||
var = f'<div class="tabs" data-variants="{pid}"></div>' if variants else ''
|
||
return f'''<div class="pair"><div class="ph"><button type="button" class="replay">Replay together</button>{f'<span class="muted small">{note}</span>' if note else ''}</div>
|
||
<div class="panes">{vids}<div class="pane"><p class="lab">{after_title}</p>{var}<div class="sbox" id="{pid}" data-scene="{scene}" data-lead="{lead}"></div></div></div></div>'''
|
||
|
||
|
||
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'''<!doctype html><html lang="en" data-lavish-annotate="off"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1"><meta name="color-scheme" content="dark"><title>Wolfskin TV: faster, quieter, smoother</title><style>
|
||
:root{{color-scheme:dark;--bg:#07070a;--panel:#13131a;--line:rgba(255,255,255,.1);--fg:#f4f4f6;--muted:rgba(244,244,246,.74);--acc:oklch(78% 0.14 305);--ok:#7ed6a0;--warn:#ffc46b;--bad:#ff8a7a}}
|
||
*{{box-sizing:border-box}}html{{-webkit-text-size-adjust:100%}}body{{margin:0;background:var(--bg);color:var(--fg);font:16px/1.5 system-ui,-apple-system,Segoe UI,sans-serif}}
|
||
main{{max-width:1400px;margin:0 auto;padding:16px 14px 120px}}h1{{font-size:28px;line-height:1.2;margin:6px 0 6px}}h2{{font-size:21px;margin:34px 0 8px;color:var(--acc);border-bottom:1px solid var(--line);padding-bottom:4px}}
|
||
h3{{font-size:17px;margin:20px 0 6px}}a{{color:var(--acc)}}.muted{{color:var(--muted)}}.small{{font-size:13px}}p{{margin:6px 0}}li{{margin:3px 0}}
|
||
.lead{{font-size:17px}}.k{{color:var(--ok)}}.w{{color:var(--warn)}}.b{{color:var(--bad)}}
|
||
.tw{{overflow-x:auto;margin:8px 0;border:1px solid var(--line);border-radius:10px}}table{{border-collapse:collapse;width:100%;font-size:14px}}
|
||
th,td{{padding:6px 9px;border-top:1px solid var(--line);text-align:left;vertical-align:top}}thead th{{border-top:0;background:#1b1a22;white-space:nowrap}}
|
||
td:first-child{{font-weight:600}}table.num td:not(:first-child){{white-space:nowrap}}
|
||
.card2{{background:var(--panel);border:1px solid var(--line);border-radius:12px;padding:12px 16px;margin:10px 0}}
|
||
.pair{{margin:14px 0 26px}}.ph{{display:flex;gap:12px;align-items:center;flex-wrap:wrap;margin:0 0 6px}}
|
||
.panes{{display:grid;grid-template-columns:repeat(auto-fit,minmax(min(380px,100%),1fr));gap:12px}}.pane{{min-width:0}}.lab{{font-size:13px;color:var(--muted);margin:0 0 4px}}
|
||
.decision{{padding:18px 22px;background:#13131a;border:1px solid var(--line);border-radius:14px;margin:14px 0}}.decision h3{{margin-top:0}}
|
||
.opt{{display:block;border:1px solid var(--line);border-radius:10px;padding:9px 12px;margin:7px 0;cursor:pointer;background:#100e16}}.opt:has(input:checked){{background:oklch(26% 0.07 305);border-color:var(--acc)}}
|
||
.opt .t{{display:block;color:var(--muted);font-size:14px;margin-left:24px}}.rec{{font-size:12px;color:var(--ok);margin-left:6px}}
|
||
textarea{{width:100%;min-height:70px;background:#0c0b10;color:var(--fg);border:1px solid var(--line);border-radius:8px;padding:8px;font:inherit;margin-top:6px}}
|
||
.tabs{{display:flex;gap:6px;flex-wrap:wrap;margin:0 0 6px}}
|
||
.tab,button.replay{{font:inherit;font-size:13px;border-radius:999px;padding:4px 11px;border:1px solid var(--line);background:#1b1a22;color:var(--fg);cursor:pointer}}.tab.on{{background:oklch(26% 0.07 305);border-color:var(--acc)}}
|
||
button.replay{{background:oklch(26% 0.07 305);border-color:var(--acc)}}
|
||
form.decision button[type=submit]{{font:inherit;font-size:15px;font-weight:600;border-radius:999px;padding:8px 18px;border:1px solid var(--acc);background:oklch(26% 0.07 305);color:var(--fg);cursor:pointer;margin-top:8px}}
|
||
form.decision [role=status]{{font-size:13px;margin-left:10px;opacity:.8}}
|
||
.ctl{{position:sticky;top:0;z-index:8;background:#07070aee;backdrop-filter:blur(6px);border-bottom:1px solid var(--line);padding:8px 0;margin:0 0 8px;display:flex;gap:18px;flex-wrap:wrap;align-items:center;font-size:14px}}
|
||
.ctl label{{display:inline-flex;gap:6px;align-items:center;cursor:pointer}}
|
||
ol.plan>li{{margin:10px 0}}code{{font-size:13px;color:oklch(88% 0.08 305)}}
|
||
{CSS}
|
||
</style></head><body><main>
|
||
<h1>Wolfskin TV: faster, quieter, smoother</h1>
|
||
<p class="lead">Measured on your Xiaomi box with <b>Wolfskin (Opt)</b> 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.</p>
|
||
|
||
<div class="card2"><b>The short version</b><ul>
|
||
<li><b>The biggest wait isn't the app.</b> Two of four cold launches showed a spinner for <b>1.5 s</b>, and the other two showed none. The box looks up <code>jelly.justinvisser.org</code> through AdGuard Private DNS, and an uncached lookup takes <b>0.45–1.35 s</b>. The address only stays cached for 60 s, so the first launch after a minute's pause pays it, and so does the first title page that fetches TMDB art. A small TV-side DNS cache (or one box setting) removes it.</li>
|
||
<li><b>The second biggest is Conductarr.</b> On a title page, the detail, season and "Ready for you" reads take <b>1.1 s, 1.1 s and 2.9 s</b> at the median. Each one re-lists the whole Radarr/Sonarr library on every request, with no cache. A title page can't settle until the slowest arrives (3.4 s on a series). This needs a Conductarr release.</li>
|
||
<li><b>Screens are already quick, but they pop.</b> Home, the grids, Discover and Settings settle in <b>0.2–0.75 s</b>. What makes the wait visible is art and fields arriving in steps, and the layout jumping when a late line lands. That's fixable on the TV alone: fade art in, reserve space for late lines, keep last-known screens, and prefetch.</li>
|
||
<li><b>Frames are slow for two reasons.</b> At the median a frame takes 30–46 ms during moves, where 60 fps needs 16.7 ms. On grid and row scrolls, <b>30–44 %</b> of frames miss. The GPU alone takes 14–21 ms per frame, so motion here has to be cheap: fades, small rises and scale on one layer. No full-screen blur or shimmer sweeps.</li>
|
||
</ul></div>
|
||
|
||
<div class="ctl"><label><input type="checkbox" id="boxrate"> Play prototypes at the box's frame rate (40 ms per frame, measured)</label><label><input type="checkbox" id="rm"> Reduced motion (fades only, ≤100 ms)</label><span class="muted small">Prototypes loop. "Replay together" restarts the recording and the prototype in step. Counters are ms after the screen first reacts to the key.</span></div>
|
||
|
||
<h2>1. Where the time goes today</h2>
|
||
<h3>Per screen, from the first frame that reacts to the key until the screen stops changing</h3>
|
||
<p class="muted small">Method: on-device <code>screenrecord</code> (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 <code>data/tv-spike-motion-speed/</code>.</p>
|
||
{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 <b class="b">+1.65 s</b> (DNS cold)', 'Spinner while <code>Users/Me</code> then <code>System/Info/Public</code> 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)', '<b class="w">2.69 s</b>', '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', '<b class="b">3.42 s</b>', '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')}
|
||
|
||
<h3>Frame timing per interaction (dumpsys gfxinfo, ART status <code>speed</code>)</h3>
|
||
{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')}
|
||
<p class="muted small">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 <b>14.3</b> / p90 <b>21.2</b> 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.</p>
|
||
|
||
<h3>Network, from tank (10 runs each; cold = new connection, warm = kept-alive)</h3>
|
||
{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', '<b class="w">96.8 KB</b>', '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)', '<b class="b">626 KB</b>', '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')}
|
||
<p class="muted small">Box-side DNS (ping timing, 6 samples 70 s apart): uncached <code>jelly.justinvisser.org</code> 490–1390 ms, <code>wolfskin-tv</code> 460–490 ms, <code>image.tmdb.org</code> 60–1170 ms; cached 40–50 ms. The box uses Private DNS <code>dns.adguard.com</code>. 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.</p>
|
||
<h3>Conductarr TV routes, server-side (Conductarr request log, 22 h, 200/304)</h3>
|
||
{table(['Route', 'n', 'p50', 'p95', 'Body p50', 'Why'], [
|
||
['activity/titles/{{tmdb}}/{{type}} (Ready for you)', '45', '<b class="b">2856 ms</b>', '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', '<b class="b">1198 ms</b>', '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', '<b class="w">204 KB, uncompressed</b>', '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')}
|
||
<p class="muted small">Every TV request also costs Conductarr an uncached Jellyfin <code>/Users/Me</code> to check the token (clients/jellyfin/token.go:42). Every authenticated response is <code>Cache-Control: no-store</code> (authority.go:32). The TV client revalidates with its own ETag store instead.</p>
|
||
|
||
<h3>Caches the app has today</h3>
|
||
{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'],
|
||
])}
|
||
|
||
<h2>2. Load states</h2>
|
||
<p>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.</p>
|
||
{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', '<b class="w">High on this GPU</b>: a moving gradient over every card, every frame', 'Light sweeps across the tiles. Not recommended'),
|
||
])}
|
||
<h3>See it: launch</h3>
|
||
{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'))}
|
||
<h3>See it: skeleton styles</h3>
|
||
<p class="muted small">Pick a style. Content lands at 900 ms in each, about the Movies grid's first visit.</p>
|
||
{pair('p-skel', 'skeleton', ('before-movies-grid', 'Today: Movies grid, first visit'), 'Options', 140, '', True)}
|
||
|
||
<h2>3. Motion and transitions</h2>
|
||
<p>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 <b>box frame rate</b> at the top to see that.</p>
|
||
{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', '<b class="w">High</b>. 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'),
|
||
])}
|
||
<h3>See it: Who's watching → Home</h3>
|
||
{pair('p-home', 'home', ('before-home-enter', 'Today: grey tiles, then art pops at 400 ms'), 'After', 200)}
|
||
<h3>See it: tab switch and row reveal (Home → Discover)</h3>
|
||
{pair('p-tab', 'tab', ('before-discover-tab', 'Today'), 'After', 150)}
|
||
<h3>See it: title page entry</h3>
|
||
{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'))}
|
||
<h3>See it: a late line on a series page</h3>
|
||
{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)}
|
||
<h3>See it: focus move</h3>
|
||
{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)}
|
||
<h3>See it: hero cross-fade</h3>
|
||
{pair('p-hero', 'hero', ('before-discover-tab', 'Today (Discover hero)'), 'Hero advances', 0, '', True)}
|
||
|
||
<h2>4. Caching</h2>
|
||
{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'),
|
||
])}
|
||
|
||
<h2>5. Backend retrieval</h2>
|
||
{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 <code>encode zstd gzip</code>)', '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 (<code>/Users/Me</code>) 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 (<code>fillWidth=256&fillHeight=384&quality=80&format=Webp</code>)', '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'),
|
||
])}
|
||
|
||
<h2>6. Kotlin and Compose</h2>
|
||
{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', '—', '—', '—', ''),
|
||
])}
|
||
|
||
<h2>7. Baseline profile</h2>
|
||
<p>Today: a hand seed of 6 lines (<code>app/src/optimized/baselineProfiles/baseline-prof.txt</code>), no generator and no Macrobenchmark. The box is at <code>speed</code> only because it was forced with <code>cmd package compile</code>. Every new install starts at <code>verify</code> (interpreted/JIT) until background dexopt runs.</p>
|
||
{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')}
|
||
<p><b>Expected from a recorded profile</b> (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 <b>not</b> change steady state on a box already at <code>speed</code>. Effort M: the journey can run on an emulator in the build container; the box itself isn't needed. Risk low.</p>
|
||
|
||
<h2>8. Recommended plan</h2>
|
||
<ol class="plan">
|
||
<li><b>DNS and launch (TV, S, ~1 day).</b> Persisted DNS answers plus parallel connection warm-up at process start. Show the picker from saved people and check the session in the background. <i>Prove it:</i> 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.</li>
|
||
<li><b>Image sizes (TV, XS, hours).</b> Sized WebP grid posters, TMDB w342, lean episode fields. <i>Prove it:</i> first Movies visit settles ≤400 ms (today 750); tank payload table.</li>
|
||
<li><b>Load states and motion (TV, M, 2–3 days, against your pick on this page).</b> Breathing shaped skeletons, art fade-in for network loads only, reserved late-line slots, staged reveal, hero cross-fade, reduced motion. <i>Prove it:</i> recordings like the ones above, no layout change after first content, gfxinfo janky no worse than today.</li>
|
||
<li><b>Prefetch and memory cache (TV, M, 2 days).</b> Dwell prefetch of title pages, a memory layer over the Conductarr cache, pre-decoded Home art behind the picker. <i>Prove it:</i> title first content ≤100 ms and Home settled ≤250 ms.</li>
|
||
<li><b>Conductarr release (M–L, 2–4 days):</b> held-title cache with webhook invalidation, readiness without duplicate reads or per-episode GETs, 304 before render, token-check cache. <b>Caddy</b>: compress the TV door. <i>Prove it:</i> 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.</li>
|
||
<li><b>Baseline profile (TV build, M, 1–2 days).</b> Recorded journey profile. <i>Prove it:</i> fresh install without a forced compile, Home scroll janky ≤30 %, cold start ≤0.5 s.</li>
|
||
<li><b>Frame cost (TV, M, 2–3 days).</b> The Compose fixes in section 6 and fewer full-screen layers. <i>Prove it:</i> Home and grid scroll janky ≤20 %, GPU p90 ≤16 ms in framestats.</li>
|
||
</ol>
|
||
<p><b>Total:</b> 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.</p>
|
||
<p class="muted small">A box-side alternative to step 1's DNS part: switch the box's Private DNS from <code>dns.adguard.com</code> 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.</p>
|
||
<h2>9. Your picks (all of them on one page)</h2>
|
||
<p class="muted">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.</p>
|
||
<form class="decision" data-decision-key="tv-motion-speed-direction" data-lavish-question="tv-motion-speed-direction"><h3>1. Load-state and motion direction</h3>
|
||
<p class="muted">Decides plan step 3 (skeletons and motion). The prototypes above show each part.</p>
|
||
<label class="opt"><input required type="radio" name="tv-motion-speed-direction" value="recommended" data-choice-key="tv-motion-speed-direction"> <b>A · As recommended</b> <span class="rec">recommended</span><span class="t">Breathing shaped skeletons, art fades in only when it came from the network, reserved space for late lines, hero cross-fade, reduced-motion safe.</span></label>
|
||
<label class="opt"><input required type="radio" name="tv-motion-speed-direction" value="flat" data-choice-key="tv-motion-speed-direction"> <b>B · Same, but flat skeletons</b><span class="t">Still card-shaped, no pulse. The calmest option.</span></label>
|
||
<label class="opt"><input required type="radio" name="tv-motion-speed-direction" value="shimmer" data-choice-key="tv-motion-speed-direction"> <b>C · Same, but shimmer skeletons</b><span class="t">A light sweep across the tiles. Costs GPU on this box every frame while loading.</span></label>
|
||
<label class="opt"><input required type="radio" name="tv-motion-speed-direction" value="changes" data-choice-key="tv-motion-speed-direction"> <b>D · Different</b><span class="t">Say what in the note: a prototype to change, a speed, an option to drop.</span></label>
|
||
<textarea aria-label="Notes for 1. Load-state and motion direction" name="tv-motion-speed-direction-note" placeholder="Optional note"></textarea><p><button type="submit">Queue this answer</button> <span role="status" aria-live="polite"></span></p></form>
|
||
<form class="decision" data-decision-key="tv-speed-quick-fixes" data-lavish-question="tv-speed-quick-fixes"><h3>2. The two quick speed fixes (about a day and a half, TV only)</h3>
|
||
<p class="muted">(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.</p>
|
||
<label class="opt"><input required type="radio" name="tv-speed-quick-fixes" value="go" data-choice-key="tv-speed-quick-fixes"> <b>Go, start now</b> <span class="rec">recommended</span><span class="t">Two small lanes, merged and staged for the next install.</span></label>
|
||
<label class="opt"><input required type="radio" name="tv-speed-quick-fixes" value="a-only" data-choice-key="tv-speed-quick-fixes"> <b>Only (a), the launch spinner</b><span class="t">Leave the image sizing for later.</span></label>
|
||
<label class="opt"><input required type="radio" name="tv-speed-quick-fixes" value="b-only" data-choice-key="tv-speed-quick-fixes"> <b>Only (b), the image sizes</b><span class="t">Leave the DNS cache for later.</span></label>
|
||
<label class="opt"><input required type="radio" name="tv-speed-quick-fixes" value="later" data-choice-key="tv-speed-quick-fixes"> <b>Not now</b><span class="t">Skip until after the motion work.</span></label>
|
||
<textarea aria-label="Notes for 2. The two quick speed fixes (about a day and a half, TV only)" name="tv-speed-quick-fixes-note" placeholder="Optional note"></textarea><p><button type="submit">Queue this answer</button> <span role="status" aria-live="polite"></span></p></form>
|
||
<form class="decision" data-decision-key="tv-box-dns" data-lavish-question="tv-box-dns"><h3>3. Change the box Private DNS (your call, system setting)</h3>
|
||
<p class="muted">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.</p>
|
||
<label class="opt"><input required type="radio" name="tv-box-dns" value="leave" data-choice-key="tv-box-dns"> <b>Leave it as it is</b> <span class="rec">recommended</span><span class="t">The TV-side cache covers the launch spinner.</span></label>
|
||
<label class="opt"><input required type="radio" name="tv-box-dns" value="automatic" data-choice-key="tv-box-dns"> <b>Switch to Automatic</b><span class="t">I change it when you free the TV and show before and after timings.</span></label>
|
||
<label class="opt"><input required type="radio" name="tv-box-dns" value="ask" data-choice-key="tv-box-dns"> <b>Ask me when the TV is free</b><span class="t">Decide then.</span></label>
|
||
<textarea aria-label="Notes for 3. Change the box Private DNS (your call, system setting)" name="tv-box-dns-note" placeholder="Optional note"></textarea><p><button type="submit">Queue this answer</button> <span role="status" aria-live="polite"></span></p></form>
|
||
<form class="decision" data-decision-key="tv-speed-later-steps" data-lavish-question="tv-speed-later-steps"><h3>4. Later plan steps to queue (TV, after the build above)</h3>
|
||
<p class="muted">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.</p>
|
||
<label class="opt"><input required type="radio" name="tv-speed-later-steps" value="all" data-choice-key="tv-speed-later-steps"> <b>Queue all three in that order</b> <span class="rec">recommended</span><span class="t">They follow the motion work, one lane at a time.</span></label>
|
||
<label class="opt"><input required type="radio" name="tv-speed-later-steps" value="prefetch" data-choice-key="tv-speed-later-steps"> <b>Only prefetch and memory cache</b><span class="t">Skip the profile and frame work for now.</span></label>
|
||
<label class="opt"><input required type="radio" name="tv-speed-later-steps" value="none" data-choice-key="tv-speed-later-steps"> <b>None yet</b><span class="t">Revisit after you have seen the motion build on the TV.</span></label>
|
||
<textarea aria-label="Notes for 4. Later plan steps to queue (TV, after the build above)" name="tv-speed-later-steps-note" placeholder="Optional note"></textarea><p><button type="submit">Queue this answer</button> <span role="status" aria-live="polite"></span></p></form>
|
||
<form class="decision" data-decision-key="tv-conductarr-retrieval" data-lavish-question="tv-conductarr-retrieval"><h3>5. Conductarr title-read speed-up (needs a Conductarr release)</h3>
|
||
<p class="muted">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).</p>
|
||
<label class="opt"><input required type="radio" name="tv-conductarr-retrieval" value="scope" data-choice-key="tv-conductarr-retrieval"> <b>Scope it first, then I decide</b> <span class="rec">recommended</span><span class="t">A short scout writes the exact change list and risks for a release; nothing ships.</span></label>
|
||
<label class="opt"><input required type="radio" name="tv-conductarr-retrieval" value="go" data-choice-key="tv-conductarr-retrieval"> <b>Go: build it and release it</b><span class="t">You are giving the word to build and release Conductarr when it is green.</span></label>
|
||
<label class="opt"><input required type="radio" name="tv-conductarr-retrieval" value="later" data-choice-key="tv-conductarr-retrieval"> <b>Not now</b><span class="t">Skip until after the TV-side wins.</span></label>
|
||
<textarea aria-label="Notes for 5. Conductarr title-read speed-up (needs a Conductarr release)" name="tv-conductarr-retrieval-note" placeholder="Optional note"></textarea><p><button type="submit">Queue this answer</button> <span role="status" aria-live="polite"></span></p></form>
|
||
<form class="decision" data-decision-key="tv-build-order" data-lavish-question="tv-build-order"><h3>6. When should these land on the TV?</h3>
|
||
<p class="muted">You said you will not be using the TV during this session.</p>
|
||
<label class="opt"><input required type="radio" name="tv-build-order" value="as-ready" data-choice-key="tv-build-order"> <b>Bundle with the next install</b> <span class="rec">recommended</span><span class="t">The speed fixes and any approved motion work join the staged build; I install when you say the TV is free.</span></label>
|
||
<label class="opt"><input required type="radio" name="tv-build-order" value="separate" data-choice-key="tv-build-order"> <b>Install the speed fixes on their own first</b><span class="t">A quick build for steps 2(a) and 2(b) only, installed as soon as it is ready if you free the TV.</span></label>
|
||
<textarea aria-label="Notes for 6. When should these land on the TV?" name="tv-build-order-note" placeholder="Optional note"></textarea><p><button type="submit">Queue this answer</button> <span role="status" aria-live="polite"></span></p></form>
|
||
<p class="muted small">Method, raw numbers and files: <code>data/tv-spike-motion-speed/report.md</code>. Nothing was changed in the app or on the server. The box was left on the Movies tab.</p>
|
||
<script>
|
||
{JS}
|
||
document.querySelectorAll('form.decision').forEach(form=>{{form.addEventListener('submit',event=>{{event.preventDefault();const key=form.dataset.lavishQuestion;const answer=new FormData(form).get(key);const status=form.querySelector('[role=status]');if(!answer){{status.textContent='Pick an option first.';return;}}if(!window.lavish||!window.lavish.queuePrompt){{status.textContent='Open this page in the live review board to queue an answer.';return;}}const note=form.querySelector('textarea').value.trim();const label=form.querySelector('h3').textContent;window.lavish.queuePrompt(label+': '+answer+(note?'\\n'+note:''),{{tag:'choice',text:label+': '+answer,element:form,queueKey:key,data:{{question:key,answer:answer,note:note}}}});status.textContent='Queued. Press Send to Agent in the review toolbar.';}});}});
|
||
</script></main></body></html>'''
|
||
|
||
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))
|