feat(zones): profile-scoped groups, zone modes, sequence brightness

- Optional profile_id on groups; UI and API for shared vs profile-only groups\n- Zone content_kind (presets vs sequences); edit modal shows matching sections; devices via groups only\n- Server sequence playback folds zone brightness into preset wire b (per MAC where needed)\n- Related preset/sequence/audio/beat-route and client updates

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
2026-05-13 01:58:00 +12:00
parent c1c3e5d71b
commit 6c9e06f33b
21 changed files with 1034 additions and 604 deletions

View File

@@ -1,4 +1,4 @@
// Sequences: lanes (parallel preset chains), shared groups, time or beat advance.
// Sequences: lanes (parallel preset chains); advance is always by audio beats or simulated BPM.
// Debug: in the browser console run setSequenceDebug(true) — toggling logs 1 (on) or 0 (off).
const SEQ_DEBUG_STORAGE_KEY = 'led-controller-sequence-debug';
@@ -24,7 +24,7 @@ function stopSequenceEditorBpmPoll() {
async function refreshSequenceEditorBpmDisplay() {
const live = document.getElementById('sequence-editor-bpm-live');
const panel = document.getElementById('sequence-editor-beats-panel');
if (!live || !panel || panel.style.display === 'none') return;
if (!live || !panel) return;
try {
const res = await fetch('/api/audio/status', { headers: { Accept: 'application/json' } });
const j = res.ok ? await res.json() : {};
@@ -39,7 +39,7 @@ async function refreshSequenceEditorBpmDisplay() {
: NaN;
if (!running) {
live.textContent =
'Audio detector is stopped — start it from the header to drive beat mode and show BPM.';
'Audio detector is stopped — the sequence uses simulated beats at the BPM you set above.';
return;
}
if (!Number.isFinite(bpm) || bpm <= 0) {
@@ -97,15 +97,13 @@ function normalizeSequenceLanes(doc) {
}
/**
* Log each preset in the sequence with its step beat count (for Audio beats mode this is how
* many detector beats the step runs; in Time mode the value is still the stored step beats).
* 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 adv = sequenceDoc.advance_mode === 'beats' ? 'beats' : 'time';
const lanes = normalizeSequenceLanes(sequenceDoc);
const nameFor = (pid) => {
const p = presetsMap && presetsMap[pid];
@@ -117,8 +115,8 @@ function logSequenceSelectionPresets(sequenceId, sequenceDoc, presetsMap) {
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: ${adv}`;
if (adv === 'beats' && multi) {
let headerLine = `Sequence "${nm}" (${sequenceId}) — advance: beats`;
if (multi) {
headerLine +=
' — header/audio beat readout follows lane 1 only (other lanes run in parallel)';
}
@@ -268,11 +266,18 @@ async function resolveSequenceSendDeviceNames(zoneId, zoneDoc, groupIds) {
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({ zone_id: String(zoneId) }),
body: JSON.stringify(body),
});
if (!res.ok) {
const err = await res.json().catch(() => ({}));
@@ -295,7 +300,10 @@ async function fetchSequencesMap() {
async function fetchGroupsMapSeq() {
try {
const res = await fetch('/groups', { headers: { Accept: 'application/json' } });
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 : {};
@@ -335,8 +343,11 @@ function createSequenceTileRow(sequenceId, sequenceDoc, zoneId, zoneDoc, allPres
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 adv = sequenceDoc.advance_mode === 'beats' ? 'beats' : 'time';
sub.textContent = `${nLanes} lane${nLanes === 1 ? '' : 's'} · ${nSteps} step${nSteps === 1 ? '' : 's'} · ${adv}`;
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', () => {
@@ -443,6 +454,14 @@ async function addSequenceToTab(sequenceId, zoneId) {
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 kind =
typeof window.normalizeZoneContentKind === 'function'
? window.normalizeZoneContentKind(tabData)
: null;
if (kind === 'presets') {
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.');
@@ -505,6 +524,16 @@ async function refreshEditTabSequencesUi(zoneId) {
const zoneRes = await fetch(`/zones/${zoneId}`, { headers: { Accept: 'application/json' } });
if (!zoneRes.ok) throw new Error('zone');
const zone = await zoneRes.json();
const kind =
typeof window.normalizeZoneContentKind === 'function'
? window.normalizeZoneContentKind(zone)
: null;
if (kind === 'presets') {
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);
@@ -586,6 +615,77 @@ async function refreshEditTabSequencesUi(zoneId) {
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';
@@ -594,6 +694,15 @@ function renderSequenceStepRow(presetsMap, step) {
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');
@@ -658,6 +767,7 @@ function renderSequenceStepRow(presetsMap, step) {
);
});
top.appendChild(dragHandle);
top.appendChild(presetWrap);
top.appendChild(beatWrap);
top.appendChild(editPresetBtn);
@@ -720,6 +830,7 @@ function renderSequenceLane(laneIndex, laneSteps, laneGroupIds, presetsMap, grou
steps.forEach((s) => {
stepsHost.appendChild(renderSequenceStepRow(presetsMap, s));
});
wireSequenceLaneStepsDragReorder(stepsHost);
wrap.appendChild(stepsHost);
return wrap;
}
@@ -763,57 +874,22 @@ function collectLanesFromEditor() {
return { lanes, lanes_group_ids };
}
function updateSequenceEditorTimeBpmHint() {
const hint = document.getElementById('sequence-editor-time-bpm-hint');
const durInput = document.getElementById('sequence-editor-duration');
const sel = document.getElementById('sequence-editor-advance-mode');
if (!hint) return;
if (sel && sel.value === 'beats') {
hint.textContent = '';
return;
}
const raw = durInput && durInput.value;
const parsed = parseInt(String(raw != null ? raw : '').trim(), 10);
if (!Number.isFinite(parsed) || parsed <= 0) {
hint.textContent = '';
return;
}
const ms = Math.max(200, parsed);
const bpm = 60000 / ms;
let rounded;
if (bpm >= 100) rounded = Math.round(bpm * 10) / 10;
else if (bpm >= 10) rounded = Math.round(bpm * 100) / 100;
else rounded = Math.round(bpm * 1000) / 1000;
hint.textContent = `${rounded} BPM`;
}
function syncSequenceAdvanceModeUi() {
const sel = document.getElementById('sequence-editor-advance-mode');
const dw = document.getElementById('sequence-editor-duration-wrap');
const tw = document.getElementById('sequence-editor-transition-wrap');
function syncSequenceBeatsPanel() {
const panel = document.getElementById('sequence-editor-beats-panel');
const beatsMode = sel && sel.value === 'beats';
if (dw) dw.style.display = beatsMode ? 'none' : 'block';
if (tw) tw.style.display = beatsMode ? 'none' : 'block';
stopSequenceEditorBpmPoll();
if (beatsMode && panel) {
panel.style.display = 'block';
if (panel) {
void refreshSequenceEditorBpmDisplay();
sequenceBpmPollTimer = setInterval(() => void refreshSequenceEditorBpmDisplay(), 1500);
} else if (panel) {
panel.style.display = 'none';
}
updateSequenceEditorTimeBpmHint();
}
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 durInput = document.getElementById('sequence-editor-duration');
const advanceSel = document.getElementById('sequence-editor-advance-mode');
const simBpmInput = document.getElementById('sequence-editor-simulated-bpm');
const lanesHost = document.getElementById('sequence-editor-lanes');
if (!modal || !nameInput || !durInput || !lanesHost) return;
if (!modal || !nameInput || !lanesHost) return;
const presetsRes = await fetch('/presets', { headers: { Accept: 'application/json' } });
const presetsMap = presetsRes.ok ? await presetsRes.json() : {};
@@ -841,16 +917,12 @@ async function openSequenceEditor(sequenceId, existing) {
doc = {};
}
nameInput.value = doc.name || '';
durInput.value = doc.step_duration_ms != null ? String(doc.step_duration_ms) : '3000';
const trInput = document.getElementById('sequence-editor-transition');
if (trInput) {
const tr = doc.sequence_transition != null ? Number(doc.sequence_transition) : 500;
trInput.value = String(Number.isFinite(tr) ? Math.min(60000, Math.max(0, Math.floor(tr))) : 500);
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);
}
if (advanceSel) {
advanceSel.value = doc.advance_mode === 'beats' ? 'beats' : 'time';
}
syncSequenceAdvanceModeUi();
syncSequenceBeatsPanel();
const lanes = normalizeSequenceLanes(doc);
lanesHost.innerHTML = '';
@@ -888,9 +960,7 @@ function resolveZoneIdForPresetStripRefresh() {
async function saveSequenceEditor() {
const nameInput = document.getElementById('sequence-editor-name');
const durInput = document.getElementById('sequence-editor-duration');
const trInput = document.getElementById('sequence-editor-transition');
const advanceSel = document.getElementById('sequence-editor-advance-mode');
const simBpmInput = document.getElementById('sequence-editor-simulated-bpm');
const { lanes, lanes_group_ids } = collectLanesFromEditor();
const idxs = [];
lanes.forEach((l, i) => {
@@ -902,17 +972,18 @@ async function saveSequenceEditor() {
}
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]] : []));
const advance_mode = advanceSel && advanceSel.value === 'beats' ? 'beats' : 'time';
const trRaw = trInput && trInput.value ? parseInt(trInput.value, 10) : 500;
const sequence_transition = Math.min(60000, Math.max(0, Number.isFinite(trRaw) ? trRaw : 500));
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,
step_duration_ms: Math.max(200, parseInt(durInput && durInput.value ? durInput.value : '3000', 10) || 3000),
sequence_transition,
advance_mode: 'beats',
simulated_bpm,
loop: true,
steps: nonEmpty.length === 1 ? nonEmpty[0] : [],
};
@@ -1089,16 +1160,6 @@ document.addEventListener('DOMContentLoaded', () => {
if (edSave) edSave.addEventListener('click', () => saveSequenceEditor());
if (edDel) edDel.addEventListener('click', () => deleteCurrentSequence());
const advanceSel = document.getElementById('sequence-editor-advance-mode');
if (advanceSel) {
advanceSel.addEventListener('change', () => syncSequenceAdvanceModeUi());
}
const durForBpmHint = document.getElementById('sequence-editor-duration');
if (durForBpmHint) {
durForBpmHint.addEventListener('input', () => updateSequenceEditorTimeBpmHint());
durForBpmHint.addEventListener('change', () => updateSequenceEditorTimeBpmHint());
}
const edAddLane = document.getElementById('sequence-editor-add-lane-btn');
if (edAddLane) {
edAddLane.addEventListener('click', async () => {