Files
wled-screen-controller-mixed/server/src/wled_controller/static/js/features/streams.ts
alexei.dolgolyov 73947eb6cb
Some checks failed
Lint & Test / test (push) Failing after 30s
refactor: replace type-dispatch if/elif chains with registry patterns and handler maps
Backend: add registry dicts (_CONDITION_MAP, _VALUE_SOURCE_MAP, _PICTURE_SOURCE_MAP)
and per-subclass from_dict() methods to eliminate ~300 lines of if/elif in factory
functions. Convert automation engine dispatch (condition eval, match_mode, match_type,
deactivation_mode) to dict-based lookup.

Frontend: extract CSS_CARD_RENDERERS, CSS_SECTION_MAP, CSS_TYPE_SETUP,
CONDITION_PILL_RENDERERS, and PICTURE_SOURCE_CARD_RENDERERS handler maps to replace
scattered type-check chains in color-strips.ts, automations.ts, and streams.ts.
2026-03-24 14:51:27 +03:00

2004 lines
101 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, setAvailableEngines,
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,
_lastValidatedImageSource, set_lastValidatedImageSource,
_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,
audioSourcesCache, audioTemplatesCache, valueSourcesCache, syncClocksCache, filtersCache,
colorStripSourcesCache,
csptCache, stripFiltersCache,
gradientsCache, GradientEntity,
} 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 { createValueSourceCard } from './value-sources.ts';
import { createSyncClockCard, initSyncClockDelegation } from './sync-clocks.ts';
import { createColorStripCard } from './color-strips.ts';
import { initAudioSourceDelegation } from './audio-sources.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_RADIO,
ICON_CAPTURE_TEMPLATE, ICON_PP_TEMPLATE, ICON_CSPT, ICON_HELP, ICON_TRASH, ICON_PALETTE,
} from '../core/icons.ts';
import * as P from '../core/icon-paths.ts';
const _icon = (d: string) => `<svg class="icon" viewBox="0 0 24 24">${d}</svg>`;
import { wrapCard } from '../core/card-colors.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';
// ── 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 _csptDeleteAction = [{ key: 'delete', labelKey: 'bulk.delete', icon: ICON_TRASH, style: 'danger', confirm: 'bulk.confirm_delete', handler: _bulkDeleteFactory('color-strip-processing-templates', csptCache, 'templates.deleted') }];
// ── 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 csAudioMulti = new CardSection('audio-multi', { titleKey: 'audio_source.group.multichannel', gridClass: 'templates-grid', addCardOnclick: "showAudioSourceModal('multichannel')", keyAttr: 'data-id', emptyKey: 'section.empty.audio_sources', bulkActions: _audioSourceDeleteAction });
const csAudioMono = new CardSection('audio-mono', { titleKey: 'audio_source.group.mono', gridClass: 'templates-grid', addCardOnclick: "showAudioSourceModal('mono')", 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 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 });
// 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,
imageSource: (document.getElementById('stream-image-source') as HTMLInputElement).value,
tags: JSON.stringify(_streamTagsInput ? _streamTagsInput.getValue() : []),
};
}
onForceClose() {
if (_streamTagsInput) { _streamTagsInput.destroy(); _streamTagsInput = null; }
(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,
} 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(),
audioTemplatesCache.fetch(),
colorStripSourcesCache.fetch(),
csptCache.fetch(),
gradientsCache.fetch(),
filtersCache.data.length === 0 ? filtersCache.fetch() : Promise.resolve(filtersCache.data),
]);
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}`)
);
localStorage.setItem('activeStreamTab', 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: [csAudioMulti, csAudioMono],
audio_templates: [csAudioTemplates],
value: [csValueSources],
sync: [csSyncClocks],
};
type StreamCardRenderer = (stream: any) => string;
const PICTURE_SOURCE_CARD_RENDERERS: Record<string, StreamCardRenderer> = {
raw: (stream) => {
let capTmplName = '';
if (stream.capture_template_id) {
const capTmpl = _cachedCaptureTemplates.find(t => t.id === stream.capture_template_id);
if (capTmpl) capTmplName = escapeHtml(capTmpl.name);
}
return `<div class="stream-card-props">
<span class="stream-card-prop" title="${t('streams.display')}">${ICON_MONITOR} ${stream.display_index ?? 0}</span>
<span class="stream-card-prop" title="${t('streams.target_fps')}">${ICON_FPS} ${stream.target_fps ?? 30}</span>
${capTmplName ? `<span class="stream-card-prop stream-card-link" title="${t('streams.capture_template')}" onclick="event.stopPropagation(); navigateToCard('streams','raw_templates','raw-templates','data-template-id','${stream.capture_template_id}')">${ICON_TEMPLATE} ${capTmplName}</span>` : ''}
</div>`;
},
processed: (stream) => {
const sourceStream = _cachedStreams.find(s => s.id === stream.source_stream_id);
const sourceName = sourceStream ? escapeHtml(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';
let ppTmplName = '';
if (stream.postprocessing_template_id) {
const ppTmpl = _cachedPPTemplates.find(p => p.id === stream.postprocessing_template_id);
if (ppTmpl) ppTmplName = escapeHtml(ppTmpl.name);
}
return `<div class="stream-card-props">
<span class="stream-card-prop stream-card-link" title="${t('streams.source')}" onclick="event.stopPropagation(); navigateToCard('streams','${sourceSubTab}','${sourceSection}','data-stream-id','${stream.source_stream_id}')">${ICON_LINK_SOURCE} ${sourceName}</span>
${ppTmplName ? `<span class="stream-card-prop stream-card-link" title="${t('streams.pp_template')}" onclick="event.stopPropagation(); navigateToCard('streams','proc_templates','proc-templates','data-pp-template-id','${stream.postprocessing_template_id}')">${ICON_TEMPLATE} ${ppTmplName}</span>` : ''}
</div>`;
},
static_image: (stream) => {
const src = stream.image_source || '';
return `<div class="stream-card-props">
<span class="stream-card-prop stream-card-prop-full" title="${escapeHtml(src)}">${ICON_WEB} ${escapeHtml(src)}</span>
</div>`;
},
video: (stream) => {
const url = stream.url || '';
const shortUrl = url.length > 40 ? url.slice(0, 37) + '...' : url;
return `<div class="stream-card-props">
<span class="stream-card-prop stream-card-prop-full" title="${escapeHtml(url)}">${ICON_WEB} ${escapeHtml(shortUrl)}</span>
<span class="stream-card-prop" title="${t('streams.target_fps')}">${ICON_FPS} ${stream.target_fps ?? 30}</span>
${stream.loop !== false ? `<span class="stream-card-prop">↻</span>` : ''}
${stream.playback_speed && stream.playback_speed !== 1.0 ? `<span class="stream-card-prop">${stream.playback_speed}×</span>` : ''}
</div>`;
},
};
function renderPictureSourcesList(streams: any) {
const container = document.getElementById('streams-list')!;
const activeTab = localStorage.getItem('activeStreamTab') || 'raw';
const renderStreamCard = (stream: any) => {
const typeIcon = getPictureSourceIcon(stream.stream_type);
const renderer = PICTURE_SOURCE_CARD_RENDERERS[stream.stream_type];
const detailsHtml = renderer ? renderer(stream) : '';
return wrapCard({
type: 'template-card',
dataAttr: 'data-stream-id',
id: stream.id,
removeOnclick: `deleteStream('${stream.id}')`,
removeTitle: t('common.delete'),
content: `
<div class="template-card-header">
<div class="template-name" title="${escapeHtml(stream.name)}">${typeIcon} ${escapeHtml(stream.name)}</div>
</div>
${detailsHtml}
${renderTagChips(stream.tags)}
${stream.description ? `<div class="template-config" style="opacity:0.7;">${escapeHtml(stream.description)}</div>` : ''}`,
actions: `
<button class="btn btn-icon btn-secondary" onclick="showTestStreamModal('${stream.id}')" title="${t('streams.test.title')}">${ICON_TEST}</button>
<button class="btn btn-icon btn-secondary" onclick="cloneStream('${stream.id}')" title="${t('common.clone')}">${ICON_CLONE}</button>
<button class="btn btn-icon btn-secondary" onclick="editStream('${stream.id}')" title="${t('common.edit')}">${ICON_EDIT}</button>`,
});
};
const renderCaptureTemplateCard = (template: any) => {
const engineIcon = getEngineIcon(template.engine_type);
const configEntries = Object.entries(template.engine_config);
return wrapCard({
type: 'template-card',
dataAttr: 'data-template-id',
id: template.id,
removeOnclick: `deleteTemplate('${template.id}')`,
removeTitle: t('common.delete'),
content: `
<div class="template-card-header">
<div class="template-name" title="${escapeHtml(template.name)}">${ICON_TEMPLATE} ${escapeHtml(template.name)}</div>
</div>
${template.description ? `<div class="template-config" style="opacity:0.7;">${escapeHtml(template.description)}</div>` : ''}
<div class="stream-card-props">
<span class="stream-card-prop" title="${t('templates.engine')}">${getEngineIcon(template.engine_type)} ${template.engine_type.toUpperCase()}</span>
${configEntries.length > 0 ? `<span class="stream-card-prop" title="${t('templates.config.show')}">${ICON_WRENCH} ${configEntries.length}</span>` : ''}
</div>
${renderTagChips(template.tags)}
${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>
` : ''}`,
actions: `
<button class="btn btn-icon btn-secondary" onclick="showTestTemplateModal('${template.id}')" title="${t('templates.test.title')}">${ICON_TEST}</button>
<button class="btn btn-icon btn-secondary" onclick="cloneCaptureTemplate('${template.id}')" title="${t('common.clone')}">${ICON_CLONE}</button>
<button class="btn btn-icon btn-secondary" onclick="editTemplate('${template.id}')" title="${t('common.edit')}">${ICON_EDIT}</button>`,
});
};
const renderPPTemplateCard = (tmpl: any) => {
let filterChainHtml = '';
if (tmpl.filters && tmpl.filters.length > 0) {
const filterNames = tmpl.filters.map(fi => `<span class="filter-chain-item">${escapeHtml(_getFilterName(fi.filter_id))}</span>`);
filterChainHtml = `<div class="filter-chain">${filterNames.join('<span class="filter-chain-arrow">→</span>')}</div>`;
}
return wrapCard({
type: 'template-card',
dataAttr: 'data-pp-template-id',
id: tmpl.id,
removeOnclick: `deletePPTemplate('${tmpl.id}')`,
removeTitle: t('common.delete'),
content: `
<div class="template-card-header">
<div class="template-name" title="${escapeHtml(tmpl.name)}">${ICON_TEMPLATE} ${escapeHtml(tmpl.name)}</div>
</div>
${tmpl.description ? `<div class="template-config" style="opacity:0.7;">${escapeHtml(tmpl.description)}</div>` : ''}
${filterChainHtml}
${renderTagChips(tmpl.tags)}`,
actions: `
<button class="btn btn-icon btn-secondary" onclick="showTestPPTemplateModal('${tmpl.id}')" title="${t('postprocessing.test.title')}">${ICON_TEST}</button>
<button class="btn btn-icon btn-secondary" onclick="clonePPTemplate('${tmpl.id}')" title="${t('common.clone')}">${ICON_CLONE}</button>
<button class="btn btn-icon btn-secondary" onclick="editPPTemplate('${tmpl.id}')" title="${t('common.edit')}">${ICON_EDIT}</button>`,
});
};
const renderCSPTCard = (tmpl: any) => {
let filterChainHtml = '';
if (tmpl.filters && tmpl.filters.length > 0) {
const filterNames = tmpl.filters.map(fi => `<span class="filter-chain-item">${escapeHtml(_getStripFilterName(fi.filter_id))}</span>`);
filterChainHtml = `<div class="filter-chain">${filterNames.join('<span class="filter-chain-arrow">\u2192</span>')}</div>`;
}
return wrapCard({
type: 'template-card',
dataAttr: 'data-cspt-id',
id: tmpl.id,
removeOnclick: `deleteCSPT('${tmpl.id}')`,
removeTitle: t('common.delete'),
content: `
<div class="template-card-header">
<div class="template-name" title="${escapeHtml(tmpl.name)}">${ICON_CSPT} ${escapeHtml(tmpl.name)}</div>
</div>
${tmpl.description ? `<div class="template-config" style="opacity:0.7;">${escapeHtml(tmpl.description)}</div>` : ''}
${filterChainHtml}
${renderTagChips(tmpl.tags)}`,
actions: `
<button class="btn btn-icon btn-secondary" onclick="event.stopPropagation(); testCSPT('${tmpl.id}')" title="${t('color_strip.test.title')}">${ICON_TEST}</button>
<button class="btn btn-icon btn-secondary" onclick="cloneCSPT('${tmpl.id}')" title="${t('common.clone')}">${ICON_CLONE}</button>
<button class="btn btn-icon btn-secondary" onclick="editCSPT('${tmpl.id}')" title="${t('common.edit')}">${ICON_EDIT}</button>`,
});
};
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 multichannelSources = _cachedAudioSources.filter(s => s.source_type === 'multichannel');
const monoSources = _cachedAudioSources.filter(s => s.source_type === 'mono');
// 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 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('static'), titleKey: 'streams.group.color_strip', count: colorStrips.length },
{ key: 'gradients', icon: ICON_PALETTE, titleKey: 'streams.group.gradients', count: gradients.length },
{ key: 'audio', icon: getAudioSourceIcon('multichannel'), titleKey: 'streams.group.audio', count: _cachedAudioSources.length },
{ key: 'audio_templates', icon: ICON_AUDIO_TEMPLATE, titleKey: 'streams.group.audio_templates', count: _cachedAudioTemplates.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 },
];
// 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('static'), titleKey: 'tree.group.strip',
children: [
{ key: 'color_strip', titleKey: 'tree.leaf.sources', icon: getColorStripIcon('static'), 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('multichannel'), titleKey: 'tree.group.audio',
children: [
{ key: 'audio', titleKey: 'tree.leaf.sources', icon: getAudioSourceIcon('multichannel'), count: _cachedAudioSources.length },
{ key: 'audio_templates', titleKey: 'tree.leaf.templates', icon: ICON_AUDIO_TEMPLATE, count: _cachedAudioTemplates.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 },
]
}
];
const renderAudioSourceCard = (src: any) => {
const isMono = src.source_type === 'mono';
const icon = getAudioSourceIcon(src.source_type);
let propsHtml = '';
if (isMono) {
const parent = _cachedAudioSources.find(s => s.id === src.audio_source_id);
const parentName = parent ? parent.name : src.audio_source_id;
const chLabel = src.channel === 'left' ? 'L' : src.channel === 'right' ? 'R' : 'M';
const parentBadge = parent
? `<span class="stream-card-prop stream-card-link" title="${escapeHtml(t('audio_source.parent'))}" onclick="event.stopPropagation(); navigateToCard('streams','audio','audio-multi','data-id','${src.audio_source_id}')">${ICON_AUDIO_LOOPBACK} ${escapeHtml(parentName)}</span>`
: `<span class="stream-card-prop" title="${escapeHtml(t('audio_source.parent'))}">${ICON_AUDIO_LOOPBACK} ${escapeHtml(parentName)}</span>`;
propsHtml = `
${parentBadge}
<span class="stream-card-prop" title="${escapeHtml(t('audio_source.channel'))}">${ICON_RADIO} ${chLabel}</span>
`;
} else {
const devIdx = src.device_index ?? -1;
const loopback = src.is_loopback !== false;
const devLabel = loopback ? `${ICON_AUDIO_LOOPBACK} Loopback` : `${ICON_AUDIO_INPUT} Input`;
const tpl = src.audio_template_id ? _cachedAudioTemplates.find(t => t.id === src.audio_template_id) : null;
const tplBadge = tpl ? `<span class="stream-card-prop stream-card-link" title="${escapeHtml(t('audio_source.audio_template'))}" onclick="event.stopPropagation(); navigateToCard('streams','audio_templates','audio-templates','data-audio-template-id','${src.audio_template_id}')">${ICON_AUDIO_TEMPLATE} ${escapeHtml(tpl.name)}</span>` : '';
propsHtml = `<span class="stream-card-prop">${devLabel} #${devIdx}</span>${tplBadge}`;
}
return wrapCard({
type: 'template-card',
dataAttr: 'data-id',
id: src.id,
removeOnclick: `deleteAudioSource('${src.id}')`,
removeTitle: t('common.delete'),
content: `
<div class="template-card-header">
<div class="template-name" title="${escapeHtml(src.name)}">${icon} ${escapeHtml(src.name)}</div>
</div>
<div class="stream-card-props">${propsHtml}</div>
${renderTagChips(src.tags)}
${src.description ? `<div class="template-config" style="opacity:0.7;">${escapeHtml(src.description)}</div>` : ''}`,
actions: `
<button class="btn btn-icon btn-secondary" data-action="test-audio" title="${t('audio_source.test')}">${ICON_TEST}</button>
<button class="btn btn-icon btn-secondary" data-action="clone-audio" title="${t('common.clone')}">${ICON_CLONE}</button>
<button class="btn btn-icon btn-secondary" data-action="edit-audio" title="${t('common.edit')}">${ICON_EDIT}</button>`,
});
};
const renderAudioTemplateCard = (template: any) => {
const configEntries = Object.entries(template.engine_config || {});
return wrapCard({
type: 'template-card',
dataAttr: 'data-audio-template-id',
id: template.id,
removeOnclick: `deleteAudioTemplate('${template.id}')`,
removeTitle: t('common.delete'),
content: `
<div class="template-card-header">
<div class="template-name" title="${escapeHtml(template.name)}">${ICON_AUDIO_TEMPLATE} ${escapeHtml(template.name)}</div>
</div>
${template.description ? `<div class="template-config" style="opacity:0.7;">${escapeHtml(template.description)}</div>` : ''}
<div class="stream-card-props">
<span class="stream-card-prop" title="${t('audio_template.engine')}">${ICON_AUDIO_TEMPLATE} ${template.engine_type.toUpperCase()}</span>
${configEntries.length > 0 ? `<span class="stream-card-prop" title="${t('audio_template.config.show')}">${ICON_WRENCH} ${configEntries.length}</span>` : ''}
</div>
${renderTagChips(template.tags)}
${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>
` : ''}`,
actions: `
<button class="btn btn-icon btn-secondary" onclick="showTestAudioTemplateModal('${template.id}')" title="${t('audio_template.test')}">${ICON_TEST}</button>
<button class="btn btn-icon btn-secondary" onclick="cloneAudioTemplate('${template.id}')" title="${t('common.clone')}">${ICON_CLONE}</button>
<button class="btn btn-icon btn-secondary" onclick="editAudioTemplate('${template.id}')" title="${t('common.edit')}">${ICON_EDIT}</button>`,
});
};
// Gradient card renderer
const renderGradientCard = (g: GradientEntity) => {
const cssStops = g.stops.map(s => `rgb(${s.color.join(',')}) ${(s.position * 100).toFixed(0)}%`).join(', ');
const stripPreview = `<div style="height:24px;border-radius:4px;background:linear-gradient(to right,${cssStops});margin-bottom:6px"></div>`;
const lockBadge = g.is_builtin ? `<span class="badge badge-info" style="font-size:0.7em;margin-left:4px">${t('gradient.builtin')}</span>` : '';
const cloneBtn = `<button class="btn btn-icon btn-secondary" onclick="cloneGradient('${g.id}')" title="${t('common.clone')}">${ICON_CLONE}</button>`;
const editBtn = g.is_builtin ? '' : `<button class="btn btn-icon btn-secondary" onclick="editGradient('${g.id}')" title="${t('common.edit')}">${ICON_EDIT}</button>`;
return wrapCard({
type: 'template-card',
dataAttr: 'data-id',
id: g.id,
removeOnclick: g.is_builtin ? '' : `deleteGradient('${g.id}')`,
removeTitle: t('common.delete'),
content: `
<div class="template-card-header">
<div class="template-name">${ICON_PALETTE} ${escapeHtml(g.name)}${lockBadge}</div>
</div>
${stripPreview}
<div class="stream-card-props">
<span class="stream-card-prop">${g.stops.length} ${t('gradient.stops_label')}</span>
</div>`,
actions: `${cloneBtn}${editBtn}`,
});
};
// 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 multiItems = csAudioMulti.applySortOrder(multichannelSources.map(s => ({ key: s.id, html: renderAudioSourceCard(s) })));
const monoItems = csAudioMono.applySortOrder(monoSources.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 csptItems = csCSPTemplates.applySortOrder(csptTemplates.map(t => ({ key: t.id, html: renderCSPTCard(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,
value: _cachedValueSources.length,
sync: _cachedSyncClocks.length,
});
csRawStreams.reconcile(rawStreamItems);
csRawTemplates.reconcile(rawTemplateItems);
csProcStreams.reconcile(procStreamItems);
csProcTemplates.reconcile(procTemplateItems);
csCSPTemplates.reconcile(csptItems);
csColorStrips.reconcile(colorStripItems);
csGradients.reconcile(gradientItems);
csAudioMulti.reconcile(multiItems);
csAudioMono.reconcile(monoItems);
csAudioTemplates.reconcile(audioTemplateItems);
csStaticStreams.reconcile(staticItems);
csVideoStreams.reconcile(videoItems);
csValueSources.reconcile(valueItems);
csSyncClocks.reconcile(syncClockItems);
} 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') panelContent = csAudioMulti.render(multiItems) + csAudioMono.render(monoItems);
else if (tab.key === 'audio_templates') panelContent = csAudioTemplates.render(audioTemplateItems);
else if (tab.key === 'value') panelContent = csValueSources.render(valueItems);
else if (tab.key === 'sync') panelContent = csSyncClocks.render(syncClockItems);
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, csAudioMulti, csAudioMono, csAudioTemplates, csStaticStreams, csVideoStreams, csValueSources, csSyncClocks]);
// Event delegation for card actions (replaces inline onclick handlers)
initSyncClockDelegation(container);
initAudioSourceDelegation(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-multi': 'audio', 'audio-mono': 'audio',
'audio-templates': 'audio_templates',
'value-sources': 'value',
'sync-clocks': 'sync',
});
}
}
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;
set_lastValidatedImageSource('');
const imgSrcInput = document.getElementById('stream-image-source') as HTMLInputElement;
imgSrcInput.value = '';
document.getElementById('stream-image-preview-container')!.style.display = 'none';
document.getElementById('stream-image-validation-status')!.style.display = 'none';
imgSrcInput.onblur = () => validateStaticImage();
imgSrcInput.onkeydown = (e) => { if (e.key === 'Enter') { e.preventDefault(); validateStaticImage(); } };
imgSrcInput.onpaste = () => setTimeout(() => validateStaticImage(), 0);
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') {
(document.getElementById('stream-image-source') as HTMLInputElement).value = cloneData.image_source || '';
if (cloneData.image_source) validateStaticImage();
} else if (streamType === 'video') {
(document.getElementById('stream-video-url') as HTMLInputElement).value = cloneData.url || '';
(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;
set_lastValidatedImageSource('');
const imgSrcInput = document.getElementById('stream-image-source') as HTMLInputElement;
document.getElementById('stream-image-preview-container')!.style.display = 'none';
document.getElementById('stream-image-validation-status')!.style.display = 'none';
imgSrcInput.onblur = () => validateStaticImage();
imgSrcInput.onkeydown = (e) => { if (e.key === 'Enter') { e.preventDefault(); validateStaticImage(); } };
imgSrcInput.onpaste = () => setTimeout(() => validateStaticImage(), 0);
onStreamTypeChange();
await populateStreamModalDropdowns();
if (stream.stream_type === 'raw') {
(document.getElementById('stream-capture-template') as HTMLSelectElement).value = stream.capture_template_id || '';
// 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') {
(document.getElementById('stream-source') as HTMLSelectElement).value = stream.source_stream_id || '';
(document.getElementById('stream-pp-template') as HTMLSelectElement).value = stream.postprocessing_template_id || '';
} else if (stream.stream_type === 'static_image') {
(document.getElementById('stream-image-source') as HTMLInputElement).value = stream.image_source || '';
if (stream.image_source) validateStaticImage();
} else if (stream.stream_type === 'video') {
(document.getElementById('stream-video-url') as HTMLInputElement).value = stream.url || '';
(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[] => []),
]);
_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;
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, description: description || null, tags: _streamTagsInput ? _streamTagsInput.getValue() : [] };
if (!streamId) payload.stream_type = streamType;
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 imageSource = (document.getElementById('stream-image-source') as HTMLInputElement).value.trim();
if (!imageSource) { showToast(t('streams.error.required'), 'error'); return; }
payload.image_source = imageSource;
} else if (streamType === 'video') {
const url = (document.getElementById('stream-video-url') as HTMLInputElement).value.trim();
if (!url) { showToast(t('streams.error.required'), 'error'); return; }
payload.url = url;
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();
}
async function validateStaticImage() {
const source = (document.getElementById('stream-image-source') as HTMLInputElement).value.trim();
const previewContainer = document.getElementById('stream-image-preview-container')!;
const previewImg = document.getElementById('stream-image-preview') as HTMLImageElement;
const infoEl = document.getElementById('stream-image-info')!;
const statusEl = document.getElementById('stream-image-validation-status')!;
if (!source) {
set_lastValidatedImageSource('');
previewContainer.style.display = 'none';
statusEl.style.display = 'none';
return;
}
if (source === _lastValidatedImageSource) return;
statusEl.textContent = t('streams.validate_image.validating');
statusEl.className = 'validation-status loading';
statusEl.style.display = 'block';
previewContainer.style.display = 'none';
try {
const response = await fetchWithAuth('/picture-sources/validate-image', {
method: 'POST',
body: JSON.stringify({ image_source: source }),
});
const data = await response.json();
set_lastValidatedImageSource(source);
if (data.valid) {
previewImg.src = data.preview;
previewImg.style.cursor = 'pointer';
previewImg.onclick = () => openFullImageLightbox(source);
infoEl.textContent = `${data.width} × ${data.height} px`;
previewContainer.style.display = '';
statusEl.textContent = t('streams.validate_image.valid');
statusEl.className = 'validation-status success';
} else {
previewContainer.style.display = 'none';
statusEl.textContent = `${t('streams.validate_image.invalid')}: ${data.error}`;
statusEl.className = 'validation-status error';
}
} catch (err) {
previewContainer.style.display = 'none';
statusEl.textContent = `${t('streams.validate_image.invalid')}: ${err.message}`;
statusEl.className = 'validation-status error';
}
}
// ===== 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;
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;
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();
}