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:
@@ -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 () => {
|
||||
|
||||
Reference in New Issue
Block a user