// 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 | 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} sequenceDoc * @param {Record} 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 = 'This zone is for presets only. Sequences are hidden.'; addEl.innerHTML = ''; 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 = 'No sequences on this zone yet.'; } 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 = 'No sequences to add. Create one in Sequences or all are already on this zone.'; } 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 = 'Failed to load sequences.'; 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 = '

Loading…

'; 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 = '

No sequences yet. Click Add.

'; 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(); }); } });