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
251 lines
20 KiB
JavaScript
251 lines
20 KiB
JavaScript
// 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';
|
||
})();
|