/** * 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, } from '../core/state.js'; import { API_BASE, getHeaders, fetchWithAuth, escapeHtml } from '../core/api.js'; import { t } from '../core/i18n.js'; import { Modal } from '../core/modal.js'; import { showToast, showConfirm, openLightbox, openFullImageLightbox, showOverlaySpinner, hideOverlaySpinner, updateOverlayPreview, setTabRefreshing, setupBackdropClose } from '../core/ui.js'; import { openDisplayPicker, formatDisplayLabel } from './displays.js'; import { CardSection } from '../core/card-sections.js'; import { TreeNav } from '../core/tree-nav.js'; import { updateSubTabHash } from './tabs.js'; import { createValueSourceCard } from './value-sources.js'; import { createSyncClockCard } from './sync-clocks.js'; import { createColorStripCard } from './color-strips.js'; 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, } from '../core/icons.js'; import * as P from '../core/icon-paths.js'; const _icon = (d) => `${d}`; import { wrapCard } from '../core/card-colors.js'; import { TagInput, renderTagChips } from '../core/tag-input.js'; import { IconSelect } from '../core/icon-select.js'; import { EntitySelect } from '../core/entity-palette.js'; import { FilterListManager } from '../core/filter-list.js'; // ── TagInput instances for modals ── let _captureTemplateTagsInput = null; let _streamTagsInput = null; let _ppTemplateTagsInput = null; let _audioTemplateTagsInput = null; let _csptTagsInput = null; // ── Card section instances ── const csRawStreams = new CardSection('raw-streams', { titleKey: 'streams.section.streams', gridClass: 'templates-grid', addCardOnclick: "showAddStreamModal('raw')", keyAttr: 'data-stream-id', emptyKey: 'section.empty.picture_sources' }); const csRawTemplates = new CardSection('raw-templates', { titleKey: 'templates.title', gridClass: 'templates-grid', addCardOnclick: "showAddTemplateModal()", keyAttr: 'data-template-id', emptyKey: 'section.empty.capture_templates' }); 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' }); const csProcTemplates = new CardSection('proc-templates', { titleKey: 'postprocessing.title', gridClass: 'templates-grid', addCardOnclick: "showAddPPTemplateModal()", keyAttr: 'data-pp-template-id', emptyKey: 'section.empty.pp_templates' }); 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' }); 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' }); 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' }); 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' }); 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' }); 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' }); const csValueSources = new CardSection('value-sources', { titleKey: 'value_source.group.title', gridClass: 'templates-grid', addCardOnclick: "showValueSourceModal()", keyAttr: 'data-id', emptyKey: 'section.empty.value_sources' }); const csSyncClocks = new CardSection('sync-clocks', { titleKey: 'sync_clock.group.title', gridClass: 'templates-grid', addCardOnclick: "showSyncClockModal()", keyAttr: 'data-id', emptyKey: 'section.empty.sync_clocks' }); const csCSPTemplates = new CardSection('css-proc-templates', { titleKey: 'css_processing.title', gridClass: 'templates-grid', addCardOnclick: "showAddCSPTModal()", keyAttr: 'data-cspt-id', emptyKey: 'section.empty.cspt' }); // Re-render picture sources when language changes document.addEventListener('languageChanged', () => { if (apiKey) loadPictureSources(); }); // ===== Modal instances ===== class CaptureTemplateModal extends Modal { constructor() { super('template-modal'); } snapshotValues() { const vals = { name: document.getElementById('template-name').value, description: document.getElementById('template-description').value, engine: document.getElementById('template-engine').value, tags: JSON.stringify(_captureTemplateTagsInput ? _captureTemplateTagsInput.getValue() : []), }; document.querySelectorAll('[data-config-key]').forEach(field => { vals['cfg_' + field.dataset.configKey] = field.value; }); return vals; } onForceClose() { if (_captureTemplateTagsInput) { _captureTemplateTagsInput.destroy(); _captureTemplateTagsInput = null; } setCurrentEditingTemplateId(null); set_templateNameManuallyEdited(false); } } class StreamEditorModal extends Modal { constructor() { super('stream-modal'); } snapshotValues() { return { name: document.getElementById('stream-name').value, description: document.getElementById('stream-description').value, type: document.getElementById('stream-type').value, displayIndex: document.getElementById('stream-display-index').value, captureTemplate: document.getElementById('stream-capture-template').value, targetFps: document.getElementById('stream-target-fps').value, source: document.getElementById('stream-source').value, ppTemplate: document.getElementById('stream-pp-template').value, imageSource: document.getElementById('stream-image-source').value, tags: JSON.stringify(_streamTagsInput ? _streamTagsInput.getValue() : []), }; } onForceClose() { if (_streamTagsInput) { _streamTagsInput.destroy(); _streamTagsInput = null; } document.getElementById('stream-type').disabled = false; set_streamNameManuallyEdited(false); } } class PPTemplateEditorModal extends Modal { constructor() { super('pp-template-modal'); } snapshotValues() { return { name: document.getElementById('pp-template-name').value, description: document.getElementById('pp-template-description').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 AudioTemplateModal extends Modal { constructor() { super('audio-template-modal'); } snapshotValues() { const vals = { name: document.getElementById('audio-template-name').value, description: document.getElementById('audio-template-description').value, engine: document.getElementById('audio-template-engine').value, tags: JSON.stringify(_audioTemplateTagsInput ? _audioTemplateTagsInput.getValue() : []), }; document.querySelectorAll('#audio-engine-config-fields [data-config-key]').forEach(field => { vals['cfg_' + field.dataset.configKey] = field.value; }); return vals; } onForceClose() { if (_audioTemplateTagsInput) { _audioTemplateTagsInput.destroy(); _audioTemplateTagsInput = null; } setCurrentEditingAudioTemplateId(null); set_audioTemplateNameManuallyEdited(false); } } class CSPTEditorModal extends Modal { constructor() { super('cspt-modal'); } snapshotValues() { return { name: document.getElementById('cspt-name').value, description: document.getElementById('cspt-description').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 templateModal = new CaptureTemplateModal(); const testTemplateModal = new Modal('test-template-modal'); 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 = null; const audioTemplateModal = new AudioTemplateModal(); const csptModal = new CSPTEditorModal(); // ===== Capture Templates ===== async function loadCaptureTemplates() { try { await captureTemplatesCache.fetch(); renderPictureSourcesList(_cachedStreams); } catch (error) { if (error.isAuth) return; console.error('Error loading capture templates:', error); showToast(t('streams.error.load'), 'error'); } } export async function showAddTemplateModal(cloneData = null) { setCurrentEditingTemplateId(null); document.getElementById('template-modal-title').innerHTML = `${ICON_CAPTURE_TEMPLATE} ${t('templates.add')}`; document.getElementById('template-form').reset(); document.getElementById('template-id').value = ''; document.getElementById('engine-config-section').style.display = 'none'; document.getElementById('template-error').style.display = 'none'; set_templateNameManuallyEdited(!!cloneData); document.getElementById('template-name').oninput = () => { set_templateNameManuallyEdited(true); }; await loadAvailableEngines(); // Pre-fill from clone data after engines are loaded if (cloneData) { document.getElementById('template-name').value = (cloneData.name || '') + ' (Copy)'; document.getElementById('template-description').value = cloneData.description || ''; document.getElementById('template-engine').value = cloneData.engine_type; await onEngineChange(); populateEngineConfig(cloneData.engine_config); } // Tags if (_captureTemplateTagsInput) { _captureTemplateTagsInput.destroy(); _captureTemplateTagsInput = null; } _captureTemplateTagsInput = new TagInput(document.getElementById('capture-template-tags-container'), { placeholder: t('tags.placeholder') }); _captureTemplateTagsInput.setValue(cloneData ? (cloneData.tags || []) : []); templateModal.open(); templateModal.snapshot(); } export async function editTemplate(templateId) { try { const response = await fetchWithAuth(`/capture-templates/${templateId}`); if (!response.ok) throw new Error(`Failed to load template: ${response.status}`); const template = await response.json(); setCurrentEditingTemplateId(templateId); document.getElementById('template-modal-title').innerHTML = `${ICON_CAPTURE_TEMPLATE} ${t('templates.edit')}`; document.getElementById('template-id').value = templateId; document.getElementById('template-name').value = template.name; document.getElementById('template-description').value = template.description || ''; await loadAvailableEngines(); document.getElementById('template-engine').value = template.engine_type; await onEngineChange(); populateEngineConfig(template.engine_config); await loadDisplaysForTest(); const testResults = document.getElementById('template-test-results'); if (testResults) testResults.style.display = 'none'; document.getElementById('template-error').style.display = 'none'; // Tags if (_captureTemplateTagsInput) { _captureTemplateTagsInput.destroy(); _captureTemplateTagsInput = null; } _captureTemplateTagsInput = new TagInput(document.getElementById('capture-template-tags-container'), { placeholder: t('tags.placeholder') }); _captureTemplateTagsInput.setValue(template.tags || []); templateModal.open(); templateModal.snapshot(); } catch (error) { console.error('Error loading template:', error); showToast(t('templates.error.load') + ': ' + error.message, 'error'); } } export async function closeTemplateModal() { await templateModal.close(); } function updateCaptureDuration(value) { document.getElementById('test-template-duration-value').textContent = value; localStorage.setItem('capture_duration', value); } function restoreCaptureDuration() { const savedDuration = localStorage.getItem('capture_duration'); if (savedDuration) { const durationInput = document.getElementById('test-template-duration'); const durationValue = document.getElementById('test-template-duration-value'); durationInput.value = savedDuration; durationValue.textContent = savedDuration; } } export async function showTestTemplateModal(templateId) { try { const templates = await captureTemplatesCache.fetch(); const template = templates.find(tp => tp.id === templateId); if (!template) { showToast(t('templates.error.load'), 'error'); return; } setCurrentTestingTemplate(template); await loadDisplaysForTest(); restoreCaptureDuration(); testTemplateModal.open(); setupBackdropClose(testTemplateModal.el, () => closeTestTemplateModal()); } catch (error) { if (error.isAuth) return; showToast(t('templates.error.load'), 'error'); } } export function closeTestTemplateModal() { testTemplateModal.forceClose(); setCurrentTestingTemplate(null); } async function loadAvailableEngines() { try { const response = await fetchWithAuth('/capture-engines'); if (!response.ok) throw new Error(`Failed to load engines: ${response.status}`); const data = await response.json(); setAvailableEngines(data.engines || []); const select = document.getElementById('template-engine'); select.innerHTML = ''; availableEngines.forEach(engine => { const option = document.createElement('option'); option.value = engine.type; option.textContent = engine.name; if (!engine.available) { option.disabled = true; option.textContent += ` (${t('templates.engine.unavailable')})`; } select.appendChild(option); }); if (!select.value) { const firstAvailable = availableEngines.find(e => e.available); if (firstAvailable) select.value = firstAvailable.type; } // Update icon-grid selector with dynamic engine list const items = availableEngines .filter(e => e.available) .map(e => ({ value: e.type, icon: getEngineIcon(e.type), label: e.name, desc: t(`templates.engine.${e.type}.desc`) })); if (_engineIconSelect) { _engineIconSelect.updateItems(items); } else { _engineIconSelect = new IconSelect({ target: select, items, columns: 2 }); } _engineIconSelect.setValue(select.value); } catch (error) { console.error('Error loading engines:', error); showToast(t('templates.error.engines') + ': ' + error.message, 'error'); } } let _engineIconSelect = null; export async function onEngineChange() { const engineType = document.getElementById('template-engine').value; if (_engineIconSelect) _engineIconSelect.setValue(engineType); const configSection = document.getElementById('engine-config-section'); const configFields = document.getElementById('engine-config-fields'); if (!engineType) { configSection.style.display = 'none'; return; } const engine = availableEngines.find(e => e.type === engineType); if (!engine) { configSection.style.display = 'none'; return; } if (!_templateNameManuallyEdited && !document.getElementById('template-id').value) { document.getElementById('template-name').value = engine.name || engineType; } const hint = document.getElementById('engine-availability-hint'); if (!engine.available) { hint.textContent = t('templates.engine.unavailable.hint'); hint.style.display = 'block'; hint.style.color = 'var(--error-color)'; } else { hint.style.display = 'none'; } configFields.innerHTML = ''; const defaultConfig = engine.default_config || {}; // Known select options for specific config keys const CONFIG_SELECT_OPTIONS = { camera_backend: ['auto', 'dshow', 'msmf', 'v4l2'], }; // IconSelect definitions for specific config keys const CONFIG_ICON_SELECT = { camera_backend: { columns: 2, items: [ { value: 'auto', icon: _icon(P.refreshCw), label: 'Auto', desc: t('templates.config.camera_backend.auto') }, { value: 'dshow', icon: _icon(P.camera), label: 'DShow', desc: t('templates.config.camera_backend.dshow') }, { value: 'msmf', icon: _icon(P.film), label: 'MSMF', desc: t('templates.config.camera_backend.msmf') }, { value: 'v4l2', icon: _icon(P.monitor), label: 'V4L2', desc: t('templates.config.camera_backend.v4l2') }, ], }, }; if (Object.keys(defaultConfig).length === 0) { configSection.style.display = 'none'; return; } else { let gridHtml = '
'; Object.entries(defaultConfig).forEach(([key, value]) => { const fieldType = typeof value === 'number' ? 'number' : 'text'; const fieldValue = typeof value === 'boolean' ? (value ? 'true' : 'false') : value; const selectOptions = CONFIG_SELECT_OPTIONS[key]; gridHtml += `
${typeof value === 'boolean' ? ` ` : selectOptions ? ` ` : ` `}
`; }); gridHtml += '
'; configFields.innerHTML = gridHtml; // Apply IconSelect to known config selects for (const [key, cfg] of Object.entries(CONFIG_ICON_SELECT)) { const sel = document.getElementById(`config-${key}`); if (sel) new IconSelect({ target: sel, items: cfg.items, columns: cfg.columns }); } } configSection.style.display = 'block'; } function populateEngineConfig(config) { Object.entries(config).forEach(([key, value]) => { const field = document.getElementById(`config-${key}`); if (field) { if (field.tagName === 'SELECT') { field.value = value.toString(); } else { field.value = value; } } }); } function collectEngineConfig() { const config = {}; const fields = document.querySelectorAll('[data-config-key]'); fields.forEach(field => { const key = field.dataset.configKey; let value = field.value; if (field.type === 'number') { value = parseFloat(value); } else if (field.tagName === 'SELECT' && (value === 'true' || value === 'false')) { value = value === 'true'; } config[key] = value; }); return config; } async function loadDisplaysForTest() { try { // Use engine-specific display list for engines with own devices (camera, scrcpy) const engineType = currentTestingTemplate?.engine_type; const engineHasOwnDisplays = availableEngines.find(e => e.type === engineType)?.has_own_displays || false; const url = engineHasOwnDisplays ? `/config/displays?engine_type=${engineType}` : '/config/displays'; // Always refetch for engines with own displays (devices may change); use cache for desktop if (!_cachedDisplays || engineHasOwnDisplays) { const response = await fetchWithAuth(url); if (!response.ok) throw new Error(`Failed to load displays: ${response.status}`); const displaysData = await response.json(); displaysCache.update(displaysData.displays || []); } let selectedIndex = null; const lastDisplay = localStorage.getItem('lastTestDisplayIndex'); if (lastDisplay !== null) { const found = _cachedDisplays.find(d => d.index === parseInt(lastDisplay)); if (found) selectedIndex = found.index; } if (selectedIndex === null) { const primary = _cachedDisplays.find(d => d.is_primary); if (primary) selectedIndex = primary.index; else if (_cachedDisplays.length > 0) selectedIndex = _cachedDisplays[0].index; } if (selectedIndex !== null) { const display = _cachedDisplays.find(d => d.index === selectedIndex); onTestDisplaySelected(selectedIndex, display); } } catch (error) { console.error('Error loading displays:', error); } } export function runTemplateTest() { if (!currentTestingTemplate) { showToast(t('templates.test.error.no_engine'), 'error'); return; } const displayIndex = document.getElementById('test-template-display').value; const captureDuration = parseFloat(document.getElementById('test-template-duration').value); if (displayIndex === '') { showToast(t('templates.test.error.no_display'), 'error'); return; } const template = currentTestingTemplate; localStorage.setItem('lastTestDisplayIndex', displayIndex); const previewWidth = Math.round(Math.min(window.innerWidth * 0.8, 1920) * Math.min(window.devicePixelRatio || 1, 2)); _runTestViaWS( '/capture-templates/test/ws', {}, { engine_type: template.engine_type, engine_config: template.engine_config, display_index: parseInt(displayIndex), capture_duration: captureDuration, preview_width: previewWidth, }, captureDuration, ); } function buildTestStatsHtml(result) { // Support both REST format (nested) and WS format (flat) const p = result.performance || result; const duration = p.capture_duration_s ?? p.elapsed_s ?? 0; const frameCount = p.frame_count ?? 0; const fps = p.actual_fps ?? p.fps ?? 0; const avgMs = p.avg_capture_time_ms ?? p.avg_capture_ms ?? 0; const w = result.full_capture?.width ?? result.width ?? 0; const h = result.full_capture?.height ?? result.height ?? 0; const res = `${w}x${h}`; let html = `
${t('templates.test.results.duration')}: ${Number(duration).toFixed(2)}s
${t('templates.test.results.frame_count')}: ${frameCount}
`; if (frameCount > 1) { html += `
${t('templates.test.results.actual_fps')}: ${Number(fps).toFixed(1)}
${t('templates.test.results.avg_capture_time')}: ${Number(avgMs).toFixed(1)}ms
`; } html += `
${t('templates.test.results.resolution')} ${res}
`; return html; } // ===== Shared WebSocket test helper ===== /** * Run a capture test via WebSocket, streaming intermediate previews into * the overlay spinner and opening the lightbox with the final result. * * @param {string} wsPath Relative WS path (e.g. '/picture-sources/{id}/test/ws') * @param {Object} queryParams Extra query params (duration, source_stream_id, etc.) * @param {Object|null} firstMessage If non-null, sent as JSON after WS opens (for template test) * @param {number} duration Test duration for overlay progress ring */ function _runTestViaWS(wsPath, queryParams = {}, firstMessage = null, duration = 5) { const protocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:'; // Dynamic preview resolution: 80% of viewport width, scaled by DPR, capped at 1920px const previewWidth = Math.round(Math.min(window.innerWidth * 0.8, 1920) * Math.min(window.devicePixelRatio || 1, 2)); const params = new URLSearchParams({ token: apiKey, preview_width: previewWidth, ...queryParams }); const wsUrl = `${protocol}//${window.location.host}${API_BASE}${wsPath}?${params}`; showOverlaySpinner(t('streams.test.running'), duration); let gotResult = false; let ws; try { ws = new WebSocket(wsUrl); } catch (e) { hideOverlaySpinner(); showToast(t('streams.test.error.failed') + ': ' + e.message, 'error'); return; } // Close WS when user cancels overlay const patchCloseBtn = () => { const closeBtn = document.querySelector('.overlay-spinner-close'); if (closeBtn) { const origHandler = closeBtn.onclick; closeBtn.onclick = () => { if (ws.readyState <= WebSocket.OPEN) ws.close(); if (origHandler) origHandler(); }; } }; patchCloseBtn(); // Also close on ESC (overlay ESC handler calls hideOverlaySpinner which aborts) const origAbort = window._overlayAbortController; if (origAbort) { origAbort.signal.addEventListener('abort', () => { if (ws.readyState <= WebSocket.OPEN) ws.close(); }, { once: true }); } ws.onopen = () => { if (firstMessage) { ws.send(JSON.stringify(firstMessage)); } }; ws.onmessage = (event) => { try { const msg = JSON.parse(event.data); if (msg.type === 'frame') { updateOverlayPreview(msg.thumbnail, msg); } else if (msg.type === 'result') { gotResult = true; hideOverlaySpinner(); openLightbox(msg.full_image, buildTestStatsHtml(msg)); ws.close(); } else if (msg.type === 'error') { hideOverlaySpinner(); showToast(msg.detail || 'Test failed', 'error'); ws.close(); } } catch (e) { console.error('Error parsing test WS message:', e); } }; ws.onerror = () => { if (!gotResult) { hideOverlaySpinner(); showToast(t('streams.test.error.failed'), 'error'); } }; ws.onclose = () => { if (!gotResult) { hideOverlaySpinner(); } }; } export async function saveTemplate() { const templateId = document.getElementById('template-id').value; const name = document.getElementById('template-name').value.trim(); const engineType = document.getElementById('template-engine').value; if (!name || !engineType) { showToast(t('templates.error.required'), 'error'); return; } const description = document.getElementById('template-description').value.trim(); const engineConfig = collectEngineConfig(); const payload = { name, engine_type: engineType, engine_config: engineConfig, description: description || null, tags: _captureTemplateTagsInput ? _captureTemplateTagsInput.getValue() : [] }; try { let response; if (templateId) { response = await fetchWithAuth(`/capture-templates/${templateId}`, { method: 'PUT', body: JSON.stringify(payload) }); } else { response = await fetchWithAuth('/capture-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('templates.updated') : t('templates.created'), 'success'); templateModal.forceClose(); captureTemplatesCache.invalidate(); await loadCaptureTemplates(); } catch (error) { console.error('Error saving template:', error); document.getElementById('template-error').textContent = error.message; document.getElementById('template-error').style.display = 'block'; } } export async function deleteTemplate(templateId) { const confirmed = await showConfirm(t('templates.delete.confirm')); if (!confirmed) return; try { const response = await fetchWithAuth(`/capture-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('templates.deleted'), 'success'); captureTemplatesCache.invalidate(); await loadCaptureTemplates(); } catch (error) { console.error('Error deleting template:', error); showToast(t('templates.error.delete') + ': ' + error.message, 'error'); } } // ===== Audio Templates ===== async function loadAvailableAudioEngines() { try { const response = await fetchWithAuth('/audio-engines'); if (!response.ok) throw new Error(`Failed to load audio engines: ${response.status}`); const data = await response.json(); setAvailableAudioEngines(data.engines || []); const select = document.getElementById('audio-template-engine'); select.innerHTML = ''; availableAudioEngines.forEach(engine => { const option = document.createElement('option'); option.value = engine.type; option.textContent = `${engine.type.toUpperCase()}`; if (!engine.available) { option.disabled = true; option.textContent += ` (${t('audio_template.engine.unavailable')})`; } select.appendChild(option); }); if (!select.value) { const firstAvailable = availableAudioEngines.find(e => e.available); if (firstAvailable) select.value = firstAvailable.type; } // Update icon-grid selector with dynamic engine list const items = availableAudioEngines .filter(e => e.available) .map(e => ({ value: e.type, icon: getAudioEngineIcon(e.type), label: e.type.toUpperCase(), desc: '' })); if (_audioEngineIconSelect) { _audioEngineIconSelect.updateItems(items); } else { _audioEngineIconSelect = new IconSelect({ target: select, items, columns: 2 }); } _audioEngineIconSelect.setValue(select.value); } catch (error) { console.error('Error loading audio engines:', error); showToast(t('audio_template.error.engines') + ': ' + error.message, 'error'); } } let _audioEngineIconSelect = null; export async function onAudioEngineChange() { const engineType = document.getElementById('audio-template-engine').value; if (_audioEngineIconSelect) _audioEngineIconSelect.setValue(engineType); const configSection = document.getElementById('audio-engine-config-section'); const configFields = document.getElementById('audio-engine-config-fields'); if (!engineType) { configSection.style.display = 'none'; return; } const engine = availableAudioEngines.find(e => e.type === engineType); if (!engine) { configSection.style.display = 'none'; return; } if (!_audioTemplateNameManuallyEdited && !document.getElementById('audio-template-id').value) { document.getElementById('audio-template-name').value = engine.type.toUpperCase(); } const hint = document.getElementById('audio-engine-availability-hint'); if (!engine.available) { hint.textContent = t('audio_template.engine.unavailable.hint'); hint.style.display = 'block'; hint.style.color = 'var(--error-color)'; } else { hint.style.display = 'none'; } configFields.innerHTML = ''; const defaultConfig = engine.default_config || {}; if (Object.keys(defaultConfig).length === 0) { configSection.style.display = 'none'; return; } else { let gridHtml = '
'; Object.entries(defaultConfig).forEach(([key, value]) => { const fieldType = typeof value === 'number' ? 'number' : 'text'; const fieldValue = typeof value === 'boolean' ? (value ? 'true' : 'false') : value; gridHtml += `
${typeof value === 'boolean' ? ` ` : ` `}
`; }); gridHtml += '
'; configFields.innerHTML = gridHtml; } configSection.style.display = 'block'; } function populateAudioEngineConfig(config) { Object.entries(config).forEach(([key, value]) => { const field = document.getElementById(`audio-config-${key}`); if (field) { if (field.tagName === 'SELECT') { field.value = value.toString(); } else { field.value = value; } } }); } function collectAudioEngineConfig() { const config = {}; document.querySelectorAll('#audio-engine-config-fields [data-config-key]').forEach(field => { const key = field.dataset.configKey; let value = field.value; if (field.type === 'number') { value = parseFloat(value); } else if (field.tagName === 'SELECT' && (value === 'true' || value === 'false')) { value = value === 'true'; } config[key] = value; }); return config; } async function loadAudioTemplates() { try { await audioTemplatesCache.fetch(); renderPictureSourcesList(_cachedStreams); } catch (error) { if (error.isAuth) return; console.error('Error loading audio templates:', error); showToast(t('audio_template.error.load'), 'error'); } } export async function showAddAudioTemplateModal(cloneData = null) { setCurrentEditingAudioTemplateId(null); document.getElementById('audio-template-modal-title').innerHTML = `${ICON_AUDIO_TEMPLATE} ${t('audio_template.add')}`; document.getElementById('audio-template-form').reset(); document.getElementById('audio-template-id').value = ''; document.getElementById('audio-engine-config-section').style.display = 'none'; document.getElementById('audio-template-error').style.display = 'none'; set_audioTemplateNameManuallyEdited(!!cloneData); document.getElementById('audio-template-name').oninput = () => { set_audioTemplateNameManuallyEdited(true); }; await loadAvailableAudioEngines(); if (cloneData) { document.getElementById('audio-template-name').value = (cloneData.name || '') + ' (Copy)'; document.getElementById('audio-template-description').value = cloneData.description || ''; document.getElementById('audio-template-engine').value = cloneData.engine_type; await onAudioEngineChange(); populateAudioEngineConfig(cloneData.engine_config); } // Tags if (_audioTemplateTagsInput) { _audioTemplateTagsInput.destroy(); _audioTemplateTagsInput = null; } _audioTemplateTagsInput = new TagInput(document.getElementById('audio-template-tags-container'), { placeholder: t('tags.placeholder') }); _audioTemplateTagsInput.setValue(cloneData ? (cloneData.tags || []) : []); audioTemplateModal.open(); audioTemplateModal.snapshot(); } export async function editAudioTemplate(templateId) { try { const response = await fetchWithAuth(`/audio-templates/${templateId}`); if (!response.ok) throw new Error(`Failed to load audio template: ${response.status}`); const template = await response.json(); setCurrentEditingAudioTemplateId(templateId); document.getElementById('audio-template-modal-title').innerHTML = `${ICON_AUDIO_TEMPLATE} ${t('audio_template.edit')}`; document.getElementById('audio-template-id').value = templateId; document.getElementById('audio-template-name').value = template.name; document.getElementById('audio-template-description').value = template.description || ''; await loadAvailableAudioEngines(); document.getElementById('audio-template-engine').value = template.engine_type; await onAudioEngineChange(); populateAudioEngineConfig(template.engine_config); document.getElementById('audio-template-error').style.display = 'none'; // Tags if (_audioTemplateTagsInput) { _audioTemplateTagsInput.destroy(); _audioTemplateTagsInput = null; } _audioTemplateTagsInput = new TagInput(document.getElementById('audio-template-tags-container'), { placeholder: t('tags.placeholder') }); _audioTemplateTagsInput.setValue(template.tags || []); audioTemplateModal.open(); audioTemplateModal.snapshot(); } catch (error) { console.error('Error loading audio template:', error); showToast(t('audio_template.error.load') + ': ' + error.message, 'error'); } } export async function closeAudioTemplateModal() { await audioTemplateModal.close(); } export async function saveAudioTemplate() { const templateId = currentEditingAudioTemplateId; const name = document.getElementById('audio-template-name').value.trim(); const engineType = document.getElementById('audio-template-engine').value; if (!name || !engineType) { showToast(t('audio_template.error.required'), 'error'); return; } const description = document.getElementById('audio-template-description').value.trim(); const engineConfig = collectAudioEngineConfig(); const payload = { name, engine_type: engineType, engine_config: engineConfig, description: description || null, tags: _audioTemplateTagsInput ? _audioTemplateTagsInput.getValue() : [] }; try { let response; if (templateId) { response = await fetchWithAuth(`/audio-templates/${templateId}`, { method: 'PUT', body: JSON.stringify(payload) }); } else { response = await fetchWithAuth('/audio-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 audio template'); } showToast(templateId ? t('audio_template.updated') : t('audio_template.created'), 'success'); audioTemplateModal.forceClose(); audioTemplatesCache.invalidate(); await loadAudioTemplates(); } catch (error) { console.error('Error saving audio template:', error); document.getElementById('audio-template-error').textContent = error.message; document.getElementById('audio-template-error').style.display = 'block'; } } export async function deleteAudioTemplate(templateId) { const confirmed = await showConfirm(t('audio_template.delete.confirm')); if (!confirmed) return; try { const response = await fetchWithAuth(`/audio-templates/${templateId}`, { method: 'DELETE' }); if (!response.ok) { const error = await response.json(); throw new Error(error.detail || error.message || 'Failed to delete audio template'); } showToast(t('audio_template.deleted'), 'success'); audioTemplatesCache.invalidate(); await loadAudioTemplates(); } catch (error) { console.error('Error deleting audio template:', error); showToast(t('audio_template.error.delete') + ': ' + error.message, 'error'); } } export async function cloneAudioTemplate(templateId) { try { const resp = await fetchWithAuth(`/audio-templates/${templateId}`); if (!resp.ok) throw new Error('Failed to load audio template'); const tmpl = await resp.json(); showAddAudioTemplateModal(tmpl); } catch (error) { if (error.isAuth) return; console.error('Failed to clone audio template:', error); showToast(t('audio_template.error.clone_failed'), 'error'); } } // ===== Audio Template Test ===== const NUM_BANDS_TPL = 64; const TPL_PEAK_DECAY = 0.02; const TPL_BEAT_FLASH_DECAY = 0.06; let _tplTestWs = null; let _tplTestAnimFrame = null; let _tplTestLatest = null; let _tplTestPeaks = new Float32Array(NUM_BANDS_TPL); let _tplTestBeatFlash = 0; let _currentTestAudioTemplateId = null; const testAudioTemplateModal = new Modal('test-audio-template-modal', { backdrop: true, lock: true }); export async function showTestAudioTemplateModal(templateId) { _currentTestAudioTemplateId = templateId; // Find template's engine type so we show the correct device list const template = _cachedAudioTemplates.find(t => t.id === templateId); const engineType = template ? template.engine_type : null; // Load audio devices for picker — filter by engine type const deviceSelect = document.getElementById('test-audio-template-device'); try { const resp = await fetchWithAuth('/audio-devices'); if (resp.ok) { const data = await resp.json(); // Use engine-specific device list if available, fall back to flat list const devices = (engineType && data.by_engine && data.by_engine[engineType]) ? data.by_engine[engineType] : (data.devices || []); deviceSelect.innerHTML = devices.map(d => { const label = d.name; const val = `${d.index}:${d.is_loopback ? '1' : '0'}`; return ``; }).join(''); if (devices.length === 0) { deviceSelect.innerHTML = ''; } } } catch { deviceSelect.innerHTML = ''; } // Restore last used device const lastDevice = localStorage.getItem('lastAudioTestDevice'); if (lastDevice) { const opt = Array.from(deviceSelect.options).find(o => o.value === lastDevice); if (opt) deviceSelect.value = lastDevice; } // Reset visual state document.getElementById('audio-template-test-canvas').style.display = 'none'; document.getElementById('audio-template-test-stats').style.display = 'none'; document.getElementById('audio-template-test-status').style.display = 'none'; document.getElementById('test-audio-template-start-btn').style.display = ''; _tplCleanupTest(); testAudioTemplateModal.open(); } export function closeTestAudioTemplateModal() { _tplCleanupTest(); testAudioTemplateModal.forceClose(); _currentTestAudioTemplateId = null; } export function startAudioTemplateTest() { if (!_currentTestAudioTemplateId) return; const deviceVal = document.getElementById('test-audio-template-device').value || '-1:1'; const [devIdx, devLoop] = deviceVal.split(':'); localStorage.setItem('lastAudioTestDevice', deviceVal); // Show canvas + stats, hide run button, disable device picker document.getElementById('audio-template-test-canvas').style.display = ''; document.getElementById('audio-template-test-stats').style.display = ''; document.getElementById('test-audio-template-start-btn').style.display = 'none'; document.getElementById('test-audio-template-device').disabled = true; const statusEl = document.getElementById('audio-template-test-status'); statusEl.textContent = t('audio_source.test.connecting'); statusEl.style.display = ''; // Reset state _tplTestLatest = null; _tplTestPeaks.fill(0); _tplTestBeatFlash = 0; document.getElementById('audio-template-test-rms').textContent = '---'; document.getElementById('audio-template-test-peak').textContent = '---'; document.getElementById('audio-template-test-beat-dot').classList.remove('active'); // Size canvas const canvas = document.getElementById('audio-template-test-canvas'); _tplSizeCanvas(canvas); // Connect WebSocket const protocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:'; const wsUrl = `${protocol}//${window.location.host}${API_BASE}/audio-templates/${_currentTestAudioTemplateId}/test/ws?token=${encodeURIComponent(apiKey)}&device_index=${devIdx}&is_loopback=${devLoop === '1' ? '1' : '0'}`; try { _tplTestWs = new WebSocket(wsUrl); _tplTestWs.onopen = () => { statusEl.style.display = 'none'; }; _tplTestWs.onmessage = (event) => { try { _tplTestLatest = JSON.parse(event.data); } catch {} }; _tplTestWs.onclose = () => { _tplTestWs = null; }; _tplTestWs.onerror = () => { showToast(t('audio_source.test.error'), 'error'); _tplCleanupTest(); }; } catch { showToast(t('audio_source.test.error'), 'error'); _tplCleanupTest(); return; } _tplTestAnimFrame = requestAnimationFrame(_tplRenderLoop); } function _tplCleanupTest() { if (_tplTestAnimFrame) { cancelAnimationFrame(_tplTestAnimFrame); _tplTestAnimFrame = null; } if (_tplTestWs) { _tplTestWs.onclose = null; _tplTestWs.close(); _tplTestWs = null; } _tplTestLatest = null; // Re-enable device picker const devSel = document.getElementById('test-audio-template-device'); if (devSel) devSel.disabled = false; } function _tplSizeCanvas(canvas) { const rect = canvas.parentElement.getBoundingClientRect(); const dpr = window.devicePixelRatio || 1; canvas.width = rect.width * dpr; canvas.height = 200 * dpr; canvas.style.height = '200px'; canvas.getContext('2d').scale(dpr, dpr); } function _tplRenderLoop() { _tplRenderSpectrum(); if (testAudioTemplateModal.isOpen && _tplTestWs) { _tplTestAnimFrame = requestAnimationFrame(_tplRenderLoop); } } function _tplRenderSpectrum() { const canvas = document.getElementById('audio-template-test-canvas'); if (!canvas) return; const ctx = canvas.getContext('2d'); const dpr = window.devicePixelRatio || 1; const w = canvas.width / dpr; const h = canvas.height / dpr; ctx.setTransform(dpr, 0, 0, dpr, 0, 0); ctx.clearRect(0, 0, w, h); const data = _tplTestLatest; if (!data || !data.spectrum) return; const spectrum = data.spectrum; const gap = 1; const barWidth = (w - gap * (NUM_BANDS_TPL - 1)) / NUM_BANDS_TPL; // Beat flash if (data.beat) _tplTestBeatFlash = Math.min(1.0, data.beat_intensity + 0.3); if (_tplTestBeatFlash > 0) { ctx.fillStyle = `rgba(255, 255, 255, ${_tplTestBeatFlash * 0.08})`; ctx.fillRect(0, 0, w, h); _tplTestBeatFlash = Math.max(0, _tplTestBeatFlash - TPL_BEAT_FLASH_DECAY); } for (let i = 0; i < NUM_BANDS_TPL; i++) { const val = Math.min(1, spectrum[i]); const barHeight = val * h; const x = i * (barWidth + gap); const y = h - barHeight; const hue = (1 - val) * 120; ctx.fillStyle = `hsl(${hue}, 85%, 50%)`; ctx.fillRect(x, y, barWidth, barHeight); if (val > _tplTestPeaks[i]) { _tplTestPeaks[i] = val; } else { _tplTestPeaks[i] = Math.max(0, _tplTestPeaks[i] - TPL_PEAK_DECAY); } const peakY = h - _tplTestPeaks[i] * h; const peakHue = (1 - _tplTestPeaks[i]) * 120; ctx.fillStyle = `hsl(${peakHue}, 90%, 70%)`; ctx.fillRect(x, peakY, barWidth, 2); } document.getElementById('audio-template-test-rms').textContent = (data.rms * 100).toFixed(1) + '%'; document.getElementById('audio-template-test-peak').textContent = (data.peak * 100).toFixed(1) + '%'; const beatDot = document.getElementById('audio-template-test-beat-dot'); if (data.beat) { beatDot.classList.add('active'); } else { beatDot.classList.remove('active'); } } // ===== 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(), 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) { 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], }; export function expandAllStreamSections() { const activeTab = localStorage.getItem('activeStreamTab') || 'raw'; CardSection.expandAll(_streamSectionMap[activeTab] || []); } export function collapseAllStreamSections() { const activeTab = localStorage.getItem('activeStreamTab') || 'raw'; CardSection.collapseAll(_streamSectionMap[activeTab] || []); } function renderPictureSourcesList(streams) { const container = document.getElementById('streams-list'); const activeTab = localStorage.getItem('activeStreamTab') || 'raw'; const renderStreamCard = (stream) => { const typeIcon = getPictureSourceIcon(stream.stream_type); let detailsHtml = ''; if (stream.stream_type === 'raw') { let capTmplName = ''; if (stream.capture_template_id) { const capTmpl = _cachedCaptureTemplates.find(t => t.id === stream.capture_template_id); if (capTmpl) capTmplName = escapeHtml(capTmpl.name); } detailsHtml = `
${ICON_MONITOR} ${stream.display_index ?? 0} ${ICON_FPS} ${stream.target_fps ?? 30} ${capTmplName ? `${ICON_TEMPLATE} ${capTmplName}` : ''}
`; } else if (stream.stream_type === 'processed') { 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); } detailsHtml = `
${ICON_LINK_SOURCE} ${sourceName} ${ppTmplName ? `${ICON_TEMPLATE} ${ppTmplName}` : ''}
`; } else if (stream.stream_type === 'static_image') { const src = stream.image_source || ''; detailsHtml = `
${ICON_WEB} ${escapeHtml(src)}
`; } else if (stream.stream_type === 'video') { const url = stream.url || ''; const shortUrl = url.length > 40 ? url.slice(0, 37) + '...' : url; detailsHtml = `
${ICON_WEB} ${escapeHtml(shortUrl)} ${ICON_FPS} ${stream.target_fps ?? 30} ${stream.loop !== false ? `` : ''} ${stream.playback_speed && stream.playback_speed !== 1.0 ? `${stream.playback_speed}×` : ''}
`; } return wrapCard({ type: 'template-card', dataAttr: 'data-stream-id', id: stream.id, removeOnclick: `deleteStream('${stream.id}')`, removeTitle: t('common.delete'), content: `
${typeIcon} ${escapeHtml(stream.name)}
${detailsHtml} ${renderTagChips(stream.tags)} ${stream.description ? `
${escapeHtml(stream.description)}
` : ''}`, actions: ` `, }); }; const renderCaptureTemplateCard = (template) => { 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: `
${ICON_TEMPLATE} ${escapeHtml(template.name)}
${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]) => ` `).join('')}
${escapeHtml(key)} ${escapeHtml(String(val))}
` : ''}`, actions: ` `, }); }; const renderPPTemplateCard = (tmpl) => { 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: `
${ICON_TEMPLATE} ${escapeHtml(tmpl.name)}
${tmpl.description ? `
${escapeHtml(tmpl.description)}
` : ''} ${filterChainHtml} ${renderTagChips(tmpl.tags)}`, actions: ` `, }); }; const renderCSPTCard = (tmpl) => { 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: `
${ICON_CSPT} ${escapeHtml(tmpl.name)}
${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 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: '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: '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) => { 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: `
${icon} ${escapeHtml(src.name)}
${propsHtml}
${renderTagChips(src.tags)} ${src.description ? `
${escapeHtml(src.description)}
` : ''}`, actions: ` `, }); }; const renderAudioTemplateCard = (template) => { 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: `
${ICON_AUDIO_TEMPLATE} ${escapeHtml(template.name)}
${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]) => ` `).join('')}
${escapeHtml(key)} ${escapeHtml(String(val))}
` : ''}`, actions: ` `, }); }; // 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 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, 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); 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 === '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, csAudioMulti, csAudioMono, csAudioTemplates, csStaticStreams, csVideoStreams, csValueSources, csSyncClocks]); // 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', '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').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, display) { document.getElementById('stream-display-index').value = displayIndex; const engineType = document.getElementById('stream-capture-template').selectedOptions[0]?.dataset?.engineType || null; document.getElementById('stream-display-picker-label').textContent = formatDisplayLabel(displayIndex, display, engineType); _autoGenerateStreamName(); } export function onTestDisplaySelected(displayIndex, display) { document.getElementById('test-template-display').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').value) return; const streamType = document.getElementById('stream-type').value; const nameInput = document.getElementById('stream-name'); if (streamType === 'raw') { const displayIndex = document.getElementById('stream-display-index').value; const templateSelect = document.getElementById('stream-capture-template'); 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'); const sourceName = sourceSelect.selectedOptions[0]?.dataset?.name || ''; const ppTemplateId = document.getElementById('stream-pp-template').value; const ppTemplate = _streamModalPPTemplates.find(t => t.id === ppTemplateId); if (!sourceName) return; if (ppTemplate && ppTemplate.name) { nameInput.value = `${sourceName} (${ppTemplate.name})`; } else { nameInput.value = sourceName; } } } export async function showAddStreamModal(presetType, cloneData = null) { const streamType = (cloneData && cloneData.stream_type) || presetType || 'raw'; const titleKeys = { 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').reset(); document.getElementById('stream-id').value = ''; document.getElementById('stream-display-index').value = ''; document.getElementById('stream-display-picker-label').textContent = t('displays.picker.select'); document.getElementById('stream-error').style.display = 'none'; document.getElementById('stream-type').value = streamType; set_lastValidatedImageSource(''); const imgSrcInput = document.getElementById('stream-image-source'); 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').oninput = () => { set_streamNameManuallyEdited(true); }; document.getElementById('stream-capture-template').onchange = () => _autoGenerateStreamName(); document.getElementById('stream-source').onchange = () => _autoGenerateStreamName(); document.getElementById('stream-pp-template').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').value = (cloneData.name || '') + ' (Copy)'; document.getElementById('stream-description').value = cloneData.description || ''; if (streamType === 'raw') { document.getElementById('stream-capture-template').value = cloneData.capture_template_id || ''; await _onCaptureTemplateChanged(); const displayIdx = cloneData.display_index ?? 0; const display = _cachedDisplays ? _cachedDisplays.find(d => d.index === displayIdx) : null; onStreamDisplaySelected(displayIdx, display); const fps = cloneData.target_fps ?? 30; document.getElementById('stream-target-fps').value = fps; document.getElementById('stream-target-fps-value').textContent = fps; } else if (streamType === 'processed') { document.getElementById('stream-source').value = cloneData.source_stream_id || ''; document.getElementById('stream-pp-template').value = cloneData.postprocessing_template_id || ''; } else if (streamType === 'static_image') { document.getElementById('stream-image-source').value = cloneData.image_source || ''; if (cloneData.image_source) validateStaticImage(); } else if (streamType === 'video') { document.getElementById('stream-video-url').value = cloneData.url || ''; document.getElementById('stream-video-loop').checked = cloneData.loop !== false; document.getElementById('stream-video-speed').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').value = cloneData.target_fps || 30; document.getElementById('stream-video-start').value = cloneData.start_time || ''; document.getElementById('stream-video-end').value = cloneData.end_time || ''; document.getElementById('stream-video-resolution').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) { try { // Open modal instantly with loading indicator document.getElementById('stream-modal-title').innerHTML = t('streams.edit'); document.getElementById('stream-form').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 = { 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').value = streamId; document.getElementById('stream-name').value = stream.name; document.getElementById('stream-description').value = stream.description || ''; document.getElementById('stream-type').value = stream.stream_type; set_lastValidatedImageSource(''); const imgSrcInput = document.getElementById('stream-image-source'); 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').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 => d.index === displayIdx) : null; onStreamDisplaySelected(displayIdx, display); const fps = stream.target_fps ?? 30; document.getElementById('stream-target-fps').value = fps; document.getElementById('stream-target-fps-value').textContent = fps; } else if (stream.stream_type === 'processed') { document.getElementById('stream-source').value = stream.source_stream_id || ''; document.getElementById('stream-pp-template').value = stream.postprocessing_template_id || ''; } else if (stream.stream_type === 'static_image') { document.getElementById('stream-image-source').value = stream.image_source || ''; if (stream.image_source) validateStaticImage(); } else if (stream.stream_type === 'video') { document.getElementById('stream-video-url').value = stream.url || ''; document.getElementById('stream-video-loop').checked = stream.loop !== false; document.getElementById('stream-video-speed').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').value = stream.target_fps || 30; document.getElementById('stream-video-start').value = stream.start_time || ''; document.getElementById('stream-video-end').value = stream.end_time || ''; document.getElementById('stream-video-resolution').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 = null; // ── EntitySelect instances for stream modal ── let _captureTemplateEntitySelect = null; let _sourceEntitySelect = null; let _ppTemplateEntitySelect = null; async function populateStreamModalDropdowns() { const [captureTemplates, streams, ppTemplates] = await Promise.all([ captureTemplatesCache.fetch().catch(() => []), streamsCache.fetch().catch(() => []), ppTemplatesCache.fetch().catch(() => []), displaysCache.fetch().catch(() => []), ]); _streamModalDisplaysEngine = null; const templateSelect = document.getElementById('stream-capture-template'); templateSelect.innerHTML = ''; captureTemplates.forEach(tmpl => { 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 => 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').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'); sourceSelect.innerHTML = ''; const editingId = document.getElementById('stream-id').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'); 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 => ({ 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').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 => ({ 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'); 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) { _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').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').value; const name = document.getElementById('stream-name').value.trim(); const streamType = document.getElementById('stream-type').value; const description = document.getElementById('stream-description').value.trim(); const errorEl = document.getElementById('stream-error'); if (!name) { showToast(t('streams.error.required'), 'error'); return; } const payload = { 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').value) || 0; payload.capture_template_id = document.getElementById('stream-capture-template').value; payload.target_fps = parseInt(document.getElementById('stream-target-fps').value) || 30; } else if (streamType === 'processed') { payload.source_stream_id = document.getElementById('stream-source').value; payload.postprocessing_template_id = document.getElementById('stream-pp-template').value; } else if (streamType === 'static_image') { const imageSource = document.getElementById('stream-image-source').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').value.trim(); if (!url) { showToast(t('streams.error.required'), 'error'); return; } payload.url = url; payload.loop = document.getElementById('stream-video-loop').checked; payload.playback_speed = parseFloat(document.getElementById('stream-video-speed').value) || 1.0; payload.target_fps = parseInt(document.getElementById('stream-video-fps').value) || 30; const startTime = parseFloat(document.getElementById('stream-video-start').value); if (!isNaN(startTime) && startTime > 0) payload.start_time = startTime; const endTime = parseFloat(document.getElementById('stream-video-end').value); if (!isNaN(endTime) && endTime > 0) payload.end_time = endTime; const resLimit = parseInt(document.getElementById('stream-video-resolution').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) { 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) { const loading = document.getElementById('stream-modal-loading'); const form = document.getElementById('stream-form'); const footer = document.querySelector('#stream-modal .modal-footer'); 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').value.trim(); const previewContainer = document.getElementById('stream-image-preview-container'); const previewImg = document.getElementById('stream-image-preview'); 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) { set_currentTestStreamId(streamId); restoreStreamTestDuration(); testStreamModal.open(); setupBackdropClose(testStreamModal.el, () => closeTestStreamModal()); } export function closeTestStreamModal() { testStreamModal.forceClose(); set_currentTestStreamId(null); } export function updateStreamTestDuration(value) { 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').value = saved; document.getElementById('test-stream-duration-value').textContent = saved; } export function runStreamTest() { if (!_currentTestStreamId) return; const captureDuration = parseFloat(document.getElementById('test-stream-duration').value); _runTestViaWS( `/picture-sources/${_currentTestStreamId}/test/ws`, { duration: captureDuration }, null, captureDuration, ); } // ===== PP Template Test ===== export async function showTestPPTemplateModal(templateId) { set_currentTestPPTemplateId(templateId); restorePPTestDuration(); const select = document.getElementById('test-pp-source-stream'); 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 => ({ value: s.id, label: s.name, icon: getPictureSourceIcon(s.stream_type), })), placeholder: t('palette.search'), }); testPPTemplateModal.open(); setupBackdropClose(testPPTemplateModal.el, () => closeTestPPTemplateModal()); } export function closeTestPPTemplateModal() { testPPTemplateModal.forceClose(); set_currentTestPPTemplateId(null); } export function updatePPTestDuration(value) { 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').value = saved; document.getElementById('test-pp-duration-value').textContent = saved; } export function runPPTemplateTest() { if (!_currentTestPPTemplateId) return; const sourceStreamId = document.getElementById('test-pp-source-stream').value; if (!sourceStreamId) { showToast(t('postprocessing.test.error.no_stream'), 'error'); return; } localStorage.setItem('lastPPTestStreamId', sourceStreamId); const captureDuration = parseFloat(document.getElementById('test-pp-duration').value); _runTestViaWS( `/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.filter_id === filterId); return def ? def.filter_name : filterId; } return translated; } function _getStripFilterName(filterId) { const key = 'filters.' + filterId; const translated = t(key); if (translated === key) { const def = _stripFilters.find(f => f.filter_id === filterId); return def ? def.filter_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) { // Palette-colored grids (e.g. palette quantization preset) container.querySelectorAll('select[data-palette-grid]').forEach(sel => { 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 => { 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 => ({ 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 = null; function _initFilterDragForContainer(containerId, filtersArr, rerenderFn) { const container = document.getElementById(containerId); if (!container) return; container.addEventListener('pointerdown', (e) => { const handle = e.target.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.dataset.filterIndex, 10); _filterDragState = { card, container, startY: e.clientY, started: false, clone: null, placeholder: null, offsetY: 0, fromIndex, scrollRaf: null, filtersArr, rerenderFn, }; const onMove = (ev) => _onFilterDragMove(ev); const onUp = () => { document.removeEventListener('pointermove', onMove); document.removeEventListener('pointerup', onUp); _onFilterDragEnd(); }; document.addEventListener('pointermove', onMove); document.addEventListener('pointerup', onUp); }); } function _onFilterDragMove(e) { 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, e) { 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, ds) { 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) { ppFilterManager.toggleExpand(index); } export function removeFilter(index) { ppFilterManager.remove(index); } export function moveFilter(index, direction) { ppFilterManager.move(index, direction); } export function updateFilterOption(filterIndex, optionKey, value) { ppFilterManager.updateOption(filterIndex, optionKey, value); } // ── CSPT filter actions (delegate to csptFilterManager) ── export function csptAddFilterFromSelect() { csptFilterManager.addFromSelect(); } export function csptToggleFilterExpand(index) { csptFilterManager.toggleExpand(index); } export function csptRemoveFilter(index) { csptFilterManager.remove(index); } export function csptUpdateFilterOption(filterIndex, optionKey, value) { csptFilterManager.updateOption(filterIndex, optionKey, value); } function collectFilters() { return ppFilterManager.collect(); } function _autoGeneratePPTemplateName() { if (_ppTemplateNameManuallyEdited) return; if (document.getElementById('pp-template-id').value) return; const nameInput = document.getElementById('pp-template-name'); 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 = 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').reset(); document.getElementById('pp-template-id').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').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').value = (cloneData.name || '') + ' (Copy)'; document.getElementById('pp-template-description').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) { 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').value = templateId; document.getElementById('pp-template-name').value = tmpl.name; document.getElementById('pp-template-description').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').value; const name = document.getElementById('pp-template-name').value.trim(); const description = document.getElementById('pp-template-description').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) { 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) { try { const resp = await fetchWithAuth(`/capture-templates/${templateId}`); if (!resp.ok) throw new Error('Failed to load template'); const tmpl = await resp.json(); showAddTemplateModal(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) { 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) { 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').value) return; const nameInput = document.getElementById('cspt-name'); 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 = null) { if (_stripFilters.length === 0) await loadStripFilters(); document.getElementById('cspt-modal-title').innerHTML = `${ICON_CSPT} ${t('css_processing.add')}`; document.getElementById('cspt-form').reset(); document.getElementById('cspt-id').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').oninput = () => { set_csptNameManuallyEdited(true); }; csptFilterManager.populateSelect(() => csptAddFilterFromSelect()); renderCSPTModalFilterList(); if (cloneData) { document.getElementById('cspt-name').value = (cloneData.name || '') + ' (Copy)'; document.getElementById('cspt-description').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) { 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').value = templateId; document.getElementById('cspt-name').value = tmpl.name; document.getElementById('cspt-description').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').value; const name = document.getElementById('cspt-name').value.trim(); const description = document.getElementById('cspt-description').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) { 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) { 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(); } // Exported helpers used by other modules export { updateCaptureDuration, buildTestStatsHtml };