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

@@ -74,9 +74,7 @@ def _css_to_response(source, overlay_active: bool = False) -> ColorStripSourceRe
color=getattr(source, "color", None), color=getattr(source, "color", None),
stops=stops, stops=stops,
colors=getattr(source, "colors", None), colors=getattr(source, "colors", None),
cycle_speed=getattr(source, "cycle_speed", None),
effect_type=getattr(source, "effect_type", None), effect_type=getattr(source, "effect_type", None),
speed=getattr(source, "speed", None),
palette=getattr(source, "palette", None), palette=getattr(source, "palette", None),
intensity=getattr(source, "intensity", None), intensity=getattr(source, "intensity", None),
scale=getattr(source, "scale", None), scale=getattr(source, "scale", None),
@@ -163,9 +161,7 @@ async def create_color_strip_source(
frame_interpolation=data.frame_interpolation, frame_interpolation=data.frame_interpolation,
animation=data.animation.model_dump() if data.animation else None, animation=data.animation.model_dump() if data.animation else None,
colors=data.colors, colors=data.colors,
cycle_speed=data.cycle_speed,
effect_type=data.effect_type, effect_type=data.effect_type,
speed=data.speed,
palette=data.palette, palette=data.palette,
intensity=data.intensity, intensity=data.intensity,
scale=data.scale, scale=data.scale,
@@ -241,9 +237,7 @@ async def update_color_strip_source(
frame_interpolation=data.frame_interpolation, frame_interpolation=data.frame_interpolation,
animation=data.animation.model_dump() if data.animation else None, animation=data.animation.model_dump() if data.animation else None,
colors=data.colors, colors=data.colors,
cycle_speed=data.cycle_speed,
effect_type=data.effect_type, effect_type=data.effect_type,
speed=data.speed,
palette=data.palette, palette=data.palette,
intensity=data.intensity, intensity=data.intensity,
scale=data.scale, scale=data.scale,

View File

@@ -64,10 +64,8 @@ class ColorStripSourceCreate(BaseModel):
stops: Optional[List[ColorStop]] = Field(None, description="Color stops for gradient type") stops: Optional[List[ColorStop]] = Field(None, description="Color stops for gradient type")
# color_cycle-type fields # color_cycle-type fields
colors: Optional[List[List[int]]] = Field(None, description="List of [R,G,B] colors to cycle (color_cycle type)") colors: Optional[List[List[int]]] = Field(None, description="List of [R,G,B] colors to cycle (color_cycle type)")
cycle_speed: Optional[float] = Field(None, description="Cycle speed multiplier 0.110.0 (color_cycle type)", ge=0.1, le=10.0)
# effect-type fields # effect-type fields
effect_type: Optional[str] = Field(None, description="Effect algorithm: fire|meteor|plasma|noise|aurora") effect_type: Optional[str] = Field(None, description="Effect algorithm: fire|meteor|plasma|noise|aurora")
speed: Optional[float] = Field(None, description="Effect speed multiplier 0.1-10.0", ge=0.1, le=10.0)
palette: Optional[str] = Field(None, description="Named palette (fire/ocean/lava/forest/rainbow/aurora/sunset/ice)") palette: Optional[str] = Field(None, description="Named palette (fire/ocean/lava/forest/rainbow/aurora/sunset/ice)")
intensity: Optional[float] = Field(None, description="Effect intensity 0.1-2.0", ge=0.1, le=2.0) intensity: Optional[float] = Field(None, description="Effect intensity 0.1-2.0", ge=0.1, le=2.0)
scale: Optional[float] = Field(None, description="Spatial scale 0.5-5.0", ge=0.5, le=5.0) scale: Optional[float] = Field(None, description="Spatial scale 0.5-5.0", ge=0.5, le=5.0)
@@ -111,10 +109,8 @@ class ColorStripSourceUpdate(BaseModel):
stops: Optional[List[ColorStop]] = Field(None, description="Color stops for gradient type") stops: Optional[List[ColorStop]] = Field(None, description="Color stops for gradient type")
# color_cycle-type fields # color_cycle-type fields
colors: Optional[List[List[int]]] = Field(None, description="List of [R,G,B] colors to cycle (color_cycle type)") colors: Optional[List[List[int]]] = Field(None, description="List of [R,G,B] colors to cycle (color_cycle type)")
cycle_speed: Optional[float] = Field(None, description="Cycle speed multiplier 0.110.0 (color_cycle type)", ge=0.1, le=10.0)
# effect-type fields # effect-type fields
effect_type: Optional[str] = Field(None, description="Effect algorithm: fire|meteor|plasma|noise|aurora") effect_type: Optional[str] = Field(None, description="Effect algorithm: fire|meteor|plasma|noise|aurora")
speed: Optional[float] = Field(None, description="Effect speed multiplier 0.1-10.0", ge=0.1, le=10.0)
palette: Optional[str] = Field(None, description="Named palette") palette: Optional[str] = Field(None, description="Named palette")
intensity: Optional[float] = Field(None, description="Effect intensity 0.1-2.0", ge=0.1, le=2.0) intensity: Optional[float] = Field(None, description="Effect intensity 0.1-2.0", ge=0.1, le=2.0)
scale: Optional[float] = Field(None, description="Spatial scale 0.5-5.0", ge=0.5, le=5.0) scale: Optional[float] = Field(None, description="Spatial scale 0.5-5.0", ge=0.5, le=5.0)
@@ -160,10 +156,8 @@ class ColorStripSourceResponse(BaseModel):
stops: Optional[List[ColorStop]] = Field(None, description="Color stops for gradient type") stops: Optional[List[ColorStop]] = Field(None, description="Color stops for gradient type")
# color_cycle-type fields # color_cycle-type fields
colors: Optional[List[List[int]]] = Field(None, description="List of [R,G,B] colors to cycle (color_cycle type)") colors: Optional[List[List[int]]] = Field(None, description="List of [R,G,B] colors to cycle (color_cycle type)")
cycle_speed: Optional[float] = Field(None, description="Cycle speed multiplier (color_cycle type)")
# effect-type fields # effect-type fields
effect_type: Optional[str] = Field(None, description="Effect algorithm") effect_type: Optional[str] = Field(None, description="Effect algorithm")
speed: Optional[float] = Field(None, description="Effect speed multiplier")
palette: Optional[str] = Field(None, description="Named palette") palette: Optional[str] = Field(None, description="Named palette")
intensity: Optional[float] = Field(None, description="Effect intensity") intensity: Optional[float] = Field(None, description="Effect intensity")
scale: Optional[float] = Field(None, description="Spatial scale") scale: Optional[float] = Field(None, description="Spatial scale")

View File

@@ -684,7 +684,7 @@ class StaticColorStripStream(ColorStripStream):
speed = clock.speed speed = clock.speed
t = clock.get_time() t = clock.get_time()
else: else:
speed = float(anim.get("speed", 1.0)) speed = 1.0
t = wall_start t = wall_start
atype = anim.get("type", "breathing") atype = anim.get("type", "breathing")
n = self._led_count n = self._led_count
@@ -798,7 +798,6 @@ class ColorCycleColorStripStream(ColorStripStream):
self._color_list = [ self._color_list = [
c for c in raw if isinstance(c, list) and len(c) == 3 c for c in raw if isinstance(c, list) and len(c) == 3
] or default ] or default
self._cycle_speed = float(source.cycle_speed) if source.cycle_speed else 1.0
self._auto_size = not source.led_count self._auto_size = not source.led_count
self._led_count = source.led_count if source.led_count > 0 else 1 self._led_count = source.led_count if source.led_count > 0 else 1
self._rebuild_colors() self._rebuild_colors()
@@ -892,7 +891,7 @@ class ColorCycleColorStripStream(ColorStripStream):
speed = clock.speed speed = clock.speed
t = clock.get_time() t = clock.get_time()
else: else:
speed = self._cycle_speed speed = 1.0
t = wall_start t = wall_start
n = self._led_count n = self._led_count
num = len(color_list) num = len(color_list)
@@ -1067,7 +1066,7 @@ class GradientColorStripStream(ColorStripStream):
speed = clock.speed speed = clock.speed
t = clock.get_time() t = clock.get_time()
else: else:
speed = float(anim.get("speed", 1.0)) speed = 1.0
t = wall_start t = wall_start
atype = anim.get("type", "breathing") atype = anim.get("type", "breathing")
n = self._led_count n = self._led_count

View File

@@ -203,7 +203,6 @@ class EffectColorStripStream(ColorStripStream):
def _update_from_source(self, source) -> None: def _update_from_source(self, source) -> None:
self._effect_type = getattr(source, "effect_type", "fire") self._effect_type = getattr(source, "effect_type", "fire")
self._speed = float(getattr(source, "speed", 1.0))
self._auto_size = not source.led_count self._auto_size = not source.led_count
self._led_count = source.led_count if source.led_count and source.led_count > 0 else 1 self._led_count = source.led_count if source.led_count and source.led_count > 0 else 1
self._palette_name = getattr(source, "palette", None) or _EFFECT_DEFAULT_PALETTE.get(self._effect_type, "fire") self._palette_name = getattr(source, "palette", None) or _EFFECT_DEFAULT_PALETTE.get(self._effect_type, "fire")
@@ -307,7 +306,7 @@ class EffectColorStripStream(ColorStripStream):
self._effective_speed = clock.speed self._effective_speed = clock.speed
else: else:
anim_time = wall_start anim_time = wall_start
self._effective_speed = self._speed self._effective_speed = 1.0
n = self._led_count n = self._led_count
if n != _pool_n: if n != _pool_n:

View File

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

View File

@@ -3,7 +3,7 @@
*/ */
import { fetchWithAuth, escapeHtml } from '../core/api.js'; 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 { t } from '../core/i18n.js';
import { showToast, showConfirm } from '../core/ui.js'; import { showToast, showConfirm } from '../core/ui.js';
import { Modal } from '../core/modal.js'; import { Modal } from '../core/modal.js';
@@ -12,7 +12,7 @@ import {
ICON_CLONE, ICON_EDIT, ICON_CALIBRATION, ICON_CLONE, ICON_EDIT, ICON_CALIBRATION,
ICON_LED, ICON_PALETTE, ICON_FPS, ICON_MAP_PIN, ICON_MUSIC, ICON_LED, ICON_PALETTE, ICON_FPS, ICON_MAP_PIN, ICON_MUSIC,
ICON_AUDIO_LOOPBACK, ICON_TIMER, ICON_LINK_SOURCE, ICON_FILM, 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'; } from '../core/icons.js';
import { wrapCard } from '../core/card-colors.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, led_count: document.getElementById('css-editor-led-count').value,
gradient_stops: type === 'gradient' ? JSON.stringify(_gradientStops) : '[]', gradient_stops: type === 'gradient' ? JSON.stringify(_gradientStops) : '[]',
animation_type: document.getElementById('css-editor-animation-type').value, 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), cycle_colors: JSON.stringify(_colorCycleColors),
effect_type: document.getElementById('css-editor-effect-type').value, 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_palette: document.getElementById('css-editor-effect-palette').value,
effect_color: document.getElementById('css-editor-effect-color').value, effect_color: document.getElementById('css-editor-effect-color').value,
effect_intensity: document.getElementById('css-editor-effect-intensity').value, effect_intensity: document.getElementById('css-editor-effect-intensity').value,
@@ -141,16 +138,7 @@ function _populateClockDropdown(selectedId) {
} }
export function onCSSClockChange() { export function onCSSClockChange() {
// When a clock is selected, hide speed sliders (speed comes from clock) // No-op: speed sliders removed; speed is now clock-only
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' : '';
}
} }
function _getAnimationPayload() { function _getAnimationPayload() {
@@ -158,15 +146,10 @@ function _getAnimationPayload() {
return { return {
enabled: type !== 'none', enabled: type !== 'none',
type: type !== 'none' ? type : 'breathing', type: type !== 'none' ? type : 'breathing',
speed: parseFloat(document.getElementById('css-editor-animation-speed').value),
}; };
} }
function _loadAnimationState(anim) { 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 // Set type after onCSSTypeChange() has populated the dropdown
if (anim && anim.enabled && anim.type) { if (anim && anim.enabled && anim.type) {
document.getElementById('css-editor-animation-type').value = anim.type; document.getElementById('css-editor-animation-type').value = anim.type;
@@ -182,8 +165,6 @@ export function onAnimationTypeChange() {
function _syncAnimationSpeedState() { function _syncAnimationSpeedState() {
const type = document.getElementById('css-editor-animation-type').value; 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'); const descEl = document.getElementById('css-editor-animation-type-desc');
if (descEl) { if (descEl) {
const desc = t('color_strip.animation.type.' + type + '.desc') || ''; const desc = t('color_strip.animation.type.' + type + '.desc') || '';
@@ -302,13 +283,6 @@ function _loadColorCycleState(css) {
? raw.map(c => rgbArrayToHex(c)) ? raw.map(c => rgbArrayToHex(c))
: [..._DEFAULT_CYCLE_COLORS]; : [..._DEFAULT_CYCLE_COLORS];
_colorCycleRenderList(); _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". */ /** 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'); const select = document.getElementById('css-editor-audio-source');
if (!select) return; if (!select) return;
try { try {
const resp = await fetchWithAuth('/audio-sources'); const sources = await audioSourcesCache.fetch();
if (!resp.ok) return;
const data = await resp.json();
const sources = data.sources || [];
select.innerHTML = sources.map(s => { select.innerHTML = sources.map(s => {
const badge = s.source_type === 'multichannel' ? ' [multichannel]' : ' [mono]'; const badge = s.source_type === 'multichannel' ? ' [multichannel]' : ' [mono]';
return `<option value="${s.id}">${escapeHtml(s.name)}${badge}</option>`; 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 anim = (isStatic || isGradient) && source.animation && source.animation.enabled ? source.animation : null;
const animBadge = anim 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>` ? `<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; let propsHtml;
@@ -647,7 +617,6 @@ export function createColorStripCard(source, pictureSourceMap, audioSourceMap) {
).join(''); ).join('');
propsHtml = ` propsHtml = `
<span class="stream-card-prop">${swatches}</span> <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>` : ''} ${source.led_count ? `<span class="stream-card-prop" title="${t('color_strip.leds')}">${ICON_LED} ${source.led_count}</span>` : ''}
${clockBadge} ${clockBadge}
`; `;
@@ -680,7 +649,6 @@ export function createColorStripCard(source, pictureSourceMap, audioSourceMap) {
propsHtml = ` propsHtml = `
<span class="stream-card-prop">${ICON_FPS} ${escapeHtml(effectLabel)}</span> <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>` : ''} ${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>` : ''} ${source.led_count ? `<span class="stream-card-prop" title="${t('color_strip.leds')}">${ICON_LED} ${source.led_count}</span>` : ''}
${clockBadge} ${clockBadge}
`; `;
@@ -771,8 +739,7 @@ export function createColorStripCard(source, pictureSourceMap, audioSourceMap) {
export async function showCSSEditor(cssId = null, cloneData = null) { export async function showCSSEditor(cssId = null, cloneData = null) {
try { try {
const sourcesResp = await fetchWithAuth('/picture-sources'); const sources = await streamsCache.fetch();
const sources = sourcesResp.ok ? ((await sourcesResp.json()).streams || []) : [];
// Fetch all color strip sources for composite layer dropdowns // Fetch all color strip sources for composite layer dropdowns
const cssListResp = await fetchWithAuth('/color-strip-sources'); const cssListResp = await fetchWithAuth('/color-strip-sources');
@@ -821,8 +788,6 @@ export async function showCSSEditor(cssId = null, cloneData = null) {
} else if (sourceType === 'effect') { } else if (sourceType === 'effect') {
document.getElementById('css-editor-effect-type').value = css.effect_type || 'fire'; document.getElementById('css-editor-effect-type').value = css.effect_type || 'fire';
onEffectTypeChange(); 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-palette').value = css.palette || 'fire';
document.getElementById('css-editor-effect-color').value = rgbArrayToHex(css.color || [255, 80, 0]); document.getElementById('css-editor-effect-color').value = rgbArrayToHex(css.color || [255, 80, 0]);
document.getElementById('css-editor-effect-intensity').value = css.intensity ?? 1.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); _loadAnimationState(null);
_loadColorCycleState(null); _loadColorCycleState(null);
document.getElementById('css-editor-effect-type').value = 'fire'; 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-palette').value = 'fire';
document.getElementById('css-editor-effect-color').value = '#ff5000'; document.getElementById('css-editor-effect-color').value = '#ff5000';
document.getElementById('css-editor-effect-intensity').value = 1.0; document.getElementById('css-editor-effect-intensity').value = 1.0;
@@ -988,7 +951,6 @@ export async function saveCSSEditor() {
payload = { payload = {
name, name,
colors: cycleColors, colors: cycleColors,
cycle_speed: parseFloat(document.getElementById('css-editor-cycle-speed').value),
}; };
if (!cssId) payload.source_type = 'color_cycle'; if (!cssId) payload.source_type = 'color_cycle';
} else if (sourceType === 'gradient') { } else if (sourceType === 'gradient') {
@@ -1010,7 +972,6 @@ export async function saveCSSEditor() {
payload = { payload = {
name, name,
effect_type: document.getElementById('css-editor-effect-type').value, 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, palette: document.getElementById('css-editor-effect-palette').value,
intensity: parseFloat(document.getElementById('css-editor-effect-intensity').value), intensity: parseFloat(document.getElementById('css-editor-effect-intensity').value),
scale: parseFloat(document.getElementById('css-editor-effect-scale').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_count: this.$('settings-led-count').value,
led_type: document.getElementById('settings-led-type')?.value || 'rgb', led_type: document.getElementById('settings-led-type')?.value || 'rgb',
send_latency: document.getElementById('settings-send-latency')?.value || '0', 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'), zoneMode: _getZoneMode('settings-zone-mode'),
}; };
} }

View File

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

View File

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

View File

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

View File

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

View File

@@ -69,7 +69,6 @@ class ColorStripSource:
"stops": None, "stops": None,
"animation": None, "animation": None,
"colors": None, "colors": None,
"cycle_speed": None,
"effect_type": None, "effect_type": None,
"palette": None, "palette": None,
"intensity": None, "intensity": None,
@@ -149,7 +148,6 @@ class ColorStripSource:
id=sid, name=name, source_type="color_cycle", id=sid, name=name, source_type="color_cycle",
created_at=created_at, updated_at=updated_at, description=description, created_at=created_at, updated_at=updated_at, description=description,
clock_id=clock_id, colors=colors, clock_id=clock_id, colors=colors,
cycle_speed=float(data.get("cycle_speed") or 1.0),
led_count=data.get("led_count") or 0, led_count=data.get("led_count") or 0,
) )
@@ -198,7 +196,6 @@ class ColorStripSource:
id=sid, name=name, source_type="effect", id=sid, name=name, source_type="effect",
created_at=created_at, updated_at=updated_at, description=description, created_at=created_at, updated_at=updated_at, description=description,
clock_id=clock_id, effect_type=data.get("effect_type") or "fire", clock_id=clock_id, effect_type=data.get("effect_type") or "fire",
speed=float(data.get("speed") or 1.0),
led_count=data.get("led_count") or 0, led_count=data.get("led_count") or 0,
palette=data.get("palette") or "fire", palette=data.get("palette") or "fire",
color=color, color=color,
@@ -340,13 +337,11 @@ class ColorCycleColorStripSource(ColorStripSource):
[255, 0, 0], [255, 255, 0], [0, 255, 0], [255, 0, 0], [255, 255, 0], [0, 255, 0],
[0, 255, 255], [0, 0, 255], [255, 0, 255], [0, 255, 255], [0, 0, 255], [255, 0, 255],
]) ])
cycle_speed: float = 1.0 # speed multiplier; 1.0 ≈ one full cycle every 20 seconds
led_count: int = 0 # 0 = use device LED count led_count: int = 0 # 0 = use device LED count
def to_dict(self) -> dict: def to_dict(self) -> dict:
d = super().to_dict() d = super().to_dict()
d["colors"] = [list(c) for c in self.colors] d["colors"] = [list(c) for c in self.colors]
d["cycle_speed"] = self.cycle_speed
d["led_count"] = self.led_count d["led_count"] = self.led_count
return d return d
@@ -361,7 +356,6 @@ class EffectColorStripSource(ColorStripSource):
""" """
effect_type: str = "fire" # fire | meteor | plasma | noise | aurora effect_type: str = "fire" # fire | meteor | plasma | noise | aurora
speed: float = 1.0 # animation speed multiplier (0.110.0)
led_count: int = 0 # 0 = use device LED count led_count: int = 0 # 0 = use device LED count
palette: str = "fire" # named color palette palette: str = "fire" # named color palette
color: list = field(default_factory=lambda: [255, 80, 0]) # [R,G,B] for meteor head color: list = field(default_factory=lambda: [255, 80, 0]) # [R,G,B] for meteor head
@@ -372,7 +366,6 @@ class EffectColorStripSource(ColorStripSource):
def to_dict(self) -> dict: def to_dict(self) -> dict:
d = super().to_dict() d = super().to_dict()
d["effect_type"] = self.effect_type d["effect_type"] = self.effect_type
d["speed"] = self.speed
d["led_count"] = self.led_count d["led_count"] = self.led_count
d["palette"] = self.palette d["palette"] = self.palette
d["color"] = list(self.color) d["color"] = list(self.color)

View File

@@ -106,9 +106,7 @@ class ColorStripStore:
frame_interpolation: bool = False, frame_interpolation: bool = False,
animation: Optional[dict] = None, animation: Optional[dict] = None,
colors: Optional[list] = None, colors: Optional[list] = None,
cycle_speed: float = 1.0,
effect_type: str = "fire", effect_type: str = "fire",
speed: float = 1.0,
palette: str = "fire", palette: str = "fire",
intensity: float = 1.0, intensity: float = 1.0,
scale: float = 1.0, scale: float = 1.0,
@@ -182,7 +180,6 @@ class ColorStripStore:
description=description, description=description,
clock_id=clock_id, clock_id=clock_id,
colors=colors if isinstance(colors, list) and len(colors) >= 2 else default_colors, colors=colors if isinstance(colors, list) and len(colors) >= 2 else default_colors,
cycle_speed=float(cycle_speed) if cycle_speed else 1.0,
led_count=led_count, led_count=led_count,
) )
elif source_type == "effect": elif source_type == "effect":
@@ -196,7 +193,6 @@ class ColorStripStore:
description=description, description=description,
clock_id=clock_id, clock_id=clock_id,
effect_type=effect_type or "fire", effect_type=effect_type or "fire",
speed=float(speed) if speed else 1.0,
led_count=led_count, led_count=led_count,
palette=palette or "fire", palette=palette or "fire",
color=rgb, color=rgb,
@@ -311,9 +307,7 @@ class ColorStripStore:
frame_interpolation: Optional[bool] = None, frame_interpolation: Optional[bool] = None,
animation: Optional[dict] = None, animation: Optional[dict] = None,
colors: Optional[list] = None, colors: Optional[list] = None,
cycle_speed: Optional[float] = None,
effect_type: Optional[str] = None, effect_type: Optional[str] = None,
speed: Optional[float] = None,
palette: Optional[str] = None, palette: Optional[str] = None,
intensity: Optional[float] = None, intensity: Optional[float] = None,
scale: Optional[float] = None, scale: Optional[float] = None,
@@ -389,15 +383,11 @@ class ColorStripStore:
elif isinstance(source, ColorCycleColorStripSource): elif isinstance(source, ColorCycleColorStripSource):
if colors is not None and isinstance(colors, list) and len(colors) >= 2: if colors is not None and isinstance(colors, list) and len(colors) >= 2:
source.colors = colors source.colors = colors
if cycle_speed is not None:
source.cycle_speed = float(cycle_speed)
if led_count is not None: if led_count is not None:
source.led_count = led_count source.led_count = led_count
elif isinstance(source, EffectColorStripSource): elif isinstance(source, EffectColorStripSource):
if effect_type is not None: if effect_type is not None:
source.effect_type = effect_type source.effect_type = effect_type
if speed is not None:
source.speed = float(speed)
if led_count is not None: if led_count is not None:
source.led_count = led_count source.led_count = led_count
if palette is not None: if palette is not None:

View File

@@ -146,18 +146,6 @@
<div id="color-cycle-colors-list"></div> <div id="color-cycle-colors-list"></div>
<button type="button" class="btn btn-secondary" onclick="colorCycleAddColor()" data-i18n="color_strip.color_cycle.add_color">+ Add Color</button> <button type="button" class="btn btn-secondary" onclick="colorCycleAddColor()" data-i18n="color_strip.color_cycle.add_color">+ Add Color</button>
</div> </div>
<div id="css-editor-cycle-speed-group" class="form-group">
<div class="label-row">
<label for="css-editor-cycle-speed">
<span data-i18n="color_strip.color_cycle.speed">Speed:</span>
<span id="css-editor-cycle-speed-val">1.0</span>×
</label>
<button type="button" class="hint-toggle" onclick="toggleHint(this)" title="?" data-i18n-aria-label="aria.hint">?</button>
</div>
<small class="input-hint" style="display:none" data-i18n="color_strip.color_cycle.speed.hint">Cycle speed multiplier. 1.0 ≈ one full cycle every 20 seconds.</small>
<input type="range" id="css-editor-cycle-speed" min="0.1" max="10.0" step="0.1" value="1.0"
oninput="document.getElementById('css-editor-cycle-speed-val').textContent = parseFloat(this.value).toFixed(1)">
</div>
</div> </div>
<!-- Gradient-specific fields --> <!-- Gradient-specific fields -->
@@ -226,19 +214,6 @@
<div id="css-editor-effect-preview" class="effect-palette-preview"></div> <div id="css-editor-effect-preview" class="effect-palette-preview"></div>
</div> </div>
<div id="css-editor-effect-speed-group" class="form-group">
<div class="label-row">
<label for="css-editor-effect-speed">
<span data-i18n="color_strip.effect.speed">Speed:</span>
<span id="css-editor-effect-speed-val">1.0</span>x
</label>
<button type="button" class="hint-toggle" onclick="toggleHint(this)" title="?" data-i18n-aria-label="aria.hint">?</button>
</div>
<small class="input-hint" style="display:none" data-i18n="color_strip.effect.speed.hint">How fast the effect animates. 1.0 = default speed.</small>
<input type="range" id="css-editor-effect-speed" min="0.1" max="10.0" step="0.1" value="1.0"
oninput="document.getElementById('css-editor-effect-speed-val').textContent = parseFloat(this.value).toFixed(1)">
</div>
<div id="css-editor-effect-palette-group" class="form-group"> <div id="css-editor-effect-palette-group" class="form-group">
<div class="label-row"> <div class="label-row">
<label for="css-editor-effect-palette" data-i18n="color_strip.effect.palette">Palette:</label> <label for="css-editor-effect-palette" data-i18n="color_strip.effect.palette">Palette:</label>
@@ -492,18 +467,6 @@
</select> </select>
<small id="css-editor-animation-type-desc" class="field-desc"></small> <small id="css-editor-animation-type-desc" class="field-desc"></small>
</div> </div>
<div id="css-editor-animation-speed-group" class="form-group">
<div class="label-row">
<label for="css-editor-animation-speed">
<span data-i18n="color_strip.animation.speed">Speed:</span>
<span id="css-editor-animation-speed-val">1.0</span>×
</label>
<button type="button" class="hint-toggle" onclick="toggleHint(this)" title="?" data-i18n-aria-label="aria.hint">?</button>
</div>
<small class="input-hint" style="display:none" data-i18n="color_strip.animation.speed.hint">Animation speed multiplier. 1.0 ≈ one cycle per second for Breathing; higher values cycle faster.</small>
<input type="range" id="css-editor-animation-speed" min="0.1" max="10.0" step="0.1" value="1.0"
oninput="document.getElementById('css-editor-animation-speed-val').textContent = parseFloat(this.value).toFixed(1)">
</div>
</div> </div>
</details> </details>
</div> </div>

View File

@@ -39,7 +39,7 @@
<button type="button" class="hint-toggle" onclick="toggleHint(this)" title="?" data-i18n-aria-label="aria.hint">?</button> <button type="button" class="hint-toggle" onclick="toggleHint(this)" title="?" data-i18n-aria-label="aria.hint">?</button>
</div> </div>
<small class="input-hint" style="display:none" data-i18n="sync_clock.description.hint">Optional notes about this clock's purpose</small> <small class="input-hint" style="display:none" data-i18n="sync_clock.description.hint">Optional notes about this clock's purpose</small>
<textarea id="sync-clock-description" rows="2" data-i18n-placeholder="sync_clock.description.placeholder" placeholder=""></textarea> <input type="text" id="sync-clock-description" data-i18n-placeholder="sync_clock.description.placeholder" placeholder="">
</div> </div>
</form> </form>
</div> </div>