/* Vista Cues — client part. One tile: the cue list most recently fired, big, with how long ago and who fired it; under it the other lists still live, the recent notes, and which senders we are in session with. All of it comes from the server's one `state` event; between events only the "how long ago" clocks move. */ const MINUS_SVG = ''; const PLUS_SVG = ''; const CLEAR_SVG = ''; const ACTION_LABEL = { go: 'Go', release: 'Release', releaseAll: 'Release all', pause: 'Pause', back: 'Back', flash: 'Flash', cleared: 'Cleared', '': '—', }; function esc(s) { return String(s ?? '').replace(/[&<>"']/g, (c) => ({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' }[c])); } /** 75 000 ms → "1:15", 3 725 000 → "1:02:05". */ function since(ms) { const s = Math.max(0, Math.floor(ms / 1000)); 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}` : `${m}:${ss}`; } function clockTime(ms) { return new Date(ms).toLocaleTimeString([], { hour: 'numeric', minute: '2-digit', second: '2-digit' }); } export default function create({ root, moduleApi }) { const prefs = () => moduleApi.instanceSettings || {}; const on = (k, dflt = true) => (prefs()[k] === undefined ? dflt : prefs()[k] !== false); const scale = () => Math.min(2.5, Math.max(0.5, Number(prefs().textScale) || 1)); let state = null; let skew = 0; // server clock − our clock let stream = null; let tick = null; let el = {}; let resizeObserver = null; const serverNow = () => Date.now() + skew; /* ── fitting the big name to its box ─────────────────────────────── */ function fit(node) { if (!node || !node.isConnected) return; node.style.fontSize = ''; const avail = node.parentElement ? node.parentElement.clientWidth : node.clientWidth; let size = parseFloat(getComputedStyle(node).fontSize) || 16; for (let i = 0; i < 3 && node.scrollWidth > avail + 0.5 && size > 10; i += 1) { size = Math.max(10, size * (avail / node.scrollWidth) * 0.98); node.style.fontSize = `${size}px`; } } /* ── drawing ─────────────────────────────────────────────────────── */ function build() { root.innerHTML = `
`; const q = (s) => root.querySelector(s); el = { root: q('.vc-root'), offline: q('.vc-offline'), pill: q('.vc-pill'), ago: q('.vc-ago'), name: q('.vc-name'), meta: q('.vc-meta'), active: q('.vc-active'), recent: q('.vc-recent'), sources: q('.vc-sources'), }; } function render() { if (!el.root) return; el.root.style.setProperty('--vc-scale', String(scale())); const s = state; if (!s) { el.name.textContent = 'Connecting…'; el.root.dataset.tone = 'muted'; return; } const connected = s.sources.filter((x) => x.connected); const offline = !s.listening || !connected.length; el.root.classList.toggle('is-offline', offline); el.offline.hidden = !offline; el.offline.textContent = !s.listening ? 'Not listening' : 'No MIDI session'; // Now const cur = s.current; if (cur) { el.root.dataset.tone = cur.state === 'paused' ? 'warn' : 'ok'; el.pill.textContent = cur.state === 'paused' ? 'Paused' : 'Live'; el.pill.hidden = false; if (el.name.textContent !== cur.name) { el.name.textContent = cur.name; fit(el.name); } const bits = []; if (cur.goes > 1) bits.push(`Go ×${cur.goes}`); if (cur.source) bits.push(cur.source); if (cur.lastAction === 'back') bits.push('Back'); el.meta.textContent = bits.join(' · '); } else { el.root.dataset.tone = 'muted'; el.pill.hidden = true; const msg = s.rules ? 'No cue list live' : 'No cue lists mapped'; if (el.name.textContent !== msg) { el.name.textContent = msg; fit(el.name); } el.meta.textContent = s.rules ? (s.log.length ? '' : 'Waiting for the first cue') : 'Add the cue sheet in /admin → Vista Cues'; } // Other live lists el.active.hidden = !on('showActive') || !s.active.length; if (!el.active.hidden) { el.active.innerHTML = s.active.map((l) => `
${l.group ? `${esc(l.group)}` : ''} ${esc(l.name)}
`).join(''); } // Recent const showUnmapped = on('showUnmapped', false); const rows = s.log.filter((e) => e.mapped || showUnmapped).slice(0, 12); el.recent.hidden = !on('showRecent') || !rows.length; if (!el.recent.hidden) { el.recent.innerHTML = rows.map((e) => `
${esc(clockTime(e.at))} ${esc(e.mapped ? (ACTION_LABEL[e.action] ?? e.action) : 'Unmapped')} ${esc(e.mapped ? e.list : e.trigger)} ${esc(e.source)}
`).join(''); } // Senders el.sources.hidden = !on('showSources') || !s.sources.length; if (!el.sources.hidden) { el.sources.innerHTML = s.sources.map((x) => ` ${esc(x.label)} `).join(''); } updateClocks(); reportStatus(); } function updateClocks() { if (!state || !el.root) return; const now = serverNow(); const cur = state.current; el.ago.textContent = cur ? since(now - cur.lastAt) : ''; el.ago.title = cur ? `Fired ${clockTime(cur.firedAt)}` : ''; root.querySelectorAll('[data-since]').forEach((n) => { n.textContent = since(now - Number(n.dataset.since)); }); } function reportStatus() { const s = state; if (!s) return; if (!s.listening) return moduleApi.setStatus('error', s.error || 'Not listening for MIDI'); const connected = s.sources.filter((x) => x.connected); const waiting = s.sources.filter((x) => !x.connected); if (!connected.length) { const why = waiting.length ? `Joining ${waiting.map((x) => `${x.label}${x.error ? ` (${x.error})` : ''}`).join(', ')}` : 'No MIDI session connected'; return moduleApi.setStatus('error', why); } const msg = `Hearing ${connected.map((x) => x.label).join(', ')}${waiting.length ? ` · not ${waiting.map((x) => x.label).join(', ')}` : ''}`; return moduleApi.setStatus(waiting.length ? 'connecting' : 'ok', msg); } function take(next) { if (!next) return; skew = Number(next.now) ? Number(next.now) - Date.now() : 0; state = next; render(); } async function refresh() { try { const r = await moduleApi.fetch('/state'); if (r.ok) take((await r.json()).state); } catch { /* the stream will say so */ } } /* ── title bar ───────────────────────────────────────────────────── */ function bump(delta) { const next = Math.round(Math.min(2.5, Math.max(0.5, scale() + delta)) * 10) / 10; moduleApi.saveInstanceSettings({ textScale: next }); render(); fit(el.name); } return { start() { build(); render(); moduleApi.header.addButton({ icon: MINUS_SVG, title: 'Smaller text', onClick: () => bump(-0.1) }); moduleApi.header.addButton({ icon: PLUS_SVG, title: 'Larger text', onClick: () => bump(0.1) }); moduleApi.header.addButton({ icon: CLEAR_SVG, title: 'Clear live lists', async onClick() { try { await moduleApi.fetch('/clear', { method: 'POST' }); } catch { /* status says why */ } }, }); moduleApi.setStatus('connecting', 'Connecting…'); stream = moduleApi.sse('/stream', { open() { refresh(); }, error() { moduleApi.setStatus('error', 'Lost connection to ProdDash'); }, events: { state(e) { try { take(JSON.parse(e.data)); } catch { /* malformed frame */ } }, }, }); refresh(); tick = setInterval(updateClocks, 1000); if (typeof ResizeObserver === 'function') { resizeObserver = new ResizeObserver(() => fit(el.name)); resizeObserver.observe(root); } }, stop() { clearInterval(tick); tick = null; if (stream) { stream.close(); stream = null; } if (resizeObserver) { resizeObserver.disconnect(); resizeObserver = null; } root.innerHTML = ''; el = {}; }, onResize() { fit(el.name); }, }; }