Add sync clock entity for synchronized animation timing

Introduces Synchronization Clocks — shared, controllable time bases
that CSS sources can optionally reference for synchronized animation.

Backend:
- New SyncClock dataclass, JSON store, Pydantic schemas, REST API
- Runtime clock with thread-safe pause/resume/reset and speed control
- Ref-counted runtime pool with eager creation for API control
- clock_id field on all ColorStripSource types
- Stream integration: clock time/speed replaces source-local values
- Paused clock skips rendering (saves CPU + stops frame pushes)
- Included in backup/restore via STORE_MAP

Frontend:
- Sync Clocks tab in Streams section with cards and controls
- Clock dropdown in CSS editor (hidden speed slider when clock set)
- Clock crosslink badge on CSS source cards (replaces speed badge)
- Targets tab uses DataCache for picture/audio sources and sync clocks

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-03-01 21:46:55 +03:00
parent 52ee4bdeb6
commit aa1e4a6afc
32 changed files with 1255 additions and 58 deletions

View File

@@ -107,7 +107,7 @@ import {
// Layer 5: color-strip sources
import {
showCSSEditor, closeCSSEditorModal, forceCSSEditorClose, saveCSSEditor, deleteColorStrip,
onCSSTypeChange, onEffectTypeChange, onAnimationTypeChange, updateEffectPreview,
onCSSTypeChange, onEffectTypeChange, onAnimationTypeChange, onCSSClockChange, updateEffectPreview,
colorCycleAddColor, colorCycleRemoveColor,
compositeAddLayer, compositeRemoveLayer,
mappedAddZone, mappedRemoveZone,
@@ -363,6 +363,7 @@ Object.assign(window, {
deleteColorStrip,
onCSSTypeChange,
onEffectTypeChange,
onCSSClockChange,
onAnimationTypeChange,
updateEffectPreview,
colorCycleAddColor,

View File

@@ -184,6 +184,9 @@ export function set_audioTemplateNameManuallyEdited(v) { _audioTemplateNameManua
// Value sources
export let _cachedValueSources = [];
// Sync clocks
export let _cachedSyncClocks = [];
// Automations
export let _automationsCache = null;
@@ -234,6 +237,12 @@ export const valueSourcesCache = new DataCache({
});
valueSourcesCache.subscribe(v => { _cachedValueSources = v; });
export const syncClocksCache = new DataCache({
endpoint: '/sync-clocks',
extractData: json => json.clocks || [],
});
syncClocksCache.subscribe(v => { _cachedSyncClocks = v; });
export const filtersCache = new DataCache({
endpoint: '/filters',
extractData: json => json.filters || [],

View File

@@ -3,6 +3,7 @@
*/
import { fetchWithAuth, escapeHtml } from '../core/api.js';
import { _cachedSyncClocks } from '../core/state.js';
import { t } from '../core/i18n.js';
import { showToast, showConfirm } from '../core/ui.js';
import { Modal } from '../core/modal.js';
@@ -11,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_LINK, ICON_SPARKLES, ICON_FAST_FORWARD, ICON_ACTIVITY, ICON_CLOCK,
} from '../core/icons.js';
import { wrapCard } from '../core/card-colors.js';
@@ -58,6 +59,7 @@ class CSSEditorModal extends Modal {
audio_mirror: document.getElementById('css-editor-audio-mirror').checked,
api_input_fallback_color: document.getElementById('css-editor-api-input-fallback-color').value,
api_input_timeout: document.getElementById('css-editor-api-input-timeout').value,
clock_id: document.getElementById('css-editor-clock').value,
};
}
}
@@ -114,6 +116,11 @@ export function onCSSTypeChange() {
document.getElementById('css-editor-led-count-group').style.display =
(type === 'composite' || type === 'mapped' || type === 'audio' || type === 'api_input') ? 'none' : '';
// Sync clock — shown for animated types (static, gradient, color_cycle, effect)
const clockTypes = ['static', 'gradient', 'color_cycle', 'effect'];
document.getElementById('css-editor-clock-group').style.display = clockTypes.includes(type) ? '' : 'none';
if (clockTypes.includes(type)) _populateClockDropdown();
if (type === 'audio') {
_loadAudioSources();
} else if (type === 'composite') {
@@ -125,6 +132,27 @@ export function onCSSTypeChange() {
}
}
function _populateClockDropdown(selectedId) {
const sel = document.getElementById('css-editor-clock');
const prev = selectedId !== undefined ? selectedId : sel.value;
sel.innerHTML = `<option value="">${t('common.none')}</option>` +
_cachedSyncClocks.map(c => `<option value="${c.id}">${escapeHtml(c.name)} (${c.speed}x)</option>`).join('');
sel.value = prev || '';
}
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' : '';
}
}
function _getAnimationPayload() {
const type = document.getElementById('css-editor-animation-type').value;
return {
@@ -589,10 +617,16 @@ export function createColorStripCard(source, pictureSourceMap, audioSourceMap) {
const isAudio = source.source_type === 'audio';
const isApiInput = source.source_type === 'api_input';
// Clock crosslink badge (replaces speed badge when clock is assigned)
const clockObj = source.clock_id ? _cachedSyncClocks.find(c => c.id === source.clock_id) : null;
const clockBadge = clockObj
? `<span class="stream-card-prop stream-card-link" title="${t('color_strip.clock')}" onclick="event.stopPropagation(); navigateToCard('streams','sync','sync-clocks','data-id','${source.clock_id}')">${ICON_CLOCK} ${escapeHtml(clockObj.name)}</span>`
: source.clock_id ? `<span class="stream-card-prop">${ICON_CLOCK} ${source.clock_id}</span>` : '';
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>`
+ `<span class="stream-card-prop" title="${t('color_strip.animation.speed')}">${ICON_FAST_FORWARD} ${(anim.speed || 1.0).toFixed(1)}×</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;
@@ -604,6 +638,7 @@ export function createColorStripCard(source, pictureSourceMap, audioSourceMap) {
</span>
${source.led_count ? `<span class="stream-card-prop" title="${t('color_strip.leds')}">${ICON_LED} ${source.led_count}</span>` : ''}
${animBadge}
${clockBadge}
`;
} else if (isColorCycle) {
const colors = source.colors || [];
@@ -612,8 +647,9 @@ export function createColorStripCard(source, pictureSourceMap, audioSourceMap) {
).join('');
propsHtml = `
<span class="stream-card-prop">${swatches}</span>
<span class="stream-card-prop" title="${t('color_strip.color_cycle.speed')}">${ICON_FAST_FORWARD} ${(source.cycle_speed || 1.0).toFixed(1)}×</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}
`;
} else if (isGradient) {
const stops = source.stops || [];
@@ -636,6 +672,7 @@ export function createColorStripCard(source, pictureSourceMap, audioSourceMap) {
<span class="stream-card-prop">${ICON_PALETTE} ${stops.length} ${t('color_strip.gradient.stops_count')}</span>
${source.led_count ? `<span class="stream-card-prop" title="${t('color_strip.leds')}">${ICON_LED} ${source.led_count}</span>` : ''}
${animBadge}
${clockBadge}
`;
} else if (isEffect) {
const effectLabel = t('color_strip.effect.' + (source.effect_type || 'fire')) || source.effect_type || 'fire';
@@ -643,8 +680,9 @@ 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>` : ''}
<span class="stream-card-prop" title="${t('color_strip.effect.speed')}">${ICON_FAST_FORWARD} ${(source.speed || 1.0).toFixed(1)}×</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}
`;
} else if (isComposite) {
const layerCount = (source.layers || []).length;
@@ -762,6 +800,12 @@ export async function showCSSEditor(cssId = null, cloneData = null) {
document.getElementById('css-editor-type').value = sourceType;
onCSSTypeChange();
// Set clock dropdown value (must be after onCSSTypeChange populates it)
if (css.clock_id) {
_populateClockDropdown(css.clock_id);
onCSSClockChange();
}
if (sourceType === 'static') {
document.getElementById('css-editor-color').value = rgbArrayToHex(css.color);
_loadAnimationState(css.animation);
@@ -1040,6 +1084,13 @@ export async function saveCSSEditor() {
if (!cssId) payload.source_type = 'picture';
}
// Attach clock_id for animated types
const clockTypes = ['static', 'gradient', 'color_cycle', 'effect'];
if (clockTypes.includes(sourceType)) {
const clockVal = document.getElementById('css-editor-clock').value;
payload.clock_id = clockVal || null;
}
try {
let response;
if (cssId) {

View File

@@ -21,6 +21,7 @@ import {
_lastValidatedImageSource, set_lastValidatedImageSource,
_cachedAudioSources,
_cachedValueSources,
_cachedSyncClocks,
_cachedAudioTemplates,
availableAudioEngines, setAvailableAudioEngines,
currentEditingAudioTemplateId, setCurrentEditingAudioTemplateId,
@@ -28,7 +29,7 @@ import {
_sourcesLoading, set_sourcesLoading,
apiKey,
streamsCache, ppTemplatesCache, captureTemplatesCache,
audioSourcesCache, audioTemplatesCache, valueSourcesCache, filtersCache,
audioSourcesCache, audioTemplatesCache, valueSourcesCache, syncClocksCache, filtersCache,
} from '../core/state.js';
import { API_BASE, getHeaders, fetchWithAuth, escapeHtml } from '../core/api.js';
import { t } from '../core/i18n.js';
@@ -38,10 +39,11 @@ import { openDisplayPicker, formatDisplayLabel } from './displays.js';
import { CardSection } from '../core/card-sections.js';
import { updateSubTabHash } from './tabs.js';
import { createValueSourceCard } from './value-sources.js';
import { createSyncClockCard } from './sync-clocks.js';
import {
getEngineIcon, getPictureSourceIcon, getAudioSourceIcon,
ICON_TEMPLATE, ICON_CLONE, ICON_EDIT, ICON_TEST, ICON_LINK_SOURCE,
ICON_FPS, ICON_WEB, ICON_VALUE_SOURCE, ICON_AUDIO_LOOPBACK, ICON_AUDIO_INPUT,
ICON_FPS, ICON_WEB, ICON_VALUE_SOURCE, ICON_CLOCK, ICON_AUDIO_LOOPBACK, ICON_AUDIO_INPUT,
ICON_AUDIO_TEMPLATE, ICON_MONITOR, ICON_WRENCH, ICON_RADIO,
ICON_CAPTURE_TEMPLATE, ICON_PP_TEMPLATE, ICON_HELP,
} from '../core/icons.js';
@@ -57,6 +59,7 @@ const csAudioMono = new CardSection('audio-mono', { titleKey: 'audio_source.grou
const csStaticStreams = new CardSection('static-streams', { titleKey: 'streams.group.static_image', gridClass: 'templates-grid', addCardOnclick: "showAddStreamModal('static_image')", keyAttr: 'data-stream-id' });
const csAudioTemplates = new CardSection('audio-templates', { titleKey: 'audio_template.title', gridClass: 'templates-grid', addCardOnclick: "showAddAudioTemplateModal()", keyAttr: 'data-audio-template-id' });
const csValueSources = new CardSection('value-sources', { titleKey: 'value_source.group.title', gridClass: 'templates-grid', addCardOnclick: "showValueSourceModal()", keyAttr: 'data-id' });
const csSyncClocks = new CardSection('sync-clocks', { titleKey: 'sync_clock.group.title', gridClass: 'templates-grid', addCardOnclick: "showSyncClockModal()", keyAttr: 'data-id' });
// Re-render picture sources when language changes
document.addEventListener('languageChanged', () => { if (apiKey) loadPictureSources(); });
@@ -1111,6 +1114,7 @@ export async function loadPictureSources() {
captureTemplatesCache.fetch(),
audioSourcesCache.fetch(),
valueSourcesCache.fetch(),
syncClocksCache.fetch(),
audioTemplatesCache.fetch(),
filtersCache.data.length === 0 ? filtersCache.fetch() : Promise.resolve(filtersCache.data),
]);
@@ -1144,6 +1148,7 @@ const _streamSectionMap = {
processed: [csProcStreams, csProcTemplates],
audio: [csAudioMulti, csAudioMono],
value: [csValueSources],
sync: [csSyncClocks],
};
export function expandAllStreamSections() {
@@ -1292,6 +1297,7 @@ function renderPictureSourcesList(streams) {
{ key: 'processed', icon: getPictureSourceIcon('processed'), titleKey: 'streams.group.processed', count: processedStreams.length },
{ key: 'audio', icon: getAudioSourceIcon('multichannel'), titleKey: 'streams.group.audio', count: _cachedAudioSources.length },
{ key: 'value', icon: ICON_VALUE_SOURCE, titleKey: 'streams.group.value', count: _cachedValueSources.length },
{ key: 'sync', icon: ICON_CLOCK, titleKey: 'streams.group.sync', count: _cachedSyncClocks.length },
];
const tabBar = `<div class="stream-tab-bar">${tabs.map(tab =>
@@ -1389,6 +1395,7 @@ function renderPictureSourcesList(streams) {
const audioTemplateItems = csAudioTemplates.applySortOrder(_cachedAudioTemplates.map(t => ({ key: t.id, html: renderAudioTemplateCard(t) })));
const staticItems = csStaticStreams.applySortOrder(staticImageStreams.map(s => ({ key: s.id, html: renderStreamCard(s) })));
const valueItems = csValueSources.applySortOrder(_cachedValueSources.map(s => ({ key: s.id, html: createValueSourceCard(s) })));
const syncClockItems = csSyncClocks.applySortOrder(_cachedSyncClocks.map(s => ({ key: s.id, html: createSyncClockCard(s) })));
if (csRawStreams.isMounted()) {
// Incremental update: reconcile cards in-place
@@ -1405,6 +1412,7 @@ function renderPictureSourcesList(streams) {
csAudioTemplates.reconcile(audioTemplateItems);
csStaticStreams.reconcile(staticItems);
csValueSources.reconcile(valueItems);
csSyncClocks.reconcile(syncClockItems);
} else {
// First render: build full HTML
const panels = tabs.map(tab => {
@@ -1413,12 +1421,13 @@ function renderPictureSourcesList(streams) {
else if (tab.key === 'processed') panelContent = csProcStreams.render(procStreamItems) + csProcTemplates.render(procTemplateItems);
else if (tab.key === 'audio') panelContent = csAudioMulti.render(multiItems) + csAudioMono.render(monoItems) + csAudioTemplates.render(audioTemplateItems);
else if (tab.key === 'value') panelContent = csValueSources.render(valueItems);
else if (tab.key === 'sync') panelContent = csSyncClocks.render(syncClockItems);
else panelContent = csStaticStreams.render(staticItems);
return `<div class="stream-tab-panel${tab.key === activeTab ? ' active' : ''}" id="stream-tab-${tab.key}">${panelContent}</div>`;
}).join('');
container.innerHTML = tabBar + panels;
CardSection.bindAll([csRawStreams, csRawTemplates, csProcStreams, csProcTemplates, csAudioMulti, csAudioMono, csAudioTemplates, csStaticStreams, csValueSources]);
CardSection.bindAll([csRawStreams, csRawTemplates, csProcStreams, csProcTemplates, csAudioMulti, csAudioMono, csAudioTemplates, csStaticStreams, csValueSources, csSyncClocks]);
}
}

View File

@@ -0,0 +1,221 @@
/**
* Sync Clocks — CRUD, runtime controls, cards.
*/
import { _cachedSyncClocks, syncClocksCache } from '../core/state.js';
import { fetchWithAuth, escapeHtml } from '../core/api.js';
import { t } from '../core/i18n.js';
import { Modal } from '../core/modal.js';
import { showToast, showConfirm } from '../core/ui.js';
import { ICON_CLOCK, ICON_CLONE, ICON_EDIT, ICON_START, ICON_PAUSE } from '../core/icons.js';
import { wrapCard } from '../core/card-colors.js';
import { loadPictureSources } from './streams.js';
// ── Modal ──
class SyncClockModal extends Modal {
constructor() { super('sync-clock-modal'); }
snapshotValues() {
return {
name: document.getElementById('sync-clock-name').value,
speed: document.getElementById('sync-clock-speed').value,
description: document.getElementById('sync-clock-description').value,
};
}
}
const syncClockModal = new SyncClockModal();
// ── Show / Close ──
export async function showSyncClockModal(editData) {
const isEdit = !!editData;
const titleKey = isEdit ? 'sync_clock.edit' : 'sync_clock.add';
document.getElementById('sync-clock-modal-title').innerHTML = `${ICON_CLOCK} ${t(titleKey)}`;
document.getElementById('sync-clock-id').value = isEdit ? editData.id : '';
document.getElementById('sync-clock-error').style.display = 'none';
if (isEdit) {
document.getElementById('sync-clock-name').value = editData.name || '';
document.getElementById('sync-clock-speed').value = editData.speed ?? 1.0;
document.getElementById('sync-clock-speed-display').textContent = editData.speed ?? 1.0;
document.getElementById('sync-clock-description').value = editData.description || '';
} else {
document.getElementById('sync-clock-name').value = '';
document.getElementById('sync-clock-speed').value = 1.0;
document.getElementById('sync-clock-speed-display').textContent = '1';
document.getElementById('sync-clock-description').value = '';
}
syncClockModal.open();
syncClockModal.snapshot();
}
export async function closeSyncClockModal() {
await syncClockModal.close();
}
// ── Save ──
export async function saveSyncClock() {
const id = document.getElementById('sync-clock-id').value;
const name = document.getElementById('sync-clock-name').value.trim();
const speed = parseFloat(document.getElementById('sync-clock-speed').value);
const description = document.getElementById('sync-clock-description').value.trim() || null;
if (!name) {
syncClockModal.showError(t('sync_clock.error.name_required'));
return;
}
const payload = { name, speed, description };
try {
const method = id ? 'PUT' : 'POST';
const url = id ? `/sync-clocks/${id}` : '/sync-clocks';
const resp = await fetchWithAuth(url, {
method,
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(payload),
});
if (!resp.ok) {
const err = await resp.json().catch(() => ({}));
throw new Error(err.detail || `HTTP ${resp.status}`);
}
showToast(t(id ? 'sync_clock.updated' : 'sync_clock.created'), 'success');
syncClockModal.forceClose();
await loadPictureSources();
} catch (e) {
if (e.isAuth) return;
syncClockModal.showError(e.message);
}
}
// ── Edit / Clone / Delete ──
export async function editSyncClock(clockId) {
try {
const resp = await fetchWithAuth(`/sync-clocks/${clockId}`);
if (!resp.ok) throw new Error(t('sync_clock.error.load'));
const data = await resp.json();
await showSyncClockModal(data);
} catch (e) {
if (e.isAuth) return;
showToast(e.message, 'error');
}
}
export async function cloneSyncClock(clockId) {
try {
const resp = await fetchWithAuth(`/sync-clocks/${clockId}`);
if (!resp.ok) throw new Error(t('sync_clock.error.load'));
const data = await resp.json();
delete data.id;
data.name = data.name + ' (copy)';
await showSyncClockModal(data);
} catch (e) {
if (e.isAuth) return;
showToast(e.message, 'error');
}
}
export async function deleteSyncClock(clockId) {
const confirmed = await showConfirm(t('sync_clock.delete.confirm'));
if (!confirmed) return;
try {
const resp = await fetchWithAuth(`/sync-clocks/${clockId}`, { method: 'DELETE' });
if (!resp.ok) {
const err = await resp.json().catch(() => ({}));
throw new Error(err.detail || `HTTP ${resp.status}`);
}
showToast(t('sync_clock.deleted'), 'success');
await loadPictureSources();
} catch (e) {
if (e.isAuth) return;
showToast(e.message, 'error');
}
}
// ── Runtime controls ──
export async function pauseSyncClock(clockId) {
try {
const resp = await fetchWithAuth(`/sync-clocks/${clockId}/pause`, { method: 'POST' });
if (!resp.ok) throw new Error(`HTTP ${resp.status}`);
showToast(t('sync_clock.paused'), 'success');
await loadPictureSources();
} catch (e) {
if (e.isAuth) return;
showToast(e.message, 'error');
}
}
export async function resumeSyncClock(clockId) {
try {
const resp = await fetchWithAuth(`/sync-clocks/${clockId}/resume`, { method: 'POST' });
if (!resp.ok) throw new Error(`HTTP ${resp.status}`);
showToast(t('sync_clock.resumed'), 'success');
await loadPictureSources();
} catch (e) {
if (e.isAuth) return;
showToast(e.message, 'error');
}
}
export async function resetSyncClock(clockId) {
try {
const resp = await fetchWithAuth(`/sync-clocks/${clockId}/reset`, { method: 'POST' });
if (!resp.ok) throw new Error(`HTTP ${resp.status}`);
showToast(t('sync_clock.reset_done'), 'success');
await loadPictureSources();
} catch (e) {
if (e.isAuth) return;
showToast(e.message, 'error');
}
}
// ── Card rendering ──
export function createSyncClockCard(clock) {
const statusIcon = clock.is_running ? ICON_START : ICON_PAUSE;
const statusLabel = clock.is_running ? t('sync_clock.status.running') : t('sync_clock.status.paused');
const toggleAction = clock.is_running
? `pauseSyncClock('${clock.id}')`
: `resumeSyncClock('${clock.id}')`;
const toggleTitle = clock.is_running ? t('sync_clock.action.pause') : t('sync_clock.action.resume');
return wrapCard({
type: 'template-card',
dataAttr: 'data-id',
id: clock.id,
removeOnclick: `deleteSyncClock('${clock.id}')`,
removeTitle: t('common.delete'),
content: `
<div class="template-card-header">
<div class="template-name">${ICON_CLOCK} ${escapeHtml(clock.name)}</div>
</div>
<div class="stream-card-props">
<span class="stream-card-prop">${statusIcon} ${statusLabel}</span>
<span class="stream-card-prop">${ICON_CLOCK} ${clock.speed}x</span>
</div>
${clock.description ? `<div class="template-config" style="opacity:0.7;">${escapeHtml(clock.description)}</div>` : ''}`,
actions: `
<button class="btn btn-icon btn-secondary" onclick="event.stopPropagation(); ${toggleAction}" title="${toggleTitle}">${clock.is_running ? ICON_PAUSE : ICON_START}</button>
<button class="btn btn-icon btn-secondary" onclick="event.stopPropagation(); resetSyncClock('${clock.id}')" title="${t('sync_clock.action.reset')}">${ICON_CLOCK}</button>
<button class="btn btn-icon btn-secondary" onclick="cloneSyncClock('${clock.id}')" title="${t('common.clone')}">${ICON_CLONE}</button>
<button class="btn btn-icon btn-secondary" onclick="editSyncClock('${clock.id}')" title="${t('common.edit')}">${ICON_EDIT}</button>`,
});
}
// ── Expose to global scope for inline onclick handlers ──
window.showSyncClockModal = showSyncClockModal;
window.closeSyncClockModal = closeSyncClockModal;
window.saveSyncClock = saveSyncClock;
window.editSyncClock = editSyncClock;
window.cloneSyncClock = cloneSyncClock;
window.deleteSyncClock = deleteSyncClock;
window.pauseSyncClock = pauseSyncClock;
window.resumeSyncClock = resumeSyncClock;
window.resetSyncClock = resetSyncClock;

View File

@@ -9,6 +9,7 @@ import {
kcWebSockets,
ledPreviewWebSockets,
_cachedValueSources, valueSourcesCache,
streamsCache, audioSourcesCache, syncClocksCache,
} from '../core/state.js';
import { API_BASE, getHeaders, fetchWithAuth, escapeHtml, isOpenrgbDevice } from '../core/api.js';
import { t } from '../core/i18n.js';
@@ -475,15 +476,17 @@ export async function loadTargetsTab() {
if (!csDevices.isMounted()) setTabRefreshing('targets-panel-content', true);
try {
// Fetch devices, targets, CSS sources, picture sources, pattern templates, and value sources in parallel
const [devicesResp, targetsResp, cssResp, psResp, patResp, valueSrcArr, asResp] = await Promise.all([
// Fetch devices, targets, CSS sources, pattern templates in parallel;
// use DataCache for picture sources, audio sources, value sources, sync clocks
const [devicesResp, targetsResp, cssResp, patResp, psArr, valueSrcArr, asSrcArr] = await Promise.all([
fetchWithAuth('/devices'),
fetchWithAuth('/picture-targets'),
fetchWithAuth('/color-strip-sources').catch(() => null),
fetchWithAuth('/picture-sources').catch(() => null),
fetchWithAuth('/pattern-templates').catch(() => null),
streamsCache.fetch().catch(() => []),
valueSourcesCache.fetch().catch(() => []),
fetchWithAuth('/audio-sources').catch(() => null),
audioSourcesCache.fetch().catch(() => []),
syncClocksCache.fetch().catch(() => []),
]);
const devicesData = await devicesResp.json();
@@ -499,10 +502,7 @@ export async function loadTargetsTab() {
}
let pictureSourceMap = {};
if (psResp && psResp.ok) {
const psData = await psResp.json();
(psData.streams || []).forEach(s => { pictureSourceMap[s.id] = s; });
}
psArr.forEach(s => { pictureSourceMap[s.id] = s; });
let patternTemplates = [];
let patternTemplateMap = {};
@@ -516,10 +516,7 @@ export async function loadTargetsTab() {
valueSrcArr.forEach(s => { valueSourceMap[s.id] = s; });
let audioSourceMap = {};
if (asResp && asResp.ok) {
const asData = await asResp.json();
(asData.sources || []).forEach(s => { audioSourceMap[s.id] = s; });
}
asSrcArr.forEach(s => { audioSourceMap[s.id] = s; });
// Fetch all device states, target states, and target metrics in batch
const [batchDevStatesResp, batchTgtStatesResp, batchTgtMetricsResp] = await Promise.all([

View File

@@ -307,6 +307,7 @@
"common.delete": "Delete",
"common.edit": "Edit",
"common.clone": "Clone",
"common.none": "None",
"section.filter.placeholder": "Filter...",
"section.filter.reset": "Clear filter",
"section.expand_all": "Expand all sections",
@@ -950,6 +951,7 @@
"audio_template.error.required": "Please fill in all required fields",
"audio_template.error.delete": "Failed to delete audio template",
"streams.group.value": "Value Sources",
"streams.group.sync": "Sync Clocks",
"value_source.group.title": "Value Sources",
"value_source.add": "Add Value Source",
"value_source.edit": "Edit Value Source",
@@ -1137,5 +1139,32 @@
"theme.switched.dark": "Switched to dark theme",
"theme.switched.light": "Switched to light theme",
"accent.color.updated": "Accent color updated",
"search.footer": "↑↓ navigate · Enter select · Esc close"
"search.footer": "↑↓ navigate · Enter select · Esc close",
"sync_clock.group.title": "Sync Clocks",
"sync_clock.add": "Add Sync Clock",
"sync_clock.edit": "Edit Sync Clock",
"sync_clock.name": "Name:",
"sync_clock.name.placeholder": "Main Animation Clock",
"sync_clock.name.hint": "A descriptive name for this synchronization clock",
"sync_clock.speed": "Speed:",
"sync_clock.speed.hint": "Speed multiplier shared by all linked sources. 1.0 = normal speed.",
"sync_clock.description": "Description (optional):",
"sync_clock.description.placeholder": "Optional description",
"sync_clock.description.hint": "Optional notes about this clock's purpose",
"sync_clock.status.running": "Running",
"sync_clock.status.paused": "Paused",
"sync_clock.action.pause": "Pause",
"sync_clock.action.resume": "Resume",
"sync_clock.action.reset": "Reset",
"sync_clock.error.name_required": "Clock name is required",
"sync_clock.error.load": "Failed to load sync clock",
"sync_clock.created": "Sync clock created",
"sync_clock.updated": "Sync clock updated",
"sync_clock.deleted": "Sync clock deleted",
"sync_clock.paused": "Clock paused",
"sync_clock.resumed": "Clock resumed",
"sync_clock.reset_done": "Clock reset to zero",
"sync_clock.delete.confirm": "Delete this sync clock? Sources using it will revert to their own speed.",
"color_strip.clock": "Sync Clock:",
"color_strip.clock.hint": "Link to a sync clock for synchronized animation. When set, speed comes from the clock."
}

View File

@@ -307,6 +307,7 @@
"common.delete": "Удалить",
"common.edit": "Редактировать",
"common.clone": "Клонировать",
"common.none": "Нет",
"section.filter.placeholder": "Фильтр...",
"section.filter.reset": "Очистить фильтр",
"section.expand_all": "Развернуть все секции",
@@ -950,6 +951,7 @@
"audio_template.error.required": "Пожалуйста, заполните все обязательные поля",
"audio_template.error.delete": "Не удалось удалить аудиошаблон",
"streams.group.value": "Источники значений",
"streams.group.sync": "Часы синхронизации",
"value_source.group.title": "Источники значений",
"value_source.add": "Добавить источник значений",
"value_source.edit": "Редактировать источник значений",
@@ -1137,5 +1139,32 @@
"theme.switched.dark": "Переключено на тёмную тему",
"theme.switched.light": "Переключено на светлую тему",
"accent.color.updated": "Цвет акцента обновлён",
"search.footer": "↑↓ навигация · Enter выбор · Esc закрыть"
"search.footer": "↑↓ навигация · Enter выбор · Esc закрыть",
"sync_clock.group.title": "Часы синхронизации",
"sync_clock.add": "Добавить часы",
"sync_clock.edit": "Редактировать часы",
"sync_clock.name": "Название:",
"sync_clock.name.placeholder": "Основные часы анимации",
"sync_clock.name.hint": "Описательное название для этих часов синхронизации",
"sync_clock.speed": "Скорость:",
"sync_clock.speed.hint": "Множитель скорости, общий для всех привязанных источников. 1.0 = нормальная скорость.",
"sync_clock.description": "Описание (необязательно):",
"sync_clock.description.placeholder": "Необязательное описание",
"sync_clock.description.hint": "Необязательные заметки о назначении этих часов",
"sync_clock.status.running": "Работает",
"sync_clock.status.paused": "Приостановлено",
"sync_clock.action.pause": "Приостановить",
"sync_clock.action.resume": "Возобновить",
"sync_clock.action.reset": "Сбросить",
"sync_clock.error.name_required": "Название часов обязательно",
"sync_clock.error.load": "Не удалось загрузить часы синхронизации",
"sync_clock.created": "Часы синхронизации созданы",
"sync_clock.updated": "Часы синхронизации обновлены",
"sync_clock.deleted": "Часы синхронизации удалены",
"sync_clock.paused": "Часы приостановлены",
"sync_clock.resumed": "Часы возобновлены",
"sync_clock.reset_done": "Часы сброшены на ноль",
"sync_clock.delete.confirm": "Удалить эти часы синхронизации? Источники, использующие их, вернутся к собственной скорости.",
"color_strip.clock": "Часы синхронизации:",
"color_strip.clock.hint": "Привязка к часам синхронизации для синхронной анимации. При установке скорость берётся из часов."
}

View File

@@ -307,6 +307,7 @@
"common.delete": "删除",
"common.edit": "编辑",
"common.clone": "克隆",
"common.none": "无",
"section.filter.placeholder": "筛选...",
"section.filter.reset": "清除筛选",
"section.expand_all": "全部展开",
@@ -950,6 +951,7 @@
"audio_template.error.required": "请填写所有必填项",
"audio_template.error.delete": "删除音频模板失败",
"streams.group.value": "值源",
"streams.group.sync": "同步时钟",
"value_source.group.title": "值源",
"value_source.add": "添加值源",
"value_source.edit": "编辑值源",
@@ -1137,5 +1139,32 @@
"theme.switched.dark": "已切换到深色主题",
"theme.switched.light": "已切换到浅色主题",
"accent.color.updated": "强调色已更新",
"search.footer": "↑↓ 导航 · Enter 选择 · Esc 关闭"
"search.footer": "↑↓ 导航 · Enter 选择 · Esc 关闭",
"sync_clock.group.title": "同步时钟",
"sync_clock.add": "添加同步时钟",
"sync_clock.edit": "编辑同步时钟",
"sync_clock.name": "名称:",
"sync_clock.name.placeholder": "主动画时钟",
"sync_clock.name.hint": "此同步时钟的描述性名称",
"sync_clock.speed": "速度:",
"sync_clock.speed.hint": "所有关联源共享的速度倍率。1.0 = 正常速度。",
"sync_clock.description": "描述(可选):",
"sync_clock.description.placeholder": "可选描述",
"sync_clock.description.hint": "关于此时钟用途的可选备注",
"sync_clock.status.running": "运行中",
"sync_clock.status.paused": "已暂停",
"sync_clock.action.pause": "暂停",
"sync_clock.action.resume": "恢复",
"sync_clock.action.reset": "重置",
"sync_clock.error.name_required": "时钟名称为必填项",
"sync_clock.error.load": "加载同步时钟失败",
"sync_clock.created": "同步时钟已创建",
"sync_clock.updated": "同步时钟已更新",
"sync_clock.deleted": "同步时钟已删除",
"sync_clock.paused": "时钟已暂停",
"sync_clock.resumed": "时钟已恢复",
"sync_clock.reset_done": "时钟已重置为零",
"sync_clock.delete.confirm": "删除此同步时钟?使用它的源将恢复为各自的速度。",
"color_strip.clock": "同步时钟:",
"color_strip.clock.hint": "关联同步时钟以实现同步动画。设置后,速度将来自时钟。"
}

View File

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