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:
@@ -157,7 +157,7 @@ function tabDeviceNamesFromSection(section) {
|
||||
: [];
|
||||
}
|
||||
|
||||
/** Device names for ``presetId`` on the current zone tab (per-preset groups or zone default). */
|
||||
/** Device names for ``presetId`` on the current zone tab (zone ``group_ids`` for presets, else tab devices). */
|
||||
async function deviceNamesForPresetOnCurrentZone(presetId) {
|
||||
const section = document.querySelector('.presets-section[data-zone-id]');
|
||||
const fallback = tabDeviceNamesFromSection(section);
|
||||
@@ -176,11 +176,11 @@ async function deviceNamesForPresetOnCurrentZone(presetId) {
|
||||
}
|
||||
}
|
||||
|
||||
function formatPresetTargetGroupsLine(zoneDoc, presetId, groupsMap) {
|
||||
function formatPresetTargetGroupsLine(zoneDoc, groupsMap) {
|
||||
const zm = window.zonesManager;
|
||||
const gids =
|
||||
zm && typeof zm.effectiveGroupIdsForZonePreset === 'function'
|
||||
? zm.effectiveGroupIdsForZonePreset(zoneDoc, presetId)
|
||||
? zm.effectiveGroupIdsForZonePreset(zoneDoc || {})
|
||||
: Array.isArray(zoneDoc && zoneDoc.group_ids)
|
||||
? zoneDoc.group_ids.map((x) => String(x).trim()).filter((x) => x.length > 0)
|
||||
: [];
|
||||
@@ -242,6 +242,7 @@ document.addEventListener('DOMContentLoaded', () => {
|
||||
const presetRemoveFromTabButton = document.getElementById('preset-remove-from-zone-btn');
|
||||
const presetSaveButton = document.getElementById('preset-save-btn');
|
||||
const presetAddFromPaletteButton = document.getElementById('preset-add-from-palette-btn');
|
||||
const presetBackgroundFromPaletteButton = document.getElementById('preset-background-from-palette-btn');
|
||||
|
||||
if (!presetsButton || !presetsModal || !presetsList || !presetSaveButton) {
|
||||
return;
|
||||
@@ -253,6 +254,8 @@ document.addEventListener('DOMContentLoaded', () => {
|
||||
let cachedPatterns = {};
|
||||
let currentPresetColors = []; // Track colors for the current preset
|
||||
let currentPresetPaletteRefs = []; // Palette index refs per color (null for direct colors)
|
||||
let currentBackgroundPaletteRef = null;
|
||||
let bgPaletteResolveGen = 0;
|
||||
|
||||
// Function to get max colors for current pattern
|
||||
const getMaxColors = () => {
|
||||
@@ -326,6 +329,10 @@ document.addEventListener('DOMContentLoaded', () => {
|
||||
presetBackgroundButton.style.backgroundColor = color;
|
||||
presetBackgroundButton.style.color = '#fff';
|
||||
presetBackgroundButton.style.borderColor = 'rgba(255, 255, 255, 0.6)';
|
||||
presetBackgroundButton.title =
|
||||
currentBackgroundPaletteRef != null
|
||||
? `Background from profile palette (index ${currentBackgroundPaletteRef}); click to pick a custom colour`
|
||||
: 'Choose background colour';
|
||||
};
|
||||
|
||||
const updateDelayVisibilityForManualMode = () => {
|
||||
@@ -640,9 +647,28 @@ document.addEventListener('DOMContentLoaded', () => {
|
||||
presetBrightnessInput.value = preset.brightness || 0;
|
||||
presetDelayInput.value = preset.delay || 0;
|
||||
if (presetBackgroundInput) {
|
||||
const rawBgRef = preset.background_palette_ref ?? preset.backgroundPaletteRef;
|
||||
let bgRef = null;
|
||||
if (rawBgRef != null && rawBgRef !== '') {
|
||||
const n = typeof rawBgRef === 'number' ? rawBgRef : parseInt(String(rawBgRef), 10);
|
||||
if (Number.isInteger(n) && n >= 0) {
|
||||
bgRef = n;
|
||||
}
|
||||
}
|
||||
currentBackgroundPaletteRef = bgRef;
|
||||
presetBackgroundInput.value = coercePresetBackground(preset);
|
||||
updatePresetBackgroundButton();
|
||||
const gen = ++bgPaletteResolveGen;
|
||||
void getCurrentProfilePaletteColors().then((pal) => {
|
||||
if (gen !== bgPaletteResolveGen || !presetBackgroundInput) {
|
||||
return;
|
||||
}
|
||||
presetBackgroundInput.value = resolvePresetBackgroundHex(preset, pal);
|
||||
updatePresetBackgroundButton();
|
||||
});
|
||||
} else {
|
||||
updatePresetBackgroundButton();
|
||||
}
|
||||
updatePresetBackgroundButton();
|
||||
if (presetManualModeInput) {
|
||||
const autoVal = typeof preset.auto === 'boolean' ? preset.auto : true;
|
||||
presetManualModeInput.checked = !autoVal;
|
||||
@@ -714,6 +740,7 @@ document.addEventListener('DOMContentLoaded', () => {
|
||||
};
|
||||
|
||||
const clearForm = () => {
|
||||
bgPaletteResolveGen += 1;
|
||||
currentEditId = null;
|
||||
currentEditTabId = null;
|
||||
currentPresetColors = [];
|
||||
@@ -742,9 +769,6 @@ document.addEventListener('DOMContentLoaded', () => {
|
||||
if (presetManualBeatNInput) {
|
||||
presetManualBeatNInput.value = '1';
|
||||
}
|
||||
if (presetBackgroundInput) {
|
||||
presetBackgroundInput.value = '#000000';
|
||||
}
|
||||
updatePresetBackgroundButton();
|
||||
updateManualModeAvailability();
|
||||
// Re-enable name and pattern when clearing (for new preset)
|
||||
@@ -825,6 +849,7 @@ document.addEventListener('DOMContentLoaded', () => {
|
||||
brightness: presetBrightnessInput ? parseInt(presetBrightnessInput.value, 10) || 0 : 0,
|
||||
delay: presetDelayInput ? parseInt(presetDelayInput.value, 10) || 0 : 0,
|
||||
background: presetBackgroundInput ? presetBackgroundInput.value : '#000000',
|
||||
background_palette_ref: currentBackgroundPaletteRef != null ? currentBackgroundPaletteRef : null,
|
||||
auto: presetManualModeInput ? !presetManualModeInput.checked : true,
|
||||
manual_beat_n: (() => {
|
||||
if (!presetManualBeatNInput) return 1;
|
||||
@@ -1302,7 +1327,15 @@ document.addEventListener('DOMContentLoaded', () => {
|
||||
throw new Error('Failed to load zone');
|
||||
}
|
||||
const tabData = await tabResponse.json();
|
||||
|
||||
const kind =
|
||||
typeof window.normalizeZoneContentKind === 'function'
|
||||
? window.normalizeZoneContentKind(tabData)
|
||||
: null;
|
||||
if (kind === 'sequences') {
|
||||
alert('This zone is for sequences only. Add a sequence from the zone Edit menu instead.');
|
||||
return;
|
||||
}
|
||||
|
||||
// Normalize to flat array to check and update usage
|
||||
let flat = [];
|
||||
if (Array.isArray(tabData.presets_flat)) {
|
||||
@@ -1324,9 +1357,6 @@ 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}`, {
|
||||
@@ -1383,6 +1413,7 @@ document.addEventListener('DOMContentLoaded', () => {
|
||||
presetBackgroundInput.click();
|
||||
});
|
||||
presetBackgroundInput.addEventListener('input', () => {
|
||||
currentBackgroundPaletteRef = null;
|
||||
updatePresetBackgroundButton();
|
||||
});
|
||||
}
|
||||
@@ -1462,10 +1493,6 @@ document.addEventListener('DOMContentLoaded', () => {
|
||||
const ref = parseInt(row.dataset.paletteIndex, 10);
|
||||
if (!color || !Number.isInteger(ref)) return;
|
||||
|
||||
if (currentPresetColors.includes(color) && currentPresetPaletteRefs.includes(ref)) {
|
||||
alert('That palette color is already linked.');
|
||||
return;
|
||||
}
|
||||
const maxColors = getMaxColors();
|
||||
if (currentPresetColors.length >= maxColors) {
|
||||
alert(`This pattern allows a maximum of ${maxColors} color${maxColors !== 1 ? 's' : ''}.`);
|
||||
@@ -1479,7 +1506,72 @@ document.addEventListener('DOMContentLoaded', () => {
|
||||
});
|
||||
} catch (err) {
|
||||
console.error('Failed to add from palette:', err);
|
||||
alert('Failed to load palette colors.');
|
||||
alert('Failed to load palette colours.');
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
if (presetBackgroundFromPaletteButton) {
|
||||
presetBackgroundFromPaletteButton.addEventListener('click', async () => {
|
||||
try {
|
||||
const paletteColors = await getCurrentProfilePaletteColors();
|
||||
if (!Array.isArray(paletteColors) || paletteColors.length === 0) {
|
||||
alert('No profile palette colours available.');
|
||||
return;
|
||||
}
|
||||
|
||||
const modal = document.createElement('div');
|
||||
modal.className = 'modal active modal-child-overlay';
|
||||
modal.innerHTML = `
|
||||
<div class="modal-content">
|
||||
<h2>Pick background colour</h2>
|
||||
<div id="pick-bg-palette-list" class="profiles-list" style="max-height: 300px; overflow-y: auto;"></div>
|
||||
<div class="modal-actions">
|
||||
<button class="btn btn-secondary" id="pick-bg-palette-close-btn">Close</button>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
document.body.appendChild(modal);
|
||||
|
||||
const list = modal.querySelector('#pick-bg-palette-list');
|
||||
paletteColors.forEach((color, idx) => {
|
||||
const row = document.createElement('div');
|
||||
row.className = 'profiles-row';
|
||||
row.style.display = 'flex';
|
||||
row.style.alignItems = 'center';
|
||||
row.style.gap = '0.75rem';
|
||||
row.dataset.paletteIndex = String(idx);
|
||||
row.dataset.paletteColor = color;
|
||||
row.innerHTML = `
|
||||
<div style="width:28px;height:28px;border-radius:4px;border:1px solid #555;background:${color};"></div>
|
||||
<span style="flex:1">${color}</span>
|
||||
<button class="btn btn-primary btn-small" type="button">Use</button>
|
||||
`;
|
||||
list.appendChild(row);
|
||||
});
|
||||
|
||||
const close = () => modal.remove();
|
||||
modal.querySelector('#pick-bg-palette-close-btn').addEventListener('click', close);
|
||||
|
||||
list.addEventListener('click', (e) => {
|
||||
const btn = e.target.closest('button');
|
||||
if (!btn) return;
|
||||
const row = e.target.closest('[data-palette-index]');
|
||||
if (!row) return;
|
||||
const color = row.dataset.paletteColor;
|
||||
const ref = parseInt(row.dataset.paletteIndex, 10);
|
||||
if (!color || !Number.isInteger(ref)) return;
|
||||
|
||||
currentBackgroundPaletteRef = ref;
|
||||
if (presetBackgroundInput) {
|
||||
presetBackgroundInput.value = color;
|
||||
}
|
||||
updatePresetBackgroundButton();
|
||||
close();
|
||||
});
|
||||
} catch (err) {
|
||||
console.error('Failed to pick background from palette:', err);
|
||||
alert('Failed to load palette colours.');
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -1663,6 +1755,26 @@ const coercePresetBackground = (preset) => {
|
||||
return '#000000';
|
||||
};
|
||||
|
||||
/** Resolved background hex; uses ``background_palette_ref`` when set and palette is available. */
|
||||
const resolvePresetBackgroundHex = (preset, paletteColors) => {
|
||||
if (!preset || typeof preset !== 'object') {
|
||||
return coercePresetBackground(preset);
|
||||
}
|
||||
const rawRef =
|
||||
preset.background_palette_ref !== undefined && preset.background_palette_ref !== null
|
||||
? preset.background_palette_ref
|
||||
: preset.backgroundPaletteRef;
|
||||
const ref = typeof rawRef === 'number' ? rawRef : parseInt(String(rawRef != null ? rawRef : ''), 10);
|
||||
const pal = Array.isArray(paletteColors) ? paletteColors : [];
|
||||
if (Number.isInteger(ref) && ref >= 0 && ref < pal.length && pal[ref]) {
|
||||
const c = String(pal[ref]).trim();
|
||||
if (/^#[0-9a-fA-F]{6}$/i.test(c)) {
|
||||
return c.toUpperCase();
|
||||
}
|
||||
}
|
||||
return coercePresetBackground(preset);
|
||||
};
|
||||
|
||||
/** Audio beat stride for manual presets (led-controller only; firmware ignores this key). */
|
||||
const coerceManualBeatN = (preset) => {
|
||||
if (!preset || typeof preset !== 'object') return 1;
|
||||
@@ -1695,7 +1807,7 @@ const sendPresetViaEspNow = async (
|
||||
|
||||
const wirePresetId = devicePresetId != null ? String(devicePresetId) : String(presetId);
|
||||
const presetAuto = coercePresetAuto(preset);
|
||||
const presetBackground = coercePresetBackground(preset);
|
||||
const presetBackground = resolvePresetBackgroundHex(preset, paletteColors);
|
||||
const presetMessage = {
|
||||
v: '1',
|
||||
presets: {
|
||||
@@ -2034,7 +2146,11 @@ const renderTabPresets = async (zoneId, options = {}) => {
|
||||
}
|
||||
const tabData = await tabResponse.json();
|
||||
const groupsMapStrip = groupsStripRes.ok ? await groupsStripRes.json() : {};
|
||||
|
||||
const ck =
|
||||
typeof window.normalizeZoneContentKind === 'function'
|
||||
? window.normalizeZoneContentKind(tabData)
|
||||
: null;
|
||||
|
||||
// Get presets - support both 2D grid and flat array (for backward compatibility)
|
||||
let presetGrid = tabData.presets;
|
||||
if (!presetGrid || !Array.isArray(presetGrid)) {
|
||||
@@ -2045,6 +2161,9 @@ const renderTabPresets = async (zoneId, options = {}) => {
|
||||
// It's a flat array, convert to grid
|
||||
presetGrid = arrayToGrid(presetGrid, 3);
|
||||
}
|
||||
if (ck === 'sequences') {
|
||||
presetGrid = [];
|
||||
}
|
||||
|
||||
if (!presetsResponse.ok) {
|
||||
throw new Error('Failed to load presets');
|
||||
@@ -2122,13 +2241,25 @@ const renderTabPresets = async (zoneId, options = {}) => {
|
||||
const validIdSet = new Set(flatPresets.map((id) => String(id)));
|
||||
pruneZonePresetSelection(zoneId, validIdSet);
|
||||
|
||||
const hasSeq =
|
||||
Array.isArray(tabData.sequence_ids) &&
|
||||
tabData.sequence_ids.some((x) => x != null && String(x).trim());
|
||||
|
||||
if (flatPresets.length === 0) {
|
||||
// Show empty message if this zone has no presets
|
||||
const empty = document.createElement('p');
|
||||
empty.className = 'muted-text';
|
||||
empty.style.gridColumn = '1 / -1'; // Span all columns
|
||||
empty.textContent = 'No presets added to this zone. Open the zone\'s Edit menu and click "Add Preset" to add one.';
|
||||
presetsList.appendChild(empty);
|
||||
if (ck === 'sequences') {
|
||||
if (!hasSeq) {
|
||||
empty.textContent =
|
||||
"No sequences on this zone yet. Open the zone's Edit menu to add one.";
|
||||
presetsList.appendChild(empty);
|
||||
}
|
||||
} else {
|
||||
empty.textContent =
|
||||
'No presets added to this zone. Open the zone\'s Edit menu and click "Add Preset" to add one.';
|
||||
presetsList.appendChild(empty);
|
||||
}
|
||||
} else {
|
||||
flatPresets.forEach((presetId) => {
|
||||
const preset = allPresets[presetId];
|
||||
@@ -2138,6 +2269,7 @@ const renderTabPresets = async (zoneId, options = {}) => {
|
||||
const displayPreset = {
|
||||
...preset,
|
||||
colors: resolveColorsWithPaletteRefs(preset.colors, preset.palette_refs, paletteColors),
|
||||
background: resolvePresetBackgroundHex(preset, paletteColors),
|
||||
};
|
||||
const wrapper = createPresetButton(
|
||||
presetId,
|
||||
@@ -2153,7 +2285,7 @@ const renderTabPresets = async (zoneId, options = {}) => {
|
||||
});
|
||||
}
|
||||
|
||||
if (typeof window.appendZoneSequenceTiles === 'function') {
|
||||
if (typeof window.appendZoneSequenceTiles === 'function' && ck !== 'presets') {
|
||||
await window.appendZoneSequenceTiles(zoneId, tabData, allPresets, paletteColors, presetsList);
|
||||
}
|
||||
} catch (error) {
|
||||
@@ -2199,7 +2331,7 @@ const createPresetButton = (presetId, preset, zoneId, isSelected, tabData, group
|
||||
presetNameLabel.className = 'pattern-button-label';
|
||||
button.appendChild(presetNameLabel);
|
||||
|
||||
const groupsText = formatPresetTargetGroupsLine(tabData || {}, presetId, groupsMap || {});
|
||||
const groupsText = formatPresetTargetGroupsLine(tabData || {}, groupsMap || {});
|
||||
if (groupsText) {
|
||||
const groupsSpan = document.createElement('span');
|
||||
groupsSpan.className = 'preset-tile-groups';
|
||||
@@ -2253,9 +2385,6 @@ const createPresetButton = (presetId, preset, zoneId, isSelected, tabData, group
|
||||
button.addEventListener('click', () => {
|
||||
if (isDraggingPreset) return;
|
||||
console.info('Preset button pressed', { zoneId, presetId, name: (preset && preset.name) || presetId });
|
||||
if (typeof window.stopZoneSequencePlayback === 'function') {
|
||||
window.stopZoneSequencePlayback();
|
||||
}
|
||||
const presetsListEl = document.getElementById('presets-list-zone');
|
||||
ensureZonePresetSelection(zoneId);
|
||||
const z = String(zoneId);
|
||||
@@ -2421,12 +2550,6 @@ 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' },
|
||||
|
||||
Reference in New Issue
Block a user