/* AbleSet — client part. One factory draws every AbleSet tile; `moduleApi.variant` says which one (the server's tiles() list). All of them read the same stream: `state` whenever something changes, `pos` five times a second while playing. Between packets the playhead and the countdowns are extrapolated from the tempo and the clock, so bars glide instead of stepping. */ const VIEWS = ['now', 'sections', 'setlist', 'transport', 'tempo', 'song-time', 'set-time', 'section', 'next-song', 'status']; /* AbleSet names its colours after Live's palette; these are close enough to read as the same colour on a dark tile. Unknown names draw no colour. */ const NAMED_COLORS = { red: '#ff4d4f', orange: '#ff8f3a', yellow: '#f5d442', lime: '#a3e635', green: '#34d399', mint: '#5eead4', teal: '#2dd4bf', cyan: '#22d3ee', sky: '#38bdf8', blue: '#3b82f6', indigo: '#6366f1', purple: '#a855f7', violet: '#8b5cf6', magenta: '#e879f9', pink: '#f472b6', rose: '#fb7185', brown: '#b0794a', gray: '#9ca3af', grey: '#9ca3af', white: '#f5f5f5', }; const ICON_STRIP = ''; const ICON_WHEEL = ''; function esc(s) { return String(s ?? '').replace(/[&<>"']/g, (c) => ({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' }[c])); } /** 83 → "01:23", 3725 → "1:02:05". Whole seconds round down, as AbleSet's own display does. */ function fmt(sec) { if (sec == null || !Number.isFinite(sec)) return '--:--'; const s = Math.max(0, Math.floor(sec + 1e-6)); const h = Math.floor(s / 3600); const m = Math.floor((s % 3600) / 60); const ss = String(s % 60).padStart(2, '0'); return h ? `${h}:${String(m).padStart(2, '0')}:${ss}` : `${String(m).padStart(2, '0')}:${ss}`; } function fmtBpm(t) { if (t == null) return '--'; return (Math.round(t * 100) / 100).toString(); } function colorFor(value) { if (!value) return null; if (/^#[0-9a-f]{6}$/i.test(value)) return value; return NAMED_COLORS[String(value).toLowerCase()] || null; } export default function create({ root, moduleApi }) { const view = VIEWS.includes(moduleApi.variant) ? moduleApi.variant : moduleApi.variant ? '' : 'now'; // an entry that vanished draws nothing const prefs = () => moduleApi.instanceSettings || {}; const on = (k) => prefs()[k] !== false; let state = null; let pos = { beats: null, remainingSong: null, remainingSet: null }; let posAt = 0; let stream = null; let raf = 0; let resizeObserver = null; let tickTimer = null; /* ── moving values, extrapolated to this frame ─────────────────── */ const moving = () => Boolean(state && state.reachable && state.playing); const dt = () => (moving() ? (performance.now() - posAt) / 1000 : 0); function beatsNow() { if (pos.beats == null) return null; const t = state?.tempo; return t ? pos.beats + dt() * (t / 60) : pos.beats; } const songLeft = () => (pos.remainingSong == null ? null : Math.max(0, pos.remainingSong - dt())); const setLeft = () => (pos.remainingSet == null ? null : Math.max(0, pos.remainingSet - dt())); function fraction(start, end, b) { if (start == null || end == null || b == null || end <= start) return null; return Math.min(1, Math.max(0, (b - start) / (end - start))); } function songsLeft() { if (!state?.song || state.song.index == null) return null; return Math.max(0, state.setlist.songs.length - state.song.index - 1); } /** Beats per bar from the time signature (Live counts quarter notes). */ function beatsPerBar() { const ts = state?.timeSig; return ts && ts[0] > 0 && ts[1] > 0 ? (ts[0] * 4) / ts[1] : 4; } /** Bar and beat within the song, 1-based. */ function barBeat() { const b = beatsNow(); const start = state?.song?.start ?? 0; if (b == null) return null; const rel = Math.max(0, b - start); const bpb = beatsPerBar(); const ts = state?.timeSig || [4, 4]; const beatLen = 4 / (ts[1] || 4); const bar = Math.floor(rel / bpb) + 1; const beat = Math.floor((rel % bpb) / beatLen + 1e-6) + 1; return { bar, beat, of: ts[0] || 4 }; } function tone(sec) { if (sec == null || !moving()) return ''; const warn = Number(prefs().warnAt ?? 30); const alert = Number(prefs().alertAt ?? 10); if (sec <= alert) return 'alert'; if (sec <= warn) return 'warn'; return ''; } /* ── fitting one-line text to its box ──────────────────────────── */ /** Shrink a nowrap element until it fits the width its parent gives it. */ function fit(el) { if (!el || !el.isConnected) return; el.style.fontSize = ''; const max = parseFloat(getComputedStyle(el).fontSize) || 16; const avail = el.parentElement ? el.parentElement.clientWidth : el.clientWidth; let size = max; for (let i = 0; i < 3 && el.scrollWidth > avail + 0.5 && size > 9; i += 1) { size = Math.max(9, size * (avail / el.scrollWidth) * 0.98); el.style.fontSize = `${size}px`; } } const fitAll = () => root.querySelectorAll('[data-fit]').forEach(fit); /** Set text, refitting only when it changed. */ function text(el, value) { if (!el) return; const v = String(value ?? ''); if (el.textContent === v) return; el.textContent = v; if (el.hasAttribute('data-fit')) fit(el); } function show(el, visible) { if (el) el.hidden = !visible; } /* ── skeleton per view ─────────────────────────────────────────── */ root.innerHTML = `
`; const shell = root.firstElementChild; const body = shell.querySelector('.as-body'); const offlineEl = shell.querySelector('.as-offline'); const $ = (sel) => body.querySelector(sel); const BAR = `
`; const SKELETONS = { now: `
${BAR}
Next:
`, sections: `
`, setlist: `
`, transport: `
`, tempo: `
BPM
`, 'song-time': `
${BAR}`, 'set-time': `
Set
`, section: `
`, 'next-song': `
Next
`, status: `
`, }; body.innerHTML = SKELETONS[view] || ''; /* ── shared pieces ─────────────────────────────────────────────── */ /** The three-part song bar: played sections, this section, the playhead. */ function paintBar(bar) { if (!bar) return; const s = state?.song; const sect = state?.section; const b = beatsNow(); const len = s && s.end > s.start ? s.end - s.start : 0; const at = (v) => (len ? Math.min(100, Math.max(0, ((v - s.start) / len) * 100)) : 0); const sStart = sect && sect.start != null ? at(sect.start) : 0; const sEnd = sect && sect.end != null ? at(sect.end) : 0; const head = b != null ? at(b) : 0; bar.style.setProperty('--done', `${sStart}%`); bar.style.setProperty('--sect-l', `${sStart}%`); bar.style.setProperty('--sect-w', `${Math.max(0, sEnd - sStart)}%`); const inSect = sEnd > sStart ? Math.min(100, Math.max(0, ((head - sStart) / (sEnd - sStart)) * 100)) : 0; bar.style.setProperty('--played', `${inSect}%`); } function songTint(el, hex) { if (hex) el.style.setProperty('--song', hex); else el.style.removeProperty('--song'); el.classList.toggle('has-song-color', Boolean(hex)); } /* ── NOW PLAYING ───────────────────────────────────────────────── */ const nowView = { render() { const s = state; const song = s.song; text($('.as-title'), song ? song.name : s.setlist.name || 'No song'); shell.classList.toggle('is-playing', moving()); songTint(shell, on('songColors') && song ? colorFor(song.color) : null); text($('.b-tempo'), s.tempo != null ? `${fmtBpm(s.tempo)} BPM` : ''); text($('.b-meter'), s.timeSig ? `${s.timeSig[0]}/${s.timeSig[1]}` : ''); text($('.b-dur'), song && song.duration != null ? fmt(song.duration) : ''); const sect = s.section; text($('.b-section'), sect ? sect.name : ''); const sc = on('songColors') && sect ? colorFor(sect.color) : null; $('.b-section').style.color = sc || ''; show($('.b-tempo'), on('showTempo') && s.tempo != null); show($('.b-meter'), on('showMeter') && Boolean(s.timeSig)); show($('.b-dur'), on('showDuration') && song?.duration != null); show($('.b-clock'), on('showClock') && pos.remainingSong != null); show($('.b-section'), on('showSection') && Boolean(sect?.name)); show($('.as-bar'), on('showProgress') && Boolean(song)); const next = s.queued?.type === 'song' ? { name: s.queued.song } : s.nextSong; text($('.as-next-label'), s.queued?.type === 'song' ? 'Queued:' : 'Next:'); $('.as-next').classList.toggle('is-queued', s.queued?.type === 'song'); text($('.as-next-title'), next ? next.name : 'End of set'); show($('.as-next-label'), on('showNext')); show($('.as-next-box'), on('showNext')); show($('.as-left'), on('showLeft') && songsLeft() != null); this.frame(); }, frame() { const left = songLeft(); const clockMode = prefs().clock || 'remaining'; const dur = state.song?.duration; const elapsed = dur != null && left != null ? Math.max(0, dur - left) : null; const clock = clockMode === 'elapsed' ? fmt(elapsed) : clockMode === 'both' ? `${fmt(elapsed)} / -${fmt(left)}` : `-${fmt(left)}`; const c = $('.b-clock'); text(c, clock); c.dataset.tone = tone(left); paintBar($('.as-bar')); const n = songsLeft(); if (n != null) text($('.as-left'), `${n} song${n === 1 ? '' : 's'} (${fmt(setLeft())}) left`); }, }; /* ── SONG SECTIONS: a strip, or a lottery wheel ────────────────── */ const WHEEL_STEP = 30; // degrees between wheel faces let sectionKey = ''; let wheelButton = null; const sectionsView = { style() { return prefs().style === 'wheel' ? 'wheel' : 'strip'; }, build() { const track = $('.as-track'); const list = state.sections || []; const key = `${state.song?.name}|${list.map((x) => x.name).join('|')}`; if (key === sectionKey && track.children.length === list.length) return; sectionKey = key; track.innerHTML = list.map((x, i) => `
${esc(x.name)}
`).join('') || '
No sections
'; this.jump(); }, /** Place without gliding — a new song, or strip ↔ wheel, lands in one step. */ jump() { const track = $('.as-track'); track.classList.add('no-anim'); requestAnimationFrame(() => requestAnimationFrame(() => track.classList.remove('no-anim'))); }, render() { const wheel = this.style() === 'wheel'; shell.classList.toggle('is-wheel', wheel); shell.classList.toggle('is-playing', moving()); text($('.as-song-name'), state.song ? state.song.name : ''); show($('.as-song-name'), on('showSong') && Boolean(state.song)); this.build(); const cur = state.section?.index ?? -1; const nxt = state.nextSection?.index ?? -1; const pills = $('.as-track').children; for (const el of pills) { const i = Number(el.dataset.i); if (Number.isNaN(i)) continue; el.classList.toggle('is-past', i < cur); el.classList.toggle('is-current', i === cur); el.classList.toggle('is-next', i === nxt); const c = on('sectionColors') ? colorFor(state.sections[i]?.color) || (i === cur ? colorFor(state.section?.color) : null) : null; if (c) el.style.setProperty('--sect', c); else el.style.removeProperty('--sect'); el.classList.toggle('has-color', Boolean(c)); } this.place(); this.frame(); }, place() { const track = $('.as-track'); const vp = $('.as-viewport'); const cur = Math.max(0, state.section?.index ?? 0); const pills = [...track.children]; if (this.style() === 'wheel') { const h = vp.clientHeight / 3.4; const r = (h / 2) / Math.tan((WHEEL_STEP / 2) * (Math.PI / 180)); track.style.setProperty('--face-h', `${h}px`); pills.forEach((el, i) => { const d = i - cur; el.style.transform = `translateY(-50%) rotateX(${-d * WHEEL_STEP}deg) translateZ(${r}px)`; el.style.opacity = Math.abs(d) * WHEEL_STEP > 75 ? "0" : String(1 - Math.abs(d) * 0.3); }); track.style.transform = `translateZ(${-r}px)`; } else { pills.forEach((el) => { el.style.transform = ''; el.style.opacity = ''; }); const el = pills[cur] || pills[0]; if (!el) return; const center = el.offsetLeft + el.offsetWidth / 2; track.style.transform = `translateX(${vp.clientWidth / 2 - center}px)`; } }, frame() { const cur = state.section?.index ?? -1; const el = $('.as-track').children[cur]; if (!el) return; const f = fraction(state.section.start, state.section.end, beatsNow()); el.style.setProperty('--p', on('showProgress') && f != null ? `${f * 100}%` : '0%'); }, toggleStyle() { const next = this.style() === 'wheel' ? 'strip' : 'wheel'; moduleApi.saveInstanceSettings({ style: next }); this.syncButton(); this.jump(); if (state) this.render(); }, syncButton() { if (!wheelButton) return; const wheel = this.style() === 'wheel'; wheelButton.innerHTML = wheel ? ICON_STRIP : ICON_WHEEL; wheelButton.title = wheel ? 'Strip view' : 'Wheel view'; }, }; /* ── SETLIST ───────────────────────────────────────────────────── */ let listKey = ''; let lastScrolled = -2; const setlistView = { render() { const s = state; text($('.as-set-name'), s.setlist.name || 'Setlist'); const list = $('.as-list'); const songs = s.setlist.songs; const key = JSON.stringify([songs, on('showDurations'), on('songColors')]); if (key !== listKey) { listKey = key; lastScrolled = -2; list.innerHTML = songs.map((x, i) => { const c = on('songColors') ? colorFor(x.color) : null; return `
${c ? '' : ''} ${esc(x.name)} ${on('showDurations') ? `${x.duration != null ? fmt(x.duration) : ''}` : ''}
`; }).join('') || '
No setlist
'; } const cur = s.song?.index ?? -1; const nxt = s.nextSong?.index ?? -1; const q = s.queued?.type === 'song' ? s.queued.songIndex : -1; for (const row of list.children) { const i = Number(row.dataset.i); if (Number.isNaN(i)) continue; row.classList.toggle('is-past', i < cur); row.classList.toggle('is-current', i === cur); row.classList.toggle('is-next', i === nxt && q < 0); row.classList.toggle('is-queued', i === q); row.hidden = !on('showPlayed') && i < cur; } shell.classList.toggle('is-playing', moving()); if (cur !== lastScrolled && list.children[cur]) { lastScrolled = cur; const row = list.children[cur]; list.scrollTo({ top: Math.max(0, row.offsetTop - list.clientHeight * 0.3), behavior: 'smooth' }); } show($('.as-set-foot'), on('showTotal')); this.frame(); }, frame() { const row = $('.as-list').children[state.song?.index ?? -1]; if (row && state.song) { const f = fraction(state.song.start, state.song.end, beatsNow()); row.style.setProperty('--p', `${(f ?? 0) * 100}%`); } const n = songsLeft(); text($('.as-set-foot'), n == null ? '' : `${n} song${n === 1 ? '' : 's'} left · ${fmt(setLeft())}`); }, }; /* ── TRANSPORT ─────────────────────────────────────────────────── */ const transportView = { render() { const s = state; let word = 'Stopped'; let t = 'muted'; if (s.loading) { word = 'Loading'; t = 'warn'; } else if (!s.live) { word = 'No Live'; t = 'alert'; } else if (s.recording && s.playing) { word = 'Recording'; t = 'alert'; } else if (s.countingIn) { word = 'Count-in'; t = 'warn'; } else if (s.playing && (s.loop.inSection || s.sectionLoop?.mode)) { word = 'Looping'; t = 'warn'; } else if (s.playing) { word = 'Playing'; t = 'ok'; } else if (s.paused) { word = 'Paused'; t = 'warn'; } text($('.as-big'), word); shell.dataset.tone = t; const loop = s.sectionLoop?.mode ? `Section loop${s.sectionLoop.count ? ` ×${s.sectionLoop.count}` : ''}` : s.loop.inSection ? 'Section loop' : ''; text($('.as-loop'), loop); show($('.as-loop'), on('showLoop') && Boolean(loop)); show($('.as-pos'), on('showPosition')); this.frame(); }, frame() { const bb = barBeat(); text($('.as-pos'), bb ? `Bar ${bb.bar} · ${bb.beat}` : ''); }, }; /* ── TEMPO ─────────────────────────────────────────────────────── */ let beatCount = 0; const tempoView = { render() { const s = state; text($('.as-bpm'), fmtBpm(s.tempo)); fit($('.as-big')); text($('.as-meter'), s.timeSig ? `${s.timeSig[0]}/${s.timeSig[1]}` : ''); show($('.as-meter'), on('showMeter')); const n = Math.min(16, Math.max(1, s.timeSig ? s.timeSig[0] : 4)); const beats = $('.as-beats'); if (beatCount !== n) { beatCount = n; beats.innerHTML = Array.from({ length: n }, (_, i) => ``).join(''); } show(beats, on('showBeats')); shell.classList.toggle('is-playing', moving()); this.frame(); }, frame() { const bb = moving() ? barBeat() : null; const lights = $('.as-beats').children; for (let i = 0; i < lights.length; i += 1) lights[i].classList.toggle('is-lit', Boolean(bb) && bb.beat === i + 1); }, }; /* ── SONG TIME ─────────────────────────────────────────────────── */ const songTimeView = { render() { const song = state.song; text($('.as-caption'), song ? song.name : 'Song'); show($('.as-caption'), on('showSong')); show($('.as-bar'), on('showProgress') && Boolean(song)); this.frame(); }, frame() { const left = songLeft(); const dur = state.song?.duration; const elapsed = dur != null && left != null ? Math.max(0, dur - left) : null; const mode = prefs().clock || 'remaining'; text($('.as-big'), mode === 'elapsed' ? fmt(elapsed) : `-${fmt(left)}`); text($('.as-sub-time'), mode === 'both' ? `${fmt(elapsed)} of ${fmt(dur)}` : dur != null ? `of ${fmt(dur)}` : ''); shell.dataset.tone = tone(left) || (moving() ? 'ok' : 'muted'); paintBar($('.as-bar')); }, }; /* ── SET TIME ──────────────────────────────────────────────────── */ const setTimeView = { render() { text($('.as-caption'), state.setlist.name || 'Set'); show($('.as-left-count'), on('showLeft')); show($('.as-end'), on('showEnd')); shell.dataset.tone = moving() ? 'ok' : 'muted'; this.frame(); }, frame() { const left = setLeft(); text($('.as-big'), fmt(left)); const n = songsLeft(); text($('.as-left-count'), n == null ? '' : `${n} song${n === 1 ? '' : 's'} left`); const end = left != null ? new Date(Date.now() + left * 1000) : null; text($('.as-end'), end ? `Ends ${end.toLocaleTimeString([], { hour: 'numeric', minute: '2-digit' })}` : ''); }, }; /* ── SECTION ───────────────────────────────────────────────────── */ const sectionView = { render() { const sect = state.section; text($('.as-big'), sect ? sect.name : '—'); const c = on('sectionColors') && sect ? colorFor(sect.color) : null; songTint(shell, c); const nx = state.queued?.type === 'section' ? state.queued.section : state.nextSection?.name; text($('.as-next-sect'), nx ? `→ ${nx}` : ''); show($('.as-next-sect'), on('showNext') && Boolean(nx)); show($('.as-count'), on('showCount')); show($('.as-sect-bar'), on('showCount')); this.frame(); }, frame() { const sect = state.section; const b = beatsNow(); const looping = Boolean(state.sectionLoop?.mode || state.loop.inSection); let label = ''; let t = moving() ? 'ok' : 'muted'; if (sect && sect.end != null && b != null) { const leftBeats = Math.max(0, sect.end - b); const bpb = beatsPerBar(); const beatLen = 4 / (state.timeSig?.[1] || 4); if (looping) { label = 'Looping'; t = 'warn'; } else if (leftBeats <= bpb + 1e-6) { const n = Math.max(1, Math.ceil(leftBeats / beatLen - 1e-6)); label = `${n} beat${n === 1 ? '' : 's'}`; if (moving()) t = 'warn'; } else { const n = Math.ceil(leftBeats / bpb - 1e-6); label = `${n} bars`; } const f = fraction(sect.start, sect.end, b); $('.as-sect-bar').style.setProperty('--p', `${(f ?? 0) * 100}%`); } text($('.as-count'), label); shell.dataset.tone = t; }, }; /* ── NEXT SONG ─────────────────────────────────────────────────── */ const nextSongView = { render() { const n = state.nextSong; text($('.as-big'), n ? n.name : 'End of set'); text($('.as-next-dur'), n?.duration != null ? fmt(n.duration) : ''); show($('.as-next-dur'), on('showDuration') && n?.duration != null); const q = state.queued; const qText = q ? (q.type === 'section' ? `Queued: ${q.section}${q.song ? ` (${q.song})` : ''}` : `Queued: ${q.song}`) : ''; text($('.as-queued'), qText); show($('.as-queued'), on('showQueued') && Boolean(q)); shell.dataset.tone = q ? 'warn' : n ? '' : 'muted'; }, frame() {}, }; /* ── STATUS ────────────────────────────────────────────────────── */ const statusView = { render() { const s = state; const rows = [ ['AbleSet', s.reachable ? 'Connected' : 'No answer', s.reachable ? 'ok' : 'alert'], ['Ableton Live', !s.reachable ? '—' : s.live ? 'Connected' : 'Not connected', !s.reachable ? 'muted' : s.live ? 'ok' : 'alert'], ]; if (s.loading) rows.push(['Project', 'Loading…', 'warn']); if (s.recording) rows.push(['Recording', 'On', 'alert']); if (on('showSetlist')) rows.push(['Setlist', s.setlist.name || '—', s.setlist.name ? '' : 'muted']); if (on('showAudio')) { if (!s.audio.length) rows.push(['Audio interface', 'None', 'muted']); for (const a of s.audio) { const map = { armed: ['Armed', 'ok'], sceneA: ['Main (A)', 'ok'], sceneB: ['Backup (B)', 'warn'], alarm: ['Alarm', 'alert'], }; const [label, t] = map[a.status] || [a.status || 'Connected', '']; rows.push([a.name, s.reachable ? label : '—', s.reachable ? t : 'muted']); } } $('.as-rows').innerHTML = rows.map(([k, v, t]) => `
${esc(k)}${esc(v)}
`).join(''); const worst = rows.some((r) => r[2] === 'alert') ? 'alert' : rows.some((r) => r[2] === 'warn') ? 'warn' : 'ok'; shell.dataset.tone = worst; }, frame() {}, }; const VIEW_IMPL = { now: nowView, sections: sectionsView, setlist: setlistView, transport: transportView, tempo: tempoView, 'song-time': songTimeView, 'set-time': setTimeView, section: sectionView, 'next-song': nextSongView, status: statusView, }; const impl = VIEW_IMPL[view]; /* ── drawing loop ──────────────────────────────────────────────── */ function render() { if (!impl || !state) return; const offline = !state.reachable && view !== 'status'; shell.classList.toggle('is-offline', offline); show(offlineEl, offline); if (offline) { text(offlineEl, !state.configured ? 'Set the AbleSet host in /admin' : 'AbleSet offline'); } impl.render(); loop(); } function loop() { if (raf || !moving()) return; const step = () => { raf = 0; if (!state || !moving()) return; impl.frame(); raf = requestAnimationFrame(step); }; raf = requestAnimationFrame(step); } function reportStatus() { if (!state) return moduleApi.setStatus('connecting', 'Connecting…'); if (!state.configured) return moduleApi.setStatus('error', 'No AbleSet host set in /admin'); if (!state.reachable) return moduleApi.setStatus('error', state.lastError ? `AbleSet: ${state.lastError}` : 'Waiting for AbleSet to answer'); if (!state.live) return moduleApi.setStatus('connecting', 'AbleSet is up, but Ableton Live is not connected'); return moduleApi.setStatus('ok', state.setlist.name ? `AbleSet · ${state.setlist.name}` : 'AbleSet'); } function takePos(p) { if (!p) return; pos = p; posAt = performance.now(); } return { start() { if (!impl) return; moduleApi.setStatus('connecting', 'Connecting…'); if (view === 'sections') { wheelButton = moduleApi.header.addButton({ icon: ICON_WHEEL, title: 'Wheel view', onClick: () => sectionsView.toggleStyle(), }); sectionsView.syncButton(); } stream = moduleApi.sse('/stream', { open() {}, error() { moduleApi.setStatus('error', 'Lost the ProdDash stream — reconnecting'); }, events: { state(e) { try { const next = JSON.parse(e.data); takePos(next.pos); state = next; } catch { return; } reportStatus(); render(); }, pos(e) { try { takePos(JSON.parse(e.data)); } catch { return; } if (state && !moving()) impl.frame(); loop(); }, }, }); resizeObserver = new ResizeObserver(() => { fitAll(); if (view === 'sections' && state) sectionsView.place(); }); resizeObserver.observe(shell); // Stopped tiles still have a clock to keep honest (Set Time's end time). tickTimer = setInterval(() => { if (state && !moving()) impl.frame(); }, 1000); }, stop() { if (stream) stream.close(); stream = null; cancelAnimationFrame(raf); raf = 0; clearInterval(tickTimer); if (resizeObserver) resizeObserver.disconnect(); root.innerHTML = ''; }, }; }