Files
led-controller/src/static/sequences.js

1302 lines
48 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// Sequences: lanes (parallel preset chains); advance is always by audio beats or simulated BPM.
// Debug: in the browser console run setSequenceDebug(true) — session only, not persisted.
/** @type {'beat'|'downbeat'} */
let sequenceSwitchWaitFor = 'beat';
let sequenceDebugEnabled = false;
let sequenceSwitchSaveInFlight = false;
async function loadSequenceSwitchWaitForFromServer() {
try {
const res = await fetch('/settings', {
cache: 'no-store',
headers: { Accept: 'application/json' },
});
if (!res.ok) return;
const data = await res.json();
const raw = data && data.sequence_switch_wait;
if (raw === 'downbeat' || raw === 'beat') {
sequenceSwitchWaitFor = raw;
} else if (raw === 'phrase') {
sequenceSwitchWaitFor = 'beat';
}
} catch {
/* keep default */
}
}
async function persistSequenceSwitchWaitFor() {
sequenceSwitchSaveInFlight = true;
try {
const res = await fetch('/settings', {
method: 'PUT',
headers: { 'Content-Type': 'application/json', Accept: 'application/json' },
body: JSON.stringify({ sequence_switch_wait: sequenceSwitchWaitFor }),
});
if (!res.ok) {
console.warn('[sequence] could not save switch wait to server', res.status);
}
} catch (e) {
console.warn('[sequence] could not save switch wait to server', e);
} finally {
sequenceSwitchSaveInFlight = false;
}
}
function getSequenceSwitchWaitFor() {
return sequenceSwitchWaitFor === 'downbeat' ? 'downbeat' : 'beat';
}
async function setSequenceSwitchWaitFor(waitFor) {
sequenceSwitchWaitFor = waitFor === 'downbeat' ? 'downbeat' : 'beat';
updateSequenceSwitchToggleUI();
await persistSequenceSwitchWaitFor();
}
function updateSequenceSwitchToggleUI() {
const mode = getSequenceSwitchWaitFor();
const ariaLabels = {
beat: 'Switch sequence on beat',
downbeat: 'Switch sequence on downbeat',
};
document.querySelectorAll('.seq-switch-toggle').forEach((btn) => {
btn.setAttribute('aria-pressed', mode === 'beat' ? 'false' : 'true');
btn.setAttribute('aria-label', ariaLabels[mode] || ariaLabels.beat);
btn.classList.toggle('seq-switch-toggle--downbeat', mode === 'downbeat');
});
document.querySelectorAll('.seq-switch-toggle-wrap').forEach((wrap) => {
wrap.classList.toggle('nav-slide-toggle-wrap--downbeat', mode === 'downbeat');
});
}
async function initSequenceSwitchToggle() {
await loadSequenceSwitchWaitForFromServer();
updateSequenceSwitchToggleUI();
document.querySelectorAll('.seq-switch-toggle').forEach((btn) => {
btn.addEventListener('click', () => {
void setSequenceSwitchWaitFor(getSequenceSwitchWaitFor() === 'beat' ? 'downbeat' : 'beat');
});
});
}
/** Sync toggle when settings changed elsewhere (e.g. another tab via audio status poll). */
function applySequenceSwitchWaitFromServer(raw) {
if (sequenceSwitchSaveInFlight) return;
let mode = 'beat';
if (raw === 'downbeat') mode = 'downbeat';
else if (raw !== 'beat' && raw !== 'phrase') return;
if (mode === getSequenceSwitchWaitFor()) return;
sequenceSwitchWaitFor = mode;
updateSequenceSwitchToggleUI();
}
function seqDebugEnabled() {
return sequenceDebugEnabled;
}
/** @type {ReturnType<typeof setInterval> | null} */
let sequenceBpmPollTimer = null;
function stopSequenceEditorBpmPoll() {
if (sequenceBpmPollTimer) {
clearInterval(sequenceBpmPollTimer);
sequenceBpmPollTimer = null;
}
}
async function refreshSequenceEditorBpmDisplay() {
const live = document.getElementById('sequence-editor-bpm-live');
const panel = document.getElementById('sequence-editor-beats-panel');
if (!live || !panel) return;
try {
const res = await fetch('/api/audio/status', { headers: { Accept: 'application/json' } });
const j = res.ok ? await res.json() : {};
const st = j && j.status ? j.status : {};
const running = !!st.running;
const bpmRaw = st.bpm;
const bpm =
typeof bpmRaw === 'number' && Number.isFinite(bpmRaw)
? bpmRaw
: typeof bpmRaw === 'string' && bpmRaw.trim()
? parseFloat(bpmRaw)
: NaN;
if (!running) {
live.textContent =
'Audio detector is stopped — the sequence uses simulated beats at the BPM you set above.';
return;
}
if (!Number.isFinite(bpm) || bpm <= 0) {
live.textContent = 'Audio detector running; BPM will appear after a few beats.';
return;
}
const msPer = Math.round(60000 / bpm);
const rounded = Math.round(bpm * 10) / 10;
live.textContent = `Current estimate: ${rounded} BPM (~${msPer} ms per beat).`;
} catch (_) {
live.textContent = 'Could not read audio status.';
}
}
/** @param {boolean} [clearSequenceTileSelection] When false, leaves the active highlight on sequence tiles (used when restarting playback so the click handlers selection is not cleared). */
async function stopZoneSequencePlayback(clearSequenceTileSelection = true) {
// Clear selection **before** awaiting fetch so overlapping stop() calls cannot finish out of
// order and strip .active from tiles after a later render or click (intermittent dead UI).
if (clearSequenceTileSelection) {
document.querySelectorAll('.sequence-tile-main.active').forEach((btn) => btn.classList.remove('active'));
}
try {
const res = await fetch('/sequences/stop', {
method: 'POST',
headers: { Accept: 'application/json' },
credentials: 'same-origin',
});
if (!res.ok) {
console.warn('Sequence stop failed:', res.status);
}
} catch (e) {
console.warn('Sequence stop:', e);
}
}
function normalizeSequenceLanes(doc) {
let lanesRaw = Array.isArray(doc && doc.lanes) ? doc.lanes : [];
let lanes = lanesRaw.filter((l) => Array.isArray(l));
const hasAnyLaneSteps = lanes.some((l) => l.length > 0);
if ((!lanes.length || !hasAnyLaneSteps) && Array.isArray(doc && doc.steps) && doc.steps.length) {
lanes = [doc.steps.slice()];
}
if (!lanes.length) lanes = [[]];
return lanes.map((lane) =>
lane
.filter((s) => s && typeof s === 'object')
.map((s) => ({
preset_id: s.preset_id != null ? String(s.preset_id) : String(s.presetId || ''),
beats: Math.max(1, parseInt(String(s.beats != null ? s.beats : 1), 10) || 1),
group_ids: Array.isArray(s.group_ids)
? s.group_ids.map((x) => String(x).trim()).filter(Boolean)
: [],
})),
);
}
/**
* Log each preset in the sequence with its step beat count (beats per step before advancing).
* @param {string} sequenceId
* @param {Record<string, unknown>} sequenceDoc
* @param {Record<string, unknown>} presetsMap
*/
function logSequenceSelectionPresets(sequenceId, sequenceDoc, presetsMap) {
if (!sequenceDoc || typeof sequenceDoc !== 'object') return;
const lanes = normalizeSequenceLanes(sequenceDoc);
const nameFor = (pid) => {
const p = presetsMap && presetsMap[pid];
if (p && typeof p === 'object' && p.name != null && String(p.name).trim()) {
return String(p.name).trim();
}
return pid || '(unknown preset)';
};
const nm = String(sequenceDoc.name || '').trim() || sequenceId;
const multi =
lanes.filter((lane) => lane.some((s) => s && s.preset_id)).length > 1;
let headerLine = `Sequence "${nm}" (${sequenceId}) — advance: beats`;
if (multi) {
headerLine +=
' — header/audio beat readout follows lane 1 only (other lanes run in parallel)';
}
console.log(headerLine);
lanes.forEach((lane, li) => {
const steps = lane.filter((s) => s && s.preset_id);
if (!steps.length) return;
if (multi) console.log(`Lane ${li + 1}`);
steps.forEach((step) => {
const b = step.beats;
console.log(` ${nameFor(step.preset_id)}: ${b} beat${b === 1 ? '' : 's'}`);
});
});
}
function groupIdsForLaneStep(sequenceDoc, step, laneIndex, numLanes) {
const lgs = Array.isArray(sequenceDoc.lanes_group_ids) ? sequenceDoc.lanes_group_ids : [];
if (laneIndex < lgs.length) {
const forLane = lgs[laneIndex];
if (Array.isArray(forLane)) {
return forLane.map((x) => String(x).trim()).filter(Boolean);
}
}
if (numLanes > 1 && laneIndex >= lgs.length) {
return [];
}
const shared = Array.isArray(sequenceDoc.group_ids)
? sequenceDoc.group_ids.map((x) => String(x).trim()).filter((x) => x.length > 0)
: [];
if (shared.length) return shared;
if (numLanes === 1 && step && Array.isArray(step.group_ids) && step.group_ids.length) {
return step.group_ids.map((x) => String(x).trim()).filter(Boolean);
}
return [];
}
function buildLaneGroupIdsForEditor(doc, laneIndex, numLanes) {
const raw = Array.isArray(doc && doc.lanes_group_ids) ? doc.lanes_group_ids : [];
const shared = Array.isArray(doc && doc.group_ids) ? doc.group_ids.map(String) : [];
if (laneIndex < raw.length) {
const row = raw[laneIndex];
if (Array.isArray(row)) {
return row.map(String).filter(Boolean);
}
}
if (numLanes > 1 && laneIndex >= raw.length) {
return [];
}
if (numLanes === 1) {
const lanes = normalizeSequenceLanes(doc);
const first = lanes[0] && lanes[0][0];
const sg =
first && Array.isArray(first.group_ids) ? first.group_ids.map(String).filter(Boolean) : [];
return sg.length ? sg : shared.slice();
}
return shared.slice();
}
function renderLaneGroupCheckboxes(groupsMap, selectedIds) {
const wrap = document.createElement('div');
wrap.className = 'sequence-lane-groups-wrap';
wrap.style.cssText = 'margin-bottom:0.6rem;';
const hint = document.createElement('div');
hint.className = 'muted-text';
hint.style.fontSize = '0.85em';
hint.style.marginBottom = '0.35rem';
hint.textContent = 'Groups for this lane (none = whole zone)';
wrap.appendChild(hint);
const row = document.createElement('div');
row.className = 'sequence-lane-groups';
row.style.cssText = 'display:flex;flex-wrap:wrap;gap:0.5rem;align-items:center;';
const sel = new Set((selectedIds || []).map((x) => String(x)));
Object.keys(groupsMap)
.sort((a, b) => a.localeCompare(b))
.forEach((gid) => {
const g = groupsMap[gid];
const gn = g && g.name ? String(g.name) : gid;
const id = `seq-lg-${gid}-${Math.random().toString(36).slice(2)}`;
const lbl = document.createElement('label');
lbl.style.cssText = 'display:inline-flex;align-items:center;gap:0.25rem;font-size:0.9em;';
const cb = document.createElement('input');
cb.type = 'checkbox';
cb.className = 'sequence-lane-group';
cb.value = gid;
cb.id = id;
if (sel.has(String(gid))) cb.checked = true;
const sp = document.createElement('span');
sp.textContent = `${gn} (${gid})`;
lbl.appendChild(cb);
lbl.appendChild(sp);
row.appendChild(lbl);
});
const editG = document.createElement('button');
editG.type = 'button';
editG.className = 'btn btn-secondary btn-small';
editG.textContent = 'Edit groups';
editG.title = 'Open Device groups';
editG.addEventListener('click', async () => {
if (typeof window.openDeviceGroupsModal === 'function') {
await window.openDeviceGroupsModal();
return;
}
const b = document.getElementById('groups-btn');
if (b) b.click();
else alert('Groups could not be opened.');
});
row.appendChild(editG);
wrap.appendChild(row);
return wrap;
}
function splitDeviceNamesForLane(allNames, laneIndex, numLanes) {
const names = Array.isArray(allNames) ? allNames.filter((n) => n && String(n).trim()) : [];
if (numLanes <= 1) return names;
if (names.length >= numLanes) {
const n = names[laneIndex];
return n ? [n] : [];
}
return names;
}
function presetsSectionElForZone(zoneId) {
if (zoneId != null && String(zoneId).length) {
const el = document.querySelector(`.presets-section[data-zone-id="${String(zoneId)}"]`);
if (el) return el;
}
return document.querySelector('.presets-section[data-zone-id]');
}
/** Match preset tiles: prefer DOM device list, then zone JSON (same as parseTabDeviceNames + computeZoneTargets). */
async function resolveSequenceSendDeviceNames(zoneId, zoneDoc, groupIds) {
const gids = Array.isArray(groupIds) ? groupIds.map((x) => String(x).trim()).filter((x) => x.length > 0) : [];
if (!gids.length) {
const section = presetsSectionElForZone(zoneId);
if (typeof window.parseTabDeviceNames === 'function' && section) {
const fromDom = window.parseTabDeviceNames(section);
if (Array.isArray(fromDom) && fromDom.length) return fromDom;
}
}
if (window.zonesManager && typeof window.zonesManager.resolveSequenceStepDeviceNames === 'function' && zoneDoc) {
return await window.zonesManager.resolveSequenceStepDeviceNames(zoneDoc, gids);
}
return [];
}
/** Start sequence playback on the server (ESP-NOW / TCP delivery from backend). */
async function requestBackendSequencePlay(sequenceId, zoneId, sequenceDoc) {
// Do not call stop here: server start() already stops any prior run. A fire-and-forget
// client stop can reorder after play and clear the new session (same tile re-click bug).
let bodyBpm;
if (sequenceDoc && typeof sequenceDoc === 'object' && sequenceDoc.simulated_bpm != null) {
const n = parseInt(String(sequenceDoc.simulated_bpm), 10);
if (Number.isFinite(n)) bodyBpm = Math.min(300, Math.max(30, n));
}
const body = { zone_id: String(zoneId) };
if (bodyBpm != null) body.simulated_bpm = bodyBpm;
const res = await fetch(`/sequences/${encodeURIComponent(sequenceId)}/play`, {
method: 'POST',
headers: { 'Content-Type': 'application/json', Accept: 'application/json' },
credentials: 'same-origin',
body: JSON.stringify(body),
});
if (!res.ok) {
const err = await res.json().catch(() => ({}));
throw new Error((err && err.error) || res.statusText);
}
console.log(Number(sequenceId));
}
async function fetchSequencesMap() {
try {
const res = await fetch('/sequences', { cache: 'no-store', headers: { Accept: 'application/json' } });
if (!res.ok) return {};
const data = await res.json();
return data && typeof data === 'object' ? data : {};
} catch (e) {
console.error('fetchSequencesMap:', e);
return {};
}
}
async function fetchGroupsMapSeq() {
try {
const res = await fetch('/groups', {
headers: { Accept: 'application/json' },
credentials: 'same-origin',
});
if (!res.ok) return {};
const data = await res.json();
return data && typeof data === 'object' ? data : {};
} catch (e) {
return {};
}
}
function createSequenceTileRow(sequenceId, sequenceDoc, zoneId, zoneDoc, allPresets, uiMode) {
const row = document.createElement('div');
const canDrag = false;
row.className = `preset-tile-row preset-tile-row--${uiMode} sequence-tile-row${canDrag ? ' draggable-preset' : ''}`;
row.dataset.sequenceId = sequenceId;
const button = document.createElement('button');
button.type = 'button';
button.className = 'pattern-button preset-tile-main sequence-tile-main';
button.title = sequenceDoc.name || `Sequence ${sequenceId}`;
const badge = document.createElement('span');
badge.textContent = 'SEQ';
badge.className = 'sequence-tile-badge';
badge.style.cssText =
'position:absolute;left:4px;top:4px;font-size:10px;font-weight:700;color:#fff;background:rgba(0,100,180,0.9);padding:2px 5px;border-radius:3px;pointer-events:none;z-index:2;';
button.style.position = 'relative';
button.appendChild(badge);
const label = document.createElement('span');
label.textContent = sequenceDoc.name || sequenceId;
label.style.fontWeight = 'bold';
label.className = 'pattern-button-label';
button.appendChild(label);
const sub = document.createElement('span');
sub.className = 'muted-text';
sub.style.cssText = 'display:block;font-size:0.8em;margin-top:0.2rem;';
const lanes = normalizeSequenceLanes(sequenceDoc);
const nLanes = lanes.filter((l) => l.length > 0).length || 1;
const nSteps = lanes.reduce((a, l) => a + l.length, 0);
const simRaw = sequenceDoc.simulated_bpm;
let sim = parseInt(String(simRaw != null ? simRaw : 120), 10);
if (!Number.isFinite(sim)) sim = 120;
sim = Math.min(300, Math.max(30, sim));
sub.textContent = `${nLanes} lane${nLanes === 1 ? '' : 's'} · ${nSteps} step${nSteps === 1 ? '' : 's'} · beats · ${sim} BPM sim`;
button.appendChild(sub);
button.addEventListener('click', () => {
const strip = document.getElementById('presets-list-zone');
const clearActiveStrip = () => {
if (strip) strip.querySelectorAll('.pattern-button').forEach((btn) => btn.classList.remove('active'));
};
clearActiveStrip();
button.classList.add('active');
void (async () => {
let seq = sequenceDoc;
let zone = zoneDoc;
let presets = allPresets;
try {
const [sr, zr, pr] = await Promise.all([
fetch(`/sequences/${encodeURIComponent(sequenceId)}`, {
headers: { Accept: 'application/json' },
credentials: 'same-origin',
}),
fetch(`/zones/${encodeURIComponent(zoneId)}`, {
headers: { Accept: 'application/json' },
credentials: 'same-origin',
}),
fetch('/presets', { headers: { Accept: 'application/json' }, credentials: 'same-origin' }),
]);
if (sr.ok) {
const j = await sr.json();
if (j && typeof j === 'object' && !j.error) seq = j;
}
if (zr.ok) {
const j = await zr.json();
if (j && typeof j === 'object' && !j.error) zone = j;
}
if (pr.ok) {
const raw = await pr.json();
if (raw && typeof raw === 'object') {
if (typeof window.filterPresetsForCurrentProfile === 'function') {
presets = await window.filterPresetsForCurrentProfile(raw);
} else {
presets = raw;
}
}
}
} catch (e) {
console.warn('Sequence play: refresh fetch failed, using cached data', e);
}
logSequenceSelectionPresets(sequenceId, seq, presets);
try {
await requestBackendSequencePlay(sequenceId, zoneId, seq);
} catch (e) {
console.error(e);
alert(e.message || 'Sequence playback failed.');
}
})();
});
row.appendChild(button);
if (uiMode === 'edit') {
const actions = document.createElement('div');
actions.className = 'preset-tile-actions';
const editBtn = document.createElement('button');
editBtn.type = 'button';
editBtn.className = 'btn btn-secondary btn-small';
editBtn.textContent = 'Edit';
editBtn.addEventListener('click', (e) => {
e.preventDefault();
e.stopPropagation();
openSequenceEditor(sequenceId, sequenceDoc);
});
actions.appendChild(editBtn);
row.appendChild(actions);
}
return row;
}
async function appendZoneSequenceTiles(zoneId, zoneDoc, allPresets, paletteColors, presetsListEl) {
if (!presetsListEl || !zoneId) return;
const ids = Array.isArray(zoneDoc.sequence_ids) ? zoneDoc.sequence_ids.map((x) => String(x)) : [];
if (!ids.length) return;
const sequences = await fetchSequencesMap();
const uiMode = typeof window.getPresetUiMode === 'function' ? window.getPresetUiMode() : 'run';
for (const sid of ids) {
const seq = sequences[sid];
if (!seq) continue;
const row = createSequenceTileRow(sid, seq, zoneId, zoneDoc, allPresets, uiMode);
presetsListEl.appendChild(row);
}
}
async function addSequenceToTab(sequenceId, zoneId) {
if (!zoneId) {
const leftPanel = document.querySelector('.presets-section[data-zone-id]');
zoneId = leftPanel ? leftPanel.dataset.zoneId : null;
}
if (!zoneId || !sequenceId) {
alert('Could not determine zone or sequence.');
return;
}
try {
const tabResponse = await fetch(`/zones/${zoneId}`, { headers: { Accept: 'application/json' } });
if (!tabResponse.ok) throw new Error('Failed to load zone');
const tabData = await tabResponse.json();
if (
typeof window.zoneAllowsSequences === 'function' &&
!window.zoneAllowsSequences(tabData)
) {
alert('This zone is for presets only. Add presets from the zone Edit menu instead.');
return;
}
const list = Array.isArray(tabData.sequence_ids) ? tabData.sequence_ids.map(String) : [];
if (list.includes(String(sequenceId))) {
alert('Sequence is already on this zone.');
return;
}
list.push(String(sequenceId));
tabData.sequence_ids = list;
const updateResponse = await fetch(`/zones/${zoneId}`, {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(tabData),
});
if (!updateResponse.ok) throw new Error('Failed to update zone');
if (typeof window.renderTabPresets === 'function') {
await window.renderTabPresets(zoneId);
}
} catch (e) {
console.error('addSequenceToTab:', e);
alert('Failed to add sequence to zone.');
}
}
async function removeSequenceFromTab(zoneId, sequenceId) {
if (!zoneId || !sequenceId) return;
try {
const tabResponse = await fetch(`/zones/${zoneId}`, { headers: { Accept: 'application/json' } });
if (!tabResponse.ok) throw new Error('Failed to load zone');
const tabData = await tabResponse.json();
const list = Array.isArray(tabData.sequence_ids) ? tabData.sequence_ids.map(String) : [];
const next = list.filter((x) => String(x) !== String(sequenceId));
if (next.length === list.length) {
alert('Sequence is not on this zone.');
return;
}
tabData.sequence_ids = next;
const updateResponse = await fetch(`/zones/${zoneId}`, {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(tabData),
});
if (!updateResponse.ok) throw new Error('Failed to update zone');
if (typeof window.refreshEditTabSequencesUi === 'function') {
await window.refreshEditTabSequencesUi(zoneId);
}
if (typeof window.renderTabPresets === 'function') {
await window.renderTabPresets(zoneId);
}
} catch (e) {
console.error('removeSequenceFromTab:', e);
alert('Failed to remove sequence from zone.');
}
}
async function refreshEditTabSequencesUi(zoneId) {
const currentEl = document.getElementById('edit-zone-sequences-current');
const addEl = document.getElementById('edit-zone-sequences-list');
if (!currentEl || !addEl || !zoneId) return;
try {
const zoneRes = await fetch(`/zones/${zoneId}`, { headers: { Accept: 'application/json' } });
if (!zoneRes.ok) throw new Error('zone');
const zone = await zoneRes.json();
if (
typeof window.zoneAllowsSequences === 'function' &&
!window.zoneAllowsSequences(zone)
) {
currentEl.innerHTML =
'<span class="muted-text">This zone is for presets only. Sequences are hidden.</span>';
addEl.innerHTML = '<span class="muted-text">—</span>';
return;
}
const onZone = Array.isArray(zone.sequence_ids) ? zone.sequence_ids.map(String) : [];
const seqMap = await fetchSequencesMap();
const onSet = new Set(onZone);
currentEl.innerHTML = '';
if (!onZone.length) {
currentEl.innerHTML = '<span class="muted-text">No sequences on this zone yet.</span>';
} else {
for (const sid of onZone) {
const sdoc = seqMap[sid] || {};
const name = sdoc.name || sid;
const row = document.createElement('div');
row.className = 'profiles-row';
row.style.display = 'flex';
row.style.justifyContent = 'space-between';
row.style.alignItems = 'center';
row.style.gap = '0.5rem';
const span = document.createElement('span');
span.textContent = `${name}${sid}`;
const rm = document.createElement('button');
rm.type = 'button';
rm.className = 'btn btn-danger btn-small';
rm.textContent = 'Remove';
rm.addEventListener('click', async () => {
if (!window.confirm(`Remove this sequence from the zone?\n\n${name}`)) return;
await removeSequenceFromTab(zoneId, sid);
});
row.appendChild(span);
row.appendChild(rm);
currentEl.appendChild(row);
}
}
addEl.innerHTML = '';
const allIds = Object.keys(seqMap);
const available = allIds.filter((id) => !onSet.has(String(id)));
if (!available.length) {
addEl.innerHTML =
'<span class="muted-text">No sequences to add. Create one in Sequences or all are already on this zone.</span>';
} else {
const wrap = document.createElement('div');
wrap.className = 'zone-devices-add profiles-actions';
const sel = document.createElement('select');
sel.className = 'zone-device-add-select';
sel.setAttribute('aria-label', 'Sequence to add to this zone');
sel.appendChild(new Option('Add sequence…', ''));
available
.slice()
.sort((a, b) => {
const na = (seqMap[a] && seqMap[a].name) || a;
const nb = (seqMap[b] && seqMap[b].name) || b;
return String(na).localeCompare(String(nb), undefined, { sensitivity: 'base' });
})
.forEach((id) => {
const n = (seqMap[id] && seqMap[id].name) || id;
sel.appendChild(new Option(`${n}${id}`, id));
});
const addBtn = document.createElement('button');
addBtn.type = 'button';
addBtn.className = 'btn btn-primary btn-small';
addBtn.textContent = 'Add';
addBtn.addEventListener('click', async () => {
const id = sel.value;
if (!id) return;
await addSequenceToTab(id, zoneId);
sel.value = '';
await refreshEditTabSequencesUi(zoneId);
});
wrap.appendChild(sel);
wrap.appendChild(addBtn);
addEl.appendChild(wrap);
}
} catch (e) {
console.error('refreshEditTabSequencesUi:', e);
currentEl.innerHTML = '<span class="muted-text">Failed to load sequences.</span>';
addEl.innerHTML = '';
}
}
let sequenceEditorId = null;
/** Insert point when dragging a step row vertically within a lane. */
function getDragAfterSequenceStepRow(container, y) {
const draggableElements = [
...container.querySelectorAll(':scope > .sequence-step-row:not(.dragging)'),
];
return draggableElements.reduce(
(closest, child) => {
const box = child.getBoundingClientRect();
const offset = y - box.top - box.height / 2;
if (offset < 0 && offset > closest.offset) {
return { offset, element: child };
}
return closest;
},
{ offset: Number.NEGATIVE_INFINITY, element: null },
).element;
}
/** Reorder step rows within one lane (DOM order = save order). */
function wireSequenceLaneStepsDragReorder(stepsHost) {
if (!stepsHost || stepsHost.dataset.sequenceLaneDndWired === '1') return;
stepsHost.dataset.sequenceLaneDndWired = '1';
let draggedRow = null;
stepsHost.addEventListener('dragstart', (e) => {
const handle = e.target.closest('.sequence-step-drag-handle');
if (!handle || !stepsHost.contains(handle)) return;
const row = handle.closest('.sequence-step-row');
if (!row || !stepsHost.contains(row)) return;
draggedRow = row;
row.classList.add('dragging');
try {
e.dataTransfer.effectAllowed = 'move';
e.dataTransfer.setData('text/plain', 'sequence-step');
} catch (_) {
/* ignore */
}
});
stepsHost.addEventListener('dragend', () => {
if (draggedRow) draggedRow.classList.remove('dragging');
draggedRow = null;
});
stepsHost.addEventListener('dragenter', (e) => {
if (!draggedRow || !stepsHost.contains(draggedRow)) return;
e.preventDefault();
});
stepsHost.addEventListener('dragover', (e) => {
if (!draggedRow || !stepsHost.contains(draggedRow)) return;
e.preventDefault();
try {
e.dataTransfer.dropEffect = 'move';
} catch (_) {
/* ignore */
}
const afterElement = getDragAfterSequenceStepRow(stepsHost, e.clientY);
if (afterElement == null) {
stepsHost.appendChild(draggedRow);
} else if (afterElement !== draggedRow) {
stepsHost.insertBefore(draggedRow, afterElement);
}
});
stepsHost.addEventListener('drop', (e) => {
if (!draggedRow) return;
e.preventDefault();
});
}
function renderSequenceStepRow(presetsMap, step) {
const row = document.createElement('div');
row.className = 'sequence-step-row profiles-row';
row.style.cssText =
'display:flex;flex-direction:column;gap:0.35rem;margin-bottom:0.75rem;padding:0.5rem;border:1px solid rgba(255,255,255,0.12);border-radius:6px;';
const top = document.createElement('div');
top.style.cssText = 'display:flex;align-items:center;gap:0.75rem;flex-wrap:wrap;';
const dragHandle = document.createElement('span');
dragHandle.className = 'sequence-step-drag-handle';
dragHandle.draggable = true;
dragHandle.title = 'Drag to reorder';
dragHandle.textContent = '⠿';
dragHandle.style.cssText =
'cursor:grab;user-select:none;flex-shrink:0;line-height:1;opacity:0.75;padding:0.15rem 0.25rem;';
const presetWrap = document.createElement('div');
presetWrap.style.cssText = 'display:flex;align-items:center;gap:0.5rem;flex-wrap:wrap;';
const pl = document.createElement('label');
pl.textContent = 'Preset';
const psel = document.createElement('select');
psel.className = 'sequence-step-preset zone-device-add-select';
psel.appendChild(new Option('Select…', ''));
const pids = Object.keys(presetsMap).sort((a, b) => {
const na = (presetsMap[a] && presetsMap[a].name) || a;
const nb = (presetsMap[b] && presetsMap[b].name) || b;
return String(na).localeCompare(String(nb), undefined, { sensitivity: 'base' });
});
const curPreset = step && step.preset_id != null ? String(step.preset_id) : '';
pids.forEach((pid) => {
const n = (presetsMap[pid] && presetsMap[pid].name) || pid;
psel.appendChild(new Option(`${n}${pid}`, pid));
});
if (curPreset) psel.value = curPreset;
presetWrap.appendChild(pl);
presetWrap.appendChild(psel);
const beatWrap = document.createElement('div');
beatWrap.style.cssText = 'display:flex;align-items:center;gap:0.35rem;';
const bl = document.createElement('label');
bl.textContent = 'Beats';
const beatsInp = document.createElement('input');
beatsInp.type = 'number';
beatsInp.className = 'sequence-step-beats';
beatsInp.autocomplete = 'off';
beatsInp.min = '1';
beatsInp.max = '256';
beatsInp.value = String(
step && step.beats != null ? Math.max(1, parseInt(String(step.beats), 10) || 1) : 1,
);
beatsInp.style.width = '4rem';
beatWrap.appendChild(bl);
beatWrap.appendChild(beatsInp);
const editPresetBtn = document.createElement('button');
editPresetBtn.type = 'button';
editPresetBtn.className = 'btn btn-secondary btn-small';
editPresetBtn.textContent = 'Edit preset';
editPresetBtn.addEventListener('click', async (e) => {
e.preventDefault();
const presetId = psel.value ? String(psel.value) : '';
if (!presetId) {
alert('Select a preset first.');
return;
}
let preset = presetsMap[presetId];
try {
const r = await fetch(`/presets/${presetId}`, { headers: { Accept: 'application/json' } });
if (r.ok) preset = await r.json();
} catch (_) {
/* keep cached */
}
preset = preset && typeof preset === 'object' ? preset : {};
document.dispatchEvent(
new CustomEvent('editPreset', {
detail: { presetId, preset, zoneId: null },
}),
);
});
top.appendChild(dragHandle);
top.appendChild(presetWrap);
top.appendChild(beatWrap);
top.appendChild(editPresetBtn);
row.appendChild(top);
const rm = document.createElement('button');
rm.type = 'button';
rm.className = 'btn btn-danger btn-small';
rm.textContent = 'Remove step';
rm.addEventListener('click', () => row.remove());
row.appendChild(rm);
return row;
}
function renderSequenceLane(laneIndex, laneSteps, laneGroupIds, presetsMap, groupsMap) {
const wrap = document.createElement('div');
wrap.className = 'sequence-lane';
wrap.dataset.laneIndex = String(laneIndex);
const head = document.createElement('div');
head.style.cssText =
'display:flex;align-items:center;justify-content:space-between;gap:0.5rem;margin-bottom:0.5rem;flex-wrap:wrap;';
const title = document.createElement('strong');
title.textContent = `Lane ${laneIndex + 1}`;
head.appendChild(title);
const headBtns = document.createElement('div');
headBtns.style.cssText = 'display:flex;gap:0.35rem;flex-wrap:wrap;';
const addStep = document.createElement('button');
addStep.type = 'button';
addStep.className = 'btn btn-secondary btn-small';
addStep.textContent = 'Add step';
addStep.addEventListener('click', () => {
const stepsHost = wrap.querySelector('.sequence-lane-steps');
if (stepsHost) {
stepsHost.appendChild(renderSequenceStepRow(presetsMap, { preset_id: '', beats: 1 }));
}
});
const rmLane = document.createElement('button');
rmLane.type = 'button';
rmLane.className = 'btn btn-danger btn-small sequence-lane-remove-lane';
rmLane.textContent = 'Remove lane';
rmLane.addEventListener('click', () => {
const host = document.getElementById('sequence-editor-lanes');
const n = host ? host.querySelectorAll('.sequence-lane').length : 1;
if (n <= 1) return;
wrap.remove();
refreshSequenceEditorLaneTitles();
});
headBtns.appendChild(addStep);
headBtns.appendChild(rmLane);
head.appendChild(headBtns);
wrap.appendChild(head);
wrap.appendChild(renderLaneGroupCheckboxes(groupsMap, laneGroupIds));
const stepsHost = document.createElement('div');
stepsHost.className = 'sequence-lane-steps';
const steps = Array.isArray(laneSteps) && laneSteps.length ? laneSteps : [{ preset_id: '', beats: 1 }];
steps.forEach((s) => {
stepsHost.appendChild(renderSequenceStepRow(presetsMap, s));
});
wireSequenceLaneStepsDragReorder(stepsHost);
wrap.appendChild(stepsHost);
return wrap;
}
function refreshSequenceEditorLaneTitles() {
const host = document.getElementById('sequence-editor-lanes');
if (!host) return;
const lanes = [...host.querySelectorAll('.sequence-lane')];
lanes.forEach((el, i) => {
const t = el.querySelector(':scope > div strong');
if (t) t.textContent = `Lane ${i + 1}`;
const rm = el.querySelector('.sequence-lane-remove-lane');
if (rm) rm.disabled = lanes.length <= 1;
});
}
function collectLanesFromEditor() {
const host = document.getElementById('sequence-editor-lanes');
const laneEls = host ? [...host.querySelectorAll('.sequence-lane')] : [];
const lanes = [];
const lanes_group_ids = [];
laneEls.forEach((laneEl) => {
const g = [];
laneEl.querySelectorAll('.sequence-lane-group:checked').forEach((c) => {
if (c.value) g.push(String(c.value));
});
lanes_group_ids.push(g);
const steps = [];
const stepsHost = laneEl.querySelector('.sequence-lane-steps');
const rows = stepsHost ? stepsHost.querySelectorAll(':scope > .sequence-step-row') : [];
rows.forEach((row) => {
const presetSel = row.querySelector('.sequence-step-preset');
const beatsInp = row.querySelector('.sequence-step-beats');
const presetId = presetSel && presetSel.value ? String(presetSel.value) : '';
if (!presetId) return;
const beats = Math.max(1, parseInt(beatsInp && beatsInp.value ? beatsInp.value : '1', 10) || 1);
steps.push({ preset_id: presetId, beats });
});
lanes.push(steps);
});
return { lanes, lanes_group_ids };
}
function syncSequenceBeatsPanel() {
const panel = document.getElementById('sequence-editor-beats-panel');
stopSequenceEditorBpmPoll();
if (panel) {
void refreshSequenceEditorBpmDisplay();
sequenceBpmPollTimer = setInterval(() => void refreshSequenceEditorBpmDisplay(), 1500);
}
}
async function openSequenceEditor(sequenceId, existing) {
sequenceEditorId = sequenceId != null && String(sequenceId).length ? String(sequenceId) : null;
const modal = document.getElementById('sequence-editor-modal');
const nameInput = document.getElementById('sequence-editor-name');
const simBpmInput = document.getElementById('sequence-editor-simulated-bpm');
const lanesHost = document.getElementById('sequence-editor-lanes');
if (!modal || !nameInput || !lanesHost) return;
const presetsRes = await fetch('/presets', { headers: { Accept: 'application/json' } });
const presetsMap = presetsRes.ok ? await presetsRes.json() : {};
const groupsMap = await fetchGroupsMapSeq();
let doc = existing;
if (sequenceEditorId) {
try {
const r = await fetch(`/sequences/${encodeURIComponent(sequenceEditorId)}`, {
cache: 'no-store',
headers: { Accept: 'application/json' },
credentials: 'same-origin',
});
if (r.ok) {
const j = await r.json();
if (j && typeof j === 'object' && !j.error) {
doc = j;
}
}
} catch (_) {
/* keep existing */
}
}
if (!doc || typeof doc !== 'object') {
doc = {};
}
nameInput.value = doc.name || '';
if (simBpmInput) {
const v = parseInt(String(doc.simulated_bpm != null ? doc.simulated_bpm : 120), 10);
const clamped = Number.isFinite(v) ? Math.min(300, Math.max(30, v)) : 120;
simBpmInput.value = String(clamped);
}
syncSequenceBeatsPanel();
const lanes = normalizeSequenceLanes(doc);
lanesHost.innerHTML = '';
if (!lanes.some((l) => l.length > 0)) {
const lg0 = buildLaneGroupIdsForEditor(doc, 0, 1);
lanesHost.appendChild(renderSequenceLane(0, [], lg0, presetsMap, groupsMap));
} else {
lanes.forEach((laneSteps, i) => {
const lg = buildLaneGroupIdsForEditor(doc, i, lanes.length);
lanesHost.appendChild(renderSequenceLane(i, laneSteps, lg, presetsMap, groupsMap));
});
}
refreshSequenceEditorLaneTitles();
const edDel = document.getElementById('sequence-editor-delete-btn');
if (edDel) edDel.style.display = sequenceEditorId ? 'inline-block' : 'none';
modal.classList.add('active');
}
/** Zone id for refreshing the visible preset/sequence strip if `getCurrentZoneId` is not set yet. */
function resolveZoneIdForPresetStripRefresh() {
if (window.zonesManager && typeof window.zonesManager.getCurrentZoneId === 'function') {
const z = window.zonesManager.getCurrentZoneId();
if (z != null && String(z).trim() !== '') {
return String(z).trim();
}
}
const sec = document.querySelector('.presets-section[data-zone-id]');
if (sec && sec.dataset.zoneId != null && String(sec.dataset.zoneId).trim() !== '') {
return String(sec.dataset.zoneId).trim();
}
return null;
}
async function saveSequenceEditor() {
const nameInput = document.getElementById('sequence-editor-name');
const simBpmInput = document.getElementById('sequence-editor-simulated-bpm');
const { lanes, lanes_group_ids } = collectLanesFromEditor();
const idxs = [];
lanes.forEach((l, i) => {
if (l.some((s) => s && s.preset_id)) idxs.push(i);
});
if (!idxs.length) {
alert('Add at least one step with a preset selected.');
return;
}
const nonEmpty = idxs.map((i) => lanes[i].filter((s) => s && s.preset_id));
const nonEmptyLg = idxs.map((i) => (lanes_group_ids[i] ? [...lanes_group_ids[i]] : []));
let simulated_bpm = 120;
if (simBpmInput && simBpmInput.value) {
const n = parseInt(String(simBpmInput.value).trim(), 10);
if (Number.isFinite(n)) simulated_bpm = Math.min(300, Math.max(30, n));
}
const payload = {
name: nameInput ? nameInput.value.trim() : '',
lanes: nonEmpty,
lanes_group_ids: nonEmptyLg,
group_ids: nonEmptyLg[0] ? [...nonEmptyLg[0]] : [],
advance_mode: 'beats',
simulated_bpm,
loop: true,
steps: nonEmpty.length === 1 ? nonEmpty[0] : [],
};
try {
if (sequenceEditorId) {
const res = await fetch(`/sequences/${sequenceEditorId}`, {
method: 'PUT',
headers: { 'Content-Type': 'application/json', Accept: 'application/json' },
body: JSON.stringify(payload),
});
if (!res.ok) {
const err = await res.json().catch(() => ({}));
throw new Error((err && err.error) || res.statusText);
}
} else {
const res = await fetch('/sequences', {
method: 'POST',
headers: { 'Content-Type': 'application/json', Accept: 'application/json' },
body: JSON.stringify(payload),
});
if (!res.ok) {
const err = await res.json().catch(() => ({}));
throw new Error((err && err.error) || res.statusText);
}
}
document.getElementById('sequence-editor-modal') && document.getElementById('sequence-editor-modal').classList.remove('active');
stopSequenceEditorBpmPoll();
await loadSequencesModalList();
const zid = resolveZoneIdForPresetStripRefresh();
if (zid && typeof window.refreshEditTabSequencesUi === 'function') {
await window.refreshEditTabSequencesUi(zid);
}
if (zid && typeof window.renderTabPresets === 'function') {
await window.renderTabPresets(zid);
}
} catch (e) {
console.error(e);
alert(e.message || 'Failed to save sequence.');
}
}
async function deleteCurrentSequence() {
const idToDelete = sequenceEditorId;
if (!idToDelete) return;
if (!window.confirm('Delete this sequence? It will be removed from the server.')) return;
try {
const res = await fetch(`/sequences/${idToDelete}`, { method: 'DELETE' });
if (!res.ok) throw new Error('Delete failed');
const edModal = document.getElementById('sequence-editor-modal');
if (edModal) edModal.classList.remove('active');
stopSequenceEditorBpmPoll();
sequenceEditorId = null;
await loadSequencesModalList();
const zid = resolveZoneIdForPresetStripRefresh();
if (zid) {
const tabResponse = await fetch(`/zones/${zid}`, { headers: { Accept: 'application/json' } });
if (tabResponse.ok) {
const tabData = await tabResponse.json();
const list = Array.isArray(tabData.sequence_ids) ? tabData.sequence_ids.map(String) : [];
const sid = String(idToDelete);
if (list.includes(sid)) {
tabData.sequence_ids = list.filter((x) => x !== sid);
await fetch(`/zones/${zid}`, {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(tabData),
});
}
}
if (typeof window.refreshEditTabSequencesUi === 'function') {
await window.refreshEditTabSequencesUi(zid);
}
if (typeof window.renderTabPresets === 'function') {
await window.renderTabPresets(zid);
}
}
} catch (e) {
console.error(e);
alert('Failed to delete sequence.');
}
}
async function loadSequencesModalList() {
const listEl = document.getElementById('sequences-list');
if (!listEl) return;
listEl.innerHTML = '<p class="muted-text">Loading…</p>';
const map = await fetchSequencesMap();
const ids = Object.keys(map).sort((a, b) => {
const na = (map[a] && map[a].name) || a;
const nb = (map[b] && map[b].name) || b;
return String(na).localeCompare(String(nb), undefined, { sensitivity: 'base' });
});
listEl.innerHTML = '';
if (!ids.length) {
listEl.innerHTML = '<p class="muted-text">No sequences yet. Click Add.</p>';
return;
}
ids.forEach((id) => {
const doc = map[id] || {};
const row = document.createElement('div');
row.className = 'profiles-row';
row.style.cssText = 'display:flex;justify-content:space-between;align-items:center;gap:0.5rem;';
const title = document.createElement('span');
const ln = normalizeSequenceLanes(doc);
const nSteps = ln.reduce((a, l) => a + l.length, 0);
const nLanes = ln.filter((l) => l.length > 0).length || 1;
title.textContent = `${doc.name || id}${nLanes} lane(s), ${nSteps} step(s)`;
const exportBtn = document.createElement('button');
exportBtn.type = 'button';
exportBtn.className = 'btn btn-secondary btn-small';
exportBtn.textContent = 'Export';
exportBtn.addEventListener('click', async () => {
try {
const response = await fetch(`/sequences/${id}/export`, {
headers: { Accept: 'application/json' },
});
if (!response.ok) throw new Error('Export failed');
const bundle = await response.json();
const safeName = String(doc.name || id).replace(/[^\w.-]+/g, '_');
window.downloadJsonFile(`sequence-${safeName}.json`, bundle);
} catch (e) {
console.error(e);
alert('Failed to export sequence.');
}
});
const edit = document.createElement('button');
edit.type = 'button';
edit.className = 'btn btn-secondary btn-small';
edit.textContent = 'Edit';
edit.addEventListener('click', () => openSequenceEditor(id, doc));
row.appendChild(title);
row.appendChild(exportBtn);
row.appendChild(edit);
listEl.appendChild(row);
});
}
window.applySequenceSwitchWaitFromServer = applySequenceSwitchWaitFromServer;
window.stopZoneSequencePlayback = stopZoneSequencePlayback;
/** @param {boolean} on */
window.setSequenceDebug = function setSequenceDebug(on) {
sequenceDebugEnabled = !!on;
console.log(seqDebugEnabled() ? 1 : 0);
};
window.appendZoneSequenceTiles = appendZoneSequenceTiles;
window.refreshEditTabSequencesUi = refreshEditTabSequencesUi;
window.addSequenceToTab = addSequenceToTab;
window.removeSequenceFromTab = removeSequenceFromTab;
document.addEventListener('DOMContentLoaded', () => {
void initSequenceSwitchToggle();
const btn = document.getElementById('sequences-btn');
const modal = document.getElementById('sequences-modal');
const closeBtn = document.getElementById('sequences-close-btn');
const addBtn = document.getElementById('sequence-add-btn');
if (btn && modal) {
btn.addEventListener('click', () => {
modal.classList.add('active');
loadSequencesModalList();
});
}
if (closeBtn && modal) {
closeBtn.addEventListener('click', () => modal.classList.remove('active'));
}
if (addBtn) {
addBtn.addEventListener('click', () => {
sequenceEditorId = null;
openSequenceEditor(null, null);
});
}
const importSeqBtn = document.getElementById('import-sequence-btn');
if (importSeqBtn) {
importSeqBtn.addEventListener('click', async () => {
const text = await window.pickJsonFile();
if (!text) return;
const bundle = window.parseJsonFileText(text);
if (!bundle || bundle.kind !== 'sequence') {
alert('Invalid sequence bundle file.');
return;
}
try {
const response = await fetch('/sequences/import', {
method: 'POST',
headers: { 'Content-Type': 'application/json', Accept: 'application/json' },
body: JSON.stringify({ bundle }),
});
if (!response.ok) {
const err = await response.json().catch(() => ({}));
throw new Error(err.error || 'Import failed');
}
await loadSequencesModalList();
} catch (e) {
console.error(e);
alert(e.message || 'Failed to import sequence.');
}
});
}
const openPresetsFromSeq = document.getElementById('sequences-open-presets-btn');
if (openPresetsFromSeq) {
openPresetsFromSeq.addEventListener('click', () => {
const b = document.getElementById('presets-btn');
if (b) b.click();
else alert('Presets is not available.');
});
}
const edClose = document.getElementById('sequence-editor-close-btn');
const edSave = document.getElementById('sequence-editor-save-btn');
const edDel = document.getElementById('sequence-editor-delete-btn');
if (edClose) {
edClose.addEventListener('click', () => {
stopSequenceEditorBpmPoll();
document.getElementById('sequence-editor-modal') && document.getElementById('sequence-editor-modal').classList.remove('active');
});
}
if (edSave) edSave.addEventListener('click', () => saveSequenceEditor());
if (edDel) edDel.addEventListener('click', () => deleteCurrentSequence());
const edAddLane = document.getElementById('sequence-editor-add-lane-btn');
if (edAddLane) {
edAddLane.addEventListener('click', async () => {
const host = document.getElementById('sequence-editor-lanes');
if (!host) return;
const presetsRes = await fetch('/presets', { headers: { Accept: 'application/json' } });
const presetsMap = presetsRes.ok ? await presetsRes.json() : {};
const groupsMap = await fetchGroupsMapSeq();
const idx = host.querySelectorAll('.sequence-lane').length;
host.appendChild(renderSequenceLane(idx, [], [], presetsMap, groupsMap));
refreshSequenceEditorLaneTitles();
});
}
});