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
483 lines
No EOL
72 KiB
HTML
483 lines
No EOL
72 KiB
HTML
<!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)}
|
||
/* Prototype stages: 1920x1080 CSS px, approved tokens from data/tv-v2/focus-2026-11-06 (colors, type, ring, card sizes at Level 2 spacing). */
|
||
.sbox{position:relative;width:100%;aspect-ratio:16/9;overflow:hidden;border-radius:8px;border:1px solid var(--line);background:#000}
|
||
.sbox video{position:absolute;inset:0;width:100%;height:100%;object-fit:cover;display:block}
|
||
.stage{position:absolute;left:0;top:0;width:1920px;height:1080px;transform-origin:0 0;overflow:hidden;background:#07070a;font-family:system-ui,-apple-system,"Segoe UI",Roboto,"Noto Sans",sans-serif;color:#f4f4f6;font-size:28px;line-height:1.3;
|
||
--bg:#07070a;--surface:#13131a;--surface2:#1b1b25;--surface3:#252532;--muted:rgb(244 244 246/.74);--dim:rgb(244 244 246/.56);--accent:oklch(78% 0.14 305);--accent-text:oklch(88% 0.08 305)}
|
||
.stage *{box-sizing:border-box}
|
||
.stage .abs{position:absolute}
|
||
/* nav */
|
||
.stage .nav{position:absolute;left:0;top:0;width:1920px;height:150px;z-index:5;display:flex;align-items:center;gap:12px;padding:44px 96px 0}
|
||
.stage .wm{font-size:36px;font-weight:800;letter-spacing:-.02em;margin-right:40px}.stage .wm b{color:var(--accent)}
|
||
.stage .ni{height:64px;padding:0 26px;border-radius:32px;display:inline-flex;align-items:center;font-size:30px;font-weight:600;color:var(--muted);position:relative}
|
||
.stage .ni.cur{color:#f4f4f6}.stage .ni.cur::after{content:"";position:absolute;left:26px;right:26px;bottom:4px;height:4px;border-radius:2px;background:var(--accent)}
|
||
.stage .ni.f{background:#f4f4f6;color:#0b0b10}.stage .ni.f::after{display:none}
|
||
.stage .nsp{flex:1}.stage .av{width:64px;height:64px;border-radius:50%;display:grid;place-items:center;font-weight:800;color:#0b0b10;font-size:30px;background:#9be38f}
|
||
/* hero */
|
||
.stage .amb{position:absolute;inset:0;background-size:cover;background-position:center;image-rendering:auto}
|
||
.stage .bd{position:absolute;inset:0;background-size:cover;background-position:center}
|
||
.stage .scrim{position:absolute;inset:0;background:linear-gradient(90deg,rgb(7 7 10/.92) 0%,rgb(7 7 10/.55) 42%,rgb(7 7 10/0) 70%),linear-gradient(0deg,#07070a 0%,rgb(7 7 10/.6) 26%,rgb(7 7 10/0) 48%)}
|
||
.stage .copy{position:absolute;left:96px;width:1000px}
|
||
.stage .kicker{font-size:26px;font-weight:650;color:var(--accent-text);margin-bottom:10px}
|
||
.stage .ht{font-size:84px;line-height:1.02;font-weight:800;letter-spacing:-.02em;margin:0 0 16px}
|
||
.stage .hm{font-size:28px;color:var(--muted);margin-bottom:14px;min-height:38px;display:flex;gap:22px;align-items:center}
|
||
.stage .ho{font-size:28px;color:var(--muted);margin:0 0 30px;max-width:960px}
|
||
.stage .btns{display:flex;gap:20px}
|
||
.stage .btn{height:76px;padding:0 38px;border-radius:38px;font-size:28px;font-weight:700;display:inline-flex;align-items:center;background:rgb(255 255 255/.14)}
|
||
.stage .btn.pri{background:#f4f4f6;color:#0b0b10}
|
||
.stage .btn.f{box-shadow:0 0 0 2px var(--accent),inset 0 0 0 2px var(--accent);transform:scale(1.05)}
|
||
.stage .rate{display:inline-flex;gap:10px;align-items:center;font-weight:700;color:#f4f4f6}.stage .rate i{width:24px;height:24px;border-radius:50%;background:#e2483a;display:inline-block}
|
||
.stage .rate i.pc{background:#f5c518;border-radius:4px}
|
||
.stage .slot{display:inline-block;height:30px;border-radius:15px;background:rgb(255 255 255/.10)}
|
||
/* rows + cards */
|
||
.stage .sh{position:absolute;left:96px;font-size:34px;font-weight:700}
|
||
.stage .card{position:absolute;border-radius:16px;overflow:hidden;background:var(--surface);background-size:cover;background-position:center}
|
||
.stage .card.poster{width:256px;height:384px}.stage .card.tile{width:400px;height:225px}
|
||
.stage .card .img{position:absolute;inset:0;background-size:cover;background-position:center}
|
||
.stage .card .cap{position:absolute;left:0;right:0;bottom:0;padding:14px 18px;background:linear-gradient(rgb(7 7 10/0),rgb(7 7 10/.92));font-size:26px;font-weight:700}
|
||
.stage .ring{box-shadow:0 0 0 2px var(--accent),inset 0 0 0 2px var(--accent),0 18px 40px rgb(0 0 0/.55)}
|
||
.stage .circle{position:absolute;width:200px;height:200px;border-radius:50%;overflow:hidden;background:var(--surface2);display:grid;place-items:center;font-size:44px;font-weight:800;color:var(--dim)}
|
||
.stage .circle .img{position:absolute;inset:0;background-size:cover;background-position:center}
|
||
.stage .bar{position:absolute;height:28px;border-radius:14px;background:var(--surface2)}
|
||
.stage .spin{position:absolute;left:940px;top:520px;width:40px;height:40px;border-radius:50%;border:4px solid rgb(155 120 255/.25);border-top-color:oklch(65% 0.2 300);animation:spin .8s linear infinite}
|
||
/* picker */
|
||
.stage .pk{position:absolute;top:290px;left:0;width:1920px;text-align:center;font-size:72px;font-weight:800;letter-spacing:-.02em}
|
||
.stage .person{position:absolute;top:426px;width:220px;height:220px;border-radius:50%;display:grid;place-items:center;font-size:110px;font-weight:800;color:#0b0b10}
|
||
.stage .pname{position:absolute;top:670px;width:220px;text-align:center;font-size:34px;font-weight:700;color:var(--muted)}
|
||
/* replay label */
|
||
.stage .tl{position:absolute;right:14px;bottom:10px;font:22px/1 ui-monospace,Menlo,monospace;background:rgb(0 0 0/.6);padding:6px 8px;border-radius:6px;color:#fff;z-index:20}
|
||
|
||
/* keyframes */
|
||
@keyframes spin{to{transform:rotate(360deg)}}
|
||
@keyframes fade{from{opacity:0}to{opacity:1}}
|
||
@keyframes fadeout{from{opacity:1}to{opacity:0}}
|
||
@keyframes rise{from{opacity:0;transform:translateY(16px)}to{opacity:1;transform:none}}
|
||
@keyframes breathe{0%,100%{opacity:.55}50%{opacity:1}}
|
||
@keyframes shimmer{from{transform:translateX(-100%)}to{transform:translateX(100%)}}
|
||
@keyframes hide{to{opacity:0;visibility:hidden}}
|
||
@keyframes show{from{opacity:0}to{opacity:1}}
|
||
|
||
/* anything with .an waits for .play */
|
||
.stage .an{opacity:0}
|
||
.stage.play .an{animation-fill-mode:both}
|
||
.stage .gone{animation-fill-mode:both}
|
||
/* skeleton styles */
|
||
.stage .sk{background:var(--surface2)}
|
||
.stage .sk.breathe{animation:breathe 1.4s ease-in-out infinite}
|
||
.stage .sk.shim{position:relative;overflow:hidden}
|
||
.stage .sk.shim::after{content:"";position:absolute;inset:0;background:linear-gradient(90deg,transparent,rgb(255 255 255/.07),transparent);animation:shimmer 1.3s linear infinite}
|
||
|
||
/* reduced motion: no movement, short fades only */
|
||
body.rm .stage.play .an{animation-name:fade!important;animation-duration:.08s!important}
|
||
body.rm .stage .sk.breathe,body.rm .stage .sk.shim::after{animation:none}
|
||
body.rm .stage .mv{transition:none!important}
|
||
|
||
</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>
|
||
<div class="tw"><table class="num"><thead><tr><th>Screen</th><th>First content</th><th>Settled</th><th>What arrives late</th><th>Repeat visit</th></tr></thead><tbody><tr><td>Cold start (am start -W TotalTime, 4 runs)</td><td>583 / 538 / 438 / 453 ms to first frame</td><td>picker at +0.3–0.4 s (DNS warm) or <b class="b">+1.65 s</b> (DNS cold)</td><td>Spinner while <code>Users/Me</code> then <code>System/Info/Public</code> run one after the other (ServerRepository.kt:104,107)</td><td>same, bimodal on the 60 s DNS cache</td></tr><tr><td>Who's watching → Home</td><td>hero text 135 ms</td><td>430 ms</td><td>shelf tiles flat grey until 400 ms, then all images pop</td><td>370 ms</td></tr><tr><td>Home → Discover tab</td><td>content 270 ms (dims first)</td><td>700 ms</td><td>posters pop in over 0.4 s</td><td>600 ms</td></tr><tr><td>Movies grid</td><td>230 ms (blank tiles)</td><td>750 ms</td><td>posters fill row by row</td><td>500 ms</td></tr><tr><td>Shows grid</td><td>280 ms</td><td>640 ms</td><td>posters</td><td>—</td></tr><tr><td>Title, movie in library (American Gangster)</td><td>430 ms (text)</td><td><b class="w">2.69 s</b></td><td>backdrop 470 ms; cast portraits 2.7 s (initials until then)</td><td>450 ms total</td></tr><tr><td>Title, Discover pick (Donnie Brasco)</td><td>980 ms (blank skeleton until then)</td><td>1.53 s</td><td>backdrop 1.37 s, cast 1.5 s</td><td>—</td></tr><tr><td>Title, series (Adults)</td><td>620 ms</td><td><b class="b">3.42 s</b></td><td>ratings and "Ready for you · by Mindel" land at 3.4 s and push the overview down</td><td>—</td></tr><tr><td>Person page (Martin Sheen)</td><td>620 ms</td><td>720 ms</td><td>filmography posters</td><td>—</td></tr><tr><td>Back from title / series / person</td><td>260 / 300 / 450 ms</td><td></td><td></td><td></td></tr><tr><td>Settings</td><td>220 ms</td><td>220 ms</td><td>—</td><td></td></tr><tr><td>Coming in sheet</td><td>74 ms</td><td>74 ms (from cache)</td><td>posters refresh silently</td><td></td></tr></tbody></table></div>
|
||
|
||
<h3>Frame timing per interaction (dumpsys gfxinfo, ART status <code>speed</code>)</h3>
|
||
<div class="tw"><table class="num"><thead><tr><th>Interaction</th><th>Frames</th><th>Janky</th><th>p50</th><th>p90</th><th>p99</th><th>Main thread slow / GPU slow</th></tr></thead><tbody><tr><td>Home rows scroll (down 7, up 6, right 5), run 1 / 2</td><td>149 / 115</td><td>38.9 % / 34.8 %</td><td>44 / 46 ms</td><td>69 / 65</td><td>89 / 101</td><td>43 / 52 · 27 / 35</td></tr><tr><td>Movies grid scroll (down 8, up 8), run 1 / 2</td><td>180 / 159</td><td>43.9 % / 32.1 %</td><td>30 / 30 ms</td><td>53 / 46</td><td>93 / 73</td><td>30 / 69 · 20 / 48</td></tr><tr><td>Discover scroll</td><td>146</td><td>30.1 %</td><td>34 ms</td><td>57</td><td>73</td><td>34 / 35</td></tr><tr><td>Tab switch ×4</td><td>158</td><td>18.4 %</td><td>28 ms</td><td>65</td><td>150</td><td>15 / 29</td></tr><tr><td>Title open + back ×3</td><td>103</td><td>33.0 %</td><td>48 ms</td><td>113</td><td>150</td><td>29 / 34</td></tr><tr><td>Who's watching → Home</td><td>15</td><td>53.3 %</td><td>61 ms</td><td>117</td><td>133</td><td>7 / 8</td></tr><tr><td>Open title (movie, first time)</td><td>20</td><td>60.0 %</td><td>57 ms</td><td>150</td><td>200</td><td>9 / 12</td></tr><tr><td>Back from person</td><td>12</td><td>75.0 %</td><td>93 ms</td><td>150</td><td>150</td><td>8 / 9</td></tr></tbody></table></div>
|
||
<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>
|
||
<div class="tw"><table class="num"><thead><tr><th>Call</th><th>Bytes</th><th>Cold p50 / p95</th><th>Warm p50 / p95</th><th>Cache headers</th></tr></thead><tbody><tr><td>Jellyfin UserViews (called 4–5× per launch)</td><td>2.3 KB</td><td>65 / 166 ms</td><td>46 / 51 ms</td><td>none</td></tr><tr><td>Home Resume (20)</td><td>1.4 KB</td><td>58 / 66</td><td>30 / 41</td><td>none</td></tr><tr><td>Home NextUp (20)</td><td>6.2 KB</td><td>71 / 91</td><td>47 / 52</td><td>none</td></tr><tr><td>Home Items/{{id}} per series (sequential)</td><td>4.0 KB</td><td>43 / 60</td><td>24 / 28</td><td>none</td></tr><tr><td>Home library shelf (20)</td><td>18.7 KB</td><td>85 / 116</td><td>74 / 84</td><td>none</td></tr><tr><td>Movies grid page (200)</td><td>51 KB</td><td>68 / 148</td><td>61 / 64</td><td>none</td></tr><tr><td>Title episodes with MediaSources + People</td><td><b class="w">96.8 KB</b></td><td>142 / 185</td><td>122 / 169</td><td>none</td></tr><tr><td>Title episodes, lean fields</td><td>6.5 KB</td><td>26 / 65</td><td>11 / 12</td><td>none</td></tr><tr><td>Grid poster, no size (today, LibraryReadFacade.kt:86)</td><td><b class="b">626 KB</b></td><td>26 / 30</td><td>8 / 9</td><td>max-age 1 y, immutable, ETag</td></tr><tr><td>Grid poster fillWidth 256×384 q96</td><td>31.6 KB</td><td>19 / 131</td><td>2 / 4</td><td>same</td></tr><tr><td>Grid poster 256×384 q80 WebP</td><td>11.2 KB</td><td>19 / 118</td><td>1 / 2</td><td>same</td></tr><tr><td>Backdrop 1280 q96 / q80 WebP</td><td>91 / 31 KB</td><td>19 / 25</td><td>2 / 2</td><td>same</td></tr><tr><td>TMDB poster w780 (today, ConductarrProjection.kt:9)</td><td>131 KB</td><td>128 / 144</td><td>49 / 70</td><td>max-age 1 y</td></tr><tr><td>TMDB poster w342 / w185</td><td>41 / 15 KB</td><td>100 / 76</td><td>31 / 30</td><td>max-age 1 y</td></tr></tbody></table></div>
|
||
<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>
|
||
<div class="tw"><table class="num"><thead><tr><th>Route</th><th>n</th><th>p50</th><th>p95</th><th>Body p50</th><th>Why</th></tr></thead><tbody><tr><td>activity/titles/{{tmdb}}/{{type}} (Ready for you)</td><td>45</td><td><b class="b">2856 ms</b></td><td>5002 ms</td><td>1.7 KB</td><td>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</td></tr><tr><td>catalogue/titles/{{tmdb}}/{{type}} (detail)</td><td>51</td><td><b class="b">1198 ms</b></td><td>3217 ms</td><td>11.9 KB</td><td>Held-title read lists every Arr library unpaged, then Seerr details, then ratings; sequential (catalogue/detail.go:14-17, library_detail.go:81)</td></tr><tr><td>catalogue/tv/{{tmdb}}/seasons/{{n}}</td><td>76 / 17 (304)</td><td>1084 / 2282 ms</td><td>2664 ms</td><td>1 KB</td><td>Same held-title read; a 304 still renders and hashes the full body first (read_validators.go)</td></tr><tr><td>…/ratings</td><td>265</td><td>147 ms</td><td>449 ms</td><td>0.5 KB</td><td>6 h server cache, 300 titles</td></tr><tr><td>…/enrichment · …/related · …/cast</td><td>46 · 42 · 26</td><td>103 · 124 · 33 ms</td><td>465 · 261 · 78</td><td>3 · 14 · 12.5 KB</td><td>enrichment uncached</td></tr><tr><td>recommendations feed</td><td>42</td><td>16 ms</td><td>40 ms</td><td><b class="w">204 KB, uncompressed</b></td><td>no gzip/br on the TV door (Caddyfile.base:316-333, serve.go:140)</td></tr><tr><td>activity (polled every 30 s)</td><td>713 × 304</td><td>52 ms</td><td>83 ms</td><td>62.5 KB when 200</td><td></td></tr><tr><td>operator · profiles · dismissals · options · display</td><td></td><td>12–25 ms</td><td>30–93 ms</td><td></td><td>cheap; display is called ≈1 400×/day, once per poster</td></tr></tbody></table></div>
|
||
<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>
|
||
<div class="tw"><table class=""><thead><tr><th>Cache</th><th>Where</th><th>Behaviour</th><th>Observed</th></tr></thead><tbody><tr><td>Wolfskin image loader (Coil 3)</td><td>WolfskinImages.kt:46-61</td><td>64 MiB memory, 128 MiB disk, 4 parallel reads, crossfade off</td><td>repeat visits paint art from memory; first grid visit downloads 626 KB per poster (no size)</td></tr><tr><td>Upstream Wholphin image loader</td><td>CoilConfig.kt:80-101</td><td>separate memory + 200 MB disk</td><td>a second copy of the same art in memory</td></tr><tr><td>Conductarr JSON</td><td>FileCacheStore, noBackupFilesDir/conductarr</td><td>saved copy shown first, then revalidated with ETag; no memory layer</td><td>title re-open: every ON_OPEN route re-fetched (cast 304, detail 875 ms again)</td></tr><tr><td>Home, library, title, person snapshots</td><td>filesDir/wolfskin-home, -library-cache, -title</td><td>disk JSON, painted first</td><td>repeat visits 370–500 ms instead of 430–750</td></tr><tr><td>HTTP cache (OkHttp)</td><td>none</td><td>—</td><td>Jellyfin JSON never cached; images rely on Coil only</td></tr><tr><td>DNS</td><td>system only</td><td>60 s TTL through Private DNS</td><td>1.5 s launch spinner when expired</td></tr></tbody></table></div>
|
||
|
||
<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>
|
||
<div class="tw"><table class=""><thead><tr><th>Option</th><th>Expected gain (measured basis)</th><th>Effort</th><th>Risk</th><th>What you would see</th><th>Ships in</th></tr></thead><tbody><tr><td>Picker from the saved people list, verify in the background</td><td>Removes the 1.5 s spinner on every DNS-cold launch (2 of 4 measured)</td><td>S</td><td>Low. A revoked session must still land on the recovery picker</td><td>The launcher fades straight into Who's watching</td><td>TV</td></tr><tr><td>Shaped skeletons that breathe (opacity only, staggered)</td><td>No speed change; replaces the flat grey and spinner states</td><td>S</td><td>Low. One alpha layer per row</td><td>Card-shaped tiles gently pulsing until the art lands</td><td>TV</td></tr><tr><td>Art fades in (120–160 ms) instead of popping</td><td>Removes the pop on every first visit</td><td>XS</td><td>Low. Keep it off for memory-cache hits so repeat visits stay instant</td><td>Posters settle instead of flickering in</td><td>TV</td></tr><tr><td>Reserve space for late fields (ratings, Ready for you, genres, overview)</td><td>No layout jump at 3.4 s on series pages</td><td>S</td><td>Low</td><td>The line fades into a gap that was already there</td><td>TV</td></tr><tr><td>Title page paints what the card already knew</td><td>First content 430–980 ms → about 60 ms</td><td>M</td><td>Medium. Card data must match detail (year, runtime, ratings)</td><td>Title, year, ratings and Play at once, over a colour wash of the poster</td><td>TV</td></tr><tr><td>Shimmer sweep skeletons</td><td>None</td><td>S</td><td><b class="w">High on this GPU</b>: a moving gradient over every card, every frame</td><td>Light sweeps across the tiles. Not recommended</td><td>TV</td></tr></tbody></table></div>
|
||
<h3>See it: launch</h3>
|
||
<div class="pair"><div class="ph"><button type="button" class="replay">Replay together</button></div>
|
||
<div class="panes"><div class="pane"><p class="lab">Today, DNS cold (2 of 4 launches): 1.5 s spinner</p><div class="sbox"><video src="tv-motion-speed/before-launch-spinner.webm" muted loop playsinline autoplay preload="auto"></video></div></div><div class="pane"><p class="lab">Today, DNS warm</p><div class="sbox"><video src="tv-motion-speed/before-launch-fast.webm" muted loop playsinline autoplay preload="auto"></video></div></div><div class="pane"><p class="lab">After: no spinner, people from the saved list</p><div class="sbox" id="p-launch" data-scene="launch" data-lead="180"></div></div></div></div>
|
||
<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>
|
||
<div class="pair"><div class="ph"><button type="button" class="replay">Replay together</button></div>
|
||
<div class="panes"><div class="pane"><p class="lab">Today: Movies grid, first visit</p><div class="sbox"><video src="tv-motion-speed/before-movies-grid.webm" muted loop playsinline autoplay preload="auto"></video></div></div><div class="pane"><p class="lab">Options</p><div class="tabs" data-variants="p-skel"></div><div class="sbox" id="p-skel" data-scene="skeleton" data-lead="140"></div></div></div></div>
|
||
|
||
<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>
|
||
<div class="tw"><table class=""><thead><tr><th>Option</th><th>Expected gain (measured basis)</th><th>Effort</th><th>Risk</th><th>What you would see</th><th>Ships in</th></tr></thead><tbody><tr><td>Staged reveal on screen entry (hero copy rises 16 px, rows rise 24 px, staggered 30–40 ms, 180 ms ease-out)</td><td>Perceived: art arrives into motion instead of popping. Cost: translate + alpha on one layer per row</td><td>S</td><td>Low. Stagger must not delay focus</td><td>Home and Discover settle like a curtain rather than tiles snapping</td><td>TV</td></tr><tr><td>Hero cross-fade (backdrop 320 ms, copy hands off)</td><td>Removes the hard cut when the hero changes</td><td>S</td><td>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</td><td>The next pick dissolves in</td><td>TV</td></tr><tr><td>Focus: 180 ms ease-out, caption rises 10 px</td><td>Same frame cost as today; the curve hides dropped frames better</td><td>XS</td><td>Low</td><td>Focus glides rather than ticks</td><td>TV</td></tr><tr><td>Title entry: grid fades 160 ms, page builds from the poster colour</td><td>Hides the 470 ms backdrop fetch</td><td>M</td><td>Medium (see load states)</td><td>No black gap between grid and title</td><td>TV</td></tr><tr><td>Shared-element poster → title sheet</td><td>None measurable</td><td>L</td><td><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</td><td>The card flies into place. Not recommended now</td><td>TV</td></tr><tr><td>Honour reduced motion (system animator scale 0 or a Wolfskin toggle)</td><td>Accessibility</td><td>S</td><td>Low</td><td>Fades only, ≤100 ms; the toggle at the top shows it</td><td>TV</td></tr></tbody></table></div>
|
||
<h3>See it: Who's watching → Home</h3>
|
||
<div class="pair"><div class="ph"><button type="button" class="replay">Replay together</button></div>
|
||
<div class="panes"><div class="pane"><p class="lab">Today: grey tiles, then art pops at 400 ms</p><div class="sbox"><video src="tv-motion-speed/before-home-enter.webm" muted loop playsinline autoplay preload="auto"></video></div></div><div class="pane"><p class="lab">After</p><div class="sbox" id="p-home" data-scene="home" data-lead="200"></div></div></div></div>
|
||
<h3>See it: tab switch and row reveal (Home → Discover)</h3>
|
||
<div class="pair"><div class="ph"><button type="button" class="replay">Replay together</button></div>
|
||
<div class="panes"><div class="pane"><p class="lab">Today</p><div class="sbox"><video src="tv-motion-speed/before-discover-tab.webm" muted loop playsinline autoplay preload="auto"></video></div></div><div class="pane"><p class="lab">After</p><div class="sbox" id="p-tab" data-scene="tab" data-lead="150"></div></div></div></div>
|
||
<h3>See it: title page entry</h3>
|
||
<div class="pair"><div class="ph"><button type="button" class="replay">Replay together</button></div>
|
||
<div class="panes"><div class="pane"><p class="lab">Today: American Gangster, first open (cast at 2.7 s)</p><div class="sbox"><video src="tv-motion-speed/before-title-movie.webm" muted loop playsinline autoplay preload="auto"></video></div></div><div class="pane"><p class="lab">Today: Donnie Brasco from Discover, blank until 980 ms</p><div class="sbox"><video src="tv-motion-speed/before-title-discover.webm" muted loop playsinline autoplay preload="auto"></video></div></div><div class="pane"><p class="lab">After: Apocalypse Now from the same grid</p><div class="sbox" id="p-title" data-scene="title" data-lead="170"></div></div></div></div>
|
||
<h3>See it: a late line on a series page</h3>
|
||
<div class="pair"><div class="ph"><button type="button" class="replay">Replay together</button></div>
|
||
<div class="panes"><div class="pane"><p class="lab">Today: Adults, the readiness line lands at 3.4 s and pushes the text down</p><div class="sbox"><video src="tv-motion-speed/before-title-series.webm" muted loop playsinline autoplay preload="auto"></video></div></div><div class="pane"><p class="lab">After</p><div class="tabs" data-variants="p-series"></div><div class="sbox" id="p-series" data-scene="series" data-lead="150"></div></div></div></div>
|
||
<h3>See it: focus move</h3>
|
||
<div class="pair"><div class="ph"><button type="button" class="replay">Replay together</button><span class="muted small">Tick box frame rate to compare at 25 fps.</span></div>
|
||
<div class="panes"><div class="pane"><p class="lab">Today, real grid</p><div class="sbox"><video src="tv-motion-speed/before-movies-grid.webm" muted loop playsinline autoplay preload="auto"></video></div></div><div class="pane"><p class="lab">Focus along a row</p><div class="tabs" data-variants="p-focus"></div><div class="sbox" id="p-focus" data-scene="focus" data-lead="0"></div></div></div></div>
|
||
<h3>See it: hero cross-fade</h3>
|
||
<div class="pair"><div class="ph"><button type="button" class="replay">Replay together</button></div>
|
||
<div class="panes"><div class="pane"><p class="lab">Today (Discover hero)</p><div class="sbox"><video src="tv-motion-speed/before-discover-tab.webm" muted loop playsinline autoplay preload="auto"></video></div></div><div class="pane"><p class="lab">Hero advances</p><div class="tabs" data-variants="p-hero"></div><div class="sbox" id="p-hero" data-scene="hero" data-lead="0"></div></div></div></div>
|
||
|
||
<h2>4. Caching</h2>
|
||
<div class="tw"><table class=""><thead><tr><th>Option</th><th>Expected gain (measured basis)</th><th>Effort</th><th>Risk</th><th>What you would see</th><th>Ships in</th></tr></thead><tbody><tr><td>Persisted DNS answers (OkHttp Dns: serve the last address at once, refresh behind)</td><td>Removes 0.45–1.35 s on launch and on the first TMDB image after any 60 s pause</td><td>S</td><td>Low. Fall back to the system lookup if the cached address fails</td><td>No spinner on launch; first cast photos in ≈0.6 s instead of 2.7 s [inference: DNS is the cast delay]</td><td>TV</td></tr><tr><td>Warm the connections at process start (Jellyfin, Conductarr door, TMDB) in parallel</td><td>Overlaps DNS + TLS with the 450 ms first frame</td><td>XS</td><td>Low</td><td>Same as above, smaller</td><td>TV</td></tr><tr><td>Memory layer over the Conductarr disk cache</td><td>Skips disk read and JSON decode on back and re-open (title re-open settles at 450 ms today; ~250 expected) [inference]</td><td>S</td><td>Low</td><td>Back and re-open feel instant</td><td>TV</td></tr><tr><td>Stale-while-revalidate for ON_OPEN title routes (skip refetch if validated in the last 5 min)</td><td>Re-opening a title stops costing the server 875–2 900 ms</td><td>S</td><td>Low. Requests and readiness may be 5 min old; keep requests on FORCE</td><td>Nothing visible; less churn</td><td>TV</td></tr><tr><td>Prefetch the focused card's title page (detail JSON + backdrop) after 350 ms of dwell</td><td>Title first content 430 ms → about 60 ms; backdrop already decoded</td><td>M</td><td>Medium. Bandwidth: at most one title per dwell; cancel on move</td><td>Opening a title is instant</td><td>TV</td></tr><tr><td>Pre-decode last-known Home art while Who's watching is up</td><td>Home settles at 430 ms → about 250 ms with no pops</td><td>S</td><td>Low. Memory: 6 backdrops ≈ 21 MiB of the 64 MiB</td><td>Home appears whole</td><td>TV</td></tr><tr><td>One image loader instead of two</td><td>Frees a second memory cache (Coil default ≈ 25 % of the heap) [inference]</td><td>M</td><td>Medium. Upstream screens use their own</td><td>Fewer re-downloads after big screens</td><td>TV</td></tr><tr><td>OkHttp HTTP cache for Jellyfin JSON</td><td>Small. Jellyfin sends no cache headers, so only conditional revalidation is possible</td><td>S</td><td>Low</td><td>Nothing visible</td><td>TV</td></tr></tbody></table></div>
|
||
|
||
<h2>5. Backend retrieval</h2>
|
||
<div class="tw"><table class=""><thead><tr><th>Option</th><th>Expected gain (measured basis)</th><th>Effort</th><th>Risk</th><th>What you would see</th><th>Ships in</th></tr></thead><tbody><tr><td>Cache the held-title read in Conductarr (Arr library list, queue, history) for 30–60 s, shared across routes</td><td>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]</td><td>M</td><td>Medium. Freshness of "Ready" after an import; invalidate on Arr webhook (already received)</td><td>Series pages settle in under a second; no late line</td><td>Conductarr release</td></tr><tr><td>Readiness: drop the duplicate held-title read and the per-episode Jellyfin GETs</td><td>The 5 s tail on readiness</td><td>M</td><td>Medium</td><td>"Ready for you" appears with the page</td><td>Conductarr release</td></tr><tr><td>Compress the TV door (Caddy <code>encode zstd gzip</code>)</td><td>Feed 204 KB → ~25–35 KB [inference: JSON typically 6–8×], activity 62 KB → ~8 KB</td><td>XS</td><td>Low</td><td>Discover opens a little faster on Wi-Fi</td><td>Caddy change (server-docs)</td></tr><tr><td>304 before rendering (keep a per-route version, not a hash of the full body)</td><td>Season 304 2.3 s → ms</td><td>M</td><td>Medium</td><td>Nothing visible except speed</td><td>Conductarr release</td></tr><tr><td>Cache the token check (<code>/Users/Me</code>) for 60 s per token</td><td>Removes a Jellyfin round trip from every TV request (~10–40 ms each, 6–10 per title page)</td><td>S</td><td>Low. Revocation lag 60 s</td><td>Nothing visible</td><td>Conductarr release</td></tr><tr><td>Batch display reads (one call for a row of posters)</td><td>≈1 400 display calls/day → a few dozen</td><td>M</td><td>Low</td><td>Coming in and Discover rows fill together</td><td>Conductarr release + TV</td></tr><tr><td>Size the grid posters (<code>fillWidth=256&fillHeight=384&quality=80&format=Webp</code>)</td><td>626 KB → 11 KB per poster (57×); 85 films 53 MB → 1 MB</td><td>XS</td><td>Low</td><td>Movies and Shows fill at once on first visit</td><td>TV</td></tr><tr><td>Ask TMDB for w342 posters, not w780</td><td>131 KB → 41 KB per Discover poster</td><td>XS</td><td>Low. 342 px for a 256 px card</td><td>Discover rows fill sooner</td><td>TV</td></tr><tr><td>Lean episode fields (drop MediaSources/MediaStreams/People from the list; fetch them on Play)</td><td>96.8 KB → 6.5 KB per season, 142 → 26 ms</td><td>S</td><td>Medium. The episode row shows file chips</td><td>Series pages settle sooner</td><td>TV</td></tr><tr><td>Fewer launch calls: UserViews 4–5× and LiveTv/Recordings/Folders 2× per launch</td><td>≈250 ms of serial calls</td><td>S</td><td>Low</td><td>Nothing visible</td><td>TV</td></tr></tbody></table></div>
|
||
|
||
<h2>6. Kotlin and Compose</h2>
|
||
<div class="tw"><table class=""><thead><tr><th>Option</th><th>Expected gain (measured basis)</th><th>Effort</th><th>Risk</th><th>What you would see</th><th>Ships in</th></tr></thead><tbody><tr><td>Library load re-emits the full list per 200-item page and re-sorts each time (LibraryReadFacade.kt:96, LibraryGridState.kt:99)</td><td>Grid first visit; matters more as the library grows</td><td>S</td><td>Low</td><td>Grid appears in one step</td><td>TV</td></tr><tr><td>Hoist the Regex in titleFallbackBackground / titleAmbientBackground (TitleArtwork.kt:152,167)</td><td>Draw-time cost per card</td><td>XS</td><td>Low</td><td>—</td><td>TV</td></tr><tr><td>Discover reads firstVisibleItemIndex in composition (DiscoverScreen.kt:107) → derivedStateOf</td><td>Fewer recompositions while scrolling Discover (34 of 44 janky frames were UI-thread)</td><td>XS</td><td>Low</td><td>Smoother Discover scroll</td><td>TV</td></tr><tr><td>NavBar label colour read in composition (NavBar.kt:86) → draw-phase</td><td>Tab switch frames</td><td>XS</td><td>Low</td><td>—</td><td>TV</td></tr><tr><td>WolfskinImages.loader() is @Synchronized and called in composition</td><td>Lock on every poster recomposition</td><td>XS</td><td>Low</td><td>—</td><td>TV</td></tr><tr><td>Cut full-screen layers: hero blur (RenderEffect) + two full-screen gradient scrims + ambient</td><td>GPU 14–21 ms/frame is the floor today; pre-blurred tiny art and one combined scrim drawn once</td><td>M</td><td>Medium. Must match the approved frames</td><td>Smoother scroll on Home and Discover</td><td>TV</td></tr><tr><td>Keys and contentType</td><td>Already done everywhere</td><td>—</td><td>—</td><td>—</td><td></td></tr></tbody></table></div>
|
||
|
||
<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>
|
||
<div class="tw"><table class="num"><thead><tr><th>State (same box, earlier tv-perf run)</th><th>Cold start</th><th>Home scroll janky / p50 / p90</th><th>Movies grid</th></tr></thead><tbody><tr><td>Opt just installed (verify)</td><td>0.5–1.0 s</td><td>51.4 % / 65 / 117 ms</td><td>—</td></tr><tr><td>Opt after forced AOT (speed)</td><td>0.39–0.44 s</td><td>29.2 % / 38 / 61 ms</td><td>17.7 % / 19 / 36 ms</td></tr></tbody></table></div>
|
||
<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>
|
||
// Wolfskin TV motion prototypes. One clock per stage drives Web Animations; "box rate" quantises the clock
|
||
// to the measured frame time on the Xiaomi box (p50 40 ms while moving, data/tv-spike-motion-speed/raw/gfx-*),
|
||
// so the same motion can be watched at 60 fps and at the rate the box actually draws today.
|
||
(() => {
|
||
const A = 'tv-motion-speed/';
|
||
const BOX_MS = 40;
|
||
const EASE = 'cubic-bezier(.25,.1,.25,1)', OUT = 'cubic-bezier(.33,1,.68,1)', INOUT = 'cubic-bezier(.65,0,.35,1)';
|
||
const POSTERS = [
|
||
['American Gangster', '2007 · 2h 36m'], ['Annie Hall', '1977 · 1h 33m'], ['Apocalypse Now', '1979 · 2h 27m'],
|
||
['Arrival', '2016 · 1h 56m'], ['Avatar: The Last Airbender', '2026 · 1h 52m'], ['Bohemian Rhapsody', '2018 · 2h 15m'],
|
||
['Braveheart', '1995 · 2h 58m'], ['Citizen Kane', '1941 · 1h 59m'], ['Spirited Away', '2001 · 2h 5m'],
|
||
['12 Years a Slave', '2013 · 2h 14m'], ['A Bronx Tale', '1993 · 2h 1m'], ['All the Money in the World', '2017 · 2h 13m'],
|
||
];
|
||
const poster = i => `${A}p${i + 1}.webp`;
|
||
const html = (s) => { const t = document.createElement('template'); t.innerHTML = s.trim(); return t.content.firstChild; };
|
||
|
||
function nav(cur, focus) {
|
||
const items = ['Home', 'Discover', 'Movies', 'Shows'];
|
||
return `<div class="nav"><span class="wm">Wolf<b>skin</b></span>${items.map((n, i) => `<span class="ni ${i === cur ? 'cur' : ''} ${i === focus ? 'f' : ''}">${n}</span>`).join('')}<span class="nsp"></span><span class="av">S</span></div>`;
|
||
}
|
||
function rate(rt, pc) { return `<span class="rate"><i></i>${rt}</span><span class="rate"><i class="pc"></i>${pc}</span>`; }
|
||
function heroCopy(o) {
|
||
return `<div class="copy" style="bottom:${o.bottom || 330}px"><div class="kicker">${o.kicker}</div><div class="ht">${o.title}</div><div class="hm">${o.meta}</div>${o.ov ? `<div class="ho">${o.ov}</div>` : ''}<div class="btns">${o.btns}</div></div>`;
|
||
}
|
||
|
||
// ---------- scenes: build(stage, T, v) where v is the selected variant ----------
|
||
const SCENES = {
|
||
// 1. launch -> Who's watching
|
||
launch: { len: 1400, variants: { after: 'After: people from the saved list' }, build(s, T) {
|
||
s.append(html(`<div class="abs" style="inset:0;background:url(${A}launcher.webp) center/cover"></div>`));
|
||
const bg = s.appendChild(html(`<div class="abs" style="inset:0;background:#07070a"></div>`));
|
||
T.a(bg, [{ opacity: 0 }, { opacity: 1 }], 0, 160);
|
||
const pk = s.appendChild(html(`<div class="abs" style="inset:0"><div class="abs" style="left:96px;top:60px;font-size:36px;font-weight:800">Wolf<b style="color:var(--accent)">skin</b></div><div class="pk">Who’s watching?</div>
|
||
${[['J', '#64c3ff', 'justin', 540], ['M', '#ffa47a', 'mindel', 850], ['S', '#9be38f', 'shared', 1160]].map(([l, c, n, x]) => `<div class="person ${n === 'shared' ? 'ring' : ''}" style="left:${x}px;background:${c}">${l}</div><div class="pname" style="left:${x}px">${n}</div>`).join('')}</div>`));
|
||
T.a(pk, [{ opacity: 0 }, { opacity: 1 }], 450, 160);
|
||
} },
|
||
|
||
// 2. picker -> Home
|
||
home: { len: 1100, variants: { after: 'After: last Home painted from disk, art pre-decoded' }, build(s, T) {
|
||
const home = s.appendChild(html(`<div class="abs" style="inset:0">
|
||
<div class="amb" style="background-image:url(${A}b2t.webp);filter:blur(30px);transform:scale(1.1)"></div>
|
||
<div class="bd" style="background-image:url(${A}b2.webp)"></div><div class="scrim"></div>
|
||
${heroCopy({ kicker: '▸ Continue watching', title: 'Arrival', meta: '2016 · 1h 56m · 38 min left ' + rate('94%', '82%'), ov: 'Taking place after alien crafts land around the world, an expert linguist is recruited by the military to determine whether they come in peace or are a threat.', btns: '<span class="btn pri f">▶ Resume</span><span class="btn">ⓘ Details</span>', bottom: 340 })}
|
||
<div class="sh" style="top:780px">Next up</div>
|
||
${[3, 4, 1, 2].map((b, i) => `<div class="card tile" style="left:${96 + i * 440}px;top:850px"><div class="img" style="background-image:url(${A}b${b}.webp)"></div><div class="cap">${['American Gangster', 'Bohemian Rhapsody', 'Apocalypse Now', 'Arrival'][i]}</div></div>`).join('')}
|
||
${nav(0)}</div>`));
|
||
const picker = s.appendChild(html(`<div class="abs" style="inset:0;background:#07070a;z-index:10"><div class="pk">Who’s watching?</div>
|
||
${[['J', '#64c3ff', 'justin', 540], ['M', '#ffa47a', 'mindel', 850], ['S', '#9be38f', 'shared', 1160]].map(([l, c, n, x]) => `<div class="person ${n === 'shared' ? 'ring' : ''}" style="left:${x}px;background:${c}">${l}</div><div class="pname" style="left:${x}px">${n}</div>`).join('')}</div>`));
|
||
T.a(picker, [{ opacity: 1 }, { opacity: 0 }], 0, 140);
|
||
T.a(home.querySelector('.amb'), [{ opacity: 0 }, { opacity: 1 }], 40, 120);
|
||
T.a(home.querySelector('.bd'), [{ opacity: 0 }, { opacity: 1 }], 120, 220, INOUT);
|
||
T.a(home.querySelector('.copy'), [{ opacity: 0, transform: 'translateY(16px)' }, { opacity: 1, transform: 'none' }], 60, 180, OUT);
|
||
T.a(home.querySelector('.sh'), [{ opacity: 0 }, { opacity: 1 }], 120, 160);
|
||
home.querySelectorAll('.card').forEach((c, i) => {
|
||
T.a(c, [{ opacity: 0, transform: 'translateY(24px)' }, { opacity: 1, transform: 'none' }], 140 + i * 40, 180, OUT);
|
||
});
|
||
} },
|
||
|
||
// 3. skeleton styles on a poster grid
|
||
skeleton: { len: 2200, variants: { breathe: 'Breathe (pick)', flat: 'Flat blocks (today)', shimmer: 'Shimmer sweep', spinner: 'Spinner (today, launch)' }, build(s, T, v) {
|
||
s.append(html(nav(2)));
|
||
s.append(html(`<div class="abs" style="left:96px;top:150px;font-size:72px;font-weight:800;letter-spacing:-.02em">Movies <span style="font-size:30px;font-weight:500;color:var(--muted)">85 films</span></div>`));
|
||
const cells = [];
|
||
for (let r = 0; r < 2; r++) for (let c = 0; c < 6; c++) cells.push([96 + c * 296, 330 + r * 460, r * 6 + c]);
|
||
if (v === 'spinner') { const sp = s.appendChild(html('<div class="spin"></div>')); T.a(sp, [{ opacity: 1 }, { opacity: 0 }], 900, 120); }
|
||
cells.forEach(([x, y, i]) => {
|
||
if (v !== 'spinner') {
|
||
const sk = s.appendChild(html(`<div class="card poster sk ${v === 'breathe' ? 'breathe' : v === 'shimmer' ? 'shim' : ''}" style="left:${x}px;top:${y}px;${v === 'breathe' ? `animation-delay:${(i % 6) * 90}ms` : ''}"></div>`));
|
||
T.a(sk, [{ opacity: 1 }, { opacity: 0 }], 900 + i * 25, 160);
|
||
}
|
||
const c = s.appendChild(html(`<div class="card poster" style="left:${x}px;top:${y}px;background-image:url(${poster(i)})"></div>`));
|
||
T.a(c, [{ opacity: 0 }, { opacity: 1 }], 900 + i * 25, 160);
|
||
});
|
||
} },
|
||
|
||
// 4. tab switch: Home -> Discover, rows reveal
|
||
tab: { len: 1100, variants: { after: 'After: last Discover painted, rows rise, art fades' }, build(s, T) {
|
||
const disc = s.appendChild(html(`<div class="abs" style="inset:0">
|
||
<div class="bd" style="background-image:url(${A}b4.webp)"></div><div class="scrim"></div>
|
||
${heroCopy({ kicker: '✦ For you', title: 'Bohemian Rhapsody', meta: 'Movie · 2018 · Drama, Music ' + rate('60%', '85%'), ov: 'Singer Freddie Mercury, guitarist Brian May, drummer Roger Taylor and bass guitarist John Deacon take the music world by storm.', btns: '<span class="btn">ⓘ Details</span>', bottom: 520 })}
|
||
<div class="sh" style="top:600px">Movies for you</div>
|
||
${[6, 7, 8, 9, 10, 11].map((p, i) => `<div class="card poster" style="left:${96 + i * 296}px;top:670px"><div class="img" style="background-image:url(${poster(p)})"></div></div>`).join('')}
|
||
</div>`));
|
||
const home = s.appendChild(html(`<div class="abs" style="inset:0"><div class="bd" style="background-image:url(${A}b2.webp)"></div><div class="scrim"></div>
|
||
${heroCopy({ kicker: '▸ Continue watching', title: 'Arrival', meta: '2016 · 1h 56m · 38 min left', ov: 'Taking place after alien crafts land around the world, an expert linguist is recruited by the military.', btns: '<span class="btn pri">▶ Resume</span><span class="btn">ⓘ Details</span>', bottom: 340 })}</div>`));
|
||
const n = s.appendChild(html(nav(1, 1)));
|
||
T.a(home, [{ opacity: 1 }, { opacity: 0 }], 0, 200, EASE);
|
||
T.a(disc.querySelector('.bd'), [{ opacity: 0 }, { opacity: 1 }], 0, 240, INOUT);
|
||
T.a(disc.querySelector('.copy'), [{ opacity: 0, transform: 'translateY(16px)' }, { opacity: 1, transform: 'none' }], 80, 180, OUT);
|
||
T.a(disc.querySelector('.sh'), [{ opacity: 0 }, { opacity: 1 }], 140, 160);
|
||
disc.querySelectorAll('.card').forEach((c, i) => T.a(c, [{ opacity: 0, transform: 'translateY(24px)' }, { opacity: 1, transform: 'none' }], 160 + i * 30, 180, OUT));
|
||
} },
|
||
|
||
// 5. title page entry from the Movies grid
|
||
title: { len: 1600, variants: { after: 'After: what the card knew, at once' }, build(s, T) {
|
||
const page = s.appendChild(html(`<div class="abs" style="inset:0">
|
||
<div class="amb" style="background-image:url(${poster(2)});filter:blur(60px) saturate(1.2) brightness(.55);transform:scale(1.2)"></div>
|
||
<div class="bd" style="background-image:url(${A}b1.webp)"></div><div class="scrim"></div>
|
||
<div class="copy" style="bottom:360px"><div class="kicker">✓ Movie · In your library</div><div class="ht">Apocalypse Now</div>
|
||
<div class="hm">1979 · 2h 27m ${rate('91%', '94%')} <span class="late" style="color:var(--muted)">Drama, War</span></div>
|
||
<div class="ho late2" style="min-height:76px">At the height of the Vietnam war, Captain Benjamin Willard is sent on a dangerous mission that, officially, “does not exist, nor will it ever exist.”</div>
|
||
<div class="btns"><span class="btn pri f">▶ Play</span><span class="btn">⋯</span><span class="btn">Add for someone</span></div></div>
|
||
<div class="sh" style="top:830px">Cast</div>
|
||
${[0, 1, 2, 3, 4, 5, 6].map(i => `<div class="circle" style="left:${96 + i * 244}px;top:900px"><span class="sk breathe abs" style="inset:0;border-radius:50%;animation-delay:${i * 90}ms"></span><div class="img" style="background-image:url(${A}c${i + 1}.webp)"></div></div>`).join('')}
|
||
${nav(2)}</div>`));
|
||
const grid = s.appendChild(html(`<div class="abs" style="inset:0;background:#07070a">${nav(2)}
|
||
<div class="abs" style="left:96px;top:150px;font-size:72px;font-weight:800;letter-spacing:-.02em">Movies <span style="font-size:30px;font-weight:500;color:var(--muted)">85 films</span></div>
|
||
${[0, 1, 2, 3, 4, 5].map(i => `<div class="card poster ${i === 2 ? 'ring' : ''}" style="left:${96 + i * 296}px;top:330px;background-image:url(${poster(i)});${i === 2 ? 'transform:scale(1.08)' : ''}"></div>`).join('')}
|
||
${[6, 7, 8, 9, 10, 11].map((p, i) => `<div class="card poster" style="left:${96 + i * 296}px;top:790px;background-image:url(${poster(p)})"></div>`).join('')}</div>`));
|
||
T.a(grid, [{ opacity: 1 }, { opacity: 0 }], 0, 160);
|
||
T.a(page.querySelector('.amb'), [{ opacity: 0 }, { opacity: 1 }], 0, 160);
|
||
T.a(page.querySelector('.copy'), [{ opacity: 0, transform: 'translateY(16px)' }, { opacity: 1, transform: 'none' }], 40, 180, OUT);
|
||
T.a(page.querySelector('.late'), [{ opacity: 0 }, { opacity: 1 }], 430, 160);
|
||
T.a(page.querySelector('.late2'), [{ opacity: 0 }, { opacity: 1 }], 430, 160);
|
||
T.a(page.querySelector('.bd'), [{ opacity: 0 }, { opacity: 1 }], 470, 280, INOUT);
|
||
T.a(page.querySelector('.sh'), [{ opacity: 0 }, { opacity: 1 }], 120, 160);
|
||
page.querySelectorAll('.circle').forEach((c, i) => {
|
||
T.a(c, [{ opacity: 0, transform: 'translateY(20px)' }, { opacity: 1, transform: 'none' }], 140 + i * 30, 180, OUT);
|
||
T.a(c.querySelector('.img'), [{ opacity: 0 }, { opacity: 1 }], 620 + i * 40, 160);
|
||
});
|
||
} },
|
||
|
||
// 6. series title: late readiness line
|
||
series: { len: 4000, variants: { tv: 'TV-only: slot reserved, fades in at 3.4 s', conductarr: 'With the Conductarr cache: arrives ≈0.4 s' }, build(s, T, v) {
|
||
const late = v === 'conductarr' ? 380 : 3400;
|
||
const page = s.appendChild(html(`<div class="abs" style="inset:0">
|
||
<div class="amb" style="background-image:url(${A}s1t.webp);filter:blur(30px);transform:scale(1.1)"></div>
|
||
<div class="bd" style="background-image:url(${A}s1.webp)"></div><div class="scrim"></div>
|
||
<div class="copy" style="bottom:430px"><div class="kicker">✓ Series · In your library · Watched</div><div class="ht">Adults</div>
|
||
<div class="hm">2025 · 2 seasons · Comedy <span class="lr">${rate('88%', '93%')}</span></div>
|
||
<div class="hm lr2" style="color:var(--ok,#9be38f);font-size:26px">✓ Ready for you · by Mindel</div>
|
||
<div class="ho" style="min-height:76px">A group of twenty-somethings in New York trying to be good people, despite being neither “good” nor “people” yet.</div>
|
||
<div class="btns"><span class="btn pri f">▶ Play S1 E1</span><span class="btn">⋯</span><span class="btn">Seasons</span></div></div>
|
||
<div class="abs" style="left:96px;top:790px;display:flex;gap:16px"><span class="btn" style="height:56px;font-size:24px;background:var(--accent-soft,#2a1f3d)">Season 1</span><span class="btn" style="height:56px;font-size:24px">Season 2</span></div>
|
||
${[0, 1, 2, 3].map(i => `<div class="card tile sk breathe" style="left:${96 + i * 440}px;top:880px;animation-delay:${i * 90}ms"><div class="img" style="background-image:url(${A}${i % 2 ? 's1' : 'e1'}.webp)"></div></div>`).join('')}
|
||
${nav(3)}</div>`));
|
||
T.a(page.querySelector('.copy'), [{ opacity: 0, transform: 'translateY(16px)' }, { opacity: 1, transform: 'none' }], 0, 180, OUT);
|
||
T.a(page.querySelector('.amb'), [{ opacity: 0 }, { opacity: 1 }], 0, 160);
|
||
T.a(page.querySelector('.bd'), [{ opacity: 0 }, { opacity: 1 }], 620, 280, INOUT);
|
||
T.a(page.querySelector('.lr'), [{ opacity: 0 }, { opacity: 1 }], late, 200);
|
||
T.a(page.querySelector('.lr2'), [{ opacity: 0 }, { opacity: 1 }], late, 200);
|
||
page.querySelectorAll('.card .img').forEach((c, i) => T.a(c, [{ opacity: 0 }, { opacity: 1 }], 700 + i * 40, 160));
|
||
} },
|
||
|
||
// 7. focus move along a row
|
||
focus: { len: 3000, variants: { after: 'Proposed: 180 ms ease-out, caption rises', today: 'Today: 140 ms, caption cross-fade' }, build(s, T, v) {
|
||
s.append(html(nav(2)));
|
||
s.append(html(`<div class="sh" style="top:250px">Movies for you</div>`));
|
||
const dur = v === 'today' ? 140 : 180, ease = v === 'today' ? EASE : OUT;
|
||
const cards = [0, 1, 2, 3, 4, 5].map(i => s.appendChild(html(`<div class="card poster" style="left:${96 + i * 296}px;top:330px;background-image:url(${poster(i + 6)})"></div>`)));
|
||
const caps = [0, 1, 2, 3, 4, 5].map(i => s.appendChild(html(`<div class="abs" style="left:${96 + i * 296}px;top:740px;width:520px"><div style="font-size:30px;font-weight:700">${POSTERS[i + 6][0]}</div><div style="font-size:26px;color:var(--muted)">${POSTERS[i + 6][1]}</div></div>`)));
|
||
const step = 600;
|
||
cards.forEach((c, i) => {
|
||
const on = i * step, off = (i + 1) * step;
|
||
const up = [{ transform: 'scale(1)', boxShadow: '0 0 0 0 transparent' }, { transform: 'scale(1.08)', boxShadow: '0 0 0 2px oklch(78% 0.14 305), inset 0 0 0 2px oklch(78% 0.14 305), 0 18px 40px rgb(0 0 0/.55)' }];
|
||
if (i > 0) T.a(c, up, on, dur, ease); else T.a(c, [up[1], up[1]], 0, 1);
|
||
if (i < 4) T.a(c, [up[1], up[0]], off, dur, ease);
|
||
const capIn = v === 'today' ? [{ opacity: 0 }, { opacity: 1 }] : [{ opacity: 0, transform: 'translateY(10px)' }, { opacity: 1, transform: 'none' }];
|
||
if (i > 0) T.a(caps[i], capIn, on + (v === 'today' ? 0 : 60), dur, ease); else T.a(caps[i], [{ opacity: 1 }, { opacity: 1 }], 0, 1);
|
||
if (i < 4) T.a(caps[i], [{ opacity: 1 }, { opacity: 0 }], off, v === 'today' ? dur : 100, ease);
|
||
if (i > 4) caps[i].style.opacity = 0;
|
||
});
|
||
} },
|
||
|
||
// 8. hero crossfade (Discover hero advances)
|
||
hero: { len: 2600, variants: { after: 'Proposed: 320 ms backdrop cross-fade, copy hand-off', today: 'Today: cut' }, build(s, T, v) {
|
||
const mk = (b, t, meta, ov) => s.appendChild(html(`<div class="abs hero" style="inset:0"><div class="bd" style="background-image:url(${A}b${b}.webp)"></div><div class="scrim"></div>${heroCopy({ kicker: '✦ For you', title: t, meta, ov, btns: '<span class="btn">ⓘ Details</span>', bottom: 520 })}</div>`));
|
||
const b = mk(2, 'Arrival', 'Movie · 2016 · Drama, Sci-Fi ' + rate('94%', '82%'), 'Taking place after alien crafts land around the world, an expert linguist is recruited by the military to determine whether they come in peace or are a threat.');
|
||
const a = mk(1, 'Apocalypse Now', 'Movie · 1979 · Drama, War ' + rate('91%', '94%'), 'At the height of the Vietnam war, Captain Benjamin Willard is sent on a dangerous mission that, officially, does not exist.');
|
||
s.append(html(nav(1)));
|
||
s.append(html(`<div class="abs" style="left:96px;top:600px;font-size:34px;font-weight:700">Movies for you</div>`));
|
||
[0, 1, 2, 3, 4, 5].map(i => s.append(html(`<div class="card poster" style="left:${96 + i * 296}px;top:670px;background-image:url(${poster(i)})"></div>`)));
|
||
const at = 900;
|
||
if (v === 'today') { T.a(a, [{ opacity: 1 }, { opacity: 0 }], at, 1); return; }
|
||
T.a(a.querySelector('.copy'), [{ opacity: 1 }, { opacity: 0 }], at, 120, EASE);
|
||
T.a(a.querySelector('.bd'), [{ opacity: 1 }, { opacity: 0 }], at, 320, INOUT);
|
||
T.a(a.querySelector('.scrim'), [{ opacity: 1 }, { opacity: 1 }], at, 320);
|
||
T.a(a, [{ opacity: 1 }, { opacity: 0 }], at + 300, 20);
|
||
T.a(b.querySelector('.copy'), [{ opacity: 0, transform: 'translateY(14px)' }, { opacity: 1, transform: 'none' }], at + 140, 200, OUT);
|
||
} },
|
||
};
|
||
|
||
// ---------- player ----------
|
||
const players = [];
|
||
let boxRate = false;
|
||
function makePlayer(box) {
|
||
const name = box.dataset.scene, sc = SCENES[name];
|
||
const lead = +(box.dataset.lead || 300);
|
||
let variant = box.dataset.variant || Object.keys(sc.variants)[0];
|
||
let anims = [], t0 = 0, raf = 0, stage, label;
|
||
const total = () => lead + sc.len + 1400;
|
||
function build() {
|
||
box.querySelectorAll('.stage').forEach(e => e.remove());
|
||
stage = html('<div class="stage"></div>'); box.appendChild(stage);
|
||
label = stage.appendChild(html('<div class="tl">0 ms</div>'));
|
||
anims = [];
|
||
const rm = document.body.classList.contains('rm');
|
||
const seen = new WeakSet();
|
||
const T = { a(el, kf, start, dur, easing = EASE, opt = {}) {
|
||
let k = kf, d = dur;
|
||
if (rm) { k = kf.map(f => { const g = { ...f }; delete g.transform; return g; }); d = Math.min(dur, 100); }
|
||
// only an element's first animation fills backwards; later ones on the same element must not override it while waiting
|
||
const fill = seen.has(el) ? 'forwards' : 'both'; seen.add(el);
|
||
const an = el.animate(k, { delay: lead + start, duration: Math.max(1, d), easing, fill, ...opt });
|
||
an.pause(); anims.push(an);
|
||
} };
|
||
sc.build(stage, T, variant);
|
||
fit();
|
||
}
|
||
function fit() { if (stage) stage.style.transform = `scale(${box.clientWidth / 1920})`; }
|
||
function tick(now) {
|
||
let t = now - t0;
|
||
if (t > total()) { t0 = now; t = 0; }
|
||
const q = boxRate ? Math.floor(t / BOX_MS) * BOX_MS : t;
|
||
for (const an of anims) an.currentTime = q;
|
||
label.textContent = `${Math.max(0, Math.round(q - lead))} ms`;
|
||
raf = requestAnimationFrame(tick);
|
||
}
|
||
function start() { cancelAnimationFrame(raf); build(); t0 = performance.now(); raf = requestAnimationFrame(tick); }
|
||
function stop() { cancelAnimationFrame(raf); raf = 0; }
|
||
new ResizeObserver(fit).observe(box);
|
||
new IntersectionObserver(es => es.forEach(e => { if (e.isIntersecting) { if (!raf) start(); } else stop(); }), { rootMargin: '100px' }).observe(box);
|
||
const p = { box, start, setVariant(v) { variant = v; start(); }, rebuild: start };
|
||
players.push(p);
|
||
return p;
|
||
}
|
||
|
||
document.querySelectorAll('.sbox[data-scene]').forEach(makePlayer);
|
||
document.querySelectorAll('[data-variants]').forEach(bar => {
|
||
const p = players.find(x => x.box.id === bar.dataset.variants);
|
||
const sc = SCENES[p.box.dataset.scene];
|
||
Object.entries(sc.variants).forEach(([k, label], i) => {
|
||
const b = document.createElement('button'); b.type = 'button'; b.className = 'tab' + (i === 0 ? ' on' : ''); b.textContent = label;
|
||
b.onclick = () => { bar.querySelectorAll('.tab').forEach(x => x.classList.toggle('on', x === b)); p.setVariant(k); };
|
||
bar.appendChild(b);
|
||
});
|
||
});
|
||
document.querySelectorAll('button.replay').forEach(b => b.addEventListener('click', () => {
|
||
const pair = b.closest('.pair');
|
||
pair.querySelectorAll('video').forEach(v => { v.currentTime = 0; v.play().catch(() => {}); });
|
||
players.filter(p => pair.contains(p.box)).forEach(p => p.start());
|
||
}));
|
||
const rate_ = document.getElementById('boxrate');
|
||
if (rate_) rate_.addEventListener('change', () => { boxRate = rate_.checked; });
|
||
const rm = document.getElementById('rm');
|
||
if (rm) {
|
||
if (matchMedia('(prefers-reduced-motion: reduce)').matches) { rm.checked = true; document.body.classList.add('rm'); }
|
||
rm.addEventListener('change', () => { document.body.classList.toggle('rm', rm.checked); players.forEach(p => p.rebuild()); });
|
||
}
|
||
document.body.dataset.ready = '1';
|
||
})();
|
||
|
||
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> |