feat(patterns): add new pattern suite and improve mobile controls
Add a broad set of LED patterns with metadata/tests and update zone/profile preset seeding, while refining mobile/desktop UI behavior for scrolling, brightness controls, and bulk pattern sending.
This commit is contained in:
@@ -4,6 +4,7 @@ document.addEventListener('DOMContentLoaded', () => {
|
||||
const patternsCloseButton = document.getElementById('patterns-close-btn');
|
||||
const patternsList = document.getElementById('patterns-list');
|
||||
const patternAddButton = document.getElementById('pattern-add-btn');
|
||||
const patternSendAllButton = document.getElementById('pattern-send-all-btn');
|
||||
const patternEditorModal = document.getElementById('pattern-editor-modal');
|
||||
const patternEditorCloseButton = document.getElementById('pattern-editor-close-btn');
|
||||
const patternCreateBtn = document.getElementById('pattern-create-btn');
|
||||
@@ -24,6 +25,71 @@ document.addEventListener('DOMContentLoaded', () => {
|
||||
return;
|
||||
}
|
||||
|
||||
const coercePresetInt = (v, def = 0) => {
|
||||
if (typeof v === 'number' && Number.isFinite(v)) {
|
||||
return v;
|
||||
}
|
||||
const t = parseInt(String(v), 10);
|
||||
return Number.isFinite(t) ? t : def;
|
||||
};
|
||||
|
||||
const getCurrentProfileId = async () => {
|
||||
try {
|
||||
const response = await fetch('/profiles/current', { headers: { Accept: 'application/json' } });
|
||||
if (!response.ok) {
|
||||
return null;
|
||||
}
|
||||
const data = await response.json();
|
||||
return data && (data.id || (data.profile && data.profile.id)) ? String(data.id || data.profile.id) : null;
|
||||
} catch (_) {
|
||||
return null;
|
||||
}
|
||||
};
|
||||
|
||||
const filterPresetsForCurrentProfile = async (presetsObj) => {
|
||||
const scoped = presetsObj && typeof presetsObj === 'object' ? presetsObj : {};
|
||||
const currentProfileId = await getCurrentProfileId();
|
||||
if (!currentProfileId) {
|
||||
return scoped;
|
||||
}
|
||||
return Object.fromEntries(
|
||||
Object.entries(scoped).filter(([, preset]) => {
|
||||
if (!preset || typeof preset !== 'object') return false;
|
||||
if (!('profile_id' in preset)) return true;
|
||||
return String(preset.profile_id) === String(currentProfileId);
|
||||
}),
|
||||
);
|
||||
};
|
||||
|
||||
const tabDeviceNamesFromSection = (section) => {
|
||||
if (typeof window.parseTabDeviceNames === 'function') {
|
||||
return window.parseTabDeviceNames(section);
|
||||
}
|
||||
const namesAttr = section && section.getAttribute('data-device-names');
|
||||
return namesAttr
|
||||
? namesAttr.split(',').map((n) => n.trim()).filter((n) => n.length > 0)
|
||||
: [];
|
||||
};
|
||||
|
||||
const postDriverSequence = async (sequence, targetMacs, delayS = 0.05) => {
|
||||
const body = {
|
||||
sequence,
|
||||
targets: Array.isArray(targetMacs) && targetMacs.length ? [...new Set(targetMacs)] : undefined,
|
||||
delay_s: delayS,
|
||||
};
|
||||
const res = await fetch('/presets/push', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json', Accept: 'application/json' },
|
||||
credentials: 'same-origin',
|
||||
body: JSON.stringify(body),
|
||||
});
|
||||
if (!res.ok) {
|
||||
const err = await res.json().catch(() => ({}));
|
||||
throw new Error((err && err.error) || res.statusText || 'Send failed');
|
||||
}
|
||||
return res.json().catch(() => ({}));
|
||||
};
|
||||
|
||||
const nReadableStringFromMeta = (meta, key) => {
|
||||
if (!meta || typeof meta !== 'object') {
|
||||
return '';
|
||||
@@ -424,4 +490,93 @@ document.addEventListener('DOMContentLoaded', () => {
|
||||
patternsCloseButton.addEventListener('click', closeModal);
|
||||
}
|
||||
|
||||
if (patternSendAllButton) {
|
||||
patternSendAllButton.addEventListener('click', async () => {
|
||||
const section = document.querySelector('.presets-section[data-zone-id]');
|
||||
const zoneId = section ? section.dataset.zoneId : null;
|
||||
if (!zoneId) {
|
||||
alert('Could not determine current zone.');
|
||||
return;
|
||||
}
|
||||
const deviceNames = tabDeviceNamesFromSection(section);
|
||||
if (!deviceNames.length) {
|
||||
alert('No devices found in the current zone.');
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const [zoneRes, presetsRes] = await Promise.all([
|
||||
fetch(`/zones/${zoneId}`, { headers: { Accept: 'application/json' } }),
|
||||
fetch('/presets', { headers: { Accept: 'application/json' } }),
|
||||
]);
|
||||
if (!zoneRes.ok || !presetsRes.ok) {
|
||||
throw new Error('Failed to load zone presets');
|
||||
}
|
||||
const zoneData = await zoneRes.json();
|
||||
const allPresetsRaw = await presetsRes.json();
|
||||
const allPresets = await filterPresetsForCurrentProfile(allPresetsRaw);
|
||||
const zonePresetIds = Array.isArray(zoneData.presets_flat)
|
||||
? zoneData.presets_flat.map((id) => String(id))
|
||||
: [];
|
||||
if (!zonePresetIds.length) {
|
||||
alert('No presets found in this zone.');
|
||||
return;
|
||||
}
|
||||
|
||||
const wirePresets = {};
|
||||
zonePresetIds.forEach((presetId) => {
|
||||
const preset = allPresets[presetId];
|
||||
if (!preset) {
|
||||
return;
|
||||
}
|
||||
const colors = Array.isArray(preset.colors) && preset.colors.length
|
||||
? preset.colors
|
||||
: ['#FFFFFF'];
|
||||
wirePresets[presetId] = {
|
||||
pattern: preset.pattern || 'off',
|
||||
colors,
|
||||
delay: typeof preset.delay === 'number' ? preset.delay : 100,
|
||||
brightness: typeof preset.brightness === 'number'
|
||||
? preset.brightness
|
||||
: (typeof preset.br === 'number' ? preset.br : 127),
|
||||
auto: typeof preset.auto === 'boolean' ? preset.auto : true,
|
||||
n1: coercePresetInt(preset.n1),
|
||||
n2: coercePresetInt(preset.n2),
|
||||
n3: coercePresetInt(preset.n3),
|
||||
n4: coercePresetInt(preset.n4),
|
||||
n5: coercePresetInt(preset.n5),
|
||||
n6: coercePresetInt(preset.n6),
|
||||
};
|
||||
});
|
||||
if (!Object.keys(wirePresets).length) {
|
||||
alert('No matching presets found to send.');
|
||||
return;
|
||||
}
|
||||
|
||||
const select = {};
|
||||
deviceNames.forEach((name) => {
|
||||
if (name) {
|
||||
select[name] = zonePresetIds.slice();
|
||||
}
|
||||
});
|
||||
const targetMacs =
|
||||
typeof window.tabsManager !== 'undefined' &&
|
||||
typeof window.tabsManager.resolveTabDeviceMacs === 'function'
|
||||
? await window.tabsManager.resolveTabDeviceMacs(deviceNames)
|
||||
: [];
|
||||
|
||||
const sequence = [
|
||||
{ v: '1', clear_presets: true, save: true },
|
||||
{ v: '1', presets: wirePresets, save: true },
|
||||
];
|
||||
if (Object.keys(select).length) {
|
||||
sequence.push({ v: '1', select });
|
||||
}
|
||||
await postDriverSequence(sequence, targetMacs, 0.05);
|
||||
} catch (error) {
|
||||
console.error('Send all patterns failed:', error);
|
||||
alert('Failed to send all patterns.');
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
});
|
||||
|
||||
@@ -175,39 +175,6 @@ async function postDriverSequence(sequence, targetMacs, delayS) {
|
||||
return res.json().catch(() => ({}));
|
||||
}
|
||||
|
||||
// Send a select message for a preset to all devices on the current zone (ESP-NOW or Wi-Fi).
|
||||
const sendSelectForCurrentTabDevices = async (presetId, sectionEl) => {
|
||||
const section = sectionEl || document.querySelector('.presets-section[data-zone-id]');
|
||||
if (!section || !presetId) {
|
||||
return;
|
||||
}
|
||||
const deviceNames = tabDeviceNamesFromSection(section);
|
||||
|
||||
if (!deviceNames.length) {
|
||||
return;
|
||||
}
|
||||
|
||||
const select = {};
|
||||
deviceNames.forEach((name) => {
|
||||
if (name) {
|
||||
select[name] = [presetId];
|
||||
}
|
||||
});
|
||||
|
||||
const targetMacs =
|
||||
typeof window.tabsManager !== 'undefined' &&
|
||||
typeof window.tabsManager.resolveTabDeviceMacs === 'function'
|
||||
? await window.tabsManager.resolveTabDeviceMacs(deviceNames)
|
||||
: [];
|
||||
|
||||
try {
|
||||
await postDriverSequence([{ v: '1', select }], targetMacs);
|
||||
} catch (err) {
|
||||
console.error('sendSelectForCurrentTabDevices:', err);
|
||||
alert('Failed to send preset selection to devices.');
|
||||
}
|
||||
};
|
||||
|
||||
document.addEventListener('DOMContentLoaded', () => {
|
||||
const presetsButton = document.getElementById('presets-btn');
|
||||
const presetsModal = document.getElementById('presets-modal');
|
||||
@@ -1332,7 +1299,7 @@ document.addEventListener('DOMContentLoaded', () => {
|
||||
// Work out the preset ID: for existing presets use currentEditId, otherwise fall back to name
|
||||
const presetId = currentEditId || payload.name;
|
||||
// Try sends preset first, then select; never persist on device.
|
||||
await sendPresetViaEspNow(presetId, payload, deviceNames, false, false);
|
||||
await sendPresetViaEspNow(presetId, payload, deviceNames, false, false, '2');
|
||||
});
|
||||
}
|
||||
|
||||
@@ -1346,8 +1313,9 @@ document.addEventListener('DOMContentLoaded', () => {
|
||||
const section = document.querySelector('.presets-section[data-zone-id]');
|
||||
const deviceNames = tabDeviceNamesFromSection(section);
|
||||
const presetId = currentEditId || payload.name;
|
||||
await sendPresetViaEspNow(presetId, payload, deviceNames, true, true, '1');
|
||||
await updateTabDefaultPreset(presetId);
|
||||
await sendDefaultPreset(presetId, deviceNames);
|
||||
await sendDefaultPreset('1', deviceNames);
|
||||
});
|
||||
}
|
||||
|
||||
@@ -1379,7 +1347,7 @@ document.addEventListener('DOMContentLoaded', () => {
|
||||
throw new Error('Failed to save preset');
|
||||
}
|
||||
|
||||
// Same device targeting as Try: zone tab supplies names → /presets/push gets targets + select.
|
||||
// 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);
|
||||
|
||||
@@ -1388,18 +1356,18 @@ document.addEventListener('DOMContentLoaded', () => {
|
||||
if (saved && typeof saved === 'object') {
|
||||
if (currentEditId) {
|
||||
// PUT returns the preset object directly; use the existing ID
|
||||
await sendPresetViaEspNow(currentEditId, saved, deviceNames, true, false);
|
||||
await sendPresetViaEspNow(currentEditId, saved, deviceNames, false, false, '2');
|
||||
} else {
|
||||
// POST returns { id: preset }
|
||||
const entries = Object.entries(saved);
|
||||
if (entries.length > 0) {
|
||||
const [newId, presetData] = entries[0];
|
||||
await sendPresetViaEspNow(newId, presetData, deviceNames, true, false);
|
||||
await sendPresetViaEspNow(newId, presetData, deviceNames, false, false, '2');
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// Fallback: send what we just built
|
||||
await sendPresetViaEspNow(currentEditId || payload.name, payload, deviceNames, true, false);
|
||||
await sendPresetViaEspNow(currentEditId || payload.name, payload, deviceNames, false, false, '2');
|
||||
}
|
||||
|
||||
await loadPresets();
|
||||
@@ -1454,7 +1422,14 @@ const coercePresetInt = (v, def = 0) => {
|
||||
// 1) preset payload (optionally with save)
|
||||
// 2) optional select for device names (never with save)
|
||||
// saveToDevice defaults to true.
|
||||
const sendPresetViaEspNow = async (presetId, preset, deviceNames, saveToDevice = true, setDefault = false) => {
|
||||
const sendPresetViaEspNow = async (
|
||||
presetId,
|
||||
preset,
|
||||
deviceNames,
|
||||
saveToDevice = true,
|
||||
setDefault = false,
|
||||
devicePresetId = null,
|
||||
) => {
|
||||
try {
|
||||
const baseColors = Array.isArray(preset.colors) && preset.colors.length
|
||||
? preset.colors
|
||||
@@ -1462,10 +1437,11 @@ const sendPresetViaEspNow = async (presetId, preset, deviceNames, saveToDevice =
|
||||
const paletteColors = await getCurrentProfilePaletteColors();
|
||||
const colors = resolveColorsWithPaletteRefs(baseColors, preset.palette_refs, paletteColors);
|
||||
|
||||
const wirePresetId = devicePresetId != null ? String(devicePresetId) : String(presetId);
|
||||
const presetMessage = {
|
||||
v: '1',
|
||||
presets: {
|
||||
[presetId]: {
|
||||
[wirePresetId]: {
|
||||
pattern: preset.pattern || 'off',
|
||||
colors,
|
||||
delay: typeof preset.delay === 'number' ? preset.delay : 100,
|
||||
@@ -1486,7 +1462,7 @@ const sendPresetViaEspNow = async (presetId, preset, deviceNames, saveToDevice =
|
||||
presetMessage.save = true;
|
||||
}
|
||||
if (setDefault) {
|
||||
presetMessage.default = presetId;
|
||||
presetMessage.default = wirePresetId;
|
||||
}
|
||||
|
||||
const names = Array.isArray(deviceNames) ? deviceNames : [];
|
||||
@@ -1502,7 +1478,7 @@ const sendPresetViaEspNow = async (presetId, preset, deviceNames, saveToDevice =
|
||||
const select = {};
|
||||
names.forEach((name) => {
|
||||
if (name) {
|
||||
select[name] = [presetId];
|
||||
select[name] = [wirePresetId];
|
||||
}
|
||||
});
|
||||
if (Object.keys(select).length > 0) {
|
||||
@@ -1879,7 +1855,8 @@ const createPresetButton = (presetId, preset, zoneId, isSelected = false) => {
|
||||
button.classList.add('active');
|
||||
selectedPresets[zoneId] = presetId;
|
||||
const section = row.closest('.presets-section');
|
||||
sendSelectForCurrentTabDevices(presetId, section).catch((err) => {
|
||||
const deviceNames = tabDeviceNamesFromSection(section);
|
||||
sendPresetViaEspNow(presetId, preset, deviceNames, false, false, '2').catch((err) => {
|
||||
console.error(err);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -149,6 +149,40 @@ header h1 {
|
||||
background-color: #333;
|
||||
}
|
||||
|
||||
.menu-brightness-control {
|
||||
padding: 0.45rem 0.75rem 0.55rem;
|
||||
border-bottom: 1px solid #333;
|
||||
}
|
||||
|
||||
.menu-brightness-control label {
|
||||
display: block;
|
||||
font-size: 0.78rem;
|
||||
color: #bdbdbd;
|
||||
margin-bottom: 0.3rem;
|
||||
}
|
||||
|
||||
.menu-brightness-control input[type="range"] {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.header-brightness-control {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
min-width: 13rem;
|
||||
padding: 0.2rem 0.1rem;
|
||||
}
|
||||
|
||||
.header-brightness-control label {
|
||||
font-size: 0.8rem;
|
||||
color: #bdbdbd;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.header-brightness-control input[type="range"] {
|
||||
width: 8.5rem;
|
||||
}
|
||||
|
||||
/* Header/menu actions that should only appear in Edit mode */
|
||||
body.preset-ui-run .edit-mode-only {
|
||||
display: none !important;
|
||||
@@ -248,7 +282,8 @@ body.preset-ui-run .edit-mode-only {
|
||||
display: block;
|
||||
overflow-y: auto;
|
||||
overflow-x: hidden;
|
||||
padding: 0.5rem 1rem 1rem;
|
||||
padding: 0.5rem 1rem calc(1rem + env(safe-area-inset-bottom, 0px) + 3.5rem);
|
||||
-webkit-overflow-scrolling: touch;
|
||||
}
|
||||
|
||||
.presets-toolbar {
|
||||
@@ -528,6 +563,12 @@ body.preset-ui-run .edit-mode-only {
|
||||
row-gap: 0.3rem;
|
||||
align-content: start;
|
||||
width: 100%;
|
||||
padding-bottom: calc(env(safe-area-inset-bottom, 0px) + 5.5rem);
|
||||
scroll-padding-bottom: calc(env(safe-area-inset-bottom, 0px) + 5.5rem);
|
||||
}
|
||||
|
||||
#presets-list-zone > :last-child {
|
||||
margin-bottom: calc(env(safe-area-inset-bottom, 0px) + 2.5rem);
|
||||
}
|
||||
|
||||
/* Settings modal layout */
|
||||
@@ -949,7 +990,7 @@ body.preset-ui-run .edit-mode-only {
|
||||
}
|
||||
|
||||
/* Mobile-friendly layout */
|
||||
@media (max-width: 800px) {
|
||||
@media (max-width: 1000px) {
|
||||
header {
|
||||
flex-direction: row;
|
||||
align-items: center;
|
||||
@@ -1001,6 +1042,9 @@ body.preset-ui-run .edit-mode-only {
|
||||
min-width: 280px;
|
||||
max-width: 95vw;
|
||||
padding: 1.25rem;
|
||||
max-height: calc(100dvh - 1rem);
|
||||
overflow-y: auto;
|
||||
padding-bottom: calc(1.25rem + env(safe-area-inset-bottom, 0px));
|
||||
}
|
||||
|
||||
.form-row {
|
||||
@@ -1018,6 +1062,10 @@ body.preset-ui-run .edit-mode-only {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
background-color: rgba(0,0,0,0.7);
|
||||
overflow-y: auto;
|
||||
padding: 1rem;
|
||||
-webkit-overflow-scrolling: touch;
|
||||
overscroll-behavior: contain;
|
||||
}
|
||||
.modal.active {
|
||||
display: flex;
|
||||
@@ -1030,6 +1078,20 @@ body.preset-ui-run .edit-mode-only {
|
||||
border-radius: 8px;
|
||||
min-width: 400px;
|
||||
max-width: 600px;
|
||||
max-height: calc(100dvh - 2rem);
|
||||
overflow-y: auto;
|
||||
padding-bottom: calc(2rem + env(safe-area-inset-bottom, 0px));
|
||||
-webkit-overflow-scrolling: touch;
|
||||
}
|
||||
|
||||
/* Real-phone viewport fallback for browsers with unstable 100dvh behavior. */
|
||||
@supports (-webkit-touch-callout: none) {
|
||||
.modal {
|
||||
min-height: -webkit-fill-available;
|
||||
}
|
||||
.modal-content {
|
||||
max-height: calc(-webkit-fill-available - 2rem);
|
||||
}
|
||||
}
|
||||
.modal-content label {
|
||||
display: block;
|
||||
@@ -1200,9 +1262,11 @@ body.preset-ui-run .edit-mode-only {
|
||||
min-height: 80px;
|
||||
}
|
||||
/* Presets list: 3 columns and vertical scroll (defined above); mobile same */
|
||||
@media (max-width: 800px) {
|
||||
@media (max-width: 1000px) {
|
||||
#presets-list-zone {
|
||||
grid-template-columns: repeat(3, minmax(0, 1fr));
|
||||
padding-bottom: calc(env(safe-area-inset-bottom, 0px) + 7rem);
|
||||
scroll-padding-bottom: calc(env(safe-area-inset-bottom, 0px) + 7rem);
|
||||
}
|
||||
}
|
||||
/* Help modal readability */
|
||||
|
||||
@@ -1,5 +1,47 @@
|
||||
// Zone management JavaScript
|
||||
let currentZoneId = null;
|
||||
let brightnessSendTimeout = null;
|
||||
|
||||
function sendZoneBrightness(value) {
|
||||
const val = Math.max(0, Math.min(255, parseInt(value, 10) || 0));
|
||||
const headerSlider = document.getElementById('header-brightness-slider');
|
||||
const menuSlider = document.getElementById('menu-brightness-slider');
|
||||
if (headerSlider && String(headerSlider.value) !== String(val)) {
|
||||
headerSlider.value = String(val);
|
||||
}
|
||||
if (menuSlider && String(menuSlider.value) !== String(val)) {
|
||||
menuSlider.value = String(val);
|
||||
}
|
||||
if (brightnessSendTimeout) {
|
||||
clearTimeout(brightnessSendTimeout);
|
||||
}
|
||||
brightnessSendTimeout = setTimeout(() => {
|
||||
(async () => {
|
||||
try {
|
||||
const section = document.querySelector('.presets-section[data-zone-id]');
|
||||
const names = typeof window.parseTabDeviceNames === 'function'
|
||||
? window.parseTabDeviceNames(section)
|
||||
: [];
|
||||
const targetMacs =
|
||||
names.length > 0 &&
|
||||
typeof window.tabsManager !== 'undefined' &&
|
||||
typeof window.tabsManager.resolveTabDeviceMacs === 'function'
|
||||
? await window.tabsManager.resolveTabDeviceMacs(names)
|
||||
: [];
|
||||
if (typeof window.postDriverSequence === 'function') {
|
||||
await window.postDriverSequence([{ v: '1', b: val, save: true }], targetMacs, 0);
|
||||
return;
|
||||
}
|
||||
// Fallback to raw websocket sender if presets.js helper isn't available yet.
|
||||
if (typeof window.sendEspnowRaw === 'function') {
|
||||
window.sendEspnowRaw({ v: '1', b: val, save: true });
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Failed to send brightness via driver sequence:', err);
|
||||
}
|
||||
})();
|
||||
}, 150);
|
||||
}
|
||||
|
||||
const isEditModeActive = () => {
|
||||
const toggle = document.querySelector('.ui-mode-toggle');
|
||||
@@ -468,37 +510,17 @@ async function loadZoneContent(zoneId) {
|
||||
const legacyAttr = legacyOk ? ` data-device-names="${escapeHtmlAttr(names.join(","))}"` : "";
|
||||
container.innerHTML = `
|
||||
<div class="presets-section" data-zone-id="${zoneId}" data-device-names-json="${namesJsonAttr}"${legacyAttr}>
|
||||
<div class="profiles-actions presets-toolbar" style="margin-bottom: 1rem;">
|
||||
<div class="zone-brightness-group">
|
||||
<label for="zone-brightness-slider">Brightness</label>
|
||||
<input type="range" id="zone-brightness-slider" min="0" max="255" value="255">
|
||||
</div>
|
||||
</div>
|
||||
<div id="presets-list-zone" class="presets-list">
|
||||
<!-- Presets will be loaded here by presets.js -->
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
|
||||
// Wire up per-zone brightness slider to send global brightness via ESPNow.
|
||||
const brightnessSlider = container.querySelector('#zone-brightness-slider');
|
||||
let brightnessSendTimeout = null;
|
||||
if (brightnessSlider) {
|
||||
brightnessSlider.addEventListener('input', (e) => {
|
||||
const val = parseInt(e.target.value, 10) || 0;
|
||||
if (brightnessSendTimeout) {
|
||||
clearTimeout(brightnessSendTimeout);
|
||||
}
|
||||
brightnessSendTimeout = setTimeout(() => {
|
||||
if (typeof window.sendEspnowRaw === 'function') {
|
||||
try {
|
||||
window.sendEspnowRaw({ v: '1', b: val, save: true });
|
||||
} catch (err) {
|
||||
console.error('Failed to send brightness via ESPNow:', err);
|
||||
}
|
||||
}
|
||||
}, 150);
|
||||
});
|
||||
// Keep header and menu brightness controls in sync.
|
||||
const brightnessSlider = document.getElementById('header-brightness-slider');
|
||||
const menuBrightnessSlider = document.getElementById('menu-brightness-slider');
|
||||
if (menuBrightnessSlider && brightnessSlider) {
|
||||
menuBrightnessSlider.value = brightnessSlider.value;
|
||||
}
|
||||
|
||||
// Trigger presets loading if the function exists
|
||||
@@ -967,6 +989,21 @@ document.addEventListener('DOMContentLoaded', () => {
|
||||
});
|
||||
}
|
||||
|
||||
const menuBrightnessSlider = document.getElementById('menu-brightness-slider');
|
||||
if (menuBrightnessSlider) {
|
||||
menuBrightnessSlider.addEventListener('input', (e) => {
|
||||
sendZoneBrightness(e.target.value);
|
||||
});
|
||||
}
|
||||
const headerBrightnessSlider = document.getElementById('header-brightness-slider');
|
||||
if (headerBrightnessSlider) {
|
||||
headerBrightnessSlider.addEventListener('input', (e) => {
|
||||
sendZoneBrightness(e.target.value);
|
||||
});
|
||||
// Initial sync so both controls start aligned.
|
||||
sendZoneBrightness(headerBrightnessSlider.value);
|
||||
}
|
||||
|
||||
// When run/edit mode toggles, refresh tabs UI so edit actions show/hide immediately.
|
||||
document.querySelectorAll('.ui-mode-toggle').forEach((btn) => {
|
||||
btn.addEventListener('click', async () => {
|
||||
|
||||
@@ -15,6 +15,10 @@
|
||||
</div>
|
||||
</div>
|
||||
<div class="header-actions">
|
||||
<div class="header-brightness-control">
|
||||
<label for="header-brightness-slider">Brightness</label>
|
||||
<input type="range" id="header-brightness-slider" min="0" max="255" value="255">
|
||||
</div>
|
||||
<button class="btn btn-secondary" id="profiles-btn">Profiles</button>
|
||||
<button class="btn btn-secondary edit-mode-only" id="devices-btn">Devices</button>
|
||||
<button class="btn btn-secondary edit-mode-only" id="zones-btn">Zones</button>
|
||||
@@ -30,6 +34,10 @@
|
||||
<button class="btn btn-secondary" id="main-menu-btn">Menu</button>
|
||||
<div id="main-menu-dropdown" class="main-menu-dropdown">
|
||||
<button type="button" class="ui-mode-toggle" id="ui-mode-toggle-mobile" aria-pressed="false">Edit mode</button>
|
||||
<div class="menu-brightness-control">
|
||||
<label for="menu-brightness-slider">Brightness</label>
|
||||
<input type="range" id="menu-brightness-slider" min="0" max="255" value="255">
|
||||
</div>
|
||||
<button type="button" data-target="profiles-btn">Profiles</button>
|
||||
<button type="button" class="edit-mode-only" data-target="devices-btn">Devices</button>
|
||||
<button type="button" class="edit-mode-only" data-target="zones-btn">Tabs</button>
|
||||
@@ -245,6 +253,7 @@
|
||||
<h2>Patterns</h2>
|
||||
<div class="modal-actions">
|
||||
<button type="button" class="btn btn-primary" id="pattern-add-btn">Add</button>
|
||||
<button type="button" class="btn btn-secondary" id="pattern-send-all-btn">Send All Patterns</button>
|
||||
</div>
|
||||
<div id="patterns-list" class="profiles-list"></div>
|
||||
<div class="modal-actions">
|
||||
|
||||
Reference in New Issue
Block a user