Remove per-source speed, fix device dirty check, and add frontend caching

Speed is now exclusively controlled via sync clocks — CSS sources no longer
carry their own speed/cycle_speed fields. Streams default to 1.0× when no
clock is assigned. Also fixes false-positive dirty check on the device
settings modal (array reference comparison) and converts several frontend
modules to use DataCache for consistent API response caching.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-03-01 22:07:54 +03:00
parent aa1e4a6afc
commit 39e41dfce7
15 changed files with 56 additions and 187 deletions

View File

@@ -3,7 +3,7 @@
*/
import {
calibrationTestState, EDGE_TEST_COLORS,
calibrationTestState, EDGE_TEST_COLORS, displaysCache,
} from '../core/state.js';
import { API_BASE, getHeaders, fetchWithAuth } from '../core/api.js';
import { t } from '../core/i18n.js';
@@ -134,9 +134,9 @@ export async function toggleCalibrationOverlay() {
export async function showCalibration(deviceId) {
try {
const [response, displaysResponse] = await Promise.all([
const [response, displays] = await Promise.all([
fetchWithAuth(`/devices/${deviceId}`),
fetchWithAuth('/config/displays'),
displaysCache.fetch().catch(() => []),
]);
if (!response.ok) { showToast(t('calibration.error.load_failed'), 'error'); return; }
@@ -145,15 +145,10 @@ export async function showCalibration(deviceId) {
const calibration = device.calibration;
const preview = document.querySelector('.calibration-preview');
if (displaysResponse.ok) {
const displaysData = await displaysResponse.json();
const displayIndex = device.settings?.display_index ?? 0;
const display = (displaysData.displays || []).find(d => d.index === displayIndex);
if (display && display.width && display.height) {
preview.style.aspectRatio = `${display.width} / ${display.height}`;
} else {
preview.style.aspectRatio = '';
}
const displayIndex = device.settings?.display_index ?? 0;
const display = displays.find(d => d.index === displayIndex);
if (display && display.width && display.height) {
preview.style.aspectRatio = `${display.width} / ${display.height}`;
} else {
preview.style.aspectRatio = '';
}

View File

@@ -3,7 +3,7 @@
*/
import { fetchWithAuth, escapeHtml } from '../core/api.js';
import { _cachedSyncClocks } from '../core/state.js';
import { _cachedSyncClocks, audioSourcesCache, streamsCache } from '../core/state.js';
import { t } from '../core/i18n.js';
import { showToast, showConfirm } from '../core/ui.js';
import { Modal } from '../core/modal.js';
@@ -12,7 +12,7 @@ import {
ICON_CLONE, ICON_EDIT, ICON_CALIBRATION,
ICON_LED, ICON_PALETTE, ICON_FPS, ICON_MAP_PIN, ICON_MUSIC,
ICON_AUDIO_LOOPBACK, ICON_TIMER, ICON_LINK_SOURCE, ICON_FILM,
ICON_LINK, ICON_SPARKLES, ICON_FAST_FORWARD, ICON_ACTIVITY, ICON_CLOCK,
ICON_LINK, ICON_SPARKLES, ICON_ACTIVITY, ICON_CLOCK,
} from '../core/icons.js';
import { wrapCard } from '../core/card-colors.js';
@@ -37,11 +37,8 @@ class CSSEditorModal extends Modal {
led_count: document.getElementById('css-editor-led-count').value,
gradient_stops: type === 'gradient' ? JSON.stringify(_gradientStops) : '[]',
animation_type: document.getElementById('css-editor-animation-type').value,
animation_speed: document.getElementById('css-editor-animation-speed').value,
cycle_speed: document.getElementById('css-editor-cycle-speed').value,
cycle_colors: JSON.stringify(_colorCycleColors),
effect_type: document.getElementById('css-editor-effect-type').value,
effect_speed: document.getElementById('css-editor-effect-speed').value,
effect_palette: document.getElementById('css-editor-effect-palette').value,
effect_color: document.getElementById('css-editor-effect-color').value,
effect_intensity: document.getElementById('css-editor-effect-intensity').value,
@@ -141,16 +138,7 @@ function _populateClockDropdown(selectedId) {
}
export function onCSSClockChange() {
// When a clock is selected, hide speed sliders (speed comes from clock)
const clockId = document.getElementById('css-editor-clock').value;
const type = document.getElementById('css-editor-type').value;
if (type === 'effect') {
document.getElementById('css-editor-effect-speed-group').style.display = clockId ? 'none' : '';
} else if (type === 'color_cycle') {
document.getElementById('css-editor-cycle-speed-group').style.display = clockId ? 'none' : '';
} else if (type === 'static' || type === 'gradient') {
document.getElementById('css-editor-animation-speed-group').style.display = clockId ? 'none' : '';
}
// No-op: speed sliders removed; speed is now clock-only
}
function _getAnimationPayload() {
@@ -158,15 +146,10 @@ function _getAnimationPayload() {
return {
enabled: type !== 'none',
type: type !== 'none' ? type : 'breathing',
speed: parseFloat(document.getElementById('css-editor-animation-speed').value),
};
}
function _loadAnimationState(anim) {
const speedEl = document.getElementById('css-editor-animation-speed');
speedEl.value = (anim && anim.speed != null) ? anim.speed : 1.0;
document.getElementById('css-editor-animation-speed-val').textContent =
parseFloat(speedEl.value).toFixed(1);
// Set type after onCSSTypeChange() has populated the dropdown
if (anim && anim.enabled && anim.type) {
document.getElementById('css-editor-animation-type').value = anim.type;
@@ -182,8 +165,6 @@ export function onAnimationTypeChange() {
function _syncAnimationSpeedState() {
const type = document.getElementById('css-editor-animation-type').value;
const isNone = type === 'none';
document.getElementById('css-editor-animation-speed').disabled = isNone;
const descEl = document.getElementById('css-editor-animation-type-desc');
if (descEl) {
const desc = t('color_strip.animation.type.' + type + '.desc') || '';
@@ -302,13 +283,6 @@ function _loadColorCycleState(css) {
? raw.map(c => rgbArrayToHex(c))
: [..._DEFAULT_CYCLE_COLORS];
_colorCycleRenderList();
const speed = (css && css.cycle_speed != null) ? css.cycle_speed : 1.0;
const speedEl = document.getElementById('css-editor-cycle-speed');
if (speedEl) {
speedEl.value = speed;
document.getElementById('css-editor-cycle-speed-val').textContent =
parseFloat(speed).toFixed(1);
}
}
/** Convert an [R, G, B] array to a CSS hex color string like "#rrggbb". */
@@ -553,10 +527,7 @@ async function _loadAudioSources() {
const select = document.getElementById('css-editor-audio-source');
if (!select) return;
try {
const resp = await fetchWithAuth('/audio-sources');
if (!resp.ok) return;
const data = await resp.json();
const sources = data.sources || [];
const sources = await audioSourcesCache.fetch();
select.innerHTML = sources.map(s => {
const badge = s.source_type === 'multichannel' ? ' [multichannel]' : ' [mono]';
return `<option value="${s.id}">${escapeHtml(s.name)}${badge}</option>`;
@@ -626,7 +597,6 @@ export function createColorStripCard(source, pictureSourceMap, audioSourceMap) {
const anim = (isStatic || isGradient) && source.animation && source.animation.enabled ? source.animation : null;
const animBadge = anim
? `<span class="stream-card-prop" title="${t('color_strip.animation')}">${ICON_SPARKLES} ${t('color_strip.animation.type.' + anim.type) || anim.type}</span>`
+ (source.clock_id ? '' : `<span class="stream-card-prop" title="${t('color_strip.animation.speed')}">${ICON_FAST_FORWARD} ${(anim.speed || 1.0).toFixed(1)}×</span>`)
: '';
let propsHtml;
@@ -647,7 +617,6 @@ export function createColorStripCard(source, pictureSourceMap, audioSourceMap) {
).join('');
propsHtml = `
<span class="stream-card-prop">${swatches}</span>
${source.clock_id ? '' : `<span class="stream-card-prop" title="${t('color_strip.color_cycle.speed')}">${ICON_FAST_FORWARD} ${(source.cycle_speed || 1.0).toFixed(1)}×</span>`}
${source.led_count ? `<span class="stream-card-prop" title="${t('color_strip.leds')}">${ICON_LED} ${source.led_count}</span>` : ''}
${clockBadge}
`;
@@ -680,7 +649,6 @@ export function createColorStripCard(source, pictureSourceMap, audioSourceMap) {
propsHtml = `
<span class="stream-card-prop">${ICON_FPS} ${escapeHtml(effectLabel)}</span>
${paletteLabel ? `<span class="stream-card-prop" title="${t('color_strip.effect.palette')}">${ICON_PALETTE} ${escapeHtml(paletteLabel)}</span>` : ''}
${source.clock_id ? '' : `<span class="stream-card-prop" title="${t('color_strip.effect.speed')}">${ICON_FAST_FORWARD} ${(source.speed || 1.0).toFixed(1)}×</span>`}
${source.led_count ? `<span class="stream-card-prop" title="${t('color_strip.leds')}">${ICON_LED} ${source.led_count}</span>` : ''}
${clockBadge}
`;
@@ -771,8 +739,7 @@ export function createColorStripCard(source, pictureSourceMap, audioSourceMap) {
export async function showCSSEditor(cssId = null, cloneData = null) {
try {
const sourcesResp = await fetchWithAuth('/picture-sources');
const sources = sourcesResp.ok ? ((await sourcesResp.json()).streams || []) : [];
const sources = await streamsCache.fetch();
// Fetch all color strip sources for composite layer dropdowns
const cssListResp = await fetchWithAuth('/color-strip-sources');
@@ -821,8 +788,6 @@ export async function showCSSEditor(cssId = null, cloneData = null) {
} else if (sourceType === 'effect') {
document.getElementById('css-editor-effect-type').value = css.effect_type || 'fire';
onEffectTypeChange();
document.getElementById('css-editor-effect-speed').value = css.speed ?? 1.0;
document.getElementById('css-editor-effect-speed-val').textContent = parseFloat(css.speed ?? 1.0).toFixed(1);
document.getElementById('css-editor-effect-palette').value = css.palette || 'fire';
document.getElementById('css-editor-effect-color').value = rgbArrayToHex(css.color || [255, 80, 0]);
document.getElementById('css-editor-effect-intensity').value = css.intensity ?? 1.0;
@@ -920,8 +885,6 @@ export async function showCSSEditor(cssId = null, cloneData = null) {
_loadAnimationState(null);
_loadColorCycleState(null);
document.getElementById('css-editor-effect-type').value = 'fire';
document.getElementById('css-editor-effect-speed').value = 1.0;
document.getElementById('css-editor-effect-speed-val').textContent = '1.0';
document.getElementById('css-editor-effect-palette').value = 'fire';
document.getElementById('css-editor-effect-color').value = '#ff5000';
document.getElementById('css-editor-effect-intensity').value = 1.0;
@@ -988,7 +951,6 @@ export async function saveCSSEditor() {
payload = {
name,
colors: cycleColors,
cycle_speed: parseFloat(document.getElementById('css-editor-cycle-speed').value),
};
if (!cssId) payload.source_type = 'color_cycle';
} else if (sourceType === 'gradient') {
@@ -1010,7 +972,6 @@ export async function saveCSSEditor() {
payload = {
name,
effect_type: document.getElementById('css-editor-effect-type').value,
speed: parseFloat(document.getElementById('css-editor-effect-speed').value),
palette: document.getElementById('css-editor-effect-palette').value,
intensity: parseFloat(document.getElementById('css-editor-effect-intensity').value),
scale: parseFloat(document.getElementById('css-editor-effect-scale').value),

View File

@@ -28,7 +28,7 @@ class DeviceSettingsModal extends Modal {
led_count: this.$('settings-led-count').value,
led_type: document.getElementById('settings-led-type')?.value || 'rgb',
send_latency: document.getElementById('settings-send-latency')?.value || '0',
zones: _getCheckedZones('settings-zone-list'),
zones: JSON.stringify(_getCheckedZones('settings-zone-list')),
zoneMode: _getZoneMode('settings-zone-mode'),
};
}

View File

@@ -8,7 +8,7 @@ import {
_kcNameManuallyEdited, set_kcNameManuallyEdited,
kcWebSockets,
PATTERN_RECT_BORDERS,
_cachedValueSources, valueSourcesCache,
_cachedValueSources, valueSourcesCache, streamsCache,
} from '../core/state.js';
import { API_BASE, getHeaders, fetchWithAuth, escapeHtml } from '../core/api.js';
import { t } from '../core/i18n.js';
@@ -413,12 +413,11 @@ function _populateKCBrightnessVsDropdown(selectedId = '') {
export async function showKCEditor(targetId = null, cloneData = null) {
try {
// Load sources, pattern templates, and value sources in parallel
const [sourcesResp, patResp, valueSources] = await Promise.all([
fetchWithAuth('/picture-sources').catch(() => null),
const [sources, patResp, valueSources] = await Promise.all([
streamsCache.fetch().catch(() => []),
fetchWithAuth('/pattern-templates').catch(() => null),
valueSourcesCache.fetch(),
]);
const sources = (sourcesResp && sourcesResp.ok) ? (await sourcesResp.json()).streams || [] : [];
const patTemplates = (patResp && patResp.ok) ? (await patResp.json()).templates || [] : [];
// Populate source select

View File

@@ -13,6 +13,7 @@ import {
patternEditorHoverHit, setPatternEditorHoverHit,
PATTERN_RECT_COLORS,
PATTERN_RECT_BORDERS,
streamsCache,
} from '../core/state.js';
import { API_BASE, getHeaders, fetchWithAuth, escapeHtml } from '../core/api.js';
import { t } from '../core/i18n.js';
@@ -76,8 +77,7 @@ export function createPatternTemplateCard(pt) {
export async function showPatternTemplateEditor(templateId = null, cloneData = null) {
try {
// Load sources for background capture
const sourcesResp = await fetchWithAuth('/picture-sources').catch(() => null);
const sources = (sourcesResp && sourcesResp.ok) ? (await sourcesResp.json()).streams || [] : [];
const sources = await streamsCache.fetch().catch(() => []);
const bgSelect = document.getElementById('pattern-bg-source');
bgSelect.innerHTML = '';

View File

@@ -247,10 +247,8 @@ function restoreCaptureDuration() {
export async function showTestTemplateModal(templateId) {
try {
const resp = await fetchWithAuth('/capture-templates');
if (!resp.ok) throw new Error(`HTTP ${resp.status}`);
const data = await resp.json();
const template = (data.templates || []).find(tp => tp.id === templateId);
const templates = await captureTemplatesCache.fetch();
const template = templates.find(tp => tp.id === templateId);
if (!template) {
showToast(t('templates.error.load'), 'error');
@@ -1597,34 +1595,25 @@ export async function editStream(streamId) {
let _streamModalDisplaysEngine = null;
async function populateStreamModalDropdowns() {
const [displaysRes, captureTemplatesRes, streamsRes, ppTemplatesRes] = await Promise.all([
fetch(`${API_BASE}/config/displays`, { headers: getHeaders() }),
fetchWithAuth('/capture-templates'),
fetchWithAuth('/picture-sources'),
fetchWithAuth('/postprocessing-templates'),
const [captureTemplates, streams, ppTemplates] = await Promise.all([
captureTemplatesCache.fetch().catch(() => []),
streamsCache.fetch().catch(() => []),
ppTemplatesCache.fetch().catch(() => []),
displaysCache.fetch().catch(() => []),
]);
// Cache desktop displays (used as default unless engine has own displays)
if (displaysRes.ok) {
const displaysData = await displaysRes.json();
displaysCache.update(displaysData.displays || []);
}
_streamModalDisplaysEngine = null;
const templateSelect = document.getElementById('stream-capture-template');
templateSelect.innerHTML = '';
if (captureTemplatesRes.ok) {
const data = await captureTemplatesRes.json();
(data.templates || []).forEach(tmpl => {
const opt = document.createElement('option');
opt.value = tmpl.id;
opt.dataset.name = tmpl.name;
opt.dataset.engineType = tmpl.engine_type;
opt.dataset.hasOwnDisplays = availableEngines.find(e => e.type === tmpl.engine_type)?.has_own_displays ? '1' : '';
opt.textContent = `${tmpl.name} (${tmpl.engine_type})`;
templateSelect.appendChild(opt);
});
}
captureTemplates.forEach(tmpl => {
const opt = document.createElement('option');
opt.value = tmpl.id;
opt.dataset.name = tmpl.name;
opt.dataset.engineType = tmpl.engine_type;
opt.dataset.hasOwnDisplays = availableEngines.find(e => e.type === tmpl.engine_type)?.has_own_displays ? '1' : '';
opt.textContent = `${tmpl.name} (${tmpl.engine_type})`;
templateSelect.appendChild(opt);
});
// When template changes, refresh displays if engine type switched
templateSelect.addEventListener('change', _onCaptureTemplateChanged);
@@ -1640,32 +1629,25 @@ async function populateStreamModalDropdowns() {
const sourceSelect = document.getElementById('stream-source');
sourceSelect.innerHTML = '';
if (streamsRes.ok) {
const data = await streamsRes.json();
const editingId = document.getElementById('stream-id').value;
(data.streams || []).forEach(s => {
if (s.id === editingId) return;
const opt = document.createElement('option');
opt.value = s.id;
opt.dataset.name = s.name;
opt.textContent = s.name;
sourceSelect.appendChild(opt);
});
}
const editingId = document.getElementById('stream-id').value;
streams.forEach(s => {
if (s.id === editingId) return;
const opt = document.createElement('option');
opt.value = s.id;
opt.dataset.name = s.name;
opt.textContent = s.name;
sourceSelect.appendChild(opt);
});
set_streamModalPPTemplates([]);
set_streamModalPPTemplates(ppTemplates);
const ppSelect = document.getElementById('stream-pp-template');
ppSelect.innerHTML = '';
if (ppTemplatesRes.ok) {
const data = await ppTemplatesRes.json();
set_streamModalPPTemplates(data.templates || []);
_streamModalPPTemplates.forEach(tmpl => {
const opt = document.createElement('option');
opt.value = tmpl.id;
opt.textContent = tmpl.name;
ppSelect.appendChild(opt);
});
}
ppTemplates.forEach(tmpl => {
const opt = document.createElement('option');
opt.value = tmpl.id;
opt.textContent = tmpl.name;
ppSelect.appendChild(opt);
});
_autoGenerateStreamName();
}

View File

@@ -7,7 +7,7 @@
* - Navigation: network-first with offline fallback
*/
const CACHE_NAME = 'ledgrab-v7';
const CACHE_NAME = 'ledgrab-v8';
// Only pre-cache static assets (no auth required).
// Do NOT pre-cache '/' — it requires API key auth and would cache an error page.