Files
ledgrab/server/src/ledgrab/static/js/features/streams.ts
T
alexei.dolgolyov 6745e25b20 feat: roadmap batch (2026-06-19) — solar/linear-light/dither/nanoleaf + integrations
Eight roadmap features from the 2026-06-19 review, each a full vertical
(backend + tests + frontend + i18n en/ru/zh); ~67 new unit tests:

- automations: SolarRule sunrise/sunset trigger (new utils/solar.py, shared
  with the daylight cycle; window logic mirrors TimeOfDayRule)
- ci: best-effort arm64 multi-arch Docker manifest via QEMU + docker manifest
  (release.yml; amd64 path untouched, continue-on-error)
- game-integration: wire the orphaned LoLPoller via a LoLPollManager + a shared
  runtime_state module (poll lifecycle on enable/CRUD/startup/shutdown)
- ui: color-harmony gradient generator (complementary/analogous/triadic/...)
- effects: audio-reactive palette modulation (new audio_energy_tap; brightness/
  saturation modulation across all 12 procedural effects)
- capture: linear-light blending + spatio-temporal dithering, opt-in per
  calibration (new utils/linear_light.py, utils/dither.py)
- devices: Nanoleaf extControl v2 per-panel UDP streaming (per_panel mode)

Also bundles the pending 2026-06-18 production-review fixes and other
in-progress work already in the working tree (manual-trigger rule, etc.),
since they share files and could not be cleanly separated.

Gate: ruff + tsc clean; pytest 2654 passed / 2 skipped. The single failing
test (automation manual_trigger handler coverage) is a separate in-progress
item owned elsewhere, intentionally left as-is.
2026-06-22 23:21:24 +03:00

2342 lines
114 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* Streams — picture sources, capture templates, PP templates, filters.
*/
import {
_cachedDisplays,
displaysCache,
_cachedStreams,
_cachedPPTemplates,
_cachedCaptureTemplates,
_availableFilters,
availableEngines,
currentEditingTemplateId, setCurrentEditingTemplateId,
_templateNameManuallyEdited, set_templateNameManuallyEdited,
_streamNameManuallyEdited, set_streamNameManuallyEdited,
_streamModalPPTemplates, set_streamModalPPTemplates,
_modalFilters, set_modalFilters,
_ppTemplateNameManuallyEdited, set_ppTemplateNameManuallyEdited,
currentTestingTemplate, setCurrentTestingTemplate,
_currentTestStreamId, set_currentTestStreamId,
_currentTestPPTemplateId, set_currentTestPPTemplateId,
_cachedAudioSources,
_cachedValueSources,
_cachedSyncClocks,
_cachedAudioTemplates,
_cachedCSPTemplates,
_csptModalFilters, set_csptModalFilters,
_csptNameManuallyEdited, set_csptNameManuallyEdited,
_stripFilters,
availableAudioEngines, setAvailableAudioEngines,
currentEditingAudioTemplateId, setCurrentEditingAudioTemplateId,
_audioTemplateNameManuallyEdited, set_audioTemplateNameManuallyEdited,
_sourcesLoading, set_sourcesLoading,
apiKey,
streamsCache, ppTemplatesCache, captureTemplatesCache, enginesCache,
audioSourcesCache, audioTemplatesCache, valueSourcesCache, syncClocksCache, assetsCache, _cachedAssets, filtersCache,
colorStripSourcesCache,
csptCache, stripFiltersCache,
gradientsCache, GradientEntity,
audioProcessingTemplatesCache, _cachedAudioProcessingTemplates,
audioFilterDefsCache,
weatherSourcesCache,
haSourcesCache,
} from '../core/state.ts';
import { API_BASE, getHeaders, fetchWithAuth, escapeHtml } from '../core/api.ts';
import { t } from '../core/i18n.ts';
import { Modal } from '../core/modal.ts';
import { showToast, showConfirm, openLightbox, openFullImageLightbox, showOverlaySpinner, hideOverlaySpinner, updateOverlayPreview, setTabRefreshing, setupBackdropClose } from '../core/ui.ts';
import { openDisplayPicker, formatDisplayLabel } from './displays.ts';
import { CardSection } from '../core/card-sections.ts';
import { TreeNav } from '../core/tree-nav.ts';
import { updateSubTabHash } from './tabs.ts';
import { getActiveSubTab, setActiveSubTab } from '../core/tab-registry.ts';
import { createValueSourceCard } from './value-sources.ts';
import { prefetchHAEntities } from './home-assistant-sources.ts';
import { createSyncClockCard, initSyncClockDelegation } from './sync-clocks.ts';
import { createAssetCard, initAssetDelegation } from './assets.ts';
import { createColorStripCard } from './color-strips/index.ts';
import { initAudioSourceDelegation } from './audio-sources.ts';
import { createAudioProcessingTemplateCard } from './audio-processing-templates.ts';
import {
getEngineIcon, getAudioEngineIcon, getPictureSourceIcon, getAudioSourceIcon, getColorStripIcon,
ICON_TEMPLATE, ICON_CLONE, ICON_EDIT, ICON_TEST, ICON_LINK_SOURCE,
ICON_FPS, ICON_WEB, ICON_VALUE_SOURCE, ICON_CLOCK, ICON_AUDIO_LOOPBACK, ICON_AUDIO_INPUT,
ICON_AUDIO_TEMPLATE, ICON_MONITOR, ICON_WRENCH,
ICON_CAPTURE_TEMPLATE, ICON_PP_TEMPLATE, ICON_CSPT, ICON_HELP, ICON_TRASH, ICON_PALETTE, ICON_ASSET,
getAssetTypeIcon,
} from '../core/icons.ts';
const _icon = (d: string) => `<svg class="icon" viewBox="0 0 24 24">${d}</svg>`;
import { wrapCard } from '../core/card-colors.ts';
import type { ModCardOpts, ModChipOpts, ModMetricOpts, LedState } from '../core/mod-card.ts';
import { TagInput, renderTagChips } from '../core/tag-input.ts';
import { IconSelect } from '../core/icon-select.ts';
import { EntitySelect } from '../core/entity-palette.ts';
import { FilterListManager } from '../core/filter-list.ts';
import { makeCardIconFields } from '../core/card-icon.ts';
import { registerIconEntityType, makeSimpleIconAdapter } from './icon-picker.ts';
// ── Icon-picker adapter registrations for streams-tab card types ──
const _reloadStreams = async () => {
if (typeof window.loadPictureSources === 'function') {
await window.loadPictureSources();
}
};
registerIconEntityType('picture_source', makeSimpleIconAdapter<any>({
cache: streamsCache,
endpointPrefix: '/picture-sources',
reload: _reloadStreams,
typeLabelKey: 'device.icon.entity.picture_source',
typeLabelFallback: 'Picture source',
cardSelectors: (id) => [`[data-stream-id="${CSS.escape(id)}"]`],
bodyExtras: (rec) => ({ stream_type: (rec as any)?.stream_type ?? 'raw' }),
}));
registerIconEntityType('capture_template', makeSimpleIconAdapter<any>({
cache: captureTemplatesCache,
endpointPrefix: '/capture-templates',
reload: _reloadStreams,
typeLabelKey: 'device.icon.entity.capture_template',
typeLabelFallback: 'Capture template',
cardSelectors: (id) => [`[data-card-section="raw-templates"] [data-template-id="${CSS.escape(id)}"]`],
}));
registerIconEntityType('pp_template', makeSimpleIconAdapter<any>({
cache: ppTemplatesCache,
endpointPrefix: '/postprocessing-templates',
reload: _reloadStreams,
typeLabelKey: 'device.icon.entity.pp_template',
typeLabelFallback: 'Post-processing template',
cardSelectors: (id) => [`[data-pp-template-id="${CSS.escape(id)}"]`],
}));
registerIconEntityType('cspt', makeSimpleIconAdapter<any>({
cache: csptCache,
endpointPrefix: '/color-strip-processing-templates',
reload: _reloadStreams,
typeLabelKey: 'device.icon.entity.cspt',
typeLabelFallback: 'Color-strip processing template',
cardSelectors: (id) => [`[data-cspt-id="${CSS.escape(id)}"]`],
}));
registerIconEntityType('audio_source', makeSimpleIconAdapter<any>({
cache: audioSourcesCache,
endpointPrefix: '/audio-sources',
reload: _reloadStreams,
typeLabelKey: 'device.icon.entity.audio_source',
typeLabelFallback: 'Audio source',
cardSelectors: (id) => [`[data-card-section="audio-sources"] [data-id="${CSS.escape(id)}"]`],
bodyExtras: (rec) => ({ source_type: (rec as any)?.source_type ?? 'capture' }),
}));
registerIconEntityType('audio_template', makeSimpleIconAdapter<any>({
cache: audioTemplatesCache,
endpointPrefix: '/audio-templates',
reload: _reloadStreams,
typeLabelKey: 'device.icon.entity.audio_template',
typeLabelFallback: 'Audio template',
cardSelectors: (id) => [`[data-audio-template-id="${CSS.escape(id)}"]`],
}));
registerIconEntityType('gradient', makeSimpleIconAdapter<any>({
cache: gradientsCache,
endpointPrefix: '/gradients',
reload: _reloadStreams,
typeLabelKey: 'device.icon.entity.gradient',
typeLabelFallback: 'Palette',
cardSelectors: (id) => [`[data-card-section="gradients"] [data-id="${CSS.escape(id)}"]`],
}));
// ── TagInput instances for modals ──
let _streamTagsInput: TagInput | null = null;
let _ppTemplateTagsInput: TagInput | null = null;
let _csptTagsInput: TagInput | null = null;
// ── Bulk action handlers ──
function _bulkDeleteFactory(endpoint: string, cache: any, toast: string) {
return async (ids: string[]) => {
const results = await Promise.allSettled(ids.map(id =>
fetchWithAuth(`/${endpoint}/${id}`, { method: 'DELETE' })
));
const failed = results.filter(r => r.status === 'rejected' || ((r as any).value && !(r as any).value.ok)).length;
if (failed) showToast(`${ids.length - failed}/${ids.length} deleted`, 'warning');
else showToast(t(toast), 'success');
cache.invalidate();
await loadPictureSources();
};
}
const _streamDeleteAction = [{ key: 'delete', labelKey: 'bulk.delete', icon: ICON_TRASH, style: 'danger', confirm: 'bulk.confirm_delete', handler: _bulkDeleteFactory('picture-sources', streamsCache, 'streams.deleted') }];
const _captureTemplateDeleteAction = [{ key: 'delete', labelKey: 'bulk.delete', icon: ICON_TRASH, style: 'danger', confirm: 'bulk.confirm_delete', handler: _bulkDeleteFactory('capture-templates', captureTemplatesCache, 'templates.deleted') }];
const _ppTemplateDeleteAction = [{ key: 'delete', labelKey: 'bulk.delete', icon: ICON_TRASH, style: 'danger', confirm: 'bulk.confirm_delete', handler: _bulkDeleteFactory('postprocessing-templates', ppTemplatesCache, 'templates.deleted') }];
const _audioSourceDeleteAction = [{ key: 'delete', labelKey: 'bulk.delete', icon: ICON_TRASH, style: 'danger', confirm: 'bulk.confirm_delete', handler: _bulkDeleteFactory('audio-sources', audioSourcesCache, 'audio_source.deleted') }];
const _audioTemplateDeleteAction = [{ key: 'delete', labelKey: 'bulk.delete', icon: ICON_TRASH, style: 'danger', confirm: 'bulk.confirm_delete', handler: _bulkDeleteFactory('audio-templates', audioTemplatesCache, 'templates.deleted') }];
const _colorStripDeleteAction = [{ key: 'delete', labelKey: 'bulk.delete', icon: ICON_TRASH, style: 'danger', confirm: 'bulk.confirm_delete', handler: _bulkDeleteFactory('color-strip-sources', colorStripSourcesCache, 'color_strip.deleted') }];
const _valueSourceDeleteAction = [{ key: 'delete', labelKey: 'bulk.delete', icon: ICON_TRASH, style: 'danger', confirm: 'bulk.confirm_delete', handler: _bulkDeleteFactory('value-sources', valueSourcesCache, 'value_source.deleted') }];
const _syncClockDeleteAction = [{ key: 'delete', labelKey: 'bulk.delete', icon: ICON_TRASH, style: 'danger', confirm: 'bulk.confirm_delete', handler: _bulkDeleteFactory('sync-clocks', syncClocksCache, 'sync_clock.deleted') }];
const _assetDeleteAction = [{ key: 'delete', labelKey: 'bulk.delete', icon: ICON_TRASH, style: 'danger', confirm: 'bulk.confirm_delete', handler: _bulkDeleteFactory('assets', assetsCache, 'asset.deleted') }];
const _csptDeleteAction = [{ key: 'delete', labelKey: 'bulk.delete', icon: ICON_TRASH, style: 'danger', confirm: 'bulk.confirm_delete', handler: _bulkDeleteFactory('color-strip-processing-templates', csptCache, 'templates.deleted') }];
const _aptDeleteAction = [{ key: 'delete', labelKey: 'bulk.delete', icon: ICON_TRASH, style: 'danger', confirm: 'bulk.confirm_delete', handler: _bulkDeleteFactory('audio-processing-templates', audioProcessingTemplatesCache, 'audio_processing.deleted') }];
/** Resolve an asset ID to its display name. */
function _getAssetName(assetId?: string | null): string {
if (!assetId) return '—';
const asset = _cachedAssets.find((a: any) => a.id === assetId);
return asset ? asset.name : assetId;
}
/** Get EntitySelect items for a given asset type (image/video). */
function _getAssetItems(assetType: string) {
return _cachedAssets
.filter((a: any) => a.asset_type === assetType)
.map((a: any) => ({ value: a.id, label: a.name, icon: getAssetTypeIcon(assetType), desc: a.filename }));
}
let _imageAssetEntitySelect: EntitySelect | null = null;
let _videoAssetEntitySelect: EntitySelect | null = null;
function _ensureImageAssetEntitySelect() {
const sel = document.getElementById('stream-image-asset') as HTMLSelectElement | null;
if (!sel) return;
if (_imageAssetEntitySelect) _imageAssetEntitySelect.destroy();
_imageAssetEntitySelect = null;
const items = _getAssetItems('image');
sel.innerHTML = `<option value="">${t('streams.image_asset.select')}</option>` +
items.map(a => `<option value="${a.value}">${escapeHtml(a.label)}</option>`).join('');
_imageAssetEntitySelect = new EntitySelect({
target: sel,
getItems: () => _getAssetItems('image'),
placeholder: t('streams.image_asset.search') || 'Search image assets…',
});
}
function _ensureVideoAssetEntitySelect() {
const sel = document.getElementById('stream-video-asset') as HTMLSelectElement | null;
if (!sel) return;
if (_videoAssetEntitySelect) _videoAssetEntitySelect.destroy();
_videoAssetEntitySelect = null;
const items = _getAssetItems('video');
sel.innerHTML = `<option value="">${t('streams.video_asset.select')}</option>` +
items.map(a => `<option value="${a.value}">${escapeHtml(a.label)}</option>`).join('');
_videoAssetEntitySelect = new EntitySelect({
target: sel,
getItems: () => _getAssetItems('video'),
placeholder: t('streams.video_asset.search') || 'Search video assets…',
});
}
function _destroyAssetEntitySelects() {
if (_imageAssetEntitySelect) { _imageAssetEntitySelect.destroy(); _imageAssetEntitySelect = null; }
if (_videoAssetEntitySelect) { _videoAssetEntitySelect.destroy(); _videoAssetEntitySelect = null; }
}
// ── Card section instances ──
const csRawStreams = new CardSection('raw-streams', { titleKey: 'streams.section.streams', gridClass: 'templates-grid', addCardOnclick: "showAddStreamModal('raw')", keyAttr: 'data-stream-id', emptyKey: 'section.empty.picture_sources', bulkActions: _streamDeleteAction });
const csRawTemplates = new CardSection('raw-templates', { titleKey: 'templates.title', gridClass: 'templates-grid', addCardOnclick: "showAddTemplateModal()", keyAttr: 'data-template-id', emptyKey: 'section.empty.capture_templates', bulkActions: _captureTemplateDeleteAction });
const csProcStreams = new CardSection('proc-streams', { titleKey: 'streams.section.streams', gridClass: 'templates-grid', addCardOnclick: "showAddStreamModal('processed')", keyAttr: 'data-stream-id', emptyKey: 'section.empty.picture_sources', bulkActions: _streamDeleteAction });
const csProcTemplates = new CardSection('proc-templates', { titleKey: 'postprocessing.title', gridClass: 'templates-grid', addCardOnclick: "showAddPPTemplateModal()", keyAttr: 'data-pp-template-id', emptyKey: 'section.empty.pp_templates', bulkActions: _ppTemplateDeleteAction });
const csAudioCapture = new CardSection('audio-capture', { titleKey: 'audio_source.group.capture', gridClass: 'templates-grid', addCardOnclick: "showAudioSourceModal('capture')", keyAttr: 'data-id', emptyKey: 'section.empty.audio_sources', bulkActions: _audioSourceDeleteAction });
const csAudioProcessed = new CardSection('audio-processed', { titleKey: 'audio_source.group.processed', gridClass: 'templates-grid', addCardOnclick: "showAudioSourceModal('processed')", keyAttr: 'data-id', emptyKey: 'section.empty.audio_sources', bulkActions: _audioSourceDeleteAction });
const csStaticStreams = new CardSection('static-streams', { titleKey: 'streams.group.static_image', gridClass: 'templates-grid', addCardOnclick: "showAddStreamModal('static_image')", keyAttr: 'data-stream-id', emptyKey: 'section.empty.picture_sources', bulkActions: _streamDeleteAction });
const csVideoStreams = new CardSection('video-streams', { titleKey: 'streams.group.video', gridClass: 'templates-grid', addCardOnclick: "showAddStreamModal('video')", keyAttr: 'data-stream-id', emptyKey: 'section.empty.picture_sources', bulkActions: _streamDeleteAction });
const csAudioTemplates = new CardSection('audio-templates', { titleKey: 'audio_template.title', gridClass: 'templates-grid', addCardOnclick: "showAddAudioTemplateModal()", keyAttr: 'data-audio-template-id', emptyKey: 'section.empty.audio_templates', bulkActions: _audioTemplateDeleteAction });
const csColorStrips = new CardSection('color-strips', { titleKey: 'targets.section.color_strips', gridClass: 'templates-grid', addCardOnclick: "showCSSEditor()", keyAttr: 'data-css-id', emptyKey: 'section.empty.color_strips', bulkActions: _colorStripDeleteAction });
const csValueSources = new CardSection('value-sources', { titleKey: 'value_source.group.title', gridClass: 'templates-grid', addCardOnclick: "showValueSourceModal()", keyAttr: 'data-id', emptyKey: 'section.empty.value_sources', bulkActions: _valueSourceDeleteAction });
const csSyncClocks = new CardSection('sync-clocks', { titleKey: 'sync_clock.group.title', gridClass: 'templates-grid', addCardOnclick: "showSyncClockModal()", keyAttr: 'data-id', emptyKey: 'section.empty.sync_clocks', bulkActions: _syncClockDeleteAction });
const csAssets = new CardSection('assets', { titleKey: 'asset.group.title', gridClass: 'templates-grid', addCardOnclick: "showAssetUploadModal()", keyAttr: 'data-id', emptyKey: 'section.empty.assets', bulkActions: _assetDeleteAction });
const csCSPTemplates = new CardSection('css-proc-templates', { titleKey: 'css_processing.title', gridClass: 'templates-grid', addCardOnclick: "showAddCSPTModal()", keyAttr: 'data-cspt-id', emptyKey: 'section.empty.cspt', bulkActions: _csptDeleteAction });
const _gradientDeleteAction = [{ key: 'delete', labelKey: 'bulk.delete', icon: ICON_TRASH, style: 'danger', confirm: 'bulk.confirm_delete', handler: _bulkDeleteFactory('gradients', gradientsCache, 'gradient.deleted') }];
const csGradients = new CardSection('gradients', { titleKey: 'gradient.group.title', gridClass: 'templates-grid', addCardOnclick: "showGradientModal()", keyAttr: 'data-id', emptyKey: 'section.empty.gradients', bulkActions: _gradientDeleteAction });
const csAudioProcessingTemplates = new CardSection('audio-processing-templates', { titleKey: 'audio_processing.title', gridClass: 'templates-grid', addCardOnclick: "showAudioProcessingTemplateModal()", keyAttr: 'data-apt-id', emptyKey: 'section.empty.audio_processing_templates', bulkActions: _aptDeleteAction });
// Re-render picture sources when language changes
document.addEventListener('languageChanged', () => { if (apiKey) loadPictureSources(); });
// ===== Modal instances =====
class StreamEditorModal extends Modal {
constructor() { super('stream-modal'); }
snapshotValues() {
return {
name: (document.getElementById('stream-name') as HTMLInputElement).value,
description: (document.getElementById('stream-description') as HTMLInputElement).value,
type: (document.getElementById('stream-type') as HTMLSelectElement).value,
displayIndex: (document.getElementById('stream-display-index') as HTMLInputElement).value,
captureTemplate: (document.getElementById('stream-capture-template') as HTMLSelectElement).value,
targetFps: (document.getElementById('stream-target-fps') as HTMLInputElement).value,
source: (document.getElementById('stream-source') as HTMLSelectElement).value,
ppTemplate: (document.getElementById('stream-pp-template') as HTMLSelectElement).value,
imageAsset: (document.getElementById('stream-image-asset') as HTMLSelectElement).value,
tags: JSON.stringify(_streamTagsInput ? _streamTagsInput.getValue() : []),
};
}
onForceClose() {
if (_streamTagsInput) { _streamTagsInput.destroy(); _streamTagsInput = null; }
_destroyAssetEntitySelects();
(document.getElementById('stream-type') as HTMLSelectElement).disabled = false;
set_streamNameManuallyEdited(false);
}
}
class PPTemplateEditorModal extends Modal {
constructor() { super('pp-template-modal'); }
snapshotValues() {
return {
name: (document.getElementById('pp-template-name') as HTMLInputElement).value,
description: (document.getElementById('pp-template-description') as HTMLInputElement).value,
filters: JSON.stringify(_modalFilters.map(fi => ({ filter_id: fi.filter_id, options: fi.options }))),
tags: JSON.stringify(_ppTemplateTagsInput ? _ppTemplateTagsInput.getValue() : []),
};
}
onForceClose() {
if (_ppTemplateTagsInput) { _ppTemplateTagsInput.destroy(); _ppTemplateTagsInput = null; }
set_modalFilters([]);
set_ppTemplateNameManuallyEdited(false);
}
}
class CSPTEditorModal extends Modal {
constructor() { super('cspt-modal'); }
snapshotValues() {
return {
name: (document.getElementById('cspt-name') as HTMLInputElement).value,
description: (document.getElementById('cspt-description') as HTMLInputElement).value,
filters: JSON.stringify(_csptModalFilters.map(fi => ({ filter_id: fi.filter_id, options: fi.options }))),
tags: JSON.stringify(_csptTagsInput ? _csptTagsInput.getValue() : []),
};
}
onForceClose() {
if (_csptTagsInput) { _csptTagsInput.destroy(); _csptTagsInput = null; }
set_csptModalFilters([]);
set_csptNameManuallyEdited(false);
}
}
const streamModal = new StreamEditorModal();
const testStreamModal = new Modal('test-stream-modal');
const ppTemplateModal = new PPTemplateEditorModal();
const testPPTemplateModal = new Modal('test-pp-template-modal');
let _ppTestSourceEntitySelect: EntitySelect | null = null;
const csptModal = new CSPTEditorModal();
// ── Capture Templates (extracted to streams-capture-templates.ts) ──
import { showAddTemplateModal as _localShowAddTemplateModal, _runTestViaWS as _localRunTestViaWS } from './streams-capture-templates.ts';
export {
showAddTemplateModal, editTemplate, closeTemplateModal, saveTemplate, deleteTemplate,
showTestTemplateModal, closeTestTemplateModal, onEngineChange, runTemplateTest,
updateCaptureDuration, _runTestViaWS, openTestDisplayPicker,
} from './streams-capture-templates.ts';
// ── Audio Templates (extracted to streams-audio-templates.ts) ──
export {
showAddAudioTemplateModal, editAudioTemplate, closeAudioTemplateModal, saveAudioTemplate, deleteAudioTemplate,
cloneAudioTemplate, onAudioEngineChange,
showTestAudioTemplateModal, closeTestAudioTemplateModal, startAudioTemplateTest,
} from './streams-audio-templates.ts';
// ===== Picture Sources =====
export async function loadPictureSources() {
if (_sourcesLoading) return;
set_sourcesLoading(true);
if (!csRawStreams.isMounted()) setTabRefreshing('streams-list', true);
try {
const [streams] = await Promise.all([
streamsCache.fetch(),
ppTemplatesCache.fetch(),
captureTemplatesCache.fetch(),
audioSourcesCache.fetch(),
valueSourcesCache.fetch(),
syncClocksCache.fetch(),
assetsCache.fetch(),
audioTemplatesCache.fetch(),
colorStripSourcesCache.fetch(),
csptCache.fetch(),
gradientsCache.fetch(),
weatherSourcesCache.fetch(),
haSourcesCache.fetch(),
audioProcessingTemplatesCache.fetch(),
audioFilterDefsCache.data.length === 0 ? audioFilterDefsCache.fetch() : Promise.resolve(audioFilterDefsCache.data),
filtersCache.data.length === 0 ? filtersCache.fetch() : Promise.resolve(filtersCache.data),
]);
renderPictureSourcesList(streams);
// Kick off HA entity friendly-name prefetch in the background.
// Done after the first render so a slow/offline HA source can't
// block the Streams tab; cards re-render once names arrive.
const haSourceIds = _cachedValueSources
.filter(v => (v as any).source_type === 'ha_entity')
.map(v => (v as any).ha_source_id as string);
if (haSourceIds.length > 0) {
prefetchHAEntities(haSourceIds).then(() => {
// Reconcile only if the Streams tab is still mounted to avoid
// touching DOM after the user navigated away.
if (csValueSources.isMounted()) renderPictureSourcesList(streams);
});
}
} catch (error) {
if (error.isAuth) return;
console.error('Error loading picture sources:', error);
document.getElementById('streams-list')!.innerHTML = `
<div class="error-message">${t('streams.error.load')}: ${error.message}</div>
`;
} finally {
set_sourcesLoading(false);
setTabRefreshing('streams-list', false);
}
}
let _streamsTreeTriggered = false;
const _streamsTree = new TreeNav('streams-tree-nav', {
onSelect: (key) => {
_streamsTreeTriggered = true;
switchStreamTab(key);
_streamsTreeTriggered = false;
}
});
export function switchStreamTab(tabKey: string) {
document.querySelectorAll('.stream-tab-panel[id^="stream-tab-"]').forEach(panel =>
panel.classList.toggle('active', panel.id === `stream-tab-${tabKey}`)
);
setActiveSubTab('streams', tabKey);
updateSubTabHash('streams', tabKey);
// Update tree active state (unless the tree triggered this switch)
if (!_streamsTreeTriggered) {
_streamsTree.setActive(tabKey);
}
}
const _streamSectionMap = {
raw: [csRawStreams],
raw_templates: [csRawTemplates],
static_image: [csStaticStreams],
video: [csVideoStreams],
processed: [csProcStreams],
proc_templates: [csProcTemplates],
css_processing: [csCSPTemplates],
color_strip: [csColorStrips],
audio_capture: [csAudioCapture],
audio_processed: [csAudioProcessed],
audio_templates: [csAudioTemplates],
audio_processing: [csAudioProcessingTemplates],
value: [csValueSources],
sync: [csSyncClocks],
};
// ── Per-type chip + meta builders for picture-source cards. Replaces
// the legacy `.stream-card-props` blocks. Each builder returns the
// mod-card chip array plus a meta line for `.mod-meta`. ──
type StreamCardDetails = {
badgeText: string;
metaHtml: string;
chips: ModChipOpts[];
};
type StreamCardDetailsBuilder = (stream: any) => StreamCardDetails;
const PICTURE_SOURCE_CARD_DETAILS: Record<string, StreamCardDetailsBuilder> = {
raw: (stream) => {
const chips: ModChipOpts[] = [];
if (stream.capture_template_id) {
const capTmpl = _cachedCaptureTemplates.find(t => t.id === stream.capture_template_id);
if (capTmpl) {
chips.push({
icon: ICON_CAPTURE_TEMPLATE,
text: capTmpl.name,
title: t('streams.capture_template'),
onclick: `event.stopPropagation(); navigateToCard('streams','raw_templates','raw-templates','data-template-id','${stream.capture_template_id}')`,
});
}
}
const metaParts = [
`${t('streams.display')} ${stream.display_index ?? 0}`,
`${stream.target_fps ?? 30} fps`,
];
return {
badgeText: 'SCREEN · IN',
metaHtml: metaParts.map(escapeHtml).join(' · '),
chips,
};
},
processed: (stream) => {
const sourceStream = _cachedStreams.find(s => s.id === stream.source_stream_id);
const sourceName = sourceStream ? sourceStream.name : (stream.source_stream_id || '-');
const sourceSubTab = sourceStream ? (sourceStream.stream_type === 'static_image' ? 'static_image' : 'raw') : 'raw';
const sourceSection = sourceStream ? (sourceStream.stream_type === 'static_image' ? 'static-streams' : 'raw-streams') : 'raw-streams';
const chips: ModChipOpts[] = [];
chips.push({
icon: ICON_LINK_SOURCE,
text: sourceName,
title: t('streams.source'),
onclick: stream.source_stream_id
? `event.stopPropagation(); navigateToCard('streams','${sourceSubTab}','${sourceSection}','data-stream-id','${stream.source_stream_id}')`
: undefined,
});
if (stream.postprocessing_template_id) {
const ppTmpl = _cachedPPTemplates.find(p => p.id === stream.postprocessing_template_id);
if (ppTmpl) {
chips.push({
icon: ICON_PP_TEMPLATE,
text: ppTmpl.name,
title: t('streams.pp_template'),
onclick: `event.stopPropagation(); navigateToCard('streams','proc_templates','proc-templates','data-pp-template-id','${stream.postprocessing_template_id}')`,
});
}
}
return {
badgeText: 'PIC · OUT',
metaHtml: chips.length > 1 ? `${chips.length} ${escapeHtml(t('streams.pp_template') || 'filters')}` : escapeHtml(t('streams.source') || 'Source'),
chips,
};
},
static_image: (stream) => {
const assetName = _getAssetName(stream.image_asset_id);
return {
badgeText: 'IMG · IN',
metaHtml: escapeHtml(assetName),
chips: [{ icon: ICON_ASSET, text: assetName, title: assetName }],
};
},
video: (stream) => {
const assetName = _getAssetName(stream.video_asset_id);
const chips: ModChipOpts[] = [
{ icon: ICON_ASSET, text: assetName, title: assetName },
{ icon: ICON_FPS, text: `${stream.target_fps ?? 30} fps`, title: t('streams.target_fps') },
];
if (stream.loop !== false) chips.push({ text: '↻ loop' });
if (stream.playback_speed && stream.playback_speed !== 1.0) chips.push({ text: `${stream.playback_speed}× speed` });
return {
badgeText: 'VIDEO · IN',
metaHtml: escapeHtml(assetName),
chips,
};
},
};
function renderPictureSourcesList(streams: any) {
const container = document.getElementById('streams-list')!;
const activeTab = getActiveSubTab('streams')!;
const renderStreamCard = (stream: any) => {
const builder = PICTURE_SOURCE_CARD_DETAILS[stream.stream_type];
const details = builder ? builder(stream)
: { badgeText: 'PIC · IN', metaHtml: '', chips: [] };
const sectionKey = stream.stream_type === 'static_image' ? 'static-streams'
: stream.stream_type === 'video' ? 'video-streams'
: stream.stream_type === 'processed' ? 'proc-streams'
: 'raw-streams';
const mod: ModCardOpts = {
head: {
badge: { text: details.badgeText },
name: stream.name,
metaHtml: details.metaHtml,
leds: ['off'],
...makeCardIconFields('picture_source', stream.id, stream),
menu: {
duplicateOnclick: `cloneStream('${stream.id}')`,
hideOnclick: `toggleCardHidden('${sectionKey}','${stream.id}')`,
deleteOnclick: `deleteStream('${stream.id}')`,
},
},
body: {
desc: stream.description || undefined,
chips: details.chips.length ? details.chips : undefined,
},
foot: {
patchState: 'idle',
patchLabel: t('patch.source'),
iconActions: [
{ icon: ICON_TEST, onclick: `showTestStreamModal('${stream.id}')`, title: t('streams.test.title') },
{ icon: ICON_EDIT, onclick: `editStream('${stream.id}')`, title: t('common.edit') },
],
},
};
const cardHtml = wrapCard({ type: 'template-card', dataAttr: 'data-stream-id', id: stream.id, mod });
const tagsHtml = renderTagChips(stream.tags);
return tagsHtml ? cardHtml.replace(/<\/div>\s*$/, `${tagsHtml}</div>`) : cardHtml;
};
const renderCaptureTemplateCard = (template: any) => {
const configEntries = Object.entries(template.engine_config || {});
const chips: ModChipOpts[] = [
{ icon: getEngineIcon(template.engine_type), text: String(template.engine_type).toUpperCase(), title: t('templates.engine') },
];
if (configEntries.length > 0) {
chips.push({ icon: ICON_WRENCH, text: `${configEntries.length} ${escapeHtml(t('templates.config.show') || 'config')}`, title: t('templates.config.show') });
}
const configBlock = configEntries.length > 0 ? `
<div class="template-config-collapse">
<button type="button" class="template-config-toggle" onclick="this.parentElement.classList.toggle('open')">${t('templates.config.show')}</button>
<div class="template-config-animate">
<div class="template-config-inner">
<table class="config-table">
${configEntries.map(([key, val]) => `
<tr>
<td class="config-key">${escapeHtml(key)}</td>
<td class="config-value">${escapeHtml(String(val))}</td>
</tr>
`).join('')}
</table>
</div>
</div>
</div>` : '';
const mod: ModCardOpts = {
head: {
badge: { text: 'TPL · CAPTURE' },
name: template.name,
metaHtml: escapeHtml(`${String(template.engine_type).toUpperCase()} · ${configEntries.length} keys`),
leds: ['off'],
...makeCardIconFields('capture_template', template.id, template),
menu: {
duplicateOnclick: `cloneCaptureTemplate('${template.id}')`,
hideOnclick: `toggleCardHidden('raw-templates','${template.id}')`,
deleteOnclick: `deleteTemplate('${template.id}')`,
},
},
body: {
desc: template.description || undefined,
chips,
extraHtml: configBlock || undefined,
},
foot: {
patchState: 'idle',
patchLabel: t('patch.template'),
iconActions: [
{ icon: ICON_TEST, onclick: `showTestTemplateModal('${template.id}')`, title: t('templates.test.title') },
{ icon: ICON_EDIT, onclick: `editTemplate('${template.id}')`, title: t('common.edit') },
],
},
};
const cardHtml = wrapCard({ type: 'template-card', dataAttr: 'data-template-id', id: template.id, mod });
const tagsHtml = renderTagChips(template.tags);
return tagsHtml ? cardHtml.replace(/<\/div>\s*$/, `${tagsHtml}</div>`) : cardHtml;
};
const renderPPTemplateCard = (tmpl: any) => {
const filters = tmpl.filters || [];
const chainExtra = filters.length > 0 ? `<div class="filter-chain">${
filters.map((fi: any, idx: number) => {
let label = _getFilterName(fi.filter_id);
if (fi.filter_id === 'filter_template' && fi.options?.template_id) {
const ref = _cachedPPTemplates.find(p => p.id === fi.options.template_id);
if (ref) label += `: ${ref.name}`;
}
const arrow = idx < filters.length - 1 ? '<span class="filter-chain-arrow">→</span>' : '';
return `<span class="filter-chain-item">${escapeHtml(label)}</span>${arrow}`;
}).join('')
}</div>` : '';
const mod: ModCardOpts = {
head: {
badge: { text: 'TPL · FILTER' },
name: tmpl.name,
metaHtml: escapeHtml(`${filters.length} ${t('postprocessing.title') || 'filters'}`),
leds: ['off'],
...makeCardIconFields('pp_template', tmpl.id, tmpl),
menu: {
duplicateOnclick: `clonePPTemplate('${tmpl.id}')`,
hideOnclick: `toggleCardHidden('proc-templates','${tmpl.id}')`,
deleteOnclick: `deletePPTemplate('${tmpl.id}')`,
},
},
body: {
desc: tmpl.description || undefined,
extraHtml: chainExtra || undefined,
},
foot: {
patchState: 'idle',
patchLabel: t('patch.pipeline'),
iconActions: [
{ icon: ICON_TEST, onclick: `showTestPPTemplateModal('${tmpl.id}')`, title: t('postprocessing.test.title') },
{ icon: ICON_EDIT, onclick: `editPPTemplate('${tmpl.id}')`, title: t('common.edit') },
],
},
};
const cardHtml = wrapCard({ type: 'template-card', dataAttr: 'data-pp-template-id', id: tmpl.id, mod });
const tagsHtml = renderTagChips(tmpl.tags);
return tagsHtml ? cardHtml.replace(/<\/div>\s*$/, `${tagsHtml}</div>`) : cardHtml;
};
const renderCSPTCard = (tmpl: any) => {
const filters = tmpl.filters || [];
const chainExtra = filters.length > 0 ? `<div class="filter-chain">${
filters.map((fi: any, idx: number) => {
let label = _getStripFilterName(fi.filter_id);
if (fi.filter_id === 'css_filter_template' && fi.options?.template_id) {
const ref = _cachedCSPTemplates.find(p => p.id === fi.options.template_id);
if (ref) label += `: ${ref.name}`;
}
const arrow = idx < filters.length - 1 ? '<span class="filter-chain-arrow">\u2192</span>' : '';
return `<span class="filter-chain-item">${escapeHtml(label)}</span>${arrow}`;
}).join('')
}</div>` : '';
const mod: ModCardOpts = {
head: {
badge: { text: 'TPL \u00b7 STRIP' },
name: tmpl.name,
metaHtml: escapeHtml(`${filters.length} ${t('css_processing.title') || 'strip filters'}`),
leds: ['off'],
...makeCardIconFields('cspt', tmpl.id, tmpl),
menu: {
duplicateOnclick: `cloneCSPT('${tmpl.id}')`,
hideOnclick: `toggleCardHidden('css-proc-templates','${tmpl.id}')`,
deleteOnclick: `deleteCSPT('${tmpl.id}')`,
},
},
body: {
desc: tmpl.description || undefined,
extraHtml: chainExtra || undefined,
},
foot: {
patchState: 'idle',
patchLabel: t('patch.pipeline'),
iconActions: [
{ icon: ICON_TEST, onclick: `event.stopPropagation(); testCSPT('${tmpl.id}')`, title: t('color_strip.test.title') },
{ icon: ICON_EDIT, onclick: `editCSPT('${tmpl.id}')`, title: t('common.edit') },
],
},
};
const cardHtml = wrapCard({ type: 'template-card', dataAttr: 'data-cspt-id', id: tmpl.id, mod });
const tagsHtml = renderTagChips(tmpl.tags);
return tagsHtml ? cardHtml.replace(/<\/div>\s*$/, `${tagsHtml}</div>`) : cardHtml;
};
const rawStreams = streams.filter(s => s.stream_type === 'raw');
const processedStreams = streams.filter(s => s.stream_type === 'processed');
const staticImageStreams = streams.filter(s => s.stream_type === 'static_image');
const videoStreams = streams.filter(s => s.stream_type === 'video');
const captureSources = _cachedAudioSources.filter(s => s.source_type === 'capture');
const processedAudioSources = _cachedAudioSources.filter(s => s.source_type === 'processed');
// CSPT templates
const csptTemplates = csptCache.data;
// Color strip sources (maps needed for card rendering)
const colorStrips = colorStripSourcesCache.data;
const pictureSourceMap = {};
streams.forEach(s => { pictureSourceMap[s.id] = s; });
const audioSourceMap = {};
_cachedAudioSources.forEach(s => { audioSourceMap[s.id] = s; });
const gradients = gradientsCache.data;
const audioProcessingTemplates = audioProcessingTemplatesCache.data;
const tabs = [
{ key: 'raw', icon: getPictureSourceIcon('raw'), titleKey: 'streams.group.raw', count: rawStreams.length },
{ key: 'raw_templates', icon: ICON_CAPTURE_TEMPLATE, titleKey: 'streams.group.raw_templates', count: _cachedCaptureTemplates.length },
{ key: 'static_image', icon: getPictureSourceIcon('static_image'), titleKey: 'streams.group.static_image', count: staticImageStreams.length },
{ key: 'video', icon: getPictureSourceIcon('video'), titleKey: 'streams.group.video', count: videoStreams.length },
{ key: 'processed', icon: getPictureSourceIcon('processed'), titleKey: 'streams.group.processed', count: processedStreams.length },
{ key: 'proc_templates', icon: ICON_PP_TEMPLATE, titleKey: 'streams.group.proc_templates', count: _cachedPPTemplates.length },
{ key: 'css_processing', icon: ICON_CSPT, titleKey: 'streams.group.css_processing', count: csptTemplates.length },
{ key: 'color_strip', icon: getColorStripIcon('single_color'), titleKey: 'streams.group.color_strip', count: colorStrips.length },
{ key: 'gradients', icon: ICON_PALETTE, titleKey: 'streams.group.gradients', count: gradients.length },
{ key: 'audio_capture', icon: getAudioSourceIcon('capture'), titleKey: 'audio_source.group.capture', count: captureSources.length },
{ key: 'audio_processed', icon: getAudioSourceIcon('processed'), titleKey: 'audio_source.group.processed', count: processedAudioSources.length },
{ key: 'audio_templates', icon: ICON_AUDIO_TEMPLATE, titleKey: 'streams.group.audio_templates', count: _cachedAudioTemplates.length },
{ key: 'audio_processing', icon: ICON_AUDIO_TEMPLATE, titleKey: 'streams.group.audio_processing', count: audioProcessingTemplates.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 },
{ key: 'assets', icon: ICON_ASSET, titleKey: 'streams.group.assets', count: _cachedAssets.length },
];
// Build tree navigation structure
const treeGroups = [
{
key: 'picture_group', icon: getPictureSourceIcon('raw'), titleKey: 'tree.group.picture',
children: [
{
key: 'capture_group', icon: getPictureSourceIcon('raw'), titleKey: 'tree.group.capture',
children: [
{ key: 'raw', titleKey: 'tree.leaf.sources', icon: getPictureSourceIcon('raw'), count: rawStreams.length },
{ key: 'raw_templates', titleKey: 'tree.leaf.engine_templates', icon: ICON_CAPTURE_TEMPLATE, count: _cachedCaptureTemplates.length },
]
},
{
key: 'static_group', icon: getPictureSourceIcon('static_image'), titleKey: 'tree.group.static',
children: [
{ key: 'static_image', titleKey: 'tree.leaf.images', icon: getPictureSourceIcon('static_image'), count: staticImageStreams.length },
{ key: 'video', titleKey: 'tree.leaf.video', icon: getPictureSourceIcon('video'), count: videoStreams.length },
]
},
{
key: 'processing_group', icon: getPictureSourceIcon('processed'), titleKey: 'tree.group.processing',
children: [
{ key: 'processed', titleKey: 'tree.leaf.sources', icon: getPictureSourceIcon('processed'), count: processedStreams.length },
{ key: 'proc_templates', titleKey: 'tree.leaf.filter_templates', icon: ICON_PP_TEMPLATE, count: _cachedPPTemplates.length },
]
},
]
},
{
key: 'strip_group', icon: getColorStripIcon('single_color'), titleKey: 'tree.group.strip',
children: [
{ key: 'color_strip', titleKey: 'tree.leaf.sources', icon: getColorStripIcon('single_color'), count: colorStrips.length },
{ key: 'gradients', titleKey: 'streams.group.gradients', icon: ICON_PALETTE, count: gradients.length },
{ key: 'css_processing', titleKey: 'tree.leaf.processing_templates', icon: ICON_CSPT, count: csptTemplates.length },
]
},
{
key: 'audio_group', icon: getAudioSourceIcon('capture'), titleKey: 'tree.group.audio',
children: [
{
key: 'audio_capture_group', icon: getAudioSourceIcon('capture'), titleKey: 'tree.group.audio_capture',
children: [
{ key: 'audio_capture', titleKey: 'tree.leaf.sources', icon: getAudioSourceIcon('capture'), count: captureSources.length },
{ key: 'audio_templates', titleKey: 'tree.leaf.engine_templates', icon: ICON_AUDIO_TEMPLATE, count: _cachedAudioTemplates.length },
]
},
{
key: 'audio_processed_group', icon: getAudioSourceIcon('processed'), titleKey: 'tree.group.audio_processed',
children: [
{ key: 'audio_processed', titleKey: 'tree.leaf.sources', icon: getAudioSourceIcon('processed'), count: processedAudioSources.length },
{ key: 'audio_processing', titleKey: 'tree.leaf.filter_templates', icon: ICON_AUDIO_TEMPLATE, count: audioProcessingTemplates.length },
]
},
]
},
{
key: 'utility_group', icon: ICON_WRENCH, titleKey: 'tree.group.utility',
children: [
{ key: 'value', titleKey: 'streams.group.value', icon: ICON_VALUE_SOURCE, count: _cachedValueSources.length },
{ key: 'sync', titleKey: 'streams.group.sync', icon: ICON_CLOCK, count: _cachedSyncClocks.length },
{ key: 'assets', titleKey: 'streams.group.assets', icon: ICON_ASSET, count: _cachedAssets.length },
]
}
];
const _getSectionForSource = (sourceType: string): string => {
if (sourceType === 'capture') return 'audio-capture';
return 'audio-processed';
};
const _getTabForSource = (sourceType: string): string => {
if (sourceType === 'capture') return 'audio_capture';
return 'audio_processed';
};
const renderAudioSourceCard = (src: any) => {
const chips: ModChipOpts[] = [];
let badgeText: string;
let metaText: string;
if (src.source_type === 'processed') {
badgeText = 'AUDIO · FX';
const parent = _cachedAudioSources.find(s => s.id === src.audio_source_id);
const parentName = parent ? parent.name : (src.audio_source_id || '—');
const parentSection = parent ? _getSectionForSource(parent.source_type) : 'audio-capture';
const parentTab = parent ? _getTabForSource(parent.source_type) : 'audio_capture';
chips.push({
icon: parent ? getAudioSourceIcon(parent.source_type) : ICON_AUDIO_LOOPBACK,
text: parentName,
title: t('audio_source.parent'),
onclick: parent
? `event.stopPropagation(); navigateToCard('streams','${parentTab}','${parentSection}','data-id','${src.audio_source_id}')`
: undefined,
});
if (src.audio_processing_template_id) {
const aptTmpl = _cachedAudioProcessingTemplates.find(tt => tt.id === src.audio_processing_template_id);
const aptName = aptTmpl ? aptTmpl.name : src.audio_processing_template_id;
chips.push({
icon: ICON_AUDIO_TEMPLATE,
text: aptName,
title: t('audio_processing.title'),
onclick: aptTmpl
? `event.stopPropagation(); navigateToCard('streams','audio_processing','audio-processing-templates','data-apt-id','${src.audio_processing_template_id}')`
: undefined,
});
}
metaText = parent ? `via ${parentName}` : 'orphan source';
} else {
const loopback = src.is_loopback !== false;
const devIdx = src.device_index ?? -1;
badgeText = loopback ? 'LOOP · IN' : 'MIC · IN';
const devLabel = loopback ? 'Loopback' : 'Input';
metaText = `${devLabel} #${devIdx}`;
const tpl = src.audio_template_id ? _cachedAudioTemplates.find(tt => tt.id === src.audio_template_id) : null;
if (tpl) {
chips.push({
icon: ICON_AUDIO_TEMPLATE,
text: tpl.name,
title: t('audio_source.audio_template'),
onclick: `event.stopPropagation(); navigateToCard('streams','audio_templates','audio-templates','data-audio-template-id','${src.audio_template_id}')`,
});
}
}
const sectionKey = src.source_type === 'processed' ? 'audio-processed' : 'audio-capture';
const mod: ModCardOpts = {
head: {
badge: { text: badgeText },
name: src.name,
metaHtml: escapeHtml(metaText),
leds: ['off'],
...makeCardIconFields('audio_source', src.id, src),
menu: {
duplicateOnclick: `cloneAudioSource('${src.id}')`,
hideOnclick: `toggleCardHidden('${sectionKey}','${src.id}')`,
deleteOnclick: `deleteAudioSource('${src.id}')`,
},
},
body: {
desc: src.description || undefined,
chips: chips.length ? chips : undefined,
},
foot: {
patchState: 'idle',
patchLabel: t('patch.source'),
iconActions: [
{ icon: ICON_TEST, onclick: '', title: t('audio_source.test'), dataAttrs: { 'data-action': 'test-audio' } },
{ icon: ICON_EDIT, onclick: '', title: t('common.edit'), dataAttrs: { 'data-action': 'edit-audio' } },
],
},
};
const cardHtml = wrapCard({ type: 'template-card', dataAttr: 'data-id', id: src.id, mod });
const tagsHtml = renderTagChips(src.tags);
return tagsHtml ? cardHtml.replace(/<\/div>\s*$/, `${tagsHtml}</div>`) : cardHtml;
};
const renderAudioTemplateCard = (template: any) => {
const configEntries = Object.entries(template.engine_config || {});
const chips: ModChipOpts[] = [
{ icon: getAudioEngineIcon(template.engine_type), text: String(template.engine_type).toUpperCase(), title: t('audio_template.engine') },
];
if (configEntries.length > 0) {
chips.push({ icon: ICON_WRENCH, text: `${configEntries.length} ${escapeHtml(t('audio_template.config.show') || 'config')}`, title: t('audio_template.config.show') });
}
const configBlock = configEntries.length > 0 ? `
<div class="template-config-collapse">
<button type="button" class="template-config-toggle" onclick="this.parentElement.classList.toggle('open')">${t('audio_template.config.show')}</button>
<div class="template-config-animate">
<div class="template-config-inner">
<table class="config-table">
${configEntries.map(([key, val]) => `
<tr>
<td class="config-key">${escapeHtml(key)}</td>
<td class="config-value">${escapeHtml(String(val))}</td>
</tr>
`).join('')}
</table>
</div>
</div>
</div>` : '';
const mod: ModCardOpts = {
head: {
badge: { text: 'TPL · AUDIO' },
name: template.name,
metaHtml: escapeHtml(`${String(template.engine_type).toUpperCase()} · ${configEntries.length} keys`),
leds: ['off'],
...makeCardIconFields('audio_template', template.id, template),
menu: {
duplicateOnclick: `cloneAudioTemplate('${template.id}')`,
hideOnclick: `toggleCardHidden('audio-templates','${template.id}')`,
deleteOnclick: `deleteAudioTemplate('${template.id}')`,
},
},
body: {
desc: template.description || undefined,
chips,
extraHtml: configBlock || undefined,
},
foot: {
patchState: 'idle',
patchLabel: t('patch.template'),
iconActions: [
{ icon: ICON_TEST, onclick: `showTestAudioTemplateModal('${template.id}')`, title: t('audio_template.test') },
{ icon: ICON_EDIT, onclick: `editAudioTemplate('${template.id}')`, title: t('common.edit') },
],
},
};
const cardHtml = wrapCard({ type: 'template-card', dataAttr: 'data-audio-template-id', id: template.id, mod });
const tagsHtml = renderTagChips(template.tags);
return tagsHtml ? cardHtml.replace(/<\/div>\s*$/, `${tagsHtml}</div>`) : cardHtml;
};
// Gradient card renderer
const renderGradientCard = (g: GradientEntity) => {
const cssStops = g.stops.map(s => `rgb(${s.color.join(',')}) ${(s.position * 100).toFixed(0)}%`).join(', ');
// The `.mod-preview` wrapper inside renderModBody doesn't accept
// inline style, so emit a sibling block via `extraHtml` so the
// gradient fills the full preview surface.
const previewBlock = `<div class="mod-preview mod-preview--strip" style="height:36px;background:linear-gradient(to right,${cssStops});">${
g.is_builtin ? `<span class="mod-preview__tag">${escapeHtml(t('gradient.builtin') || 'BUILTIN').toUpperCase()}</span>` : ''
}</div>`;
const iconActions: any[] = [
{ icon: ICON_CLONE, onclick: `cloneGradient('${g.id}')`, title: t('common.clone') },
];
if (!g.is_builtin) {
iconActions.push({ icon: ICON_EDIT, onclick: `editGradient('${g.id}')`, title: t('common.edit') });
}
const mod: ModCardOpts = {
head: {
badge: { text: 'PALETTE · GRD' },
name: g.name,
metaHtml: escapeHtml(`${g.stops.length} ${t('gradient.stops_label') || 'stops'}`),
leds: ['off'],
...(g.is_builtin ? {} : makeCardIconFields('gradient', g.id, g)),
menu: {
duplicateOnclick: `cloneGradient('${g.id}')`,
hideOnclick: `toggleCardHidden('gradients','${g.id}')`,
deleteOnclick: g.is_builtin ? undefined : `deleteGradient('${g.id}')`,
},
},
body: {
extraHtml: previewBlock,
},
foot: {
patchState: 'idle',
patchLabel: t('patch.preset'),
iconActions,
},
};
return wrapCard({ type: 'template-card', dataAttr: 'data-id', id: g.id, mod });
};
// Build item arrays for all sections
const rawStreamItems = csRawStreams.applySortOrder(rawStreams.map(s => ({ key: s.id, html: renderStreamCard(s) })));
const rawTemplateItems = csRawTemplates.applySortOrder(_cachedCaptureTemplates.map(t => ({ key: t.id, html: renderCaptureTemplateCard(t) })));
const procStreamItems = csProcStreams.applySortOrder(processedStreams.map(s => ({ key: s.id, html: renderStreamCard(s) })));
const procTemplateItems = csProcTemplates.applySortOrder(_cachedPPTemplates.map(t => ({ key: t.id, html: renderPPTemplateCard(t) })));
const captureItems = csAudioCapture.applySortOrder(captureSources.map(s => ({ key: s.id, html: renderAudioSourceCard(s) })));
const processedAudioItems = csAudioProcessed.applySortOrder(processedAudioSources.map(s => ({ key: s.id, html: renderAudioSourceCard(s) })));
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 videoItems = csVideoStreams.applySortOrder(videoStreams.map(s => ({ key: s.id, html: renderStreamCard(s) })));
const colorStripItems = csColorStrips.applySortOrder(colorStrips.map(s => ({ key: s.id, html: createColorStripCard(s, pictureSourceMap, audioSourceMap) })));
const gradientItems = csGradients.applySortOrder(gradients.map(g => ({ key: g.id, html: renderGradientCard(g) })));
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) })));
const assetItems = csAssets.applySortOrder(_cachedAssets.map(a => ({ key: a.id, html: createAssetCard(a) })));
const csptItems = csCSPTemplates.applySortOrder(csptTemplates.map(t => ({ key: t.id, html: renderCSPTCard(t) })));
const audioProcessingTemplateItems = csAudioProcessingTemplates.applySortOrder(audioProcessingTemplates.map(t => ({ key: t.id, html: createAudioProcessingTemplateCard(t) })));
if (csRawStreams.isMounted()) {
// Incremental update: reconcile cards in-place
_streamsTree.updateCounts({
raw: rawStreams.length,
raw_templates: _cachedCaptureTemplates.length,
static_image: staticImageStreams.length,
video: videoStreams.length,
processed: processedStreams.length,
proc_templates: _cachedPPTemplates.length,
css_processing: csptTemplates.length,
color_strip: colorStrips.length,
gradients: gradients.length,
audio: _cachedAudioSources.length,
audio_templates: _cachedAudioTemplates.length,
audio_processing: audioProcessingTemplates.length,
value: _cachedValueSources.length,
sync: _cachedSyncClocks.length,
assets: _cachedAssets.length,
});
csRawStreams.reconcile(rawStreamItems);
csRawTemplates.reconcile(rawTemplateItems);
csProcStreams.reconcile(procStreamItems);
csProcTemplates.reconcile(procTemplateItems);
csCSPTemplates.reconcile(csptItems);
csColorStrips.reconcile(colorStripItems);
csGradients.reconcile(gradientItems);
csAudioCapture.reconcile(captureItems);
csAudioProcessed.reconcile(processedAudioItems);
csAudioTemplates.reconcile(audioTemplateItems);
csAudioProcessingTemplates.reconcile(audioProcessingTemplateItems);
csStaticStreams.reconcile(staticItems);
csVideoStreams.reconcile(videoItems);
csValueSources.reconcile(valueItems);
csSyncClocks.reconcile(syncClockItems);
csAssets.reconcile(assetItems);
} else {
// First render: build full HTML
const panels = tabs.map(tab => {
let panelContent = '';
if (tab.key === 'raw') panelContent = csRawStreams.render(rawStreamItems);
else if (tab.key === 'raw_templates') panelContent = csRawTemplates.render(rawTemplateItems);
else if (tab.key === 'processed') panelContent = csProcStreams.render(procStreamItems);
else if (tab.key === 'proc_templates') panelContent = csProcTemplates.render(procTemplateItems);
else if (tab.key === 'css_processing') panelContent = csCSPTemplates.render(csptItems);
else if (tab.key === 'color_strip') panelContent = csColorStrips.render(colorStripItems);
else if (tab.key === 'gradients') panelContent = csGradients.render(gradientItems);
else if (tab.key === 'audio_capture') panelContent = csAudioCapture.render(captureItems);
else if (tab.key === 'audio_processed') panelContent = csAudioProcessed.render(processedAudioItems);
else if (tab.key === 'audio_templates') panelContent = csAudioTemplates.render(audioTemplateItems);
else if (tab.key === 'audio_processing') panelContent = csAudioProcessingTemplates.render(audioProcessingTemplateItems);
else if (tab.key === 'value') panelContent = csValueSources.render(valueItems);
else if (tab.key === 'sync') panelContent = csSyncClocks.render(syncClockItems);
else if (tab.key === 'assets') panelContent = csAssets.render(assetItems);
else if (tab.key === 'video') panelContent = csVideoStreams.render(videoItems);
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 = panels;
CardSection.bindAll([csRawStreams, csRawTemplates, csProcStreams, csProcTemplates, csCSPTemplates, csColorStrips, csGradients, csAudioCapture, csAudioProcessed, csAudioTemplates, csAudioProcessingTemplates, csStaticStreams, csVideoStreams, csValueSources, csSyncClocks, csAssets]);
// Event delegation for card actions (replaces inline onclick handlers)
initSyncClockDelegation(container);
initAudioSourceDelegation(container);
initAssetDelegation(container);
// Render tree sidebar with expand/collapse buttons
_streamsTree.setExtraHtml(`<button class="tutorial-trigger-btn" onclick="startSourcesTutorial()" data-i18n-title="tour.restart" title="${t('tour.restart')}">${ICON_HELP}</button>`);
_streamsTree.update(treeGroups, activeTab);
_streamsTree.observeSections('streams-list', {
'raw-streams': 'raw', 'raw-templates': 'raw_templates',
'static-streams': 'static_image',
'video-streams': 'video',
'proc-streams': 'processed', 'proc-templates': 'proc_templates',
'css-proc-templates': 'css_processing',
'color-strips': 'color_strip',
'gradients': 'gradients',
'audio-capture': 'audio_capture', 'audio-processed': 'audio_processed',
'audio-templates': 'audio_templates',
'audio-processing-templates': 'audio_processing',
'value-sources': 'value',
'sync-clocks': 'sync',
'assets': 'assets',
});
}
}
export function onStreamTypeChange() {
const streamType = (document.getElementById('stream-type') as HTMLSelectElement).value;
document.getElementById('stream-raw-fields')!.style.display = streamType === 'raw' ? '' : 'none';
document.getElementById('stream-processed-fields')!.style.display = streamType === 'processed' ? '' : 'none';
document.getElementById('stream-static-image-fields')!.style.display = streamType === 'static_image' ? '' : 'none';
document.getElementById('stream-video-fields')!.style.display = streamType === 'video' ? '' : 'none';
}
export function onStreamDisplaySelected(displayIndex: any, display: any) {
(document.getElementById('stream-display-index') as HTMLInputElement).value = displayIndex;
const engineType = (document.getElementById('stream-capture-template') as HTMLSelectElement).selectedOptions[0]?.dataset?.engineType || null;
document.getElementById('stream-display-picker-label')!.textContent = formatDisplayLabel(displayIndex, display, engineType);
_autoGenerateStreamName();
}
export function onTestDisplaySelected(displayIndex: any, display: any) {
(document.getElementById('test-template-display') as HTMLInputElement).value = displayIndex;
const engineType = currentTestingTemplate?.engine_type || null;
document.getElementById('test-display-picker-label')!.textContent = formatDisplayLabel(displayIndex, display, engineType);
}
function _autoGenerateStreamName() {
if (_streamNameManuallyEdited) return;
if ((document.getElementById('stream-id') as HTMLInputElement).value) return;
const streamType = (document.getElementById('stream-type') as HTMLSelectElement).value;
const nameInput = document.getElementById('stream-name') as HTMLInputElement;
if (streamType === 'raw') {
const displayIndex = (document.getElementById('stream-display-index') as HTMLInputElement).value;
const templateSelect = document.getElementById('stream-capture-template') as HTMLSelectElement;
const templateName = templateSelect.selectedOptions[0]?.dataset?.name || '';
if (displayIndex === '' || !templateName) return;
nameInput.value = `D${displayIndex}_${templateName}`;
} else if (streamType === 'processed') {
const sourceSelect = document.getElementById('stream-source') as HTMLSelectElement;
const sourceName = sourceSelect.selectedOptions[0]?.dataset?.name || '';
const ppTemplateId = (document.getElementById('stream-pp-template') as HTMLSelectElement).value;
const ppTemplate = _streamModalPPTemplates.find((t: any) => t.id === ppTemplateId);
if (!sourceName) return;
if (ppTemplate && ppTemplate.name) {
nameInput.value = `${sourceName} (${ppTemplate.name})`;
} else {
nameInput.value = sourceName;
}
}
}
export async function showAddStreamModal(presetType: any, cloneData: any = null) {
const streamType = (cloneData && cloneData.stream_type) || presetType || 'raw';
const titleKeys: any = { raw: 'streams.add.raw', processed: 'streams.add.processed', static_image: 'streams.add.static_image', video: 'streams.add.video' };
document.getElementById('stream-modal-title')!.innerHTML = `${getPictureSourceIcon(streamType)} ${t(titleKeys[streamType] || 'streams.add')}`;
(document.getElementById('stream-form') as HTMLFormElement).reset();
(document.getElementById('stream-id') as HTMLInputElement).value = '';
(document.getElementById('stream-display-index') as HTMLInputElement).value = '';
document.getElementById('stream-display-picker-label')!.textContent = t('displays.picker.select');
document.getElementById('stream-error')!.style.display = 'none';
(document.getElementById('stream-type') as HTMLSelectElement).value = streamType;
_ensureImageAssetEntitySelect();
_ensureVideoAssetEntitySelect();
onStreamTypeChange();
set_streamNameManuallyEdited(!!cloneData);
(document.getElementById('stream-name') as HTMLInputElement).oninput = () => { set_streamNameManuallyEdited(true); };
(document.getElementById('stream-capture-template') as HTMLSelectElement).onchange = () => _autoGenerateStreamName();
(document.getElementById('stream-source') as HTMLSelectElement).onchange = () => _autoGenerateStreamName();
(document.getElementById('stream-pp-template') as HTMLSelectElement).onchange = () => _autoGenerateStreamName();
// Open modal instantly with loading indicator
_showStreamModalLoading(true);
streamModal.open();
await populateStreamModalDropdowns();
// Pre-fill from clone data after dropdowns are populated
if (cloneData) {
(document.getElementById('stream-name') as HTMLInputElement).value = (cloneData.name || '') + ' (Copy)';
(document.getElementById('stream-description') as HTMLInputElement).value = cloneData.description || '';
if (streamType === 'raw') {
(document.getElementById('stream-capture-template') as HTMLSelectElement).value = cloneData.capture_template_id || '';
await _onCaptureTemplateChanged();
const displayIdx = cloneData.display_index ?? 0;
const display = _cachedDisplays ? _cachedDisplays.find((d: any) => d.index === displayIdx) : null;
onStreamDisplaySelected(displayIdx, display);
const fps = cloneData.target_fps ?? 30;
(document.getElementById('stream-target-fps') as HTMLInputElement).value = fps;
document.getElementById('stream-target-fps-value')!.textContent = fps;
} else if (streamType === 'processed') {
(document.getElementById('stream-source') as HTMLSelectElement).value = cloneData.source_stream_id || '';
(document.getElementById('stream-pp-template') as HTMLSelectElement).value = cloneData.postprocessing_template_id || '';
} else if (streamType === 'static_image') {
if (cloneData.image_asset_id) {
(document.getElementById('stream-image-asset') as HTMLSelectElement).value = cloneData.image_asset_id;
if (_imageAssetEntitySelect) _imageAssetEntitySelect.setValue(cloneData.image_asset_id);
}
} else if (streamType === 'video') {
if (cloneData.video_asset_id) {
(document.getElementById('stream-video-asset') as HTMLSelectElement).value = cloneData.video_asset_id;
if (_videoAssetEntitySelect) _videoAssetEntitySelect.setValue(cloneData.video_asset_id);
}
(document.getElementById('stream-video-loop') as HTMLInputElement).checked = cloneData.loop !== false;
(document.getElementById('stream-video-speed') as HTMLInputElement).value = cloneData.playback_speed || 1.0;
const cloneSpeedLabel = document.getElementById('stream-video-speed-value');
if (cloneSpeedLabel) cloneSpeedLabel.textContent = cloneData.playback_speed || 1.0;
(document.getElementById('stream-video-fps') as HTMLInputElement).value = cloneData.target_fps || 30;
(document.getElementById('stream-video-start') as HTMLInputElement).value = cloneData.start_time || '';
(document.getElementById('stream-video-end') as HTMLInputElement).value = cloneData.end_time || '';
(document.getElementById('stream-video-resolution') as HTMLInputElement).value = cloneData.resolution_limit || '';
}
}
_showStreamModalLoading(false);
// Tags
if (_streamTagsInput) { _streamTagsInput.destroy(); _streamTagsInput = null; }
_streamTagsInput = new TagInput(document.getElementById('stream-tags-container'), { placeholder: t('tags.placeholder') });
_streamTagsInput.setValue(cloneData ? (cloneData.tags || []) : []);
streamModal.snapshot();
}
export async function editStream(streamId: any) {
try {
// Open modal instantly with loading indicator
document.getElementById('stream-modal-title')!.innerHTML = t('streams.edit');
(document.getElementById('stream-form') as HTMLFormElement).reset();
document.getElementById('stream-error')!.style.display = 'none';
_showStreamModalLoading(true);
streamModal.open();
const response = await fetchWithAuth(`/picture-sources/${streamId}`);
if (!response.ok) throw new Error(`Failed to load stream: ${response.status}`);
const stream = await response.json();
const editTitleKeys: any = { raw: 'streams.edit.raw', processed: 'streams.edit.processed', static_image: 'streams.edit.static_image', video: 'streams.edit.video' };
document.getElementById('stream-modal-title')!.innerHTML = `${getPictureSourceIcon(stream.stream_type)} ${t(editTitleKeys[stream.stream_type] || 'streams.edit')}`;
(document.getElementById('stream-id') as HTMLInputElement).value = streamId;
(document.getElementById('stream-name') as HTMLInputElement).value = stream.name;
(document.getElementById('stream-description') as HTMLInputElement).value = stream.description || '';
(document.getElementById('stream-type') as HTMLSelectElement).value = stream.stream_type;
_ensureImageAssetEntitySelect();
_ensureVideoAssetEntitySelect();
onStreamTypeChange();
await populateStreamModalDropdowns();
if (stream.stream_type === 'raw') {
const tplId = stream.capture_template_id || '';
(document.getElementById('stream-capture-template') as HTMLSelectElement).value = tplId;
if (_captureTemplateEntitySelect) _captureTemplateEntitySelect.setValue(tplId);
// Ensure correct engine displays are loaded for this template
await _onCaptureTemplateChanged();
const displayIdx = stream.display_index ?? 0;
const display = _cachedDisplays ? _cachedDisplays.find((d: any) => d.index === displayIdx) : null;
onStreamDisplaySelected(displayIdx, display);
const fps = stream.target_fps ?? 30;
(document.getElementById('stream-target-fps') as HTMLInputElement).value = fps;
document.getElementById('stream-target-fps-value')!.textContent = fps;
} else if (stream.stream_type === 'processed') {
const srcId = stream.source_stream_id || '';
const ppId = stream.postprocessing_template_id || '';
(document.getElementById('stream-source') as HTMLSelectElement).value = srcId;
if (_sourceEntitySelect) _sourceEntitySelect.setValue(srcId);
(document.getElementById('stream-pp-template') as HTMLSelectElement).value = ppId;
if (_ppTemplateEntitySelect) _ppTemplateEntitySelect.setValue(ppId);
} else if (stream.stream_type === 'static_image') {
if (stream.image_asset_id) {
(document.getElementById('stream-image-asset') as HTMLSelectElement).value = stream.image_asset_id;
if (_imageAssetEntitySelect) _imageAssetEntitySelect.setValue(stream.image_asset_id);
}
} else if (stream.stream_type === 'video') {
if (stream.video_asset_id) {
(document.getElementById('stream-video-asset') as HTMLSelectElement).value = stream.video_asset_id;
if (_videoAssetEntitySelect) _videoAssetEntitySelect.setValue(stream.video_asset_id);
}
(document.getElementById('stream-video-loop') as HTMLInputElement).checked = stream.loop !== false;
(document.getElementById('stream-video-speed') as HTMLInputElement).value = stream.playback_speed || 1.0;
const speedLabel = document.getElementById('stream-video-speed-value');
if (speedLabel) speedLabel.textContent = stream.playback_speed || 1.0;
(document.getElementById('stream-video-fps') as HTMLInputElement).value = stream.target_fps || 30;
(document.getElementById('stream-video-start') as HTMLInputElement).value = stream.start_time || '';
(document.getElementById('stream-video-end') as HTMLInputElement).value = stream.end_time || '';
(document.getElementById('stream-video-resolution') as HTMLInputElement).value = stream.resolution_limit || '';
}
_showStreamModalLoading(false);
// Tags
if (_streamTagsInput) { _streamTagsInput.destroy(); _streamTagsInput = null; }
_streamTagsInput = new TagInput(document.getElementById('stream-tags-container'), { placeholder: t('tags.placeholder') });
_streamTagsInput.setValue(stream.tags || []);
streamModal.snapshot();
} catch (error) {
console.error('Error loading stream:', error);
streamModal.forceClose();
showToast(t('streams.error.load') + ': ' + error.message, 'error');
}
}
/** Track which engine type the stream-modal displays were loaded for. */
let _streamModalDisplaysEngine: string | null = null;
// ── EntitySelect instances for stream modal ──
let _captureTemplateEntitySelect: EntitySelect | null = null;
let _sourceEntitySelect: EntitySelect | null = null;
let _ppTemplateEntitySelect: EntitySelect | null = null;
async function populateStreamModalDropdowns() {
const [captureTemplates, streams, ppTemplates] = await Promise.all([
captureTemplatesCache.fetch().catch((): any[] => []),
streamsCache.fetch().catch((): any[] => []),
ppTemplatesCache.fetch().catch((): any[] => []),
displaysCache.fetch().catch((): any[] => []),
enginesCache.fetch().catch((): any[] => []),
]);
_streamModalDisplaysEngine = null;
const templateSelect = document.getElementById('stream-capture-template') as HTMLSelectElement;
templateSelect.innerHTML = '';
captureTemplates.forEach((tmpl: any) => {
const opt = document.createElement('option');
opt.value = tmpl.id;
opt.dataset.name = tmpl.name;
opt.dataset.engineType = tmpl.engine_type;
opt.dataset.hasOwnDisplays = availableEngines.find((e: any) => e.type === tmpl.engine_type)?.has_own_displays ? '1' : '';
opt.textContent = `${tmpl.name} (${tmpl.engine_type})`;
templateSelect.appendChild(opt);
});
// When template changes, refresh displays if engine type switched
templateSelect.addEventListener('change', _onCaptureTemplateChanged);
// Load displays for the selected engine (engine-specific or desktop)
const firstOpt = templateSelect.selectedOptions[0];
if (firstOpt?.dataset?.hasOwnDisplays === '1') {
await _refreshStreamDisplaysForEngine(firstOpt.dataset.engineType);
} else if (!(document.getElementById('stream-display-index') as HTMLInputElement).value && _cachedDisplays && _cachedDisplays.length > 0) {
const primary = _cachedDisplays.find(d => d.is_primary) || _cachedDisplays[0];
onStreamDisplaySelected(primary.index, primary);
}
const sourceSelect = document.getElementById('stream-source') as HTMLSelectElement;
sourceSelect.innerHTML = '';
const editingId = (document.getElementById('stream-id') as HTMLInputElement).value;
streams.forEach(s => {
if (s.id === editingId) return;
const opt = document.createElement('option');
opt.value = s.id;
opt.dataset.name = s.name;
opt.textContent = s.name;
sourceSelect.appendChild(opt);
});
set_streamModalPPTemplates(ppTemplates);
const ppSelect = document.getElementById('stream-pp-template') as HTMLSelectElement;
ppSelect.innerHTML = '';
ppTemplates.forEach(tmpl => {
const opt = document.createElement('option');
opt.value = tmpl.id;
opt.textContent = tmpl.name;
ppSelect.appendChild(opt);
});
// Entity palette selectors
if (_captureTemplateEntitySelect) _captureTemplateEntitySelect.destroy();
_captureTemplateEntitySelect = new EntitySelect({
target: templateSelect,
getItems: () => captureTemplates.map((tmpl: any) => ({
value: tmpl.id,
label: tmpl.name,
icon: getEngineIcon(tmpl.engine_type),
desc: tmpl.engine_type,
})),
placeholder: t('palette.search'),
});
if (_sourceEntitySelect) _sourceEntitySelect.destroy();
_sourceEntitySelect = new EntitySelect({
target: sourceSelect,
getItems: () => {
const editingId = (document.getElementById('stream-id') as HTMLInputElement).value;
return streams.filter(s => s.id !== editingId).map(s => ({
value: s.id,
label: s.name,
icon: getPictureSourceIcon(s.stream_type),
}));
},
placeholder: t('palette.search'),
});
if (_ppTemplateEntitySelect) _ppTemplateEntitySelect.destroy();
_ppTemplateEntitySelect = new EntitySelect({
target: ppSelect,
getItems: () => ppTemplates.map((tmpl: any) => ({
value: tmpl.id,
label: tmpl.name,
icon: ICON_PP_TEMPLATE,
})),
placeholder: t('palette.search'),
});
_autoGenerateStreamName();
}
async function _onCaptureTemplateChanged() {
const templateSelect = document.getElementById('stream-capture-template') as HTMLSelectElement;
const engineType = templateSelect.selectedOptions[0]?.dataset?.engineType || null;
const hasOwnDisplays = templateSelect.selectedOptions[0]?.dataset?.hasOwnDisplays === '1';
const currentEngine = hasOwnDisplays ? engineType : null;
// Only refetch if the engine category actually changed
if (currentEngine !== _streamModalDisplaysEngine) {
await _refreshStreamDisplaysForEngine(currentEngine);
}
_autoGenerateStreamName();
}
async function _refreshStreamDisplaysForEngine(engineType: any) {
_streamModalDisplaysEngine = engineType;
const url = engineType ? `/config/displays?engine_type=${engineType}` : '/config/displays';
try {
const resp = await fetchWithAuth(url);
if (resp.ok) {
const data = await resp.json();
displaysCache.update(data.displays || []);
}
} catch (error) {
console.error('Error refreshing displays for engine:', error);
}
// Reset display selection and pick the first available
(document.getElementById('stream-display-index') as HTMLInputElement).value = '';
document.getElementById('stream-display-picker-label')!.textContent = t('displays.picker.select');
if (_cachedDisplays && _cachedDisplays.length > 0) {
const primary = _cachedDisplays.find(d => d.is_primary) || _cachedDisplays[0];
onStreamDisplaySelected(primary.index, primary);
}
}
export async function saveStream() {
const streamId = (document.getElementById('stream-id') as HTMLInputElement).value;
if (streamModal.closeIfPristine(streamId)) return;
const name = (document.getElementById('stream-name') as HTMLInputElement).value.trim();
const streamType = (document.getElementById('stream-type') as HTMLSelectElement).value;
const description = (document.getElementById('stream-description') as HTMLInputElement).value.trim();
const errorEl = document.getElementById('stream-error')!;
if (!name) { showToast(t('streams.error.required'), 'error'); return; }
const payload: any = { name, stream_type: streamType, description: description || null, tags: _streamTagsInput ? _streamTagsInput.getValue() : [] };
if (streamType === 'raw') {
payload.display_index = parseInt((document.getElementById('stream-display-index') as HTMLInputElement).value) || 0;
payload.capture_template_id = (document.getElementById('stream-capture-template') as HTMLSelectElement).value;
payload.target_fps = parseInt((document.getElementById('stream-target-fps') as HTMLInputElement).value) || 30;
} else if (streamType === 'processed') {
payload.source_stream_id = (document.getElementById('stream-source') as HTMLSelectElement).value;
payload.postprocessing_template_id = (document.getElementById('stream-pp-template') as HTMLSelectElement).value;
} else if (streamType === 'static_image') {
const imageAssetId = (document.getElementById('stream-image-asset') as HTMLSelectElement).value;
if (!imageAssetId) { showToast(t('streams.error.required'), 'error'); return; }
payload.image_asset_id = imageAssetId;
} else if (streamType === 'video') {
const videoAssetId = (document.getElementById('stream-video-asset') as HTMLSelectElement).value;
if (!videoAssetId) { showToast(t('streams.error.required'), 'error'); return; }
payload.video_asset_id = videoAssetId;
payload.loop = (document.getElementById('stream-video-loop') as HTMLInputElement).checked;
payload.playback_speed = parseFloat((document.getElementById('stream-video-speed') as HTMLInputElement).value) || 1.0;
payload.target_fps = parseInt((document.getElementById('stream-video-fps') as HTMLInputElement).value) || 30;
const startTime = parseFloat((document.getElementById('stream-video-start') as HTMLInputElement).value);
if (!isNaN(startTime) && startTime > 0) payload.start_time = startTime;
const endTime = parseFloat((document.getElementById('stream-video-end') as HTMLInputElement).value);
if (!isNaN(endTime) && endTime > 0) payload.end_time = endTime;
const resLimit = parseInt((document.getElementById('stream-video-resolution') as HTMLInputElement).value);
if (!isNaN(resLimit) && resLimit > 0) payload.resolution_limit = resLimit;
}
try {
let response;
if (streamId) {
response = await fetchWithAuth(`/picture-sources/${streamId}`, { method: 'PUT', body: JSON.stringify(payload) });
} else {
response = await fetchWithAuth('/picture-sources', { method: 'POST', body: JSON.stringify(payload) });
}
if (!response.ok) {
const error = await response.json();
throw new Error(error.detail || error.message || 'Failed to save stream');
}
showToast(streamId ? t('streams.updated') : t('streams.created'), 'success');
streamModal.forceClose();
streamsCache.invalidate();
await loadPictureSources();
} catch (error) {
console.error('Error saving stream:', error);
errorEl.textContent = error.message;
errorEl.style.display = 'block';
}
}
export async function deleteStream(streamId: any) {
const confirmed = await showConfirm(t('streams.delete.confirm'));
if (!confirmed) return;
try {
const response = await fetchWithAuth(`/picture-sources/${streamId}`, { method: 'DELETE' });
if (!response.ok) {
const error = await response.json();
throw new Error(error.detail || error.message || 'Failed to delete stream');
}
showToast(t('streams.deleted'), 'success');
streamsCache.invalidate();
await loadPictureSources();
} catch (error) {
console.error('Error deleting stream:', error);
showToast(t('streams.error.delete') + ': ' + error.message, 'error');
}
}
/** Toggle loading overlay in stream modal — hides form while data loads. */
function _showStreamModalLoading(show: boolean) {
const loading = document.getElementById('stream-modal-loading');
const form = document.getElementById('stream-form');
const footer = document.querySelector('#stream-modal .modal-footer') as HTMLElement | null;
if (loading) loading.style.display = show ? '' : 'none';
if (form) form.style.display = show ? 'none' : '';
if (footer) footer.style.visibility = show ? 'hidden' : '';
}
export async function closeStreamModal() {
await streamModal.close();
}
// ===== Picture Source Test =====
export async function showTestStreamModal(streamId: any) {
set_currentTestStreamId(streamId);
restoreStreamTestDuration();
testStreamModal.open();
setupBackdropClose((testStreamModal as any).el, () => closeTestStreamModal());
}
export function closeTestStreamModal() {
testStreamModal.forceClose();
set_currentTestStreamId(null);
}
export function updateStreamTestDuration(value: any) {
document.getElementById('test-stream-duration-value')!.textContent = value;
localStorage.setItem('lastStreamTestDuration', value);
}
function restoreStreamTestDuration() {
const saved = localStorage.getItem('lastStreamTestDuration') || '5';
(document.getElementById('test-stream-duration') as HTMLInputElement).value = saved;
document.getElementById('test-stream-duration-value')!.textContent = saved;
}
export function runStreamTest() {
if (!_currentTestStreamId) return;
const captureDuration = parseFloat((document.getElementById('test-stream-duration') as HTMLInputElement).value);
_localRunTestViaWS(
`/picture-sources/${_currentTestStreamId}/test/ws`,
{ duration: captureDuration },
null,
captureDuration,
);
}
// ===== PP Template Test =====
export async function showTestPPTemplateModal(templateId: any) {
set_currentTestPPTemplateId(templateId);
restorePPTestDuration();
const select = document.getElementById('test-pp-source-stream') as HTMLSelectElement;
select.innerHTML = '';
if (_cachedStreams.length === 0) {
try {
await streamsCache.fetch();
} catch (e) { console.warn('Could not load streams for PP test:', e); }
}
for (const s of _cachedStreams) {
const opt = document.createElement('option');
opt.value = s.id;
opt.textContent = s.name;
select.appendChild(opt);
}
const lastStream = localStorage.getItem('lastPPTestStreamId');
if (lastStream && _cachedStreams.find(s => s.id === lastStream)) {
select.value = lastStream;
}
// EntitySelect for source stream picker
if (_ppTestSourceEntitySelect) _ppTestSourceEntitySelect.destroy();
_ppTestSourceEntitySelect = new EntitySelect({
target: select,
getItems: () => _cachedStreams.map((s: any) => ({
value: s.id,
label: s.name,
icon: getPictureSourceIcon(s.stream_type),
})),
placeholder: t('palette.search'),
});
testPPTemplateModal.open();
setupBackdropClose((testPPTemplateModal as any).el, () => closeTestPPTemplateModal());
}
export function closeTestPPTemplateModal() {
testPPTemplateModal.forceClose();
set_currentTestPPTemplateId(null);
}
export function updatePPTestDuration(value: any) {
document.getElementById('test-pp-duration-value')!.textContent = value;
localStorage.setItem('lastPPTestDuration', value);
}
function restorePPTestDuration() {
const saved = localStorage.getItem('lastPPTestDuration') || '5';
(document.getElementById('test-pp-duration') as HTMLInputElement).value = saved;
document.getElementById('test-pp-duration-value')!.textContent = saved;
}
export function runPPTemplateTest() {
if (!_currentTestPPTemplateId) return;
const sourceStreamId = (document.getElementById('test-pp-source-stream') as HTMLSelectElement).value;
if (!sourceStreamId) { showToast(t('postprocessing.test.error.no_stream'), 'error'); return; }
localStorage.setItem('lastPPTestStreamId', sourceStreamId);
const captureDuration = parseFloat((document.getElementById('test-pp-duration') as HTMLInputElement).value);
_localRunTestViaWS(
`/postprocessing-templates/${_currentTestPPTemplateId}/test/ws`,
{ duration: captureDuration, source_stream_id: sourceStreamId },
null,
captureDuration,
);
}
// ===== PP Templates =====
async function loadAvailableFilters() {
await filtersCache.fetch();
}
async function loadPPTemplates() {
try {
if (_availableFilters.length === 0) await filtersCache.fetch();
await ppTemplatesCache.fetch();
renderPictureSourcesList(_cachedStreams);
} catch (error) {
console.error('Error loading PP templates:', error);
}
}
function _getFilterName(filterId) {
const key = 'filters.' + filterId;
const translated = t(key);
if (translated === key) {
const def = _availableFilters.find(f => f.id === filterId);
return def ? def.name : filterId;
}
return translated;
}
function _getStripFilterName(filterId) {
const key = 'filters.' + filterId;
const translated = t(key);
if (translated === key) {
const def = _stripFilters.find(f => f.id === filterId);
return def ? def.name : filterId;
}
return translated;
}
// ── PP FilterListManager instance ──
const ppFilterManager = new FilterListManager({
getFilters: () => _modalFilters,
getFilterDefs: () => _availableFilters,
getFilterName: _getFilterName,
selectId: 'pp-add-filter-select',
containerId: 'pp-filter-list',
prefix: '',
editingIdInputId: 'pp-template-id',
selfRefFilterId: 'filter_template',
autoNameFn: () => _autoGeneratePPTemplateName(),
initDrag: _initFilterDragForContainer,
initPaletteGrids: _initFilterPaletteGrids,
});
// _renderFilterListGeneric has been replaced by FilterListManager.render()
/** Stored IconSelect instances for filter option selects (keyed by select element id). */
const _filterOptionIconSelects = {};
function _paletteSwatchHTML(hexStr) {
const hexColors = hexStr.split(',').map(s => s.trim());
if (hexColors.length === 1) {
return `<span style="display:inline-block;width:60px;height:14px;border-radius:3px;background:${hexColors[0]}"></span>`;
}
const stops = hexColors.map((c, i) => `${c} ${(i / (hexColors.length - 1) * 100).toFixed(0)}%`).join(', ');
return `<span style="display:inline-block;width:60px;height:14px;border-radius:3px;background:linear-gradient(to right,${stops})"></span>`;
}
function _initFilterPaletteGrids(container: any) {
// Palette-colored grids (e.g. palette quantization preset)
container.querySelectorAll('select[data-palette-grid]').forEach((sel: any) => {
if (_filterOptionIconSelects[sel.id]) _filterOptionIconSelects[sel.id].destroy();
try {
const choices = JSON.parse(sel.dataset.paletteGrid);
const items = choices.map(c => ({
value: c.value,
label: c.label,
icon: _paletteSwatchHTML(c.colors || ''),
}));
_filterOptionIconSelects[sel.id] = new IconSelect({ target: sel, items, columns: 2 });
} catch { /* ignore parse errors */ }
});
// Template reference selects → EntitySelect (searchable palette)
container.querySelectorAll('select[data-entity-select]').forEach((sel: any) => {
if (_filterOptionIconSelects[sel.id]) _filterOptionIconSelects[sel.id].destroy();
const icon = sel.dataset.entitySelect === 'template' ? ICON_PP_TEMPLATE : ICON_CSPT;
_filterOptionIconSelects[sel.id] = new EntitySelect({
target: sel,
getItems: () => Array.from(sel.options).map((opt: any) => ({
value: opt.value,
label: opt.textContent,
icon,
})),
placeholder: t('palette.search'),
});
});
}
export function renderModalFilterList() {
ppFilterManager.render();
}
export function renderCSPTModalFilterList() {
csptFilterManager.render();
}
/* ── Generic filter drag-and-drop reordering ── */
const _FILTER_DRAG_THRESHOLD = 5;
const _FILTER_SCROLL_EDGE = 60;
const _FILTER_SCROLL_SPEED = 12;
let _filterDragState: {
card: HTMLElement;
container: any;
startY: number;
started: boolean;
clone: HTMLElement | null;
placeholder: HTMLElement | null;
offsetY: number;
fromIndex: number;
scrollRaf: number | null;
filtersArr: any;
rerenderFn: any;
lastTarget?: HTMLElement;
lastBefore?: boolean;
} | null = null;
function _initFilterDragForContainer(containerId: string, filtersArr: any, rerenderFn: any) {
const container = document.getElementById(containerId) as any;
if (!container) return;
// Update refs each render so the pointerdown closure always sees current data
container._filterDragFilters = filtersArr;
container._filterDragRerender = rerenderFn;
// Guard against stacking listeners across re-renders
if (container._filterDragBound) return;
container._filterDragBound = true;
container.addEventListener('pointerdown', (e: any) => {
const handle = (e.target as HTMLElement).closest('.pp-filter-drag-handle');
if (!handle) return;
const card = handle.closest('.pp-filter-card');
if (!card) return;
e.preventDefault();
e.stopPropagation();
const fromIndex = parseInt((card as HTMLElement).dataset.filterIndex!, 10);
_filterDragState = {
card: card as HTMLElement,
container,
startY: e.clientY,
started: false,
clone: null,
placeholder: null,
offsetY: 0,
fromIndex,
scrollRaf: null,
filtersArr: (container as any)._filterDragFilters,
rerenderFn: (container as any)._filterDragRerender,
};
const onMove = (ev) => _onFilterDragMove(ev);
const cleanup = () => {
document.removeEventListener('pointermove', onMove);
document.removeEventListener('pointerup', cleanup);
document.removeEventListener('pointercancel', cleanup);
_onFilterDragEnd();
};
document.addEventListener('pointermove', onMove);
document.addEventListener('pointerup', cleanup);
document.addEventListener('pointercancel', cleanup);
});
}
function _onFilterDragMove(e: any) {
const ds = _filterDragState;
if (!ds) return;
if (!ds.started) {
if (Math.abs(e.clientY - ds.startY) < _FILTER_DRAG_THRESHOLD) return;
_startFilterDrag(ds, e);
}
// Position clone at pointer
ds.clone!.style.top = (e.clientY - ds.offsetY) + 'px';
// Find drop target by vertical midpoint
const cards = ds.container.querySelectorAll('.pp-filter-card');
for (const card of cards) {
if (card.style.display === 'none') continue;
const r = card.getBoundingClientRect();
if (e.clientY >= r.top && e.clientY <= r.bottom) {
const before = e.clientY < r.top + r.height / 2;
if (card === ds.lastTarget && before === ds.lastBefore) break;
ds.lastTarget = card;
ds.lastBefore = before;
if (before) {
ds.container.insertBefore(ds.placeholder, card);
} else {
ds.container.insertBefore(ds.placeholder, card.nextSibling);
}
break;
}
}
// Auto-scroll near viewport edges
_filterAutoScroll(e.clientY, ds);
}
function _startFilterDrag(ds: any, e: any) {
ds.started = true;
const rect = ds.card.getBoundingClientRect();
// Clone for visual feedback
const clone = ds.card.cloneNode(true);
clone.className = ds.card.className + ' pp-filter-drag-clone';
clone.style.width = rect.width + 'px';
clone.style.left = rect.left + 'px';
clone.style.top = rect.top + 'px';
document.body.appendChild(clone);
ds.clone = clone;
ds.offsetY = e.clientY - rect.top;
// Placeholder
const placeholder = document.createElement('div');
placeholder.className = 'pp-filter-drag-placeholder';
placeholder.style.height = rect.height + 'px';
ds.card.parentNode.insertBefore(placeholder, ds.card);
ds.placeholder = placeholder;
// Hide original
ds.card.style.display = 'none';
document.body.classList.add('pp-filter-dragging');
}
function _onFilterDragEnd() {
const ds = _filterDragState;
_filterDragState = null;
if (!ds || !ds.started) return;
if (ds.scrollRaf) cancelAnimationFrame(ds.scrollRaf);
// Determine new index from placeholder position among children
let toIndex = 0;
for (const child of ds.container.children) {
if (child === ds.placeholder) break;
if (child.classList.contains('pp-filter-card') && child.style.display !== 'none') {
toIndex++;
}
}
// Cleanup DOM
ds.card.style.display = '';
ds.placeholder!.remove();
ds.clone!.remove();
document.body.classList.remove('pp-filter-dragging');
// Reorder filters array
if (toIndex !== ds.fromIndex) {
const [item] = ds.filtersArr.splice(ds.fromIndex, 1);
ds.filtersArr.splice(toIndex, 0, item);
ds.rerenderFn();
}
}
function _filterAutoScroll(clientY: number, ds: any) {
if (ds.scrollRaf) cancelAnimationFrame(ds.scrollRaf);
const modal = ds.container.closest('.modal-body');
if (!modal) return;
const rect = modal.getBoundingClientRect();
let speed = 0;
if (clientY < rect.top + _FILTER_SCROLL_EDGE) {
speed = -_FILTER_SCROLL_SPEED;
} else if (clientY > rect.bottom - _FILTER_SCROLL_EDGE) {
speed = _FILTER_SCROLL_SPEED;
}
if (speed === 0) return;
const scroll = () => {
modal.scrollTop += speed;
ds.scrollRaf = requestAnimationFrame(scroll);
};
ds.scrollRaf = requestAnimationFrame(scroll);
}
// _addFilterGeneric and _updateFilterOptionGeneric have been replaced by FilterListManager methods
// ── PP filter actions (delegate to ppFilterManager) ──
export function addFilterFromSelect() { ppFilterManager.addFromSelect(); }
export function toggleFilterExpand(index: any) { ppFilterManager.toggleExpand(index); }
export function removeFilter(index: any) { ppFilterManager.remove(index); }
export function moveFilter(index: any, direction: any) { ppFilterManager.move(index, direction); }
export function updateFilterOption(filterIndex: any, optionKey: any, value: any) { ppFilterManager.updateOption(filterIndex, optionKey, value); }
// ── CSPT filter actions (delegate to csptFilterManager) ──
export function csptAddFilterFromSelect() { csptFilterManager.addFromSelect(); }
export function csptToggleFilterExpand(index: any) { csptFilterManager.toggleExpand(index); }
export function csptRemoveFilter(index: any) { csptFilterManager.remove(index); }
export function csptUpdateFilterOption(filterIndex: any, optionKey: any, value: any) { csptFilterManager.updateOption(filterIndex, optionKey, value); }
function collectFilters() {
return ppFilterManager.collect();
}
function _autoGeneratePPTemplateName() {
if (_ppTemplateNameManuallyEdited) return;
if ((document.getElementById('pp-template-id') as HTMLInputElement).value) return;
const nameInput = document.getElementById('pp-template-name') as HTMLInputElement;
if (_modalFilters.length > 0) {
const filterNames = _modalFilters.map(f => _getFilterName(f.filter_id)).join(' + ');
nameInput.value = filterNames;
} else {
nameInput.value = '';
}
}
export async function showAddPPTemplateModal(cloneData: any = null) {
if (_availableFilters.length === 0) await loadAvailableFilters();
document.getElementById('pp-template-modal-title')!.innerHTML = `${ICON_PP_TEMPLATE} ${t('postprocessing.add')}`;
(document.getElementById('pp-template-form') as HTMLFormElement).reset();
(document.getElementById('pp-template-id') as HTMLInputElement).value = '';
document.getElementById('pp-template-error')!.style.display = 'none';
if (cloneData) {
set_modalFilters((cloneData.filters || []).map(fi => ({
filter_id: fi.filter_id,
options: { ...fi.options },
})));
set_ppTemplateNameManuallyEdited(true);
} else {
set_modalFilters([]);
set_ppTemplateNameManuallyEdited(false);
}
(document.getElementById('pp-template-name') as HTMLInputElement).oninput = () => { set_ppTemplateNameManuallyEdited(true); };
ppFilterManager.populateSelect(() => addFilterFromSelect());
renderModalFilterList();
// Pre-fill from clone data after form is set up
if (cloneData) {
(document.getElementById('pp-template-name') as HTMLInputElement).value = (cloneData.name || '') + ' (Copy)';
(document.getElementById('pp-template-description') as HTMLInputElement).value = cloneData.description || '';
}
// Tags
if (_ppTemplateTagsInput) { _ppTemplateTagsInput.destroy(); _ppTemplateTagsInput = null; }
_ppTemplateTagsInput = new TagInput(document.getElementById('pp-template-tags-container'), { placeholder: t('tags.placeholder') });
_ppTemplateTagsInput.setValue(cloneData ? (cloneData.tags || []) : []);
ppTemplateModal.open();
ppTemplateModal.snapshot();
}
export async function editPPTemplate(templateId: any) {
try {
if (_availableFilters.length === 0) await loadAvailableFilters();
const response = await fetchWithAuth(`/postprocessing-templates/${templateId}`);
if (!response.ok) throw new Error(`Failed to load template: ${response.status}`);
const tmpl = await response.json();
document.getElementById('pp-template-modal-title')!.innerHTML = `${ICON_PP_TEMPLATE} ${t('postprocessing.edit')}`;
(document.getElementById('pp-template-id') as HTMLInputElement).value = templateId;
(document.getElementById('pp-template-name') as HTMLInputElement).value = tmpl.name;
(document.getElementById('pp-template-description') as HTMLInputElement).value = tmpl.description || '';
document.getElementById('pp-template-error')!.style.display = 'none';
set_modalFilters((tmpl.filters || []).map(fi => ({
filter_id: fi.filter_id,
options: { ...fi.options },
})));
ppFilterManager.populateSelect(() => addFilterFromSelect());
renderModalFilterList();
// Tags
if (_ppTemplateTagsInput) { _ppTemplateTagsInput.destroy(); _ppTemplateTagsInput = null; }
_ppTemplateTagsInput = new TagInput(document.getElementById('pp-template-tags-container'), { placeholder: t('tags.placeholder') });
_ppTemplateTagsInput.setValue(tmpl.tags || []);
ppTemplateModal.open();
ppTemplateModal.snapshot();
} catch (error) {
console.error('Error loading PP template:', error);
showToast(t('postprocessing.error.load') + ': ' + error.message, 'error');
}
}
export async function savePPTemplate() {
const templateId = (document.getElementById('pp-template-id') as HTMLInputElement).value;
if (ppTemplateModal.closeIfPristine(templateId)) return;
const name = (document.getElementById('pp-template-name') as HTMLInputElement).value.trim();
const description = (document.getElementById('pp-template-description') as HTMLInputElement).value.trim();
const errorEl = document.getElementById('pp-template-error')!;
if (!name) { showToast(t('postprocessing.error.required'), 'error'); return; }
const payload = { name, filters: collectFilters(), description: description || null, tags: _ppTemplateTagsInput ? _ppTemplateTagsInput.getValue() : [] };
try {
let response;
if (templateId) {
response = await fetchWithAuth(`/postprocessing-templates/${templateId}`, { method: 'PUT', body: JSON.stringify(payload) });
} else {
response = await fetchWithAuth('/postprocessing-templates', { method: 'POST', body: JSON.stringify(payload) });
}
if (!response.ok) {
const error = await response.json();
throw new Error(error.detail || error.message || 'Failed to save template');
}
showToast(templateId ? t('postprocessing.updated') : t('postprocessing.created'), 'success');
ppTemplateModal.forceClose();
ppTemplatesCache.invalidate();
await loadPPTemplates();
} catch (error) {
console.error('Error saving PP template:', error);
errorEl.textContent = error.message;
errorEl.style.display = 'block';
}
}
// ===== Clone functions =====
export async function cloneStream(streamId: any) {
try {
const resp = await fetchWithAuth(`/picture-sources/${streamId}`);
if (!resp.ok) throw new Error('Failed to load stream');
const stream = await resp.json();
showAddStreamModal(stream.stream_type, stream);
} catch (error) {
if (error.isAuth) return;
console.error('Failed to clone stream:', error);
showToast(t('stream.error.clone_picture_failed'), 'error');
}
}
export async function cloneCaptureTemplate(templateId: any) {
try {
const resp = await fetchWithAuth(`/capture-templates/${templateId}`);
if (!resp.ok) throw new Error('Failed to load template');
const tmpl = await resp.json();
_localShowAddTemplateModal(tmpl);
} catch (error) {
if (error.isAuth) return;
console.error('Failed to clone capture template:', error);
showToast(t('stream.error.clone_capture_failed'), 'error');
}
}
export async function clonePPTemplate(templateId: any) {
try {
const resp = await fetchWithAuth(`/postprocessing-templates/${templateId}`);
if (!resp.ok) throw new Error('Failed to load template');
const tmpl = await resp.json();
showAddPPTemplateModal(tmpl);
} catch (error) {
if (error.isAuth) return;
console.error('Failed to clone PP template:', error);
showToast(t('stream.error.clone_pp_failed'), 'error');
}
}
export async function deletePPTemplate(templateId: any) {
const confirmed = await showConfirm(t('postprocessing.delete.confirm'));
if (!confirmed) return;
try {
const response = await fetchWithAuth(`/postprocessing-templates/${templateId}`, { method: 'DELETE' });
if (!response.ok) {
const error = await response.json();
throw new Error(error.detail || error.message || 'Failed to delete template');
}
showToast(t('postprocessing.deleted'), 'success');
ppTemplatesCache.invalidate();
await loadPPTemplates();
} catch (error) {
console.error('Error deleting PP template:', error);
showToast(t('postprocessing.error.delete') + ': ' + error.message, 'error');
}
}
export async function closePPTemplateModal() {
await ppTemplateModal.close();
}
// ===== Color Strip Processing Templates (CSPT) =====
// ── CSPT FilterListManager instance ──
const csptFilterManager = new FilterListManager({
getFilters: () => _csptModalFilters,
getFilterDefs: () => _stripFilters,
getFilterName: _getStripFilterName,
selectId: 'cspt-add-filter-select',
containerId: 'cspt-filter-list',
prefix: 'cspt',
editingIdInputId: 'cspt-id',
selfRefFilterId: 'css_filter_template',
autoNameFn: () => _autoGenerateCSPTName(),
initDrag: _initFilterDragForContainer,
initPaletteGrids: _initFilterPaletteGrids,
});
async function loadStripFilters() {
await stripFiltersCache.fetch();
}
async function loadCSPTemplates() {
try {
if (_stripFilters.length === 0) await stripFiltersCache.fetch();
await csptCache.fetch();
renderPictureSourcesList(_cachedStreams);
} catch (error) {
console.error('Error loading CSPT:', error);
}
}
function _autoGenerateCSPTName() {
if (_csptNameManuallyEdited) return;
if ((document.getElementById('cspt-id') as HTMLInputElement).value) return;
const nameInput = document.getElementById('cspt-name') as HTMLInputElement;
if (_csptModalFilters.length > 0) {
const filterNames = _csptModalFilters.map(f => _getStripFilterName(f.filter_id)).join(' + ');
nameInput.value = filterNames;
} else {
nameInput.value = '';
}
}
function collectCSPTFilters() {
return csptFilterManager.collect();
}
export async function showAddCSPTModal(cloneData: any = null) {
if (_stripFilters.length === 0) await loadStripFilters();
document.getElementById('cspt-modal-title')!.innerHTML = `${ICON_CSPT} ${t('css_processing.add')}`;
(document.getElementById('cspt-form') as HTMLFormElement).reset();
(document.getElementById('cspt-id') as HTMLInputElement).value = '';
document.getElementById('cspt-error')!.style.display = 'none';
if (cloneData) {
set_csptModalFilters((cloneData.filters || []).map(fi => ({
filter_id: fi.filter_id,
options: { ...fi.options },
})));
set_csptNameManuallyEdited(true);
} else {
set_csptModalFilters([]);
set_csptNameManuallyEdited(false);
}
(document.getElementById('cspt-name') as HTMLInputElement).oninput = () => { set_csptNameManuallyEdited(true); };
csptFilterManager.populateSelect(() => csptAddFilterFromSelect());
renderCSPTModalFilterList();
if (cloneData) {
(document.getElementById('cspt-name') as HTMLInputElement).value = (cloneData.name || '') + ' (Copy)';
(document.getElementById('cspt-description') as HTMLInputElement).value = cloneData.description || '';
}
if (_csptTagsInput) { _csptTagsInput.destroy(); _csptTagsInput = null; }
_csptTagsInput = new TagInput(document.getElementById('cspt-tags-container'), { placeholder: t('tags.placeholder') });
_csptTagsInput.setValue(cloneData ? (cloneData.tags || []) : []);
csptModal.open();
csptModal.snapshot();
}
export async function editCSPT(templateId: any) {
try {
if (_stripFilters.length === 0) await loadStripFilters();
const response = await fetchWithAuth(`/color-strip-processing-templates/${templateId}`);
if (!response.ok) throw new Error(`Failed to load template: ${response.status}`);
const tmpl = await response.json();
document.getElementById('cspt-modal-title')!.innerHTML = `${ICON_CSPT} ${t('css_processing.edit')}`;
(document.getElementById('cspt-id') as HTMLInputElement).value = templateId;
(document.getElementById('cspt-name') as HTMLInputElement).value = tmpl.name;
(document.getElementById('cspt-description') as HTMLInputElement).value = tmpl.description || '';
document.getElementById('cspt-error')!.style.display = 'none';
set_csptModalFilters((tmpl.filters || []).map(fi => ({
filter_id: fi.filter_id,
options: { ...fi.options },
})));
csptFilterManager.populateSelect(() => csptAddFilterFromSelect());
renderCSPTModalFilterList();
if (_csptTagsInput) { _csptTagsInput.destroy(); _csptTagsInput = null; }
_csptTagsInput = new TagInput(document.getElementById('cspt-tags-container'), { placeholder: t('tags.placeholder') });
_csptTagsInput.setValue(tmpl.tags || []);
csptModal.open();
csptModal.snapshot();
} catch (error) {
console.error('Error loading CSPT:', error);
showToast(t('css_processing.error.load') + ': ' + error.message, 'error');
}
}
export async function saveCSPT() {
const templateId = (document.getElementById('cspt-id') as HTMLInputElement).value;
if (csptModal.closeIfPristine(templateId)) return;
const name = (document.getElementById('cspt-name') as HTMLInputElement).value.trim();
const description = (document.getElementById('cspt-description') as HTMLInputElement).value.trim();
const errorEl = document.getElementById('cspt-error')!;
if (!name) { showToast(t('css_processing.error.required'), 'error'); return; }
const payload = { name, filters: collectCSPTFilters(), description: description || null, tags: _csptTagsInput ? _csptTagsInput.getValue() : [] };
try {
let response;
if (templateId) {
response = await fetchWithAuth(`/color-strip-processing-templates/${templateId}`, { method: 'PUT', body: JSON.stringify(payload) });
} else {
response = await fetchWithAuth('/color-strip-processing-templates', { method: 'POST', body: JSON.stringify(payload) });
}
if (!response.ok) {
const error = await response.json();
throw new Error(error.detail || error.message || 'Failed to save template');
}
showToast(templateId ? t('css_processing.updated') : t('css_processing.created'), 'success');
csptModal.forceClose();
csptCache.invalidate();
await loadCSPTemplates();
} catch (error) {
console.error('Error saving CSPT:', error);
errorEl.textContent = error.message;
errorEl.style.display = 'block';
}
}
export async function cloneCSPT(templateId: any) {
try {
const resp = await fetchWithAuth(`/color-strip-processing-templates/${templateId}`);
if (!resp.ok) throw new Error('Failed to load template');
const tmpl = await resp.json();
showAddCSPTModal(tmpl);
} catch (error) {
if (error.isAuth) return;
console.error('Failed to clone CSPT:', error);
showToast(t('css_processing.error.clone_failed') + ': ' + error.message, 'error');
}
}
export async function deleteCSPT(templateId: any) {
const confirmed = await showConfirm(t('css_processing.delete.confirm'));
if (!confirmed) return;
try {
const response = await fetchWithAuth(`/color-strip-processing-templates/${templateId}`, { method: 'DELETE' });
if (!response.ok) {
const error = await response.json();
throw new Error(error.detail || error.message || 'Failed to delete template');
}
showToast(t('css_processing.deleted'), 'success');
csptCache.invalidate();
await loadCSPTemplates();
} catch (error) {
console.error('Error deleting CSPT:', error);
showToast(t('css_processing.error.delete') + ': ' + error.message, 'error');
}
}
export async function closeCSPTModal() {
await csptModal.close();
}