feat(ui): edit tab zones, audio readout, live reload

- Zones/presets/sequence strip and Pipfile dev command fix
- Optional live reload and beat test audio asset + generator

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
2026-05-13 00:44:20 +12:00
parent c64dd736f2
commit c1c3e5d71b
16 changed files with 1377 additions and 122 deletions

View File

@@ -29,6 +29,9 @@ const filterPresetsForCurrentProfile = async (presetsObj) => {
}),
);
};
try {
window.filterPresetsForCurrentProfile = filterPresetsForCurrentProfile;
} catch (e) {}
const getCurrentProfileData = async () => {
try {
@@ -154,7 +157,44 @@ function tabDeviceNamesFromSection(section) {
: [];
}
async function postDriverSequence(sequence, targetMacs, delayS) {
/** Device names for ``presetId`` on the current zone tab (per-preset groups or zone default). */
async function deviceNamesForPresetOnCurrentZone(presetId) {
const section = document.querySelector('.presets-section[data-zone-id]');
const fallback = tabDeviceNamesFromSection(section);
if (!section || !presetId) return fallback;
const zm = window.zonesManager;
if (!zm || typeof zm.resolveDeviceNamesForZonePreset !== 'function') return fallback;
const zoneId = section.dataset.zoneId;
try {
const res = await fetch(`/zones/${zoneId}`, { headers: { Accept: 'application/json' } });
if (!res.ok) return fallback;
const zd = await res.json();
const names = await zm.resolveDeviceNamesForZonePreset(zd, String(presetId));
return names.length ? names : fallback;
} catch (_) {
return fallback;
}
}
function formatPresetTargetGroupsLine(zoneDoc, presetId, groupsMap) {
const zm = window.zonesManager;
const gids =
zm && typeof zm.effectiveGroupIdsForZonePreset === 'function'
? zm.effectiveGroupIdsForZonePreset(zoneDoc, presetId)
: Array.isArray(zoneDoc && zoneDoc.group_ids)
? zoneDoc.group_ids.map((x) => String(x).trim()).filter((x) => x.length > 0)
: [];
const parts = (gids || [])
.map((id) => {
const g = groupsMap && groupsMap[id];
const gn = g && g.name ? String(g.name).trim() : '';
return gn;
})
.filter(Boolean);
return parts.length ? parts.join(', ') : '';
}
async function postDriverSequence(sequence, targetMacs, delayS, pushOptions) {
const body = {
sequence,
targets: Array.isArray(targetMacs) && targetMacs.length ? [...new Set(targetMacs)] : undefined,
@@ -1169,7 +1209,7 @@ document.addEventListener('DOMContentLoaded', () => {
// Create modal
const modal = document.createElement('div');
modal.className = 'modal active';
modal.className = 'modal active modal-child-overlay';
modal.id = 'add-preset-to-zone-modal';
modal.innerHTML = `
<div class="modal-content">
@@ -1284,7 +1324,10 @@ document.addEventListener('DOMContentLoaded', () => {
const newGrid = arrayToGrid(flat, 3);
tabData.presets = newGrid;
tabData.presets_flat = flat;
if (!tabData.preset_group_ids || typeof tabData.preset_group_ids !== 'object') {
tabData.preset_group_ids = {};
}
// Update zone
const updateResponse = await fetch(`/zones/${zoneId}`, {
method: 'PUT',
@@ -1378,7 +1421,7 @@ document.addEventListener('DOMContentLoaded', () => {
}
const modal = document.createElement('div');
modal.className = 'modal active';
modal.className = 'modal active modal-child-overlay';
modal.innerHTML = `
<div class="modal-content">
<h2>Pick Palette Color</h2>
@@ -1449,12 +1492,10 @@ document.addEventListener('DOMContentLoaded', () => {
alert('Preset name is required to send.');
return;
}
// Send current editor values and then select on all devices in the current zone (if any)
const section = document.querySelector('.presets-section[data-zone-id]');
const deviceNames = tabDeviceNamesFromSection(section);
// Work out the preset ID: for existing presets use currentEditId, otherwise fall back to name
// Send current editor values to zone devices (if any); never persist on device.
const presetId = currentEditId || payload.name;
// Try sends preset first, then select; never persist on device.
const deviceNames = await deviceNamesForPresetOnCurrentZone(presetId);
// Auto: load + immediate select. Manual: load only; first advance on the next audio beat.
await sendPresetViaEspNow(presetId, payload, deviceNames, false, false, '2');
});
}
@@ -1466,9 +1507,8 @@ document.addEventListener('DOMContentLoaded', () => {
alert('Preset name is required.');
return;
}
const section = document.querySelector('.presets-section[data-zone-id]');
const deviceNames = tabDeviceNamesFromSection(section);
const presetId = currentEditId || payload.name;
const deviceNames = await deviceNamesForPresetOnCurrentZone(presetId);
await sendPresetViaEspNow(presetId, payload, deviceNames, true, true, '1');
await updateTabDefaultPreset(presetId);
await sendDefaultPreset('1', deviceNames);
@@ -1503,9 +1543,9 @@ document.addEventListener('DOMContentLoaded', () => {
throw new Error('Failed to save preset');
}
// Same device targeting as Try: zone tab supplies names and selection without persistence.
const section = document.querySelector('.presets-section[data-zone-id]');
const deviceNames = tabDeviceNamesFromSection(section);
// Same device targeting as Try: per-preset zone groups when in a zone tab.
const presetIdForSend = currentEditId || payload.name;
const deviceNames = await deviceNamesForPresetOnCurrentZone(presetIdForSend);
// Use saved preset from server response for sending
const saved = await response.json().catch(() => null);
@@ -1644,6 +1684,7 @@ const sendPresetViaEspNow = async (
saveToDevice = true,
setDefault = false,
devicePresetId = null,
pushOptions = null,
) => {
try {
const baseColors = Array.isArray(preset.colors) && preset.colors.length
@@ -1707,7 +1748,7 @@ const sendPresetViaEspNow = async (
}
}
await postDriverSequence(sequence, targetMacs, 0.05);
await postDriverSequence(sequence, targetMacs, 0.05, pushOptions);
} catch (error) {
console.error('Failed to send preset to devices:', error);
alert('Failed to send preset to devices.');
@@ -1776,6 +1817,48 @@ try {
// window may not exist in some environments; ignore.
}
// Store selected preset(s) per zone (multi-select; merge send order = click order, last wins on device).
const zoneSelectedPresetIds = {};
const zonePresetSelectionOrder = {};
function ensureZonePresetSelection(zoneId) {
const z = String(zoneId);
if (!zoneSelectedPresetIds[z]) zoneSelectedPresetIds[z] = new Set();
if (!zonePresetSelectionOrder[z]) zonePresetSelectionOrder[z] = [];
}
function pruneZonePresetSelection(zoneId, validIdSet) {
const z = String(zoneId);
ensureZonePresetSelection(z);
const set = zoneSelectedPresetIds[z];
for (const id of [...set]) {
if (!validIdSet.has(String(id))) set.delete(id);
}
zonePresetSelectionOrder[z] = (zonePresetSelectionOrder[z] || []).filter((id) => set.has(String(id)));
}
function getOrderedZonePresetSelection(zoneId) {
const z = String(zoneId);
ensureZonePresetSelection(z);
const set = zoneSelectedPresetIds[z];
return (zonePresetSelectionOrder[z] || []).filter((id) => set.has(String(id)));
}
async function sendMergedZonePresetSelection(zoneId, tabData, allPresets) {
const ids = getOrderedZonePresetSelection(zoneId);
if (!ids.length) return;
for (let i = 0; i < ids.length; i += 1) {
const pid = ids[i];
const preset = allPresets[pid];
if (!preset) continue;
const names =
window.zonesManager && typeof window.zonesManager.resolveDeviceNamesForZonePreset === 'function'
? await window.zonesManager.resolveDeviceNamesForZonePreset(tabData, pid)
: [];
await sendPresetViaEspNow(pid, preset, names, false, false, '2');
}
}
// Store selected preset per zone
const selectedPresets = {};
// Store selected preset payload per zone for beat-trigger reliability.
@@ -1920,19 +2003,37 @@ const insertDraggingOntoTarget = (presetsList, dragging, dropTarget) => {
};
// Function to render presets for a specific zone in 2D grid
const renderTabPresets = async (zoneId) => {
/**
* @param {string} zoneId
* @param {{ stopSequencePlayback?: boolean }} [options] - pass `{ stopSequencePlayback: true }` only when
* the UI action should stop server zone sequence playback (default: do not POST /sequences/stop).
*/
const renderTabPresets = async (zoneId, options = {}) => {
const presetsList = document.getElementById('presets-list-zone');
if (!presetsList) return;
const stopSeq = options.stopSequencePlayback === true;
if (stopSeq && typeof window.stopZoneSequencePlayback === 'function') {
// Pass false: an earlier render's stop() can finish after this pass rebuilds the DOM and
// would otherwise clear .active from new sequence tiles (breaks edit/run selection).
await window.stopZoneSequencePlayback(false);
}
try {
// Get zone data to see which presets are associated
const tabResponse = await fetch(`/zones/${zoneId}`, {
headers: { Accept: 'application/json' },
});
const [tabResponse, groupsStripRes, presetsResponse] = await Promise.all([
fetch(`/zones/${zoneId}`, {
headers: { Accept: 'application/json' },
}),
fetch('/groups', { headers: { Accept: 'application/json' } }),
fetch('/presets', {
headers: { Accept: 'application/json' },
}),
]);
if (!tabResponse.ok) {
throw new Error('Failed to load zone');
}
const tabData = await tabResponse.json();
const groupsMapStrip = groupsStripRes.ok ? await groupsStripRes.json() : {};
// Get presets - support both 2D grid and flat array (for backward compatibility)
let presetGrid = tabData.presets;
@@ -1945,10 +2046,6 @@ const renderTabPresets = async (zoneId) => {
presetGrid = arrayToGrid(presetGrid, 3);
}
// Get all presets
const presetsResponse = await fetch('/presets', {
headers: { Accept: 'application/json' },
});
if (!presetsResponse.ok) {
throw new Error('Failed to load presets');
}
@@ -2021,13 +2118,10 @@ const renderTabPresets = async (zoneId) => {
});
}
// Get the currently selected preset for this zone
const selectedPresetId = selectedPresets[zoneId];
// Render presets in grid layout
// Flatten the grid and render all presets (grid CSS will handle layout)
const flatPresets = presetGrid.flat().filter(id => id);
const validIdSet = new Set(flatPresets.map((id) => String(id)));
pruneZonePresetSelection(zoneId, validIdSet);
if (flatPresets.length === 0) {
// Show empty message if this zone has no presets
const empty = document.createElement('p');
@@ -2039,23 +2133,36 @@ const renderTabPresets = async (zoneId) => {
flatPresets.forEach((presetId) => {
const preset = allPresets[presetId];
if (preset) {
const isSelected = presetId === selectedPresetId;
ensureZonePresetSelection(zoneId);
const isSelected = zoneSelectedPresetIds[String(zoneId)].has(String(presetId));
const displayPreset = {
...preset,
colors: resolveColorsWithPaletteRefs(preset.colors, preset.palette_refs, paletteColors),
};
const wrapper = createPresetButton(presetId, displayPreset, zoneId, isSelected);
const wrapper = createPresetButton(
presetId,
displayPreset,
zoneId,
isSelected,
tabData,
groupsMapStrip,
allPresets,
);
presetsList.appendChild(wrapper);
}
});
}
if (typeof window.appendZoneSequenceTiles === 'function') {
await window.appendZoneSequenceTiles(zoneId, tabData, allPresets, paletteColors, presetsList);
}
} catch (error) {
console.error('Failed to render zone presets:', error);
presetsList.innerHTML = '<p class="muted-text">Failed to load presets.</p>';
}
};
const createPresetButton = (presetId, preset, zoneId, isSelected = false) => {
const createPresetButton = (presetId, preset, zoneId, isSelected, tabData, groupsMap, allPresets) => {
const uiMode = getPresetUiMode();
const row = document.createElement('div');
@@ -2069,7 +2176,6 @@ const createPresetButton = (presetId, preset, zoneId, isSelected = false) => {
button.className = 'pattern-button preset-tile-main';
if (isSelected) {
button.classList.add('active');
selectedPresetPayloads[zoneId] = preset;
}
const colors = Array.isArray(preset.colors) ? preset.colors.filter((c) => c) : [];
@@ -2093,6 +2199,14 @@ const createPresetButton = (presetId, preset, zoneId, isSelected = false) => {
presetNameLabel.className = 'pattern-button-label';
button.appendChild(presetNameLabel);
const groupsText = formatPresetTargetGroupsLine(tabData || {}, presetId, groupsMap || {});
if (groupsText) {
const groupsSpan = document.createElement('span');
groupsSpan.className = 'preset-tile-groups';
groupsSpan.textContent = groupsText;
button.appendChild(groupsSpan);
}
const bgSwatch = document.createElement('span');
const bgColor = coercePresetBackground(preset);
bgSwatch.title = `Background: ${bgColor}`;
@@ -2111,7 +2225,7 @@ const createPresetButton = (presetId, preset, zoneId, isSelected = false) => {
`;
button.appendChild(bgSwatch);
const isManualPreset = preset && typeof preset.auto === 'boolean' ? !preset.auto : false;
const isManualPreset = preset && !coercePresetAuto(preset);
if (isManualPreset) {
const manualBadge = document.createElement('span');
manualBadge.textContent = '1';
@@ -2138,18 +2252,42 @@ const createPresetButton = (presetId, preset, zoneId, isSelected = false) => {
button.addEventListener('click', () => {
if (isDraggingPreset) return;
const presetsListEl = document.getElementById('presets-list-zone');
if (presetsListEl) {
presetsListEl.querySelectorAll('.pattern-button').forEach((btn) => btn.classList.remove('active'));
console.info('Preset button pressed', { zoneId, presetId, name: (preset && preset.name) || presetId });
if (typeof window.stopZoneSequencePlayback === 'function') {
window.stopZoneSequencePlayback();
}
button.classList.add('active');
selectedPresets[zoneId] = presetId;
selectedPresetPayloads[zoneId] = preset;
const section = row.closest('.presets-section');
const deviceNames = tabDeviceNamesFromSection(section);
sendPresetViaEspNow(presetId, preset, deviceNames, false, false, '2').catch((err) => {
console.error(err);
});
const presetsListEl = document.getElementById('presets-list-zone');
ensureZonePresetSelection(zoneId);
const z = String(zoneId);
const set = zoneSelectedPresetIds[z];
const order = zonePresetSelectionOrder[z];
const idStr = String(presetId);
if (set.has(idStr)) {
set.delete(idStr);
zonePresetSelectionOrder[z] = order.filter((x) => String(x) !== idStr);
} else {
set.add(idStr);
order.push(idStr);
}
if (presetsListEl) {
presetsListEl.querySelectorAll('.preset-tile-row:not(.sequence-tile-row)').forEach((rw) => {
const pid = rw.dataset.presetId;
const btnEl = rw.querySelector('.preset-tile-main');
if (!btnEl || !pid) return;
if (set.has(String(pid))) btnEl.classList.add('active');
else btnEl.classList.remove('active');
});
}
const orderList = getOrderedZonePresetSelection(zoneId);
if (orderList.length) {
const lastPid = orderList[orderList.length - 1];
selectedPresets[zoneId] = lastPid;
selectedPresetPayloads[zoneId] = (allPresets && allPresets[lastPid]) || preset;
} else {
delete selectedPresets[zoneId];
delete selectedPresetPayloads[zoneId];
}
void sendMergedZonePresetSelection(zoneId, tabData, allPresets);
});
if (canDrag) {
@@ -2173,7 +2311,9 @@ const createPresetButton = (presetId, preset, zoneId, isSelected = false) => {
});
}
row.appendChild(button);
const top = document.createElement('div');
top.className = 'preset-tile-row-top';
top.appendChild(button);
if (uiMode === 'edit') {
const actions = document.createElement('div');
@@ -2192,9 +2332,11 @@ const createPresetButton = (presetId, preset, zoneId, isSelected = false) => {
});
actions.appendChild(editBtn);
row.appendChild(actions);
top.appendChild(actions);
}
row.appendChild(top);
return row;
};
@@ -2279,6 +2421,12 @@ const removePresetFromTab = async (zoneId, presetId) => {
tabData.presets = newGrid;
tabData.presets_flat = flat;
if (tabData.preset_group_ids && typeof tabData.preset_group_ids === 'object') {
const pg = { ...tabData.preset_group_ids };
delete pg[String(presetId)];
tabData.preset_group_ids = pg;
}
const updateResponse = await fetch(`/zones/${zoneId}`, {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
@@ -2297,6 +2445,10 @@ const removePresetFromTab = async (zoneId, presetId) => {
try {
window.removePresetFromTab = removePresetFromTab;
} catch (e) {}
try {
window.renderTabPresets = renderTabPresets;
window.getPresetUiMode = getPresetUiMode;
} catch (e) {}
// Listen for HTMX swaps to render presets
document.body.addEventListener('htmx:afterSwap', (event) => {
@@ -2327,10 +2479,7 @@ document.addEventListener('DOMContentLoaded', () => {
}
const mainMenu = document.getElementById('main-menu-dropdown');
if (mainMenu) mainMenu.classList.remove('open');
const leftPanel = document.querySelector('.presets-section[data-zone-id]');
if (leftPanel) {
renderTabPresets(leftPanel.dataset.zoneId);
}
// Preset strip re-renders from `zones.js` after `loadZones()` (no driver/playback side effects).
});
});
});