wip(mobile): checkpoint engine and experience round three in progress

Co-Authored-By: Claude Opus 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01VVYRAqiQj9Y1V9ZfG1RcDH
This commit is contained in:
Claude committed 2026-10-23 07:29:27 +00:00
1 parent a6f98ce3c1
commit a720b10913
83 files changed
+50005 -416

No files matched your search

+704
View File
@@ -0,0 +1,704 @@
/**
* The Moments player (DESIGN.md §12.4): a cinematic, full-screen replay of a
* 4D reconstruction. The globe plays the Moment (globe.playMoment); a scrubber
* covers just the Moment's window; captures pop in at their times with their
* First-eyes byline, seal and view cone; the chat replays beside it (the
* social agent's chatOverlay when present); record posts appear as they were
* made; a legend reads each track's callsign, operator, altitude and speed.
*
* On a phone it is an overlay on the globe; on a tablet the chat, captures and
* record move to the leading pane and the globe keeps the trailing pane.
*/
import { icon } from './icons.js';
import {
$,
$$,
h,
esc,
haptic,
toast,
clamp,
esriThumb,
reducedMotion,
emitter,
} from './dom.js';
import { utc, fmtKm } from './bands.js';
import { trackAt } from './stubMoments.js';
import { social } from './socialBridge.js';
const TRACK_COL = [
'#a3d8ff',
'#ffd27a',
'#ff9db0',
'#9be15d',
'#b39dff',
'#7cf0d0',
];
const SPEEDS = [1, 10, 30, 60];
const KIND = {
witness: ['witness', 'Witness'],
context: ['context', 'Context'],
question: ['question', 'Question'],
correction: ['correction', 'Correction'],
prediction: ['prediction', 'Prediction'],
synthesis: ['sparkles', 'Clerk · synthesis'],
};
const BADGE = {
'first-eyes': ['First eyes', 'seal'],
witness: ['Was there', 'witness'],
pilot: ['Pilot', 'sky'],
local: ['Local', 'pin'],
mod: ['Mod', 'checkmark'],
};
const at = (h) =>
!h || h.startsWith('@') || h === 'Clerk' || h === 'Record' ? h : `@${h}`;
/** The engine's Moment and the shell's share one shape after this. */
export function normMoment(m) {
const subject = m.subject || { lon: m.lon, lat: m.lat };
const R = 6371008.8;
const dist = (a) => {
const dLat = ((subject.lat - a.lat) * Math.PI) / 180;
const dLon = ((subject.lon - a.lon) * Math.PI) / 180;
const x =
Math.sin(dLat / 2) ** 2 +
Math.cos((a.lat * Math.PI) / 180) *
Math.cos((subject.lat * Math.PI) / 180) *
Math.sin(dLon / 2) ** 2;
return 2 * R * Math.asin(Math.min(1, Math.sqrt(x)));
};
return {
...m,
subject,
tracks: (m.tracks || []).map((tr) => ({
...tr,
arriveAt: tr.arriveAt ?? tr.points?.find((p) => dist(p) < 900)?.t,
})),
captures: (m.captures || []).map((c) => ({
...c,
handle: at(c.handle),
where: c.where || c.place || '',
media: c.media || c.kind || 'photo',
live: c.live || c.kind === 'live',
distanceM: c.distanceM ?? c.rangeM ?? Math.round(dist(c)),
})),
chat: (m.chat || []).map((c) => ({ ...c, handle: at(c.handle) })),
posts: (m.posts || []).map((p) => ({
...p,
handle: at(p.handle),
votes:
typeof p.votes === 'number'
? { corroborate: p.votes, dispute: 0 }
: p.votes || { corroborate: 0, dispute: 0 },
})),
};
}
/** A capture's thumbnail: the place, with its view cone toward the subject. */
export function coneThumb(c, m, { w = 168, hgt = 120 } = {}) {
const span = 0.006;
const mPerPx = (span * 111320 * (hgt / w)) / hgt;
const len = clamp((c.distanceM || 250) / mPerPx, 26, w * 0.7);
const a = ((c.headingDeg || 0) - 90) * (Math.PI / 180);
const f = ((c.fovDeg || 52) / 2) * (Math.PI / 180);
const cx = w / 2;
const cy = hgt / 2;
const p1 = [cx + len * Math.cos(a - f), cy + len * Math.sin(a - f)];
const p2 = [cx + len * Math.cos(a + f), cy + len * Math.sin(a + f)];
const tx = cx + len * 0.92 * Math.cos(a);
const ty = cy + len * 0.92 * Math.sin(a);
return `<span class="cone-thumb" style="aspect-ratio:${w}/${hgt}">
<img src="${esc(esriThumb(c.lon, c.lat, span, w * 2, hgt * 2))}" alt="" loading="lazy" decoding="async"/>
<svg viewBox="0 0 ${w} ${hgt}" aria-hidden="true"><defs><linearGradient id="cg-${esc(c.id)}" x1="0" y1="0" x2="1" y2="0" gradientTransform="rotate(${(c.headingDeg || 0) - 90} .5 .5)"><stop offset="0" stop-color="#ebc777" stop-opacity=".75"/><stop offset="1" stop-color="#ebc777" stop-opacity=".08"/></linearGradient></defs>
<path d="M${cx} ${cy}L${p1[0].toFixed(1)} ${p1[1].toFixed(1)}A${len} ${len} 0 0 1 ${p2[0].toFixed(1)} ${p2[1].toFixed(1)}Z" fill="url(#cg-${esc(c.id)})" stroke="#ebc777" stroke-opacity=".8" stroke-width="1"/>
<circle cx="${cx}" cy="${cy}" r="3.6" fill="#ebc777" stroke="#050607" stroke-width="1.4"/>
${m?.id === 'empire-state' ? `<path d="M${tx - 5} ${ty - 5}h3M${tx - 5} ${ty - 5}v3M${tx + 5} ${ty - 5}h-3M${tx + 5} ${ty - 5}v3M${tx - 5} ${ty + 5}h3M${tx - 5} ${ty + 5}v-3M${tx + 5} ${ty + 5}h-3M${tx + 5} ${ty + 5}v-3" stroke="#fff" stroke-width="1.1"/>` : ''}
</svg></span>`;
}
export function createMomentPlayer({
app,
globe,
onExit,
onOpenEntity,
onOpenRoom,
isTwoPane,
}) {
const ev = emitter();
const el = h(`<section class="mp" aria-label="Moment player" hidden>
<div class="mp-stage">
<header class="mp-top">
<button class="icon-btn glass-strong liquid mp-back" type="button" aria-label="Close Moment">${icon('chevronLeft')}</button>
<div class="mp-titles"><div class="mp-kicker tele"></div><h2 class="mp-title"></h2></div>
<button class="icon-btn glass-strong liquid mp-room" type="button" aria-label="Open the room">${icon('rooms')}</button>
</header>
<div class="mp-clock tele"><b class="num mp-utc">—</b><span class="mp-rel num"></span></div>
<div class="mp-legend glass-strong" role="list" aria-label="Tracks"></div>
<div class="mp-pops" aria-live="polite"></div>
<div class="mp-react" role="group" aria-label="React">
<button type="button" data-react="heart" aria-label="Heart">❤️</button><button type="button" data-react="fire" aria-label="Fire">🔥</button><button type="button" data-react="eyes" aria-label="Eyes">👀</button><button type="button" data-react="wow" aria-label="Wow">😮</button>
</div>
<div class="mp-bar glass-strong">
<button class="mp-play" type="button" aria-label="Pause">${icon('pause')}</button>
<div class="mp-scrub" role="slider" tabindex="0" aria-label="Moment time"><canvas></canvas><i class="mp-head"></i></div>
<div class="mp-speeds" role="radiogroup" aria-label="Speed">${SPEEDS.map((s) => `<button type="button" role="radio" data-speed="${s}" aria-checked="${s === 30}">${s}×</button>`).join('')}</div>
</div>
</div>
<aside class="mp-side glass-strong">
<div class="mp-side-title"><div class="mp-st-k tele"></div><h3></h3><p class="mp-st-s"></p></div>
<div class="mp-side-head">
<div class="segmented mp-seg" role="tablist"><button role="tab" type="button" data-seg="chat" aria-selected="true">Chat<span class="mp-n" data-n="chat"></span></button><button role="tab" type="button" data-seg="record" aria-selected="false">Record<span class="mp-n" data-n="record"></span></button><button role="tab" type="button" data-seg="captures" aria-selected="false">Captures<span class="mp-n" data-n="captures"></span></button></div>
</div>
<div class="mp-pane" data-pane="chat"><div class="mp-chat"></div></div>
<div class="mp-pane" data-pane="record" hidden><div class="mp-record"></div></div>
<div class="mp-pane" data-pane="captures" hidden><div class="mp-caps"></div></div>
<p class="mp-src tele"></p>
</aside>
</section>`);
app.appendChild(el);
const legend = $('.mp-legend', el);
const pops = $('.mp-pops', el);
const scrub = $('.mp-scrub', el);
const canvas = $('canvas', scrub);
const sctx = canvas.getContext('2d');
const chatEl = $('.mp-chat', el);
const recEl = $('.mp-record', el);
const capsEl = $('.mp-caps', el);
let m = null;
let raf = 0;
let t = 0;
let speed = 30;
let shown = { chat: 0, posts: 0, caps: 0 };
let ext = null; // social chatOverlay
let seg = 'chat';
let wasPlaying = false;
let reactCooldown = 0;
let offOwn = null;
/* ---------- rendering pieces ---------- */
const rel = (ms) => {
const s = Math.max(0, Math.round((ms - m.time.start) / 1000));
return `+${Math.floor(s / 60)}:${String(s % 60).padStart(2, '0')}`;
};
function legendHtml() {
return (m.tracks || [])
.map(
(
tr,
i,
) => `<button class="mp-tr" type="button" role="listitem" data-track="${esc(tr.id)}" style="--c:${esc(tr.color || TRACK_COL[i % TRACK_COL.length])}">
<i class="sw"></i><span class="mp-tr-main"><b>${esc(tr.callsign || String(tr.label).replace(/\s*\(illustrative\)/i, ''))}</b><small>${esc(tr.operator || tr.type || tr.kind || '')}</small></span>
<span class="mp-tr-v tele"><b class="num" data-alt>—</b><span data-spd>—</span></span></button>`,
)
.join('');
}
function capCard(c, { pop = false } = {}) {
const fe = c.firstEyes;
return `<button class="mp-cap ${pop ? 'pop' : ''} ${fe ? 'first' : ''}" type="button" data-cap="${esc(c.id)}">
${coneThumb(c, m)}
<span class="mp-cap-body">
${c.self ? `<span class="fe tele">${icon('sealFill')}YOURS${fe ? ' · FIRST EYES' : ''}</span>` : fe ? `<span class="fe tele">${icon('sealFill')}FIRST EYES</span>` : `<span class="fe tele">${icon(c.live ? 'live' : 'seal')}${c.live ? 'LIVE · SEALED' : 'SEALED'}</span>`}
<b>${esc(c.title)}</b>
<small>${esc(c.handle)} · ${esc(utc(c.t))} · ${esc(fmtKm((c.distanceM || 0) / 1000))}</small>
</span></button>`;
}
function chatHtml(c) {
const b = c.badge && BADGE[c.badge];
return `<div class="mp-msg ${c.badge ? `b-${esc(c.badge)}` : ''}"><span class="mp-who">${b ? `<span class="mp-badge" title="${esc(b[0])}">${icon(b[1])}</span>` : ''}${esc(c.handle)}</span> <span class="mp-txt">${esc(c.text)}</span></div>`;
}
function postHtml(p) {
const k = KIND[p.kind] || KIND.context;
return `<article class="mp-post k-${esc(p.kind)}"><header><span class="mp-pk">${icon(k[0])}${esc(k[1])}</span><span class="tele">${esc(utc(p.t, { seconds: false }))}</span></header>
<p><b>${esc(p.handle)}</b> ${esc(p.text)}</p>
${p.kind !== 'synthesis' ? `<footer class="tele"><span>${icon('agree')}${p.votes?.corroborate ?? 0} corroborate</span><span>${icon('dispute')}${p.votes?.dispute ?? 0} dispute</span>${p.captureId ? `<button type="button" data-cap="${esc(p.captureId)}">${icon('seal')}Sealed capture</button>` : ''}</footer>` : '<footer class="tele"><span>AI synthesis · cites 5 captures and 37 messages</span></footer>'}</article>`;
}
function drawScrub() {
const r = scrub.getBoundingClientRect();
const dpr = Math.min(2, window.devicePixelRatio || 1);
const W = r.width;
const H = r.height;
if (canvas.width !== Math.round(W * dpr)) {
canvas.width = Math.round(W * dpr);
canvas.height = Math.round(H * dpr);
}
sctx.setTransform(dpr, 0, 0, dpr, 0, 0);
sctx.clearRect(0, 0, W, H);
const s = m.time.start;
const e = m.time.end;
const x = (ms) => 6 + ((ms - s) / (e - s)) * (W - 12);
const mid = H / 2 + 6;
// Chat density (a quiet histogram).
const bins = new Array(60).fill(0);
for (const c of m.chat || [])
bins[Math.min(59, Math.floor(((c.t - s) / (e - s)) * 60))] += 1;
const peak = Math.max(1, ...bins);
sctx.fillStyle = 'rgba(255,255,255,0.12)';
bins.forEach((b, i) => {
const bh = (b / peak) * 12;
sctx.fillRect(
6 + (i / 60) * (W - 12),
mid - 6 - bh,
(W - 12) / 60 - 1,
bh,
);
});
// Track and progress
sctx.fillStyle = 'rgba(255,255,255,0.14)';
sctx.fillRect(6, mid - 1.5, W - 12, 3);
sctx.fillStyle = '#fff';
sctx.fillRect(6, mid - 1.5, x(t) - 6, 3);
// Arrivals, captures and posts
// Beats (the story's chapters), else arrivals.
const beats = m.beats?.length
? m.beats.map((b) => b.t)
: (m.tracks || []).map((tr) => tr.arriveAt).filter(Boolean);
for (const bt of beats) {
sctx.fillStyle =
bt <= t ? 'rgba(163,216,255,0.95)' : 'rgba(163,216,255,0.4)';
sctx.fillRect(x(bt) - 1, mid - 9, 2, 18);
}
for (const p of m.posts || []) {
sctx.fillStyle =
p.t <= t ? 'rgba(255,255,255,0.9)' : 'rgba(255,255,255,0.35)';
sctx.beginPath();
sctx.arc(x(p.t), mid + 9, 2, 0, Math.PI * 2);
sctx.fill();
}
for (const c of m.captures || []) {
sctx.fillStyle = c.t <= t ? '#ebc777' : 'rgba(235,199,119,0.4)';
sctx.beginPath();
sctx.arc(x(c.t), mid, c.firstEyes ? 5 : 3.6, 0, Math.PI * 2);
sctx.fill();
sctx.strokeStyle = '#08090b';
sctx.lineWidth = 1.2;
sctx.stroke();
}
$('.mp-head', el).style.transform = `translateX(${x(t)}px)`;
}
/* ---------- the clock ---------- */
function sync(force = false) {
const nc = (m.chat || []).filter((c) => c.t <= t).length;
const np = (m.posts || []).filter((p) => p.t <= t).length;
const caps = (m.captures || []).filter((c) => c.t <= t);
const nk = caps.length;
// Seeking back re-renders; playing forward appends with a pop.
// The social overlay replays the raw chat by the clock itself.
if (ext) {
try {
ext.setTime?.(t);
} catch {
/* ignore */
}
}
if (force || nc < shown.chat) {
chatEl.innerHTML =
(m.chat || [])
.filter((c) => c.t <= t)
.slice(-40)
.map(chatHtml)
.join('') ||
`<p class="mp-empty tele">${(m.chat || []).length ? 'The chat starts as the Moment does' : 'No chat was recorded in this window'}</p>`;
} else
for (const c of (m.chat || []).slice(shown.chat, nc)) {
chatEl.querySelector('.mp-empty')?.remove();
chatEl.insertAdjacentHTML('beforeend', chatHtml(c));
const last = chatEl.lastElementChild;
last.classList.add('in');
while (chatEl.children.length > 60) chatEl.firstElementChild.remove();
// Reactions in chat rise from the city on the globe.
const k = /❤️|🙏|♥/.test(c.text)
? 'heart'
: /🔥/.test(c.text)
? 'fire'
: /👀/.test(c.text)
? 'eyes'
: /😮|wild|wow/i.test(c.text)
? 'wow'
: null;
if (k) burst(k, 4);
}
if (force || np < shown.posts)
recEl.innerHTML =
(m.posts || [])
.filter((p) => p.t <= t)
.map(postHtml)
.reverse()
.join('') || emptyRec();
else
for (const p of (m.posts || []).slice(shown.posts, np)) {
recEl.querySelector('.mp-empty')?.remove();
recEl.insertAdjacentHTML('afterbegin', postHtml(p));
recEl.firstElementChild.classList.add('in');
}
if (force || nk < shown.caps) {
capsEl.innerHTML =
caps
.map((c) => capCard(c))
.reverse()
.join('') ||
'<p class="mp-empty tele">Captures appear here at the moment they were sealed</p>';
// After a seek, the latest capture stays pinned (settled) for context.
pops.innerHTML = caps.length
? capCard(caps[caps.length - 1]).replace(
'class="mp-cap ',
'class="mp-cap settled ',
)
: '';
} else
for (const c of caps.slice(shown.caps)) {
capsEl.querySelector('.mp-empty')?.remove();
capsEl.insertAdjacentHTML('afterbegin', capCard(c));
popCapture(c);
}
if (nc !== shown.chat && seg === 'chat') {
const pane = chatEl.parentElement;
pane.scrollTop = pane.scrollHeight;
}
shown = { chat: nc, posts: np, caps: nk };
$('[data-n="chat"]', el).textContent = nc ? ` ${nc}` : '';
$('[data-n="record"]', el).textContent = np ? ` ${np}` : '';
$('[data-n="captures"]', el).textContent = nk ? ` ${nk}` : '';
// Legend: live per-track readouts.
(m.tracks || []).forEach((tr) => {
const row = legend.querySelector(`[data-track="${CSS.escape(tr.id)}"]`);
if (!row) return;
const p = trackAt(tr, t);
row.classList.toggle('absent', !p);
$('[data-alt]', row).textContent = p
? `${Math.round((p.altM * 3.28084) / 10) * 10} FT`
: '—';
$('[data-spd]', row).textContent = !p
? 'NOT YET'
: p.speedMps
? `${Math.round(p.speedMps * 1.94384)} KT`
: 'LANDED';
});
$('.mp-utc', el).textContent = utc(t);
$('.mp-rel', el).textContent = `${rel(t)} / ${rel(m.time.end)}`;
drawScrub();
}
const emptyRec = () =>
'<p class="mp-empty tele">Record posts appear as they were made</p>';
function popCapture(c) {
const card = h(capCard(c, { pop: true }));
pops.prepend(card);
haptic(c.firstEyes ? 'seal' : 'light');
while (pops.children.length > (isTwoPane() ? 3 : 1))
pops.lastElementChild.remove();
setTimeout(() => card.classList.add('settle'), 4200);
}
function burst(kind, count = 3) {
const now = performance.now();
if (now - reactCooldown < 200) return;
reactCooldown = now;
const jitter = () => (Math.random() - 0.5) * 0.01;
globe.react({
lon: (m.lon ?? 0) + jitter(),
lat: (m.lat ?? 0) + jitter(),
kind,
count,
});
}
function loop() {
raf = 0;
if (!m) return;
const a = globe.activeMoment();
const st = globe.playback.state();
const nt = Number.isFinite(a?.t) ? a.t : Number.isFinite(st?.t) ? st.t : t;
if (nt !== t) {
t = clamp(nt, m.time.start, m.time.end);
sync();
}
setPlayingUi(!!(a?.playing ?? st?.playing));
raf = requestAnimationFrame(loop);
}
function setPlayingUi(p) {
if (el.classList.contains('playing') === p) return;
el.classList.toggle('playing', p);
$('.mp-play', el).innerHTML = icon(p ? 'pause' : 'play');
$('.mp-play', el).setAttribute('aria-label', p ? 'Pause' : 'Play');
}
/* ---------- open / close ---------- */
async function open(
idOrMoment,
{ at: atT = null, speed: s = null, autoplay = true } = {},
) {
const raw =
typeof idOrMoment === 'string'
? await globe.moment(idOrMoment)
: idOrMoment;
const mm = raw ? normMoment(raw) : null;
if (!mm) {
toast('That Moment is not available', { icon: icon('film') });
return null;
}
m = mm;
speed =
s ?? (SPEEDS.includes(m.recommendedSpeed) ? m.recommendedSpeed : 30);
$$('[data-speed]', el).forEach((b) =>
b.setAttribute('aria-checked', String(Number(b.dataset.speed) === speed)),
);
el.dataset.illustrative = String(!!m.illustrative);
$('.mp-kicker', el).innerHTML =
`<span class="mp-dot"></span>MOMENT · ${esc(m.place || '')}${m.simulated ? ' <span class="tag-ill">Simulated</span>' : m.illustrative ? ' <span class="tag-ill">Illustrative</span>' : ' <span class="tag-real">Recorded</span>'}`;
$('.mp-title', el).textContent = m.title;
$('.mp-side-title h3', el).textContent = m.title;
$('.mp-st-k', el).innerHTML = $('.mp-kicker', el).innerHTML;
$('.mp-st-s', el).textContent =
`${Math.round((m.time.end - m.time.start) / 60e3)} min · ${(m.tracks || []).length} tracks · ${(m.captures || []).length} sealed captures · ${(m.posts || []).length} posts on the record`;
legend.innerHTML = legendHtml();
const extra = (m.tracks || []).length - 4;
if (extra > 0)
legend.insertAdjacentHTML(
'beforeend',
`<div class="mp-more tele">+${extra} more tracks on the globe</div>`,
);
legend.hidden = !(m.tracks || []).length;
$('.mp-src', el).textContent = (m.sources || [])
.map((x) => `${x.name}${x.detail ? ` — ${x.detail}` : ''}`)
.join(' · ');
shown = { chat: 0, posts: 0, caps: 0 };
chatEl.innerHTML = '';
recEl.innerHTML = emptyRec();
capsEl.innerHTML = '';
pops.innerHTML = '';
// The social agent's chat replay, when it exists.
ext = null;
const sm = await social();
// Your own sealed captures posted into this Moment join its timeline.
const addOwn = (list) => {
for (const c of list || []) {
const id = String(c.id).split(':').pop();
if (m.captures.some((x) => x.id === id)) continue;
m.captures.push(
normMoment({
...m,
captures: [
{
...c,
id,
t: clamp(+c.t || m.time.end, m.time.start, m.time.end),
self: true,
title: c.title || 'Your sealed capture',
},
],
}).captures[0],
);
}
m.captures.sort((a, b) => a.t - b.t);
};
try {
addOwn(await sm?.momentCaptures?.(m.id));
} catch {
/* optional */
}
offOwn?.();
offOwn =
sm?.onSocial?.('moment-capture', (e) => {
if (!m || e?.momentId !== m.id) return;
addOwn([e.capture]);
sync(true);
}) || null;
const slot = $('.mp-pane[data-pane="chat"]', el);
slot.classList.remove('has-ext');
el.classList.remove('has-ext');
$('.mp-ext', slot)?.remove();
// Clips carry no chat: the overlay would only invent one.
if (typeof sm?.chatOverlay === 'function' && (m.chat || []).length) {
try {
const host = h('<div class="mp-ext"></div>');
slot.prepend(host);
ext = sm.chatOverlay(host, { moment: raw, globe, getTime: () => t });
ext?.setMoment?.({ ...raw, chat: [] });
slot.classList.add('has-ext');
el.classList.add('has-ext');
} catch (err) {
console.warn(
'[moment] chatOverlay failed; using the built-in replay',
err,
);
ext = null;
}
}
el.hidden = false;
app.classList.add('is-moment');
void el.offsetWidth;
el.classList.add('in');
t = atT ?? m.time.start;
await globe.playMoment(raw, { speed, camera: true, autoplay: false });
if (atT != null) globe.seekMoment(atT);
sync(true);
if (autoplay) globe.playback.play({ speed });
cancelAnimationFrame(raf);
raf = requestAnimationFrame(loop);
ev.emit('open', m);
return m;
}
function close() {
if (!m) return;
cancelAnimationFrame(raf);
raf = 0;
globe.exitMoment();
offOwn?.();
offOwn = null;
const was = m;
m = null;
el.classList.remove('in');
app.classList.remove('is-moment');
setTimeout(
() => {
if (!m) el.hidden = true;
},
reducedMotion() ? 0 : 320,
);
ev.emit('close', was);
onExit?.(was);
}
/* ---------- input ---------- */
$('.mp-back', el).addEventListener('click', () => {
haptic('tick');
close();
});
$('.mp-room', el).addEventListener('click', () => m && onOpenRoom?.(m));
$('.mp-play', el).addEventListener('click', () => {
haptic('tick');
const st = globe.playback.state();
if (st.playing) globe.playback.stop();
else globe.playback.play({ speed });
});
$('.mp-speeds', el).addEventListener('click', (e) => {
const b = e.target.closest('[data-speed]');
if (!b) return;
haptic('tick');
speed = Number(b.dataset.speed);
$$('[data-speed]', el).forEach((x) =>
x.setAttribute('aria-checked', String(x === b)),
);
if (globe.playback.state().playing) globe.playback.play({ speed });
});
$('.mp-react', el).addEventListener('click', (e) => {
const b = e.target.closest('[data-react]');
if (!b || !m) return;
haptic('light');
reactCooldown = 0;
burst(b.dataset.react, 3);
b.animate(
[
{ transform: 'scale(1)' },
{ transform: 'scale(1.35)' },
{ transform: 'scale(1)' },
],
{ duration: 320, easing: 'cubic-bezier(.32,1.25,.4,1)' },
);
});
const seekTo = (clientX) => {
const r = scrub.getBoundingClientRect();
const k = clamp((clientX - r.left - 6) / (r.width - 12), 0, 1);
const ms = m.time.start + k * (m.time.end - m.time.start);
globe.seekMoment(ms);
t = ms;
sync(true);
};
let dragging = false;
scrub.addEventListener('pointerdown', (e) => {
if (!m) return;
scrub.setPointerCapture(e.pointerId);
dragging = true;
wasPlaying = globe.playback.state().playing;
globe.playback.stop();
el.classList.add('scrubbing');
seekTo(e.clientX);
});
scrub.addEventListener('pointermove', (e) => dragging && seekTo(e.clientX));
const up = () => {
if (!dragging) return;
dragging = false;
el.classList.remove('scrubbing');
if (wasPlaying) globe.playback.play({ speed });
};
scrub.addEventListener('pointerup', up);
scrub.addEventListener('pointercancel', up);
scrub.addEventListener('keydown', (e) => {
if (!m) return;
const step = e.shiftKey ? 60e3 : 10e3;
if (e.key === 'ArrowRight')
globe.seekMoment(Math.min(m.time.end, t + step));
else if (e.key === 'ArrowLeft')
globe.seekMoment(Math.max(m.time.start, t - step));
else if (e.key === ' ') $('.mp-play', el).click();
else return;
e.preventDefault();
t = globe.activeMoment()?.t ?? t;
sync(true);
});
$('.mp-seg', el).addEventListener('click', (e) => {
const b = e.target.closest('[data-seg]');
if (!b) return;
haptic('tick');
seg = b.dataset.seg;
$$('[data-seg]', el).forEach((x) =>
x.setAttribute('aria-selected', String(x === b)),
);
$$('.mp-pane', el).forEach((p) => (p.hidden = p.dataset.pane !== seg));
});
el.addEventListener('click', (e) => {
const tr = e.target.closest('[data-track]');
if (tr && m) return onOpenEntity?.(`moment:${m.id}:${tr.dataset.track}`);
const c = e.target.closest('[data-cap]');
if (c && m) return onOpenEntity?.(`capture:${m.id}:${c.dataset.cap}`);
});
document.addEventListener('keydown', (e) => {
if (!m || e.target.closest?.('input, textarea')) return;
if (
e.key === 'Escape' &&
!document.querySelector(
'.sheet-layer:not([hidden]) .sheet[aria-modal="true"]',
)
)
close();
});
new ResizeObserver(() => m && drawScrub()).observe(scrub);
return {
open,
close,
on: ev.on,
get current() {
return m;
},
get time() {
return t;
},
/** Deterministic setup for the director. */
seek(ms, { play = false } = {}) {
if (!m) return;
globe.seekMoment(ms);
t = ms;
sync(true);
if (play) globe.playback.play({ speed });
else globe.playback.stop();
},
setSeg(s) {
$(`[data-seg="${s}"]`, el)?.click();
},
};
}