/**
* 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) => ``;
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 = `
${t('streams.error.load')}: ${error.message}
`;
} 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 = {
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 `
${ICON_MONITOR} ${stream.display_index ?? 0}
${ICON_FPS} ${stream.target_fps ?? 30}
${capTmplName ? `${ICON_TEMPLATE} ${capTmplName}` : ''}
`;
},
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 `
${ICON_LINK_SOURCE} ${sourceName}
${ppTmplName ? `${ICON_TEMPLATE} ${ppTmplName}` : ''}
`;
},
static_image: (stream) => {
const src = stream.image_source || '';
return `
${ICON_WEB} ${escapeHtml(src)}
`;
},
video: (stream) => {
const url = stream.url || '';
const shortUrl = url.length > 40 ? url.slice(0, 37) + '...' : url;
return `
${ICON_WEB} ${escapeHtml(shortUrl)}
${ICON_FPS} ${stream.target_fps ?? 30}
${stream.loop !== false ? `↻` : ''}
${stream.playback_speed && stream.playback_speed !== 1.0 ? `${stream.playback_speed}×` : ''}
`;
},
};
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: `
${detailsHtml}
${renderTagChips(stream.tags)}
${stream.description ? `${escapeHtml(stream.description)}
` : ''}`,
actions: `
`,
});
};
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: `
${template.description ? `${escapeHtml(template.description)}
` : ''}
${getEngineIcon(template.engine_type)} ${template.engine_type.toUpperCase()}
${configEntries.length > 0 ? `${ICON_WRENCH} ${configEntries.length}` : ''}
${renderTagChips(template.tags)}
${configEntries.length > 0 ? `
${configEntries.map(([key, val]) => `
| ${escapeHtml(key)} |
${escapeHtml(String(val))} |
`).join('')}
` : ''}`,
actions: `
`,
});
};
const renderPPTemplateCard = (tmpl: any) => {
let filterChainHtml = '';
if (tmpl.filters && tmpl.filters.length > 0) {
const filterNames = tmpl.filters.map(fi => `${escapeHtml(_getFilterName(fi.filter_id))}`);
filterChainHtml = `${filterNames.join('→')}
`;
}
return wrapCard({
type: 'template-card',
dataAttr: 'data-pp-template-id',
id: tmpl.id,
removeOnclick: `deletePPTemplate('${tmpl.id}')`,
removeTitle: t('common.delete'),
content: `
${tmpl.description ? `${escapeHtml(tmpl.description)}
` : ''}
${filterChainHtml}
${renderTagChips(tmpl.tags)}`,
actions: `
`,
});
};
const renderCSPTCard = (tmpl: any) => {
let filterChainHtml = '';
if (tmpl.filters && tmpl.filters.length > 0) {
const filterNames = tmpl.filters.map(fi => `${escapeHtml(_getStripFilterName(fi.filter_id))}`);
filterChainHtml = `${filterNames.join('\u2192')}
`;
}
return wrapCard({
type: 'template-card',
dataAttr: 'data-cspt-id',
id: tmpl.id,
removeOnclick: `deleteCSPT('${tmpl.id}')`,
removeTitle: t('common.delete'),
content: `
${tmpl.description ? `${escapeHtml(tmpl.description)}
` : ''}
${filterChainHtml}
${renderTagChips(tmpl.tags)}`,
actions: `
`,
});
};
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
? `${ICON_AUDIO_LOOPBACK} ${escapeHtml(parentName)}`
: `${ICON_AUDIO_LOOPBACK} ${escapeHtml(parentName)}`;
propsHtml = `
${parentBadge}
${ICON_RADIO} ${chLabel}
`;
} 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 ? `${ICON_AUDIO_TEMPLATE} ${escapeHtml(tpl.name)}` : '';
propsHtml = `${devLabel} #${devIdx}${tplBadge}`;
}
return wrapCard({
type: 'template-card',
dataAttr: 'data-id',
id: src.id,
removeOnclick: `deleteAudioSource('${src.id}')`,
removeTitle: t('common.delete'),
content: `
${propsHtml}
${renderTagChips(src.tags)}
${src.description ? `${escapeHtml(src.description)}
` : ''}`,
actions: `
`,
});
};
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: `
${template.description ? `${escapeHtml(template.description)}
` : ''}
${ICON_AUDIO_TEMPLATE} ${template.engine_type.toUpperCase()}
${configEntries.length > 0 ? `${ICON_WRENCH} ${configEntries.length}` : ''}
${renderTagChips(template.tags)}
${configEntries.length > 0 ? `
${configEntries.map(([key, val]) => `
| ${escapeHtml(key)} |
${escapeHtml(String(val))} |
`).join('')}
` : ''}`,
actions: `
`,
});
};
// 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 = ``;
const lockBadge = g.is_builtin ? `${t('gradient.builtin')}` : '';
const cloneBtn = ``;
const editBtn = g.is_builtin ? '' : ``;
return wrapCard({
type: 'template-card',
dataAttr: 'data-id',
id: g.id,
removeOnclick: g.is_builtin ? '' : `deleteGradient('${g.id}')`,
removeTitle: t('common.delete'),
content: `
${stripPreview}
${g.stops.length} ${t('gradient.stops_label')}
`,
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 `${panelContent}
`;
}).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(``);
_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 ``;
}
const stops = hexColors.map((c, i) => `${c} ${(i / (hexColors.length - 1) * 100).toFixed(0)}%`).join(', ');
return ``;
}
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();
}