// 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 ``; } function rate(rt, pc) { return `${rt}${pc}`; } function heroCopy(o) { return `
${o.kicker}
${o.title}
${o.meta}
${o.ov ? `
${o.ov}
` : ''}
${o.btns}
`; } // ---------- 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(`
`)); const bg = s.appendChild(html(`
`)); T.a(bg, [{ opacity: 0 }, { opacity: 1 }], 0, 160); const pk = s.appendChild(html(`
Wolfskin
Who’s watching?
${[['J', '#64c3ff', 'justin', 540], ['M', '#ffa47a', 'mindel', 850], ['S', '#9be38f', 'shared', 1160]].map(([l, c, n, x]) => `
${l}
${n}
`).join('')}
`)); 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(`
${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: '▶ Resumeⓘ Details', bottom: 340 })}
Next up
${[3, 4, 1, 2].map((b, i) => `
${['American Gangster', 'Bohemian Rhapsody', 'Apocalypse Now', 'Arrival'][i]}
`).join('')} ${nav(0)}
`)); const picker = s.appendChild(html(`
Who’s watching?
${[['J', '#64c3ff', 'justin', 540], ['M', '#ffa47a', 'mindel', 850], ['S', '#9be38f', 'shared', 1160]].map(([l, c, n, x]) => `
${l}
${n}
`).join('')}
`)); 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(`
Movies 85 films
`)); 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('
')); T.a(sp, [{ opacity: 1 }, { opacity: 0 }], 900, 120); } cells.forEach(([x, y, i]) => { if (v !== 'spinner') { const sk = s.appendChild(html(`
`)); T.a(sk, [{ opacity: 1 }, { opacity: 0 }], 900 + i * 25, 160); } const c = s.appendChild(html(`
`)); 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(`
${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: 'ⓘ Details', bottom: 520 })}
Movies for you
${[6, 7, 8, 9, 10, 11].map((p, i) => `
`).join('')}
`)); const home = s.appendChild(html(`
${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: '▶ Resumeⓘ Details', bottom: 340 })}
`)); 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(`
✓ Movie · In your library
Apocalypse Now
1979 · 2h 27m ${rate('91%', '94%')} Drama, War
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.”
▶ Play⋯Add for someone
Cast
${[0, 1, 2, 3, 4, 5, 6].map(i => `
`).join('')} ${nav(2)}
`)); const grid = s.appendChild(html(`
${nav(2)}
Movies 85 films
${[0, 1, 2, 3, 4, 5].map(i => `
`).join('')} ${[6, 7, 8, 9, 10, 11].map((p, i) => `
`).join('')}
`)); 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(`
✓ Series · In your library · Watched
Adults
2025 · 2 seasons · Comedy ${rate('88%', '93%')}
✓ Ready for you · by Mindel
A group of twenty-somethings in New York trying to be good people, despite being neither “good” nor “people” yet.
▶ Play S1 E1⋯Seasons
Season 1Season 2
${[0, 1, 2, 3].map(i => `
`).join('')} ${nav(3)}
`)); 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(`
Movies for you
`)); const dur = v === 'today' ? 140 : 180, ease = v === 'today' ? EASE : OUT; const cards = [0, 1, 2, 3, 4, 5].map(i => s.appendChild(html(`
`))); const caps = [0, 1, 2, 3, 4, 5].map(i => s.appendChild(html(`
${POSTERS[i + 6][0]}
${POSTERS[i + 6][1]}
`))); 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(`
${heroCopy({ kicker: '✦ For you', title: t, meta, ov, btns: 'ⓘ Details', bottom: 520 })}
`)); 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(`
Movies for you
`)); [0, 1, 2, 3, 4, 5].map(i => s.append(html(`
`))); 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('
'); box.appendChild(stage); label = stage.appendChild(html('
0 ms
')); 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'; })();