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:
@@ -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).
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user