Files
wled-screen-controller-mixed/server/src/wled_controller/static/js/features/streams.js
alexei.dolgolyov 05152a0f51 Settings tabs, log overlay, external URL, Sources tree restructure, audio fixes
- Settings modal split into 3 tabs: General, Backup, MQTT
- Log viewer moved to full-screen overlay with compact toolbar
- External URL setting: API endpoints + UI for configuring server domain
  used in webhook/WS URLs instead of auto-detected local IP
- Sources tab tree restructured: Picture Source (Screen Capture/Static/
  Processed sub-groups), Color Strip, Audio, Utility
- TreeNav extended to support nested groups (3-level tree)
- Audio tab split into Sources and Templates sub-tabs
- Fix audio template test: device picker now filters by engine type
  (was showing WASAPI indices for sounddevice templates)
- Audio template test device picker disabled during active test
- Rename "Input Source" to "Source" in CSS test preview (en/ru/zh)
- Fix i18n: log filter/level items deferred to avoid stale t() calls

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-17 17:16:57 +03:00

2969 lines
132 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* Streams — picture sources, capture templates, PP templates, filters.
*/
import {
_cachedDisplays,
displaysCache,
_cachedStreams,
_cachedPPTemplates,
_cachedCaptureTemplates,
_availableFilters,
availableEngines, setAvailableEngines,
currentEditingTemplateId, setCurrentEditingTemplateId,
_templateNameManuallyEdited, set_templateNameManuallyEdited,
_streamNameManuallyEdited, set_streamNameManuallyEdited,
_streamModalPPTemplates, set_streamModalPPTemplates,
_modalFilters, set_modalFilters,
_ppTemplateNameManuallyEdited, set_ppTemplateNameManuallyEdited,
currentTestingTemplate, setCurrentTestingTemplate,
_currentTestStreamId, set_currentTestStreamId,
_currentTestPPTemplateId, set_currentTestPPTemplateId,
_lastValidatedImageSource, set_lastValidatedImageSource,
_cachedAudioSources,
_cachedValueSources,
_cachedSyncClocks,
_cachedAudioTemplates,
_cachedCSPTemplates,
_csptModalFilters, set_csptModalFilters,
_csptNameManuallyEdited, set_csptNameManuallyEdited,
_stripFilters,
availableAudioEngines, setAvailableAudioEngines,
currentEditingAudioTemplateId, setCurrentEditingAudioTemplateId,
_audioTemplateNameManuallyEdited, set_audioTemplateNameManuallyEdited,
_sourcesLoading, set_sourcesLoading,
apiKey,
streamsCache, ppTemplatesCache, captureTemplatesCache,
audioSourcesCache, audioTemplatesCache, valueSourcesCache, syncClocksCache, filtersCache,
colorStripSourcesCache,
csptCache, stripFiltersCache,
} 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) => `<svg class="icon" viewBox="0 0 24 24">${d}</svg>`;
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 = '<div class="config-grid">';
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 += `
<label class="config-grid-label" for="config-${key}">${key}</label>
<div class="config-grid-value">
${typeof value === 'boolean' ? `
<select id="config-${key}" data-config-key="${key}">
<option value="true" ${value ? 'selected' : ''}>true</option>
<option value="false" ${!value ? 'selected' : ''}>false</option>
</select>
` : selectOptions ? `
<select id="config-${key}" data-config-key="${key}">
${selectOptions.map(opt => `<option value="${opt}" ${opt === String(value) ? 'selected' : ''}>${opt}</option>`).join('')}
</select>
` : `
<input type="${fieldType}" id="config-${key}" data-config-key="${key}" value="${fieldValue}">
`}
</div>
`;
});
gridHtml += '</div>';
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 = `
<div class="stat-item"><span>${t('templates.test.results.duration')}:</span> <strong>${Number(duration).toFixed(2)}s</strong></div>
<div class="stat-item"><span>${t('templates.test.results.frame_count')}:</span> <strong>${frameCount}</strong></div>`;
if (frameCount > 1) {
html += `
<div class="stat-item"><span>${t('templates.test.results.actual_fps')}:</span> <strong>${Number(fps).toFixed(1)}</strong></div>
<div class="stat-item"><span>${t('templates.test.results.avg_capture_time')}:</span> <strong>${Number(avgMs).toFixed(1)}ms</strong></div>`;
}
html += `
<div class="stat-item"><span>${t('templates.test.results.resolution')}</span> <strong>${res}</strong></div>`;
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 = '<div class="config-grid">';
Object.entries(defaultConfig).forEach(([key, value]) => {
const fieldType = typeof value === 'number' ? 'number' : 'text';
const fieldValue = typeof value === 'boolean' ? (value ? 'true' : 'false') : value;
gridHtml += `
<label class="config-grid-label" for="audio-config-${key}">${key}</label>
<div class="config-grid-value">
${typeof value === 'boolean' ? `
<select id="audio-config-${key}" data-config-key="${key}">
<option value="true" ${value ? 'selected' : ''}>true</option>
<option value="false" ${!value ? 'selected' : ''}>false</option>
</select>
` : `
<input type="${fieldType}" id="audio-config-${key}" data-config-key="${key}" value="${fieldValue}">
`}
</div>
`;
});
gridHtml += '</div>';
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 `<option value="${val}">${escapeHtml(label)}</option>`;
}).join('');
if (devices.length === 0) {
deviceSelect.innerHTML = '<option value="-1:1">Default</option>';
}
}
} catch {
deviceSelect.innerHTML = '<option value="-1:1">Default</option>';
}
// 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 = `
<div class="error-message">${t('streams.error.load')}: ${error.message}</div>
`;
} finally {
set_sourcesLoading(false);
setTabRefreshing('streams-list', false);
}
}
let _streamsTreeTriggered = false;
const _streamsTree = new TreeNav('streams-tree-nav', {
onSelect: (key) => {
_streamsTreeTriggered = true;
switchStreamTab(key);
_streamsTreeTriggered = false;
}
});
export function switchStreamTab(tabKey) {
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 = `<div class="stream-card-props">
<span class="stream-card-prop" title="${t('streams.display')}">${ICON_MONITOR} ${stream.display_index ?? 0}</span>
<span class="stream-card-prop" title="${t('streams.target_fps')}">${ICON_FPS} ${stream.target_fps ?? 30}</span>
${capTmplName ? `<span class="stream-card-prop stream-card-link" title="${t('streams.capture_template')}" onclick="event.stopPropagation(); navigateToCard('streams','raw_templates','raw-templates','data-template-id','${stream.capture_template_id}')">${ICON_TEMPLATE} ${capTmplName}</span>` : ''}
</div>`;
} 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 = `<div class="stream-card-props">
<span class="stream-card-prop stream-card-link" title="${t('streams.source')}" onclick="event.stopPropagation(); navigateToCard('streams','${sourceSubTab}','${sourceSection}','data-stream-id','${stream.source_stream_id}')">${ICON_LINK_SOURCE} ${sourceName}</span>
${ppTmplName ? `<span class="stream-card-prop stream-card-link" title="${t('streams.pp_template')}" onclick="event.stopPropagation(); navigateToCard('streams','proc_templates','proc-templates','data-pp-template-id','${stream.postprocessing_template_id}')">${ICON_TEMPLATE} ${ppTmplName}</span>` : ''}
</div>`;
} else if (stream.stream_type === 'static_image') {
const src = stream.image_source || '';
detailsHtml = `<div class="stream-card-props">
<span class="stream-card-prop stream-card-prop-full" title="${escapeHtml(src)}">${ICON_WEB} ${escapeHtml(src)}</span>
</div>`;
} else if (stream.stream_type === 'video') {
const url = stream.url || '';
const shortUrl = url.length > 40 ? url.slice(0, 37) + '...' : url;
detailsHtml = `<div class="stream-card-props">
<span class="stream-card-prop stream-card-prop-full" title="${escapeHtml(url)}">${ICON_WEB} ${escapeHtml(shortUrl)}</span>
<span class="stream-card-prop" title="${t('streams.target_fps')}">${ICON_FPS} ${stream.target_fps ?? 30}</span>
${stream.loop !== false ? `<span class="stream-card-prop">↻</span>` : ''}
${stream.playback_speed && stream.playback_speed !== 1.0 ? `<span class="stream-card-prop">${stream.playback_speed}×</span>` : ''}
</div>`;
}
return wrapCard({
type: 'template-card',
dataAttr: 'data-stream-id',
id: stream.id,
removeOnclick: `deleteStream('${stream.id}')`,
removeTitle: t('common.delete'),
content: `
<div class="template-card-header">
<div class="template-name">${typeIcon} ${escapeHtml(stream.name)}</div>
</div>
${detailsHtml}
${renderTagChips(stream.tags)}
${stream.description ? `<div class="template-config" style="opacity:0.7;">${escapeHtml(stream.description)}</div>` : ''}`,
actions: `
<button class="btn btn-icon btn-secondary" onclick="showTestStreamModal('${stream.id}')" title="${t('streams.test.title')}">${ICON_TEST}</button>
<button class="btn btn-icon btn-secondary" onclick="cloneStream('${stream.id}')" title="${t('common.clone')}">${ICON_CLONE}</button>
<button class="btn btn-icon btn-secondary" onclick="editStream('${stream.id}')" title="${t('common.edit')}">${ICON_EDIT}</button>`,
});
};
const renderCaptureTemplateCard = (template) => {
const engineIcon = getEngineIcon(template.engine_type);
const configEntries = Object.entries(template.engine_config);
return wrapCard({
type: 'template-card',
dataAttr: 'data-template-id',
id: template.id,
removeOnclick: `deleteTemplate('${template.id}')`,
removeTitle: t('common.delete'),
content: `
<div class="template-card-header">
<div class="template-name">${ICON_TEMPLATE} ${escapeHtml(template.name)}</div>
</div>
${template.description ? `<div class="template-config" style="opacity:0.7;">${escapeHtml(template.description)}</div>` : ''}
<div class="stream-card-props">
<span class="stream-card-prop" title="${t('templates.engine')}">${getEngineIcon(template.engine_type)} ${template.engine_type.toUpperCase()}</span>
${configEntries.length > 0 ? `<span class="stream-card-prop" title="${t('templates.config.show')}">${ICON_WRENCH} ${configEntries.length}</span>` : ''}
</div>
${renderTagChips(template.tags)}
${configEntries.length > 0 ? `
<div class="template-config-collapse">
<button type="button" class="template-config-toggle" onclick="this.parentElement.classList.toggle('open')">${t('templates.config.show')}</button>
<div class="template-config-animate">
<div class="template-config-inner">
<table class="config-table">
${configEntries.map(([key, val]) => `
<tr>
<td class="config-key">${escapeHtml(key)}</td>
<td class="config-value">${escapeHtml(String(val))}</td>
</tr>
`).join('')}
</table>
</div>
</div>
</div>
` : ''}`,
actions: `
<button class="btn btn-icon btn-secondary" onclick="showTestTemplateModal('${template.id}')" title="${t('templates.test.title')}">${ICON_TEST}</button>
<button class="btn btn-icon btn-secondary" onclick="cloneCaptureTemplate('${template.id}')" title="${t('common.clone')}">${ICON_CLONE}</button>
<button class="btn btn-icon btn-secondary" onclick="editTemplate('${template.id}')" title="${t('common.edit')}">${ICON_EDIT}</button>`,
});
};
const renderPPTemplateCard = (tmpl) => {
let filterChainHtml = '';
if (tmpl.filters && tmpl.filters.length > 0) {
const filterNames = tmpl.filters.map(fi => `<span class="filter-chain-item">${escapeHtml(_getFilterName(fi.filter_id))}</span>`);
filterChainHtml = `<div class="filter-chain">${filterNames.join('<span class="filter-chain-arrow">→</span>')}</div>`;
}
return wrapCard({
type: 'template-card',
dataAttr: 'data-pp-template-id',
id: tmpl.id,
removeOnclick: `deletePPTemplate('${tmpl.id}')`,
removeTitle: t('common.delete'),
content: `
<div class="template-card-header">
<div class="template-name">${ICON_TEMPLATE} ${escapeHtml(tmpl.name)}</div>
</div>
${tmpl.description ? `<div class="template-config" style="opacity:0.7;">${escapeHtml(tmpl.description)}</div>` : ''}
${filterChainHtml}
${renderTagChips(tmpl.tags)}`,
actions: `
<button class="btn btn-icon btn-secondary" onclick="showTestPPTemplateModal('${tmpl.id}')" title="${t('postprocessing.test.title')}">${ICON_TEST}</button>
<button class="btn btn-icon btn-secondary" onclick="clonePPTemplate('${tmpl.id}')" title="${t('common.clone')}">${ICON_CLONE}</button>
<button class="btn btn-icon btn-secondary" onclick="editPPTemplate('${tmpl.id}')" title="${t('common.edit')}">${ICON_EDIT}</button>`,
});
};
const renderCSPTCard = (tmpl) => {
let filterChainHtml = '';
if (tmpl.filters && tmpl.filters.length > 0) {
const filterNames = tmpl.filters.map(fi => `<span class="filter-chain-item">${escapeHtml(_getStripFilterName(fi.filter_id))}</span>`);
filterChainHtml = `<div class="filter-chain">${filterNames.join('<span class="filter-chain-arrow">\u2192</span>')}</div>`;
}
return wrapCard({
type: 'template-card',
dataAttr: 'data-cspt-id',
id: tmpl.id,
removeOnclick: `deleteCSPT('${tmpl.id}')`,
removeTitle: t('common.delete'),
content: `
<div class="template-card-header">
<div class="template-name">${ICON_CSPT} ${escapeHtml(tmpl.name)}</div>
</div>
${tmpl.description ? `<div class="template-config" style="opacity:0.7;">${escapeHtml(tmpl.description)}</div>` : ''}
${filterChainHtml}
${renderTagChips(tmpl.tags)}`,
actions: `
<button class="btn btn-icon btn-secondary" onclick="event.stopPropagation(); testCSPT('${tmpl.id}')" title="${t('color_strip.test.title')}">${ICON_TEST}</button>
<button class="btn btn-icon btn-secondary" onclick="cloneCSPT('${tmpl.id}')" title="${t('common.clone')}">${ICON_CLONE}</button>
<button class="btn btn-icon btn-secondary" onclick="editCSPT('${tmpl.id}')" title="${t('common.edit')}">${ICON_EDIT}</button>`,
});
};
const rawStreams = streams.filter(s => s.stream_type === 'raw');
const processedStreams = streams.filter(s => s.stream_type === 'processed');
const staticImageStreams = streams.filter(s => s.stream_type === 'static_image');
const videoStreams = streams.filter(s => s.stream_type === 'video');
const multichannelSources = _cachedAudioSources.filter(s => s.source_type === 'multichannel');
const monoSources = _cachedAudioSources.filter(s => s.source_type === 'mono');
// CSPT templates
const csptTemplates = csptCache.data;
// Color strip sources (maps needed for card rendering)
const colorStrips = colorStripSourcesCache.data;
const pictureSourceMap = {};
streams.forEach(s => { pictureSourceMap[s.id] = s; });
const audioSourceMap = {};
_cachedAudioSources.forEach(s => { audioSourceMap[s.id] = s; });
const 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
? `<span class="stream-card-prop stream-card-link" title="${escapeHtml(t('audio_source.parent'))}" onclick="event.stopPropagation(); navigateToCard('streams','audio','audio-multi','data-id','${src.audio_source_id}')">${ICON_AUDIO_LOOPBACK} ${escapeHtml(parentName)}</span>`
: `<span class="stream-card-prop" title="${escapeHtml(t('audio_source.parent'))}">${ICON_AUDIO_LOOPBACK} ${escapeHtml(parentName)}</span>`;
propsHtml = `
${parentBadge}
<span class="stream-card-prop" title="${escapeHtml(t('audio_source.channel'))}">${ICON_RADIO} ${chLabel}</span>
`;
} else {
const devIdx = src.device_index ?? -1;
const loopback = src.is_loopback !== false;
const devLabel = loopback ? `${ICON_AUDIO_LOOPBACK} Loopback` : `${ICON_AUDIO_INPUT} Input`;
const tpl = src.audio_template_id ? _cachedAudioTemplates.find(t => t.id === src.audio_template_id) : null;
const tplBadge = tpl ? `<span class="stream-card-prop stream-card-link" title="${escapeHtml(t('audio_source.audio_template'))}" onclick="event.stopPropagation(); navigateToCard('streams','audio_templates','audio-templates','data-audio-template-id','${src.audio_template_id}')">${ICON_AUDIO_TEMPLATE} ${escapeHtml(tpl.name)}</span>` : '';
propsHtml = `<span class="stream-card-prop">${devLabel} #${devIdx}</span>${tplBadge}`;
}
return wrapCard({
type: 'template-card',
dataAttr: 'data-id',
id: src.id,
removeOnclick: `deleteAudioSource('${src.id}')`,
removeTitle: t('common.delete'),
content: `
<div class="template-card-header">
<div class="template-name">${icon} ${escapeHtml(src.name)}</div>
</div>
<div class="stream-card-props">${propsHtml}</div>
${renderTagChips(src.tags)}
${src.description ? `<div class="template-config" style="opacity:0.7;">${escapeHtml(src.description)}</div>` : ''}`,
actions: `
<button class="btn btn-icon btn-secondary" onclick="testAudioSource('${src.id}')" title="${t('audio_source.test')}">${ICON_TEST}</button>
<button class="btn btn-icon btn-secondary" onclick="cloneAudioSource('${src.id}')" title="${t('common.clone')}">${ICON_CLONE}</button>
<button class="btn btn-icon btn-secondary" onclick="editAudioSource('${src.id}')" title="${t('common.edit')}">${ICON_EDIT}</button>`,
});
};
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: `
<div class="template-card-header">
<div class="template-name">${ICON_AUDIO_TEMPLATE} ${escapeHtml(template.name)}</div>
</div>
${template.description ? `<div class="template-config" style="opacity:0.7;">${escapeHtml(template.description)}</div>` : ''}
<div class="stream-card-props">
<span class="stream-card-prop" title="${t('audio_template.engine')}">${ICON_AUDIO_TEMPLATE} ${template.engine_type.toUpperCase()}</span>
${configEntries.length > 0 ? `<span class="stream-card-prop" title="${t('audio_template.config.show')}">${ICON_WRENCH} ${configEntries.length}</span>` : ''}
</div>
${renderTagChips(template.tags)}
${configEntries.length > 0 ? `
<div class="template-config-collapse">
<button type="button" class="template-config-toggle" onclick="this.parentElement.classList.toggle('open')">${t('audio_template.config.show')}</button>
<div class="template-config-animate">
<div class="template-config-inner">
<table class="config-table">
${configEntries.map(([key, val]) => `
<tr>
<td class="config-key">${escapeHtml(key)}</td>
<td class="config-value">${escapeHtml(String(val))}</td>
</tr>
`).join('')}
</table>
</div>
</div>
</div>
` : ''}`,
actions: `
<button class="btn btn-icon btn-secondary" onclick="showTestAudioTemplateModal('${template.id}')" title="${t('audio_template.test')}">${ICON_TEST}</button>
<button class="btn btn-icon btn-secondary" onclick="cloneAudioTemplate('${template.id}')" title="${t('common.clone')}">${ICON_CLONE}</button>
<button class="btn btn-icon btn-secondary" onclick="editAudioTemplate('${template.id}')" title="${t('common.edit')}">${ICON_EDIT}</button>`,
});
};
// 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 `<div class="stream-tab-panel${tab.key === activeTab ? ' active' : ''}" id="stream-tab-${tab.key}">${panelContent}</div>`;
}).join('');
container.innerHTML = panels;
CardSection.bindAll([csRawStreams, csRawTemplates, csProcStreams, csProcTemplates, csCSPTemplates, csColorStrips, csAudioMulti, csAudioMono, csAudioTemplates, csStaticStreams, csVideoStreams, csValueSources, csSyncClocks]);
// Render tree sidebar with expand/collapse buttons
_streamsTree.setExtraHtml(`<button class="btn-expand-collapse" onclick="expandAllStreamSections()" data-i18n-title="section.expand_all" title="${t('section.expand_all')}">⊞</button><button class="btn-expand-collapse" onclick="collapseAllStreamSections()" data-i18n-title="section.collapse_all" title="${t('section.collapse_all')}">⊟</button><button class="tutorial-trigger-btn" onclick="startSourcesTutorial()" data-i18n-title="tour.restart" title="${t('tour.restart')}">${ICON_HELP}</button>`);
_streamsTree.update(treeGroups, activeTab);
_streamsTree.observeSections('streams-list', {
'raw-streams': 'raw', 'raw-templates': 'raw_templates',
'static-streams': 'static_image',
'video-streams': 'video',
'proc-streams': 'processed', 'proc-templates': 'proc_templates',
'css-proc-templates': 'css_processing',
'color-strips': 'color_strip',
'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 `<span style="display:inline-block;width:60px;height:14px;border-radius:3px;background:${hexColors[0]}"></span>`;
}
const stops = hexColors.map((c, i) => `${c} ${(i / (hexColors.length - 1) * 100).toFixed(0)}%`).join(', ');
return `<span style="display:inline-block;width:60px;height:14px;border-radius:3px;background:linear-gradient(to right,${stops})"></span>`;
}
function _initFilterPaletteGrids(container) {
// 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 };