Every gear change remounts the tile, and the remounted tile joins the page's shared stream mid-flow, so it heard nothing until AbleSet next changed — with the transport stopped, never. Tiles went blank until a reload. The tile now fetches /state when it starts and whenever the stream (re)opens. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
728 lines
31 KiB
JavaScript
728 lines
31 KiB
JavaScript
/* 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 = '<svg viewBox="0 0 16 16" width="13" height="13" fill="none" stroke="currentColor" stroke-width="1.6" stroke-linecap="round" stroke-linejoin="round"><rect x="1.5" y="5" width="4" height="6" rx="1"/><rect x="6.5" y="4" width="4" height="8" rx="1"/><rect x="11.5" y="5" width="3" height="6" rx="1"/></svg>';
|
||
const ICON_WHEEL = '<svg viewBox="0 0 16 16" width="13" height="13" fill="none" stroke="currentColor" stroke-width="1.6" stroke-linecap="round" stroke-linejoin="round"><path d="M4 2.5h8M3 6h10M3 10h10M4 13.5h8"/><rect x="2" y="5" width="12" height="6" rx="1.2"/></svg>';
|
||
|
||
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 = `<div class="as-root as-${view || 'none'}"><div class="as-offline" hidden></div><div class="as-body"></div></div>`;
|
||
const shell = root.firstElementChild;
|
||
const body = shell.querySelector('.as-body');
|
||
const offlineEl = shell.querySelector('.as-offline');
|
||
const $ = (sel) => body.querySelector(sel);
|
||
|
||
const BAR = `<div class="as-bar"><div class="as-bar-done"></div><div class="as-bar-sect"><div class="as-bar-played"></div></div></div>`;
|
||
|
||
const SKELETONS = {
|
||
now: `
|
||
<div class="as-title-box"><div class="as-title" data-fit></div></div>
|
||
<div class="as-badges">
|
||
<span class="as-badge b-tempo"></span><span class="as-badge b-meter"></span><span class="as-badge b-dur"></span><span class="as-badge b-clock"></span><span class="as-badge b-section"></span>
|
||
</div>
|
||
${BAR}
|
||
<div class="as-next">
|
||
<div class="as-next-label">Next:</div>
|
||
<div class="as-next-box"><div class="as-next-title" data-fit></div></div>
|
||
<div class="as-left"></div>
|
||
</div>`,
|
||
sections: `
|
||
<div class="as-song-name" data-fit></div>
|
||
<div class="as-viewport"><div class="as-track"></div></div>`,
|
||
setlist: `
|
||
<div class="as-set-head"><span class="as-set-name"></span></div>
|
||
<div class="as-list"></div>
|
||
<div class="as-set-foot"></div>`,
|
||
transport: `
|
||
<div class="as-big-box"><div class="as-big" data-fit></div></div>
|
||
<div class="as-sub"><span class="as-pos"></span><span class="as-loop"></span></div>`,
|
||
tempo: `
|
||
<div class="as-big-box"><div class="as-big" data-fit><span class="as-bpm"></span><small> BPM</small></div></div>
|
||
<div class="as-sub"><span class="as-meter"></span></div>
|
||
<div class="as-beats"></div>`,
|
||
'song-time': `
|
||
<div class="as-caption" data-fit></div>
|
||
<div class="as-big-box"><div class="as-big" data-fit></div></div>
|
||
<div class="as-sub as-sub-time"></div>
|
||
${BAR}`,
|
||
'set-time': `
|
||
<div class="as-caption" data-fit>Set</div>
|
||
<div class="as-big-box"><div class="as-big" data-fit></div></div>
|
||
<div class="as-sub"><span class="as-left-count"></span><span class="as-end"></span></div>`,
|
||
section: `
|
||
<div class="as-big-box"><div class="as-big" data-fit></div></div>
|
||
<div class="as-sub"><span class="as-next-sect"></span><span class="as-count"></span></div>
|
||
<div class="as-sect-bar"><div></div></div>`,
|
||
'next-song': `
|
||
<div class="as-caption">Next</div>
|
||
<div class="as-big-box"><div class="as-big" data-fit></div></div>
|
||
<div class="as-sub"><span class="as-next-dur"></span></div>
|
||
<div class="as-queued" hidden></div>`,
|
||
status: `<div class="as-rows"></div>`,
|
||
};
|
||
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) => `<div class="as-pill" data-i="${i}"><span class="as-pill-fill"></span><span class="as-pill-text">${esc(x.name)}</span></div>`).join('')
|
||
|| '<div class="as-pill is-empty"><span class="as-pill-text">No sections</span></div>';
|
||
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 `<div class="as-row" data-i="${i}"${c ? ` style="--song:${c}"` : ''}>
|
||
<span class="as-row-fill"></span>
|
||
${c ? '<span class="as-swatch"></span>' : ''}
|
||
<span class="as-row-name">${esc(x.name)}</span>
|
||
${on('showDurations') ? `<span class="as-row-dur">${x.duration != null ? fmt(x.duration) : ''}</span>` : ''}
|
||
</div>`;
|
||
}).join('') || '<div class="as-empty">No setlist</div>';
|
||
}
|
||
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) => `<i class="${i === 0 ? 'is-down' : ''}"></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]) => `<div class="as-srow" data-tone="${t}"><i></i><span class="as-sk">${esc(k)}</span><span class="as-sv">${esc(v)}</span></div>`).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 applyState(next) {
|
||
if (!next || typeof next !== 'object') return;
|
||
takePos(next.pos);
|
||
state = next;
|
||
reportStatus();
|
||
render();
|
||
}
|
||
|
||
/* The page's one stream is shared, so a tile mounted onto it (a gear
|
||
change remounts the tile) joins mid-flow and hears nothing until
|
||
AbleSet next changes — which, stopped, may be never. Ask for the state. */
|
||
let stopped = false;
|
||
async function backfill() {
|
||
try {
|
||
const res = await moduleApi.fetch('/state');
|
||
if (!res.ok) return;
|
||
const body = await res.json();
|
||
if (!stopped) applyState(body.state);
|
||
} catch { /* the stream's error handler reports the outage */ }
|
||
}
|
||
|
||
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() { backfill(); },
|
||
error() {
|
||
moduleApi.setStatus('error', 'Lost the ProdDash stream — reconnecting');
|
||
},
|
||
events: {
|
||
state(e) {
|
||
let next;
|
||
try { next = JSON.parse(e.data); } catch { return; }
|
||
applyState(next);
|
||
},
|
||
pos(e) {
|
||
try { takePos(JSON.parse(e.data)); } catch { return; }
|
||
if (state && !moving()) impl.frame();
|
||
loop();
|
||
},
|
||
},
|
||
});
|
||
backfill();
|
||
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() {
|
||
stopped = true;
|
||
if (stream) stream.close();
|
||
stream = null;
|
||
cancelAnimationFrame(raf);
|
||
raf = 0;
|
||
clearInterval(tickTimer);
|
||
if (resizeObserver) resizeObserver.disconnect();
|
||
root.innerHTML = '';
|
||
},
|
||
};
|
||
}
|