/** * Streams — picture sources, capture templates, PP templates, filters. */ import { _cachedDisplays, set_cachedDisplays, _cachedStreams, set_cachedStreams, _cachedPPTemplates, set_cachedPPTemplates, _cachedCaptureTemplates, set_cachedCaptureTemplates, _availableFilters, set_availableFilters, availableEngines, setAvailableEngines, currentEditingTemplateId, setCurrentEditingTemplateId, _templateNameManuallyEdited, set_templateNameManuallyEdited, _streamNameManuallyEdited, set_streamNameManuallyEdited, _streamModalPPTemplates, set_streamModalPPTemplates, _modalFilters, set_modalFilters, _ppTemplateNameManuallyEdited, set_ppTemplateNameManuallyEdited, _currentTestStreamId, set_currentTestStreamId, _currentTestPPTemplateId, set_currentTestPPTemplateId, _lastValidatedImageSource, set_lastValidatedImageSource, _cachedAudioSources, set_cachedAudioSources, _cachedValueSources, set_cachedValueSources, _cachedAudioTemplates, set_cachedAudioTemplates, availableAudioEngines, setAvailableAudioEngines, currentEditingAudioTemplateId, setCurrentEditingAudioTemplateId, _audioTemplateNameManuallyEdited, set_audioTemplateNameManuallyEdited, _sourcesLoading, set_sourcesLoading, apiKey, } 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, setTabRefreshing } from '../core/ui.js'; import { openDisplayPicker, formatDisplayLabel } from './displays.js'; import { CardSection } from '../core/card-sections.js'; import { updateSubTabHash } from './tabs.js'; import { createValueSourceCard } from './value-sources.js'; import { getEngineIcon, getPictureSourceIcon, getAudioSourceIcon, ICON_TEMPLATE, ICON_CLONE, ICON_EDIT, ICON_TEST, ICON_LINK_SOURCE, ICON_FPS, ICON_WEB, ICON_VALUE_SOURCE, ICON_AUDIO_LOOPBACK, ICON_AUDIO_INPUT, ICON_AUDIO_TEMPLATE, } from '../core/icons.js'; // ── Card section instances ── const csRawStreams = new CardSection('raw-streams', { titleKey: 'streams.section.streams', gridClass: 'templates-grid', addCardOnclick: "showAddStreamModal('raw')" }); const csRawTemplates = new CardSection('raw-templates', { titleKey: 'templates.title', gridClass: 'templates-grid', addCardOnclick: "showAddTemplateModal()" }); const csProcStreams = new CardSection('proc-streams', { titleKey: 'streams.section.streams', gridClass: 'templates-grid', addCardOnclick: "showAddStreamModal('processed')" }); const csProcTemplates = new CardSection('proc-templates', { titleKey: 'postprocessing.title', gridClass: 'templates-grid', addCardOnclick: "showAddPPTemplateModal()" }); const csAudioMulti = new CardSection('audio-multi', { titleKey: 'audio_source.group.multichannel', gridClass: 'templates-grid', addCardOnclick: "showAudioSourceModal('multichannel')" }); const csAudioMono = new CardSection('audio-mono', { titleKey: 'audio_source.group.mono', gridClass: 'templates-grid', addCardOnclick: "showAudioSourceModal('mono')" }); const csStaticStreams = new CardSection('static-streams', { titleKey: 'streams.group.static_image', gridClass: 'templates-grid', addCardOnclick: "showAddStreamModal('static_image')" }); const csAudioTemplates = new CardSection('audio-templates', { titleKey: 'audio_template.title', gridClass: 'templates-grid', addCardOnclick: "showAddAudioTemplateModal()" }); const csValueSources = new CardSection('value-sources', { titleKey: 'value_source.group.title', gridClass: 'templates-grid', addCardOnclick: "showValueSourceModal()" }); // 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, }; document.querySelectorAll('[data-config-key]').forEach(field => { vals['cfg_' + field.dataset.configKey] = field.value; }); return vals; } onForceClose() { 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, }; } onForceClose() { 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 }))), }; } onForceClose() { 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, }; document.querySelectorAll('#audio-engine-config-fields [data-config-key]').forEach(field => { vals['cfg_' + field.dataset.configKey] = field.value; }); return vals; } onForceClose() { setCurrentEditingAudioTemplateId(null); set_audioTemplateNameManuallyEdited(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'); const audioTemplateModal = new AudioTemplateModal(); // ===== Capture Templates ===== async function loadCaptureTemplates() { try { const response = await fetchWithAuth('/capture-templates'); if (!response.ok) throw new Error(`Failed to load templates: ${response.status}`); const data = await response.json(); set_cachedCaptureTemplates(data.templates || []); 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').textContent = 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); } 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').textContent = 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'; 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) { const templates = await fetchWithAuth('/capture-templates').then(r => r.json()); const template = templates.templates.find(t => t.id === templateId); if (!template) { showToast(t('templates.error.load'), 'error'); return; } window.currentTestingTemplate = template; await loadDisplaysForTest(); restoreCaptureDuration(); testTemplateModal.open(); } export function closeTestTemplateModal() { testTemplateModal.forceClose(); window.currentTestingTemplate = 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 = `${getEngineIcon(engine.type)} ${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; } } catch (error) { console.error('Error loading engines:', error); showToast(t('templates.error.engines') + ': ' + error.message, 'error'); } } export async function onEngineChange() { const engineType = document.getElementById('template-engine').value; 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 || {}; 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 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 when testing a scrcpy template const engineType = window.currentTestingTemplate?.engine_type; const url = engineType === 'scrcpy' ? `/config/displays?engine_type=scrcpy` : '/config/displays'; // Always refetch for scrcpy (devices may change); use cache for desktop if (!_cachedDisplays || engineType === 'scrcpy') { const response = await fetchWithAuth(url); if (!response.ok) throw new Error(`Failed to load displays: ${response.status}`); const displaysData = await response.json(); set_cachedDisplays(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 async function runTemplateTest() { if (!window.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 = window.currentTestingTemplate; showOverlaySpinner(t('templates.test.running'), captureDuration); const signal = window._overlayAbortController?.signal; try { const response = await fetchWithAuth('/capture-templates/test', { method: 'POST', body: JSON.stringify({ engine_type: template.engine_type, engine_config: template.engine_config, display_index: parseInt(displayIndex), capture_duration: captureDuration }), signal }); if (!response.ok) { const error = await response.json(); throw new Error(error.detail || error.message || 'Test failed'); } const result = await response.json(); localStorage.setItem('lastTestDisplayIndex', displayIndex); displayTestResults(result); } catch (error) { if (error.name === 'AbortError') return; console.error('Error running test:', error); hideOverlaySpinner(); showToast(t('templates.test.error.failed'), 'error'); } } function buildTestStatsHtml(result) { const p = result.performance; const res = `${result.full_capture.width}x${result.full_capture.height}`; let html = `
${t('templates.test.results.duration')}: ${p.capture_duration_s.toFixed(2)}s
${t('templates.test.results.frame_count')}: ${p.frame_count}
`; if (p.frame_count > 1) { html += `
${t('templates.test.results.actual_fps')}: ${p.actual_fps.toFixed(1)}
${t('templates.test.results.avg_capture_time')}: ${p.avg_capture_time_ms.toFixed(1)}ms
`; } html += `
Resolution: ${res}
`; return html; } function displayTestResults(result) { hideOverlaySpinner(); const fullImageSrc = result.full_capture.full_image || result.full_capture.image; openLightbox(fullImageSrc, buildTestStatsHtml(result)); } 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 }; 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(); 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'); 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; } } catch (error) { console.error('Error loading audio engines:', error); showToast(t('audio_template.error.engines') + ': ' + error.message, 'error'); } } export async function onAudioEngineChange() { const engineType = document.getElementById('audio-template-engine').value; 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 { const response = await fetchWithAuth('/audio-templates'); if (!response.ok) throw new Error(`Failed to load audio templates: ${response.status}`); const data = await response.json(); set_cachedAudioTemplates(data.templates || []); 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').textContent = 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); } 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').textContent = 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'; 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 }; 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(); 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'); 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('Failed to clone audio template', 'error'); } } // ===== Picture Sources ===== export async function loadPictureSources() { if (_sourcesLoading) return; set_sourcesLoading(true); setTabRefreshing('streams-list', true); try { const [filtersResp, ppResp, captResp, streamsResp, audioResp, valueResp, audioTplResp] = await Promise.all([ _availableFilters.length === 0 ? fetchWithAuth('/filters') : Promise.resolve(null), fetchWithAuth('/postprocessing-templates'), fetchWithAuth('/capture-templates'), fetchWithAuth('/picture-sources'), fetchWithAuth('/audio-sources'), fetchWithAuth('/value-sources'), fetchWithAuth('/audio-templates'), ]); if (filtersResp && filtersResp.ok) { const fd = await filtersResp.json(); set_availableFilters(fd.filters || []); } if (ppResp.ok) { const pd = await ppResp.json(); set_cachedPPTemplates(pd.templates || []); } if (captResp.ok) { const cd = await captResp.json(); set_cachedCaptureTemplates(cd.templates || []); } if (audioResp && audioResp.ok) { const ad = await audioResp.json(); set_cachedAudioSources(ad.sources || []); } if (valueResp && valueResp.ok) { const vd = await valueResp.json(); set_cachedValueSources(vd.sources || []); } if (audioTplResp && audioTplResp.ok) { const atd = await audioTplResp.json(); set_cachedAudioTemplates(atd.templates || []); } if (!streamsResp.ok) throw new Error(`Failed to load streams: ${streamsResp.status}`); const data = await streamsResp.json(); set_cachedStreams(data.streams || []); renderPictureSourcesList(_cachedStreams); } 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); } } export function switchStreamTab(tabKey) { document.querySelectorAll('.stream-tab-btn[data-stream-tab]').forEach(btn => btn.classList.toggle('active', btn.dataset.streamTab === 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); } const _streamSectionMap = { raw: [csRawStreams, csRawTemplates], static_image: [csStaticStreams], processed: [csProcStreams, csProcTemplates], audio: [csAudioMulti, csAudioMono], value: [csValueSources], }; 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 = `
🖥️ ${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)}
`; } return `
${typeIcon} ${escapeHtml(stream.name)}
${detailsHtml} ${stream.description ? `
${escapeHtml(stream.description)}
` : ''}
`; }; const renderCaptureTemplateCard = (template) => { const engineIcon = getEngineIcon(template.engine_type); const configEntries = Object.entries(template.engine_config); return `
${ICON_TEMPLATE} ${escapeHtml(template.name)}
${template.description ? `
${escapeHtml(template.description)}
` : ''}
${getEngineIcon(template.engine_type)} ${template.engine_type.toUpperCase()} ${configEntries.length > 0 ? `🔧 ${configEntries.length}` : ''}
${configEntries.length > 0 ? `
${t('templates.config.show')} ${configEntries.map(([key, val]) => ` `).join('')}
${escapeHtml(key)} ${escapeHtml(String(val))}
` : ''}
`; }; 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 `
${ICON_TEMPLATE} ${escapeHtml(tmpl.name)}
${tmpl.description ? `
${escapeHtml(tmpl.description)}
` : ''} ${filterChainHtml}
`; }; 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 multichannelSources = _cachedAudioSources.filter(s => s.source_type === 'multichannel'); const monoSources = _cachedAudioSources.filter(s => s.source_type === 'mono'); const tabs = [ { key: 'raw', icon: getPictureSourceIcon('raw'), titleKey: 'streams.group.raw', count: rawStreams.length }, { key: 'static_image', icon: getPictureSourceIcon('static_image'), titleKey: 'streams.group.static_image', count: staticImageStreams.length }, { key: 'processed', icon: getPictureSourceIcon('processed'), titleKey: 'streams.group.processed', count: processedStreams.length }, { key: 'audio', icon: getAudioSourceIcon('multichannel'), titleKey: 'streams.group.audio', count: _cachedAudioSources.length }, { key: 'value', icon: ICON_VALUE_SOURCE, titleKey: 'streams.group.value', count: _cachedValueSources.length }, ]; const tabBar = `
${tabs.map(tab => `` ).join('')}
`; 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'; propsHtml = ` ${ICON_AUDIO_LOOPBACK} ${escapeHtml(parentName)} 📻 ${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 `
${icon} ${escapeHtml(src.name)}
${propsHtml}
${src.description ? `
${escapeHtml(src.description)}
` : ''}
`; }; const renderAudioTemplateCard = (template) => { const configEntries = Object.entries(template.engine_config || {}); return `
${ICON_AUDIO_TEMPLATE} ${escapeHtml(template.name)}
${template.description ? `
${escapeHtml(template.description)}
` : ''}
${ICON_AUDIO_TEMPLATE} ${template.engine_type.toUpperCase()} ${configEntries.length > 0 ? `🔧 ${configEntries.length}` : ''}
${configEntries.length > 0 ? `
${t('audio_template.config.show')} ${configEntries.map(([key, val]) => ` `).join('')}
${escapeHtml(key)} ${escapeHtml(String(val))}
` : ''}
`; }; const panels = tabs.map(tab => { let panelContent = ''; if (tab.key === 'raw') { panelContent = csRawStreams.render(rawStreams.map(s => ({ key: s.id, html: renderStreamCard(s) }))) + csRawTemplates.render(_cachedCaptureTemplates.map(t => ({ key: t.id, html: renderCaptureTemplateCard(t) }))); } else if (tab.key === 'processed') { panelContent = csProcStreams.render(processedStreams.map(s => ({ key: s.id, html: renderStreamCard(s) }))) + csProcTemplates.render(_cachedPPTemplates.map(t => ({ key: t.id, html: renderPPTemplateCard(t) }))); } else if (tab.key === 'audio') { panelContent = csAudioMulti.render(multichannelSources.map(s => ({ key: s.id, html: renderAudioSourceCard(s) }))) + csAudioMono.render(monoSources.map(s => ({ key: s.id, html: renderAudioSourceCard(s) }))) + csAudioTemplates.render(_cachedAudioTemplates.map(t => ({ key: t.id, html: renderAudioTemplateCard(t) }))); } else if (tab.key === 'value') { panelContent = csValueSources.render(_cachedValueSources.map(s => ({ key: s.id, html: createValueSourceCard(s) }))); } else { panelContent = csStaticStreams.render(staticImageStreams.map(s => ({ key: s.id, html: renderStreamCard(s) }))); } return `
${panelContent}
`; }).join(''); container.innerHTML = tabBar + panels; CardSection.bindAll([csRawStreams, csRawTemplates, csProcStreams, csProcTemplates, csAudioMulti, csAudioMono, csAudioTemplates, csStaticStreams, csValueSources]); } 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'; } export function onStreamDisplaySelected(displayIndex, display) { document.getElementById('stream-display-index').value = displayIndex; document.getElementById('stream-display-picker-label').textContent = formatDisplayLabel(displayIndex, display); _autoGenerateStreamName(); } export function onTestDisplaySelected(displayIndex, display) { document.getElementById('test-template-display').value = displayIndex; document.getElementById('test-display-picker-label').textContent = formatDisplayLabel(displayIndex, display); } 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' }; document.getElementById('stream-modal-title').textContent = 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(); 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') { const displayIdx = cloneData.display_index ?? 0; const display = _cachedDisplays ? _cachedDisplays.find(d => d.index === displayIdx) : null; onStreamDisplaySelected(displayIdx, display); document.getElementById('stream-capture-template').value = cloneData.capture_template_id || ''; 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(); } } streamModal.open(); streamModal.snapshot(); } export async function editStream(streamId) { try { 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' }; document.getElementById('stream-modal-title').textContent = 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-error').style.display = 'none'; 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') { const displayIdx = stream.display_index ?? 0; const display = _cachedDisplays ? _cachedDisplays.find(d => d.index === displayIdx) : null; onStreamDisplaySelected(displayIdx, display); document.getElementById('stream-capture-template').value = stream.capture_template_id || ''; 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(); } streamModal.open(); streamModal.snapshot(); } catch (error) { console.error('Error loading stream:', error); showToast(t('streams.error.load') + ': ' + error.message, 'error'); } } /** Track which engine type the stream-modal displays were loaded for. */ let _streamModalDisplaysEngine = null; async function populateStreamModalDropdowns() { const [displaysRes, captureTemplatesRes, streamsRes, ppTemplatesRes] = await Promise.all([ fetch(`${API_BASE}/config/displays`, { headers: getHeaders() }), fetchWithAuth('/capture-templates'), fetchWithAuth('/picture-sources'), fetchWithAuth('/postprocessing-templates'), ]); if (displaysRes.ok) { const displaysData = await displaysRes.json(); set_cachedDisplays(displaysData.displays || []); } _streamModalDisplaysEngine = null; // desktop displays loaded 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 templateSelect = document.getElementById('stream-capture-template'); templateSelect.innerHTML = ''; if (captureTemplatesRes.ok) { const data = await captureTemplatesRes.json(); (data.templates || []).forEach(tmpl => { const opt = document.createElement('option'); opt.value = tmpl.id; opt.dataset.name = tmpl.name; opt.dataset.engineType = tmpl.engine_type; opt.textContent = `${getEngineIcon(tmpl.engine_type)} ${tmpl.name}`; templateSelect.appendChild(opt); }); } // When template changes, refresh displays if engine type switched templateSelect.addEventListener('change', _onCaptureTemplateChanged); const sourceSelect = document.getElementById('stream-source'); sourceSelect.innerHTML = ''; if (streamsRes.ok) { const data = await streamsRes.json(); const editingId = document.getElementById('stream-id').value; (data.streams || []).forEach(s => { if (s.id === editingId) return; const opt = document.createElement('option'); opt.value = s.id; opt.dataset.name = s.name; opt.textContent = `${getPictureSourceIcon(s.stream_type)} ${s.name}`; sourceSelect.appendChild(opt); }); } set_streamModalPPTemplates([]); const ppSelect = document.getElementById('stream-pp-template'); ppSelect.innerHTML = ''; if (ppTemplatesRes.ok) { const data = await ppTemplatesRes.json(); set_streamModalPPTemplates(data.templates || []); _streamModalPPTemplates.forEach(tmpl => { const opt = document.createElement('option'); opt.value = tmpl.id; opt.textContent = tmpl.name; ppSelect.appendChild(opt); }); } _autoGenerateStreamName(); // If the first template is an scrcpy engine, reload displays immediately const firstOpt = templateSelect.selectedOptions[0]; if (firstOpt?.dataset?.engineType === 'scrcpy') { await _refreshStreamDisplaysForEngine('scrcpy'); } } async function _onCaptureTemplateChanged() { const templateSelect = document.getElementById('stream-capture-template'); const engineType = templateSelect.selectedOptions[0]?.dataset?.engineType || null; const needsEngineDisplays = engineType === 'scrcpy'; const currentEngine = needsEngineDisplays ? 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(); set_cachedDisplays(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 }; 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; } 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(); 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'); await loadPictureSources(); } catch (error) { console.error('Error deleting stream:', error); showToast(t('streams.error.delete') + ': ' + error.message, 'error'); } } 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(); } 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 async function runStreamTest() { if (!_currentTestStreamId) return; const captureDuration = parseFloat(document.getElementById('test-stream-duration').value); showOverlaySpinner(t('streams.test.running'), captureDuration); const signal = window._overlayAbortController?.signal; try { const response = await fetchWithAuth(`/picture-sources/${_currentTestStreamId}/test`, { method: 'POST', body: JSON.stringify({ capture_duration: captureDuration }), signal }); if (!response.ok) { const error = await response.json(); throw new Error(error.detail || error.message || 'Test failed'); } const result = await response.json(); hideOverlaySpinner(); const fullImageSrc = result.full_capture.full_image || result.full_capture.image; openLightbox(fullImageSrc, buildTestStatsHtml(result)); } catch (error) { if (error.name === 'AbortError') return; console.error('Error running stream test:', error); hideOverlaySpinner(); showToast(t('streams.test.error.failed') + ': ' + error.message, 'error'); } } // ===== 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 { const resp = await fetchWithAuth('/picture-sources'); if (resp.ok) { const d = await resp.json(); set_cachedStreams(d.streams || []); } } 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; } testPPTemplateModal.open(); } 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 async 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); showOverlaySpinner(t('postprocessing.test.running'), captureDuration); const signal = window._overlayAbortController?.signal; try { const response = await fetchWithAuth(`/postprocessing-templates/${_currentTestPPTemplateId}/test`, { method: 'POST', body: JSON.stringify({ source_stream_id: sourceStreamId, capture_duration: captureDuration }), signal }); if (!response.ok) { const error = await response.json(); throw new Error(error.detail || error.message || 'Test failed'); } const result = await response.json(); hideOverlaySpinner(); const fullImageSrc = result.full_capture.full_image || result.full_capture.image; openLightbox(fullImageSrc, buildTestStatsHtml(result)); } catch (error) { if (error.name === 'AbortError') return; console.error('Error running PP template test:', error); hideOverlaySpinner(); showToast(t('postprocessing.test.error.failed') + ': ' + error.message, 'error'); } } // ===== PP Templates ===== async function loadAvailableFilters() { try { const response = await fetchWithAuth('/filters'); if (!response.ok) throw new Error(`Failed to load filters: ${response.status}`); const data = await response.json(); set_availableFilters(data.filters || []); } catch (error) { console.error('Error loading available filters:', error); set_availableFilters([]); } } async function loadPPTemplates() { try { if (_availableFilters.length === 0) await loadAvailableFilters(); const response = await fetchWithAuth('/postprocessing-templates'); if (!response.ok) throw new Error(`Failed to load templates: ${response.status}`); const data = await response.json(); set_cachedPPTemplates(data.templates || []); 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 _populateFilterSelect() { const select = document.getElementById('pp-add-filter-select'); select.innerHTML = ``; for (const f of _availableFilters) { const name = _getFilterName(f.filter_id); select.innerHTML += ``; } } export function renderModalFilterList() { const container = document.getElementById('pp-filter-list'); if (_modalFilters.length === 0) { container.innerHTML = `
${t('filters.empty')}
`; return; } let html = ''; _modalFilters.forEach((fi, index) => { const filterDef = _availableFilters.find(f => f.filter_id === fi.filter_id); const filterName = _getFilterName(fi.filter_id); const isExpanded = fi._expanded === true; let summary = ''; if (filterDef && !isExpanded) { summary = filterDef.options_schema.map(opt => { const val = fi.options[opt.key] !== undefined ? fi.options[opt.key] : opt.default; return val; }).join(', '); } html += `
${isExpanded ? '▼' : '▶'} ${escapeHtml(filterName)} ${summary ? `${escapeHtml(summary)}` : ''}
`; }); container.innerHTML = html; } export function addFilterFromSelect() { const select = document.getElementById('pp-add-filter-select'); const filterId = select.value; if (!filterId) return; const filterDef = _availableFilters.find(f => f.filter_id === filterId); if (!filterDef) return; const options = {}; for (const opt of filterDef.options_schema) { // For select options with empty default, use the first choice's value if (opt.type === 'select' && !opt.default && Array.isArray(opt.choices) && opt.choices.length > 0) { options[opt.key] = opt.choices[0].value; } else { options[opt.key] = opt.default; } } _modalFilters.push({ filter_id: filterId, options, _expanded: true }); select.value = ''; renderModalFilterList(); _autoGeneratePPTemplateName(); } export function toggleFilterExpand(index) { if (_modalFilters[index]) { _modalFilters[index]._expanded = !_modalFilters[index]._expanded; renderModalFilterList(); } } export function removeFilter(index) { _modalFilters.splice(index, 1); renderModalFilterList(); _autoGeneratePPTemplateName(); } export function moveFilter(index, direction) { const newIndex = index + direction; if (newIndex < 0 || newIndex >= _modalFilters.length) return; const tmp = _modalFilters[index]; _modalFilters[index] = _modalFilters[newIndex]; _modalFilters[newIndex] = tmp; renderModalFilterList(); _autoGeneratePPTemplateName(); } export function updateFilterOption(filterIndex, optionKey, value) { if (_modalFilters[filterIndex]) { const fi = _modalFilters[filterIndex]; const filterDef = _availableFilters.find(f => f.filter_id === fi.filter_id); if (filterDef) { const optDef = filterDef.options_schema.find(o => o.key === optionKey); if (optDef && optDef.type === 'bool') { fi.options[optionKey] = !!value; } else if (optDef && optDef.type === 'select') { fi.options[optionKey] = String(value); } else if (optDef && optDef.type === 'int') { fi.options[optionKey] = parseInt(value); } else { fi.options[optionKey] = parseFloat(value); } } else { fi.options[optionKey] = parseFloat(value); } } } function collectFilters() { return _modalFilters.map(fi => ({ filter_id: fi.filter_id, options: { ...fi.options }, })); } 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').textContent = 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); }; _populateFilterSelect(); 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 || ''; } 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').textContent = 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 }, }))); _populateFilterSelect(); renderModalFilterList(); 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 }; 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(); 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('Failed to clone picture source', '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('Failed to clone capture template', '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('Failed to clone postprocessing template', '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'); 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(); } // Exported helpers used by other modules export { updateCaptureDuration, buildTestStatsHtml };