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:
@@ -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) {
|
||||
|
||||
@@ -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]);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
221
server/src/wled_controller/static/js/features/sync-clocks.js
Normal file
221
server/src/wled_controller/static/js/features/sync-clocks.js
Normal 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;
|
||||
@@ -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([
|
||||
|
||||
Reference in New Issue
Block a user