1302 lines
48 KiB
JavaScript
1302 lines
48 KiB
JavaScript
// 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 handler’s 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();
|
||
});
|
||
}
|
||
});
|