/**
* 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,
_currentTestStreamId, set_currentTestStreamId,
_currentTestPPTemplateId, set_currentTestPPTemplateId,
_lastValidatedImageSource, set_lastValidatedImageSource,
_cachedAudioSources,
_cachedValueSources,
_cachedAudioTemplates,
availableAudioEngines, setAvailableAudioEngines,
currentEditingAudioTemplateId, setCurrentEditingAudioTemplateId,
_audioTemplateNameManuallyEdited, set_audioTemplateNameManuallyEdited,
_sourcesLoading, set_sourcesLoading,
apiKey,
streamsCache, ppTemplatesCache, captureTemplatesCache,
audioSourcesCache, audioTemplatesCache, valueSourcesCache, filtersCache,
} 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 } from '../core/ui.js';
import { openDisplayPicker, formatDisplayLabel } from './displays.js';
import { CardSection } from '../core/card-sections.js';
import { updateSubTabHash } from './tabs.js';
import { createValueSourceCard } from './value-sources.js';
import {
getEngineIcon, getPictureSourceIcon, getAudioSourceIcon,
ICON_TEMPLATE, ICON_CLONE, ICON_EDIT, ICON_TEST, ICON_LINK_SOURCE,
ICON_FPS, ICON_WEB, ICON_VALUE_SOURCE, ICON_AUDIO_LOOPBACK, ICON_AUDIO_INPUT,
ICON_AUDIO_TEMPLATE, ICON_MONITOR, ICON_WRENCH, ICON_RADIO,
ICON_CAPTURE_TEMPLATE, ICON_PP_TEMPLATE, ICON_HELP,
} from '../core/icons.js';
import { wrapCard } from '../core/card-colors.js';
// ── Card section instances ──
const csRawStreams = new CardSection('raw-streams', { titleKey: 'streams.section.streams', gridClass: 'templates-grid', addCardOnclick: "showAddStreamModal('raw')", keyAttr: 'data-stream-id' });
const csRawTemplates = new CardSection('raw-templates', { titleKey: 'templates.title', gridClass: 'templates-grid', addCardOnclick: "showAddTemplateModal()", keyAttr: 'data-template-id' });
const csProcStreams = new CardSection('proc-streams', { titleKey: 'streams.section.streams', gridClass: 'templates-grid', addCardOnclick: "showAddStreamModal('processed')", keyAttr: 'data-stream-id' });
const csProcTemplates = new CardSection('proc-templates', { titleKey: 'postprocessing.title', gridClass: 'templates-grid', addCardOnclick: "showAddPPTemplateModal()", keyAttr: 'data-pp-template-id' });
const csAudioMulti = new CardSection('audio-multi', { titleKey: 'audio_source.group.multichannel', gridClass: 'templates-grid', addCardOnclick: "showAudioSourceModal('multichannel')", keyAttr: 'data-id' });
const csAudioMono = new CardSection('audio-mono', { titleKey: 'audio_source.group.mono', gridClass: 'templates-grid', addCardOnclick: "showAudioSourceModal('mono')", keyAttr: 'data-id' });
const csStaticStreams = new CardSection('static-streams', { titleKey: 'streams.group.static_image', gridClass: 'templates-grid', addCardOnclick: "showAddStreamModal('static_image')", keyAttr: 'data-stream-id' });
const csAudioTemplates = new CardSection('audio-templates', { titleKey: 'audio_template.title', gridClass: 'templates-grid', addCardOnclick: "showAddAudioTemplateModal()", keyAttr: 'data-audio-template-id' });
const csValueSources = new CardSection('value-sources', { titleKey: 'value_source.group.title', gridClass: 'templates-grid', addCardOnclick: "showValueSourceModal()", keyAttr: 'data-id' });
// Re-render picture sources when language changes
document.addEventListener('languageChanged', () => { if (apiKey) loadPictureSources(); });
// ===== Modal instances =====
class CaptureTemplateModal extends Modal {
constructor() { super('template-modal'); }
snapshotValues() {
const vals = {
name: document.getElementById('template-name').value,
description: document.getElementById('template-description').value,
engine: document.getElementById('template-engine').value,
};
document.querySelectorAll('[data-config-key]').forEach(field => {
vals['cfg_' + field.dataset.configKey] = field.value;
});
return vals;
}
onForceClose() {
setCurrentEditingTemplateId(null);
set_templateNameManuallyEdited(false);
}
}
class StreamEditorModal extends Modal {
constructor() { super('stream-modal'); }
snapshotValues() {
return {
name: document.getElementById('stream-name').value,
description: document.getElementById('stream-description').value,
type: document.getElementById('stream-type').value,
displayIndex: document.getElementById('stream-display-index').value,
captureTemplate: document.getElementById('stream-capture-template').value,
targetFps: document.getElementById('stream-target-fps').value,
source: document.getElementById('stream-source').value,
ppTemplate: document.getElementById('stream-pp-template').value,
imageSource: document.getElementById('stream-image-source').value,
};
}
onForceClose() {
document.getElementById('stream-type').disabled = false;
set_streamNameManuallyEdited(false);
}
}
class PPTemplateEditorModal extends Modal {
constructor() { super('pp-template-modal'); }
snapshotValues() {
return {
name: document.getElementById('pp-template-name').value,
description: document.getElementById('pp-template-description').value,
filters: JSON.stringify(_modalFilters.map(fi => ({ filter_id: fi.filter_id, options: fi.options }))),
};
}
onForceClose() {
set_modalFilters([]);
set_ppTemplateNameManuallyEdited(false);
}
}
class AudioTemplateModal extends Modal {
constructor() { super('audio-template-modal'); }
snapshotValues() {
const vals = {
name: document.getElementById('audio-template-name').value,
description: document.getElementById('audio-template-description').value,
engine: document.getElementById('audio-template-engine').value,
};
document.querySelectorAll('#audio-engine-config-fields [data-config-key]').forEach(field => {
vals['cfg_' + field.dataset.configKey] = field.value;
});
return vals;
}
onForceClose() {
setCurrentEditingAudioTemplateId(null);
set_audioTemplateNameManuallyEdited(false);
}
}
const templateModal = new CaptureTemplateModal();
const testTemplateModal = new Modal('test-template-modal');
const streamModal = new StreamEditorModal();
const testStreamModal = new Modal('test-stream-modal');
const ppTemplateModal = new PPTemplateEditorModal();
const testPPTemplateModal = new Modal('test-pp-template-modal');
const audioTemplateModal = new AudioTemplateModal();
// ===== Capture Templates =====
async function loadCaptureTemplates() {
try {
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);
}
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';
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 resp = await fetchWithAuth('/capture-templates');
if (!resp.ok) throw new Error(`HTTP ${resp.status}`);
const data = await resp.json();
const template = (data.templates || []).find(tp => tp.id === templateId);
if (!template) {
showToast(t('templates.error.load'), 'error');
return;
}
window.currentTestingTemplate = template;
await loadDisplaysForTest();
restoreCaptureDuration();
testTemplateModal.open();
} catch (error) {
if (error.isAuth) return;
showToast(t('templates.error.load'), 'error');
}
}
export function closeTestTemplateModal() {
testTemplateModal.forceClose();
window.currentTestingTemplate = null;
}
async function loadAvailableEngines() {
try {
const response = await fetchWithAuth('/capture-engines');
if (!response.ok) throw new Error(`Failed to load engines: ${response.status}`);
const data = await response.json();
setAvailableEngines(data.engines || []);
const select = document.getElementById('template-engine');
select.innerHTML = '';
availableEngines.forEach(engine => {
const option = document.createElement('option');
option.value = engine.type;
option.textContent = engine.name;
if (!engine.available) {
option.disabled = true;
option.textContent += ` (${t('templates.engine.unavailable')})`;
}
select.appendChild(option);
});
if (!select.value) {
const firstAvailable = availableEngines.find(e => e.available);
if (firstAvailable) select.value = firstAvailable.type;
}
} catch (error) {
console.error('Error loading engines:', error);
showToast(t('templates.error.engines') + ': ' + error.message, 'error');
}
}
export async function onEngineChange() {
const engineType = document.getElementById('template-engine').value;
const configSection = document.getElementById('engine-config-section');
const configFields = document.getElementById('engine-config-fields');
if (!engineType) { configSection.style.display = 'none'; return; }
const engine = availableEngines.find(e => e.type === engineType);
if (!engine) { configSection.style.display = 'none'; return; }
if (!_templateNameManuallyEdited && !document.getElementById('template-id').value) {
document.getElementById('template-name').value = engine.name || engineType;
}
const hint = document.getElementById('engine-availability-hint');
if (!engine.available) {
hint.textContent = t('templates.engine.unavailable.hint');
hint.style.display = 'block';
hint.style.color = 'var(--error-color)';
} else {
hint.style.display = 'none';
}
configFields.innerHTML = '';
const defaultConfig = engine.default_config || {};
// Known select options for specific config keys
const CONFIG_SELECT_OPTIONS = {
camera_backend: ['auto', 'dshow', 'msmf', 'v4l2'],
};
if (Object.keys(defaultConfig).length === 0) {
configSection.style.display = 'none';
return;
} else {
let gridHtml = '
';
Object.entries(defaultConfig).forEach(([key, value]) => {
const fieldType = typeof value === 'number' ? 'number' : 'text';
const fieldValue = typeof value === 'boolean' ? (value ? 'true' : 'false') : value;
const selectOptions = CONFIG_SELECT_OPTIONS[key];
gridHtml += `
${typeof value === 'boolean' ? `
` : selectOptions ? `
` : `
`}
`;
});
gridHtml += '
';
configFields.innerHTML = gridHtml;
}
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 = window.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 (!window.currentTestingTemplate) {
showToast(t('templates.test.error.no_engine'), 'error');
return;
}
const displayIndex = document.getElementById('test-template-display').value;
const captureDuration = parseFloat(document.getElementById('test-template-duration').value);
if (displayIndex === '') {
showToast(t('templates.test.error.no_display'), 'error');
return;
}
const template = window.currentTestingTemplate;
localStorage.setItem('lastTestDisplayIndex', displayIndex);
const previewWidth = Math.round(Math.min(window.innerWidth * 0.8, 1920) * Math.min(window.devicePixelRatio || 1, 2));
_runTestViaWS(
'/capture-templates/test/ws',
{},
{
engine_type: template.engine_type,
engine_config: template.engine_config,
display_index: parseInt(displayIndex),
capture_duration: captureDuration,
preview_width: previewWidth,
},
captureDuration,
);
}
function buildTestStatsHtml(result) {
// Support both REST format (nested) and WS format (flat)
const p = result.performance || result;
const duration = p.capture_duration_s ?? p.elapsed_s ?? 0;
const frameCount = p.frame_count ?? 0;
const fps = p.actual_fps ?? p.fps ?? 0;
const avgMs = p.avg_capture_time_ms ?? p.avg_capture_ms ?? 0;
const w = result.full_capture?.width ?? result.width ?? 0;
const h = result.full_capture?.height ?? result.height ?? 0;
const res = `${w}x${h}`;
let html = `
${t('templates.test.results.duration')}: ${Number(duration).toFixed(2)}s
${t('templates.test.results.frame_count')}: ${frameCount}
`;
if (frameCount > 1) {
html += `
${t('templates.test.results.actual_fps')}: ${Number(fps).toFixed(1)}
${t('templates.test.results.avg_capture_time')}: ${Number(avgMs).toFixed(1)}ms
`;
}
html += `
Resolution: ${res}
`;
return html;
}
// ===== Shared WebSocket test helper =====
/**
* Run a capture test via WebSocket, streaming intermediate previews into
* the overlay spinner and opening the lightbox with the final result.
*
* @param {string} wsPath Relative WS path (e.g. '/picture-sources/{id}/test/ws')
* @param {Object} queryParams Extra query params (duration, source_stream_id, etc.)
* @param {Object|null} firstMessage If non-null, sent as JSON after WS opens (for template test)
* @param {number} duration Test duration for overlay progress ring
*/
function _runTestViaWS(wsPath, queryParams = {}, firstMessage = null, duration = 5) {
const protocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:';
// Dynamic preview resolution: 80% of viewport width, scaled by DPR, capped at 1920px
const previewWidth = Math.round(Math.min(window.innerWidth * 0.8, 1920) * Math.min(window.devicePixelRatio || 1, 2));
const params = new URLSearchParams({ token: apiKey, preview_width: previewWidth, ...queryParams });
const wsUrl = `${protocol}//${window.location.host}${API_BASE}${wsPath}?${params}`;
showOverlaySpinner(t('streams.test.running'), duration);
let gotResult = false;
let ws;
try {
ws = new WebSocket(wsUrl);
} catch (e) {
hideOverlaySpinner();
showToast(t('streams.test.error.failed') + ': ' + e.message, 'error');
return;
}
// Close WS when user cancels overlay
const patchCloseBtn = () => {
const closeBtn = document.querySelector('.overlay-spinner-close');
if (closeBtn) {
const origHandler = closeBtn.onclick;
closeBtn.onclick = () => {
if (ws.readyState <= WebSocket.OPEN) ws.close();
if (origHandler) origHandler();
};
}
};
patchCloseBtn();
// Also close on ESC (overlay ESC handler calls hideOverlaySpinner which aborts)
const origAbort = window._overlayAbortController;
if (origAbort) {
origAbort.signal.addEventListener('abort', () => {
if (ws.readyState <= WebSocket.OPEN) ws.close();
}, { once: true });
}
ws.onopen = () => {
if (firstMessage) {
ws.send(JSON.stringify(firstMessage));
}
};
ws.onmessage = (event) => {
try {
const msg = JSON.parse(event.data);
if (msg.type === 'frame') {
updateOverlayPreview(msg.thumbnail, msg);
} else if (msg.type === 'result') {
gotResult = true;
hideOverlaySpinner();
openLightbox(msg.full_image, buildTestStatsHtml(msg));
ws.close();
} else if (msg.type === 'error') {
hideOverlaySpinner();
showToast(msg.detail || 'Test failed', 'error');
ws.close();
}
} catch (e) {
console.error('Error parsing test WS message:', e);
}
};
ws.onerror = () => {
if (!gotResult) {
hideOverlaySpinner();
showToast(t('streams.test.error.failed'), 'error');
}
};
ws.onclose = () => {
if (!gotResult) {
hideOverlaySpinner();
}
};
}
export async function saveTemplate() {
const templateId = document.getElementById('template-id').value;
const name = document.getElementById('template-name').value.trim();
const engineType = document.getElementById('template-engine').value;
if (!name || !engineType) {
showToast(t('templates.error.required'), 'error');
return;
}
const description = document.getElementById('template-description').value.trim();
const engineConfig = collectEngineConfig();
const payload = { name, engine_type: engineType, engine_config: engineConfig, description: description || null };
try {
let response;
if (templateId) {
response = await fetchWithAuth(`/capture-templates/${templateId}`, { method: 'PUT', body: JSON.stringify(payload) });
} else {
response = await fetchWithAuth('/capture-templates', { method: 'POST', body: JSON.stringify(payload) });
}
if (!response.ok) {
const error = await response.json();
throw new Error(error.detail || error.message || 'Failed to save template');
}
showToast(templateId ? t('templates.updated') : t('templates.created'), 'success');
templateModal.forceClose();
await loadCaptureTemplates();
} catch (error) {
console.error('Error saving template:', error);
document.getElementById('template-error').textContent = error.message;
document.getElementById('template-error').style.display = 'block';
}
}
export async function deleteTemplate(templateId) {
const confirmed = await showConfirm(t('templates.delete.confirm'));
if (!confirmed) return;
try {
const response = await fetchWithAuth(`/capture-templates/${templateId}`, { method: 'DELETE' });
if (!response.ok) {
const error = await response.json();
throw new Error(error.detail || error.message || 'Failed to delete template');
}
showToast(t('templates.deleted'), 'success');
await loadCaptureTemplates();
} catch (error) {
console.error('Error deleting template:', error);
showToast(t('templates.error.delete') + ': ' + error.message, 'error');
}
}
// ===== Audio Templates =====
async function loadAvailableAudioEngines() {
try {
const response = await fetchWithAuth('/audio-engines');
if (!response.ok) throw new Error(`Failed to load audio engines: ${response.status}`);
const data = await response.json();
setAvailableAudioEngines(data.engines || []);
const select = document.getElementById('audio-template-engine');
select.innerHTML = '';
availableAudioEngines.forEach(engine => {
const option = document.createElement('option');
option.value = engine.type;
option.textContent = `${engine.type.toUpperCase()}`;
if (!engine.available) {
option.disabled = true;
option.textContent += ` (${t('audio_template.engine.unavailable')})`;
}
select.appendChild(option);
});
if (!select.value) {
const firstAvailable = availableAudioEngines.find(e => e.available);
if (firstAvailable) select.value = firstAvailable.type;
}
} catch (error) {
console.error('Error loading audio engines:', error);
showToast(t('audio_template.error.engines') + ': ' + error.message, 'error');
}
}
export async function onAudioEngineChange() {
const engineType = document.getElementById('audio-template-engine').value;
const configSection = document.getElementById('audio-engine-config-section');
const configFields = document.getElementById('audio-engine-config-fields');
if (!engineType) { configSection.style.display = 'none'; return; }
const engine = availableAudioEngines.find(e => e.type === engineType);
if (!engine) { configSection.style.display = 'none'; return; }
if (!_audioTemplateNameManuallyEdited && !document.getElementById('audio-template-id').value) {
document.getElementById('audio-template-name').value = engine.type.toUpperCase();
}
const hint = document.getElementById('audio-engine-availability-hint');
if (!engine.available) {
hint.textContent = t('audio_template.engine.unavailable.hint');
hint.style.display = 'block';
hint.style.color = 'var(--error-color)';
} else {
hint.style.display = 'none';
}
configFields.innerHTML = '';
const defaultConfig = engine.default_config || {};
if (Object.keys(defaultConfig).length === 0) {
configSection.style.display = 'none';
return;
} else {
let gridHtml = '';
Object.entries(defaultConfig).forEach(([key, value]) => {
const fieldType = typeof value === 'number' ? 'number' : 'text';
const fieldValue = typeof value === 'boolean' ? (value ? 'true' : 'false') : value;
gridHtml += `
${typeof value === 'boolean' ? `
` : `
`}
`;
});
gridHtml += '
';
configFields.innerHTML = gridHtml;
}
configSection.style.display = 'block';
}
function populateAudioEngineConfig(config) {
Object.entries(config).forEach(([key, value]) => {
const field = document.getElementById(`audio-config-${key}`);
if (field) {
if (field.tagName === 'SELECT') {
field.value = value.toString();
} else {
field.value = value;
}
}
});
}
function collectAudioEngineConfig() {
const config = {};
document.querySelectorAll('#audio-engine-config-fields [data-config-key]').forEach(field => {
const key = field.dataset.configKey;
let value = field.value;
if (field.type === 'number') {
value = parseFloat(value);
} else if (field.tagName === 'SELECT' && (value === 'true' || value === 'false')) {
value = value === 'true';
}
config[key] = value;
});
return config;
}
async function loadAudioTemplates() {
try {
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);
}
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';
audioTemplateModal.open();
audioTemplateModal.snapshot();
} catch (error) {
console.error('Error loading audio template:', error);
showToast(t('audio_template.error.load') + ': ' + error.message, 'error');
}
}
export async function closeAudioTemplateModal() {
await audioTemplateModal.close();
}
export async function saveAudioTemplate() {
const templateId = currentEditingAudioTemplateId;
const name = document.getElementById('audio-template-name').value.trim();
const engineType = document.getElementById('audio-template-engine').value;
if (!name || !engineType) {
showToast(t('audio_template.error.required'), 'error');
return;
}
const description = document.getElementById('audio-template-description').value.trim();
const engineConfig = collectAudioEngineConfig();
const payload = { name, engine_type: engineType, engine_config: engineConfig, description: description || null };
try {
let response;
if (templateId) {
response = await fetchWithAuth(`/audio-templates/${templateId}`, { method: 'PUT', body: JSON.stringify(payload) });
} else {
response = await fetchWithAuth('/audio-templates', { method: 'POST', body: JSON.stringify(payload) });
}
if (!response.ok) {
const error = await response.json();
throw new Error(error.detail || error.message || 'Failed to save audio template');
}
showToast(templateId ? t('audio_template.updated') : t('audio_template.created'), 'success');
audioTemplateModal.forceClose();
await loadAudioTemplates();
} catch (error) {
console.error('Error saving audio template:', error);
document.getElementById('audio-template-error').textContent = error.message;
document.getElementById('audio-template-error').style.display = 'block';
}
}
export async function deleteAudioTemplate(templateId) {
const confirmed = await showConfirm(t('audio_template.delete.confirm'));
if (!confirmed) return;
try {
const response = await fetchWithAuth(`/audio-templates/${templateId}`, { method: 'DELETE' });
if (!response.ok) {
const error = await response.json();
throw new Error(error.detail || error.message || 'Failed to delete audio template');
}
showToast(t('audio_template.deleted'), 'success');
await loadAudioTemplates();
} catch (error) {
console.error('Error deleting audio template:', error);
showToast(t('audio_template.error.delete') + ': ' + error.message, 'error');
}
}
export async function cloneAudioTemplate(templateId) {
try {
const resp = await fetchWithAuth(`/audio-templates/${templateId}`);
if (!resp.ok) throw new Error('Failed to load audio template');
const tmpl = await resp.json();
showAddAudioTemplateModal(tmpl);
} catch (error) {
if (error.isAuth) return;
console.error('Failed to clone audio template:', error);
showToast(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;
// Load audio devices for picker
const deviceSelect = document.getElementById('test-audio-template-device');
try {
const resp = await fetchWithAuth('/audio-devices');
if (resp.ok) {
const data = await resp.json();
const devices = data.devices || [];
deviceSelect.innerHTML = devices.map(d => {
const label = d.name;
const val = `${d.index}:${d.is_loopback ? '1' : '0'}`;
return ``;
}).join('');
if (devices.length === 0) {
deviceSelect.innerHTML = '';
}
}
} catch {
deviceSelect.innerHTML = '';
}
// Restore last used device
const lastDevice = localStorage.getItem('lastAudioTestDevice');
if (lastDevice) {
const opt = Array.from(deviceSelect.options).find(o => o.value === lastDevice);
if (opt) deviceSelect.value = lastDevice;
}
// Reset visual state
document.getElementById('audio-template-test-canvas').style.display = 'none';
document.getElementById('audio-template-test-stats').style.display = 'none';
document.getElementById('audio-template-test-status').style.display = 'none';
document.getElementById('test-audio-template-start-btn').style.display = '';
_tplCleanupTest();
testAudioTemplateModal.open();
}
export function closeTestAudioTemplateModal() {
_tplCleanupTest();
testAudioTemplateModal.forceClose();
_currentTestAudioTemplateId = null;
}
export function startAudioTemplateTest() {
if (!_currentTestAudioTemplateId) return;
const deviceVal = document.getElementById('test-audio-template-device').value || '-1:1';
const [devIdx, devLoop] = deviceVal.split(':');
localStorage.setItem('lastAudioTestDevice', deviceVal);
// Show canvas + stats, hide run button
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';
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;
}
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(),
audioTemplatesCache.fetch(),
filtersCache.data.length === 0 ? filtersCache.fetch() : Promise.resolve(filtersCache.data),
]);
renderPictureSourcesList(streams);
} catch (error) {
if (error.isAuth) return;
console.error('Error loading picture sources:', error);
document.getElementById('streams-list').innerHTML = `
${t('streams.error.load')}: ${error.message}
`;
} finally {
set_sourcesLoading(false);
setTabRefreshing('streams-list', false);
}
}
export function switchStreamTab(tabKey) {
document.querySelectorAll('.stream-tab-btn[data-stream-tab]').forEach(btn =>
btn.classList.toggle('active', btn.dataset.streamTab === tabKey)
);
document.querySelectorAll('.stream-tab-panel[id^="stream-tab-"]').forEach(panel =>
panel.classList.toggle('active', panel.id === `stream-tab-${tabKey}`)
);
localStorage.setItem('activeStreamTab', tabKey);
updateSubTabHash('streams', tabKey);
}
const _streamSectionMap = {
raw: [csRawStreams, csRawTemplates],
static_image: [csStaticStreams],
processed: [csProcStreams, csProcTemplates],
audio: [csAudioMulti, csAudioMono],
value: [csValueSources],
};
export function expandAllStreamSections() {
const activeTab = localStorage.getItem('activeStreamTab') || 'raw';
CardSection.expandAll(_streamSectionMap[activeTab] || []);
}
export function collapseAllStreamSections() {
const activeTab = localStorage.getItem('activeStreamTab') || 'raw';
CardSection.collapseAll(_streamSectionMap[activeTab] || []);
}
function renderPictureSourcesList(streams) {
const container = document.getElementById('streams-list');
const activeTab = localStorage.getItem('activeStreamTab') || 'raw';
const renderStreamCard = (stream) => {
const typeIcon = getPictureSourceIcon(stream.stream_type);
let detailsHtml = '';
if (stream.stream_type === 'raw') {
let capTmplName = '';
if (stream.capture_template_id) {
const capTmpl = _cachedCaptureTemplates.find(t => t.id === stream.capture_template_id);
if (capTmpl) capTmplName = escapeHtml(capTmpl.name);
}
detailsHtml = `
${ICON_MONITOR} ${stream.display_index ?? 0}
${ICON_FPS} ${stream.target_fps ?? 30}
${capTmplName ? `${ICON_TEMPLATE} ${capTmplName}` : ''}
`;
} else if (stream.stream_type === 'processed') {
const sourceStream = _cachedStreams.find(s => s.id === stream.source_stream_id);
const sourceName = sourceStream ? escapeHtml(sourceStream.name) : (stream.source_stream_id || '-');
const sourceSubTab = sourceStream ? (sourceStream.stream_type === 'static_image' ? 'static_image' : 'raw') : 'raw';
const sourceSection = sourceStream ? (sourceStream.stream_type === 'static_image' ? 'static-streams' : 'raw-streams') : 'raw-streams';
let ppTmplName = '';
if (stream.postprocessing_template_id) {
const ppTmpl = _cachedPPTemplates.find(p => p.id === stream.postprocessing_template_id);
if (ppTmpl) ppTmplName = escapeHtml(ppTmpl.name);
}
detailsHtml = `
${ICON_LINK_SOURCE} ${sourceName}
${ppTmplName ? `${ICON_TEMPLATE} ${ppTmplName}` : ''}
`;
} else if (stream.stream_type === 'static_image') {
const src = stream.image_source || '';
detailsHtml = `
${ICON_WEB} ${escapeHtml(src)}
`;
}
return wrapCard({
type: 'template-card',
dataAttr: 'data-stream-id',
id: stream.id,
removeOnclick: `deleteStream('${stream.id}')`,
removeTitle: t('common.delete'),
content: `
${detailsHtml}
${stream.description ? `${escapeHtml(stream.description)}
` : ''}`,
actions: `
`,
});
};
const renderCaptureTemplateCard = (template) => {
const engineIcon = getEngineIcon(template.engine_type);
const configEntries = Object.entries(template.engine_config);
return wrapCard({
type: 'template-card',
dataAttr: 'data-template-id',
id: template.id,
removeOnclick: `deleteTemplate('${template.id}')`,
removeTitle: t('common.delete'),
content: `
${template.description ? `${escapeHtml(template.description)}
` : ''}
${getEngineIcon(template.engine_type)} ${template.engine_type.toUpperCase()}
${configEntries.length > 0 ? `${ICON_WRENCH} ${configEntries.length}` : ''}
${configEntries.length > 0 ? `
${t('templates.config.show')}
${configEntries.map(([key, val]) => `
| ${escapeHtml(key)} |
${escapeHtml(String(val))} |
`).join('')}
` : ''}`,
actions: `
`,
});
};
const renderPPTemplateCard = (tmpl) => {
let filterChainHtml = '';
if (tmpl.filters && tmpl.filters.length > 0) {
const filterNames = tmpl.filters.map(fi => `${escapeHtml(_getFilterName(fi.filter_id))}`);
filterChainHtml = `${filterNames.join('→')}
`;
}
return wrapCard({
type: 'template-card',
dataAttr: 'data-pp-template-id',
id: tmpl.id,
removeOnclick: `deletePPTemplate('${tmpl.id}')`,
removeTitle: t('common.delete'),
content: `
${tmpl.description ? `${escapeHtml(tmpl.description)}
` : ''}
${filterChainHtml}`,
actions: `
`,
});
};
const rawStreams = streams.filter(s => s.stream_type === 'raw');
const processedStreams = streams.filter(s => s.stream_type === 'processed');
const staticImageStreams = streams.filter(s => s.stream_type === 'static_image');
const multichannelSources = _cachedAudioSources.filter(s => s.source_type === 'multichannel');
const monoSources = _cachedAudioSources.filter(s => s.source_type === 'mono');
const tabs = [
{ key: 'raw', icon: getPictureSourceIcon('raw'), titleKey: 'streams.group.raw', count: rawStreams.length },
{ key: 'static_image', icon: getPictureSourceIcon('static_image'), titleKey: 'streams.group.static_image', count: staticImageStreams.length },
{ key: 'processed', icon: getPictureSourceIcon('processed'), titleKey: 'streams.group.processed', count: processedStreams.length },
{ key: 'audio', icon: getAudioSourceIcon('multichannel'), titleKey: 'streams.group.audio', count: _cachedAudioSources.length },
{ key: 'value', icon: ICON_VALUE_SOURCE, titleKey: 'streams.group.value', count: _cachedValueSources.length },
];
const tabBar = `${tabs.map(tab =>
``
).join('')}
`;
const renderAudioSourceCard = (src) => {
const isMono = src.source_type === 'mono';
const icon = getAudioSourceIcon(src.source_type);
let propsHtml = '';
if (isMono) {
const parent = _cachedAudioSources.find(s => s.id === src.audio_source_id);
const parentName = parent ? parent.name : src.audio_source_id;
const chLabel = src.channel === 'left' ? 'L' : src.channel === 'right' ? 'R' : 'M';
const parentBadge = parent
? `${ICON_AUDIO_LOOPBACK} ${escapeHtml(parentName)}`
: `${ICON_AUDIO_LOOPBACK} ${escapeHtml(parentName)}`;
propsHtml = `
${parentBadge}
${ICON_RADIO} ${chLabel}
`;
} else {
const devIdx = src.device_index ?? -1;
const loopback = src.is_loopback !== false;
const devLabel = loopback ? `${ICON_AUDIO_LOOPBACK} Loopback` : `${ICON_AUDIO_INPUT} Input`;
const tpl = src.audio_template_id ? _cachedAudioTemplates.find(t => t.id === src.audio_template_id) : null;
const tplBadge = tpl ? `${ICON_AUDIO_TEMPLATE} ${escapeHtml(tpl.name)}` : '';
propsHtml = `${devLabel} #${devIdx}${tplBadge}`;
}
return wrapCard({
type: 'template-card',
dataAttr: 'data-id',
id: src.id,
removeOnclick: `deleteAudioSource('${src.id}')`,
removeTitle: t('common.delete'),
content: `
${propsHtml}
${src.description ? `${escapeHtml(src.description)}
` : ''}`,
actions: `
`,
});
};
const renderAudioTemplateCard = (template) => {
const configEntries = Object.entries(template.engine_config || {});
return wrapCard({
type: 'template-card',
dataAttr: 'data-audio-template-id',
id: template.id,
removeOnclick: `deleteAudioTemplate('${template.id}')`,
removeTitle: t('common.delete'),
content: `
${template.description ? `${escapeHtml(template.description)}
` : ''}
${ICON_AUDIO_TEMPLATE} ${template.engine_type.toUpperCase()}
${configEntries.length > 0 ? `${ICON_WRENCH} ${configEntries.length}` : ''}
${configEntries.length > 0 ? `
${t('audio_template.config.show')}
${configEntries.map(([key, val]) => `
| ${escapeHtml(key)} |
${escapeHtml(String(val))} |
`).join('')}
` : ''}`,
actions: `
`,
});
};
// Build item arrays for all sections
const rawStreamItems = csRawStreams.applySortOrder(rawStreams.map(s => ({ key: s.id, html: renderStreamCard(s) })));
const rawTemplateItems = csRawTemplates.applySortOrder(_cachedCaptureTemplates.map(t => ({ key: t.id, html: renderCaptureTemplateCard(t) })));
const procStreamItems = csProcStreams.applySortOrder(processedStreams.map(s => ({ key: s.id, html: renderStreamCard(s) })));
const procTemplateItems = csProcTemplates.applySortOrder(_cachedPPTemplates.map(t => ({ key: t.id, html: renderPPTemplateCard(t) })));
const multiItems = csAudioMulti.applySortOrder(multichannelSources.map(s => ({ key: s.id, html: renderAudioSourceCard(s) })));
const monoItems = csAudioMono.applySortOrder(monoSources.map(s => ({ key: s.id, html: renderAudioSourceCard(s) })));
const audioTemplateItems = csAudioTemplates.applySortOrder(_cachedAudioTemplates.map(t => ({ key: t.id, html: renderAudioTemplateCard(t) })));
const staticItems = csStaticStreams.applySortOrder(staticImageStreams.map(s => ({ key: s.id, html: renderStreamCard(s) })));
const valueItems = csValueSources.applySortOrder(_cachedValueSources.map(s => ({ key: s.id, html: createValueSourceCard(s) })));
if (csRawStreams.isMounted()) {
// Incremental update: reconcile cards in-place
tabs.forEach(tab => {
const btn = container.querySelector(`.stream-tab-btn[data-stream-tab="${tab.key}"] .stream-tab-count`);
if (btn) btn.textContent = tab.count;
});
csRawStreams.reconcile(rawStreamItems);
csRawTemplates.reconcile(rawTemplateItems);
csProcStreams.reconcile(procStreamItems);
csProcTemplates.reconcile(procTemplateItems);
csAudioMulti.reconcile(multiItems);
csAudioMono.reconcile(monoItems);
csAudioTemplates.reconcile(audioTemplateItems);
csStaticStreams.reconcile(staticItems);
csValueSources.reconcile(valueItems);
} else {
// First render: build full HTML
const panels = tabs.map(tab => {
let panelContent = '';
if (tab.key === 'raw') panelContent = csRawStreams.render(rawStreamItems) + csRawTemplates.render(rawTemplateItems);
else if (tab.key === 'processed') panelContent = csProcStreams.render(procStreamItems) + csProcTemplates.render(procTemplateItems);
else if (tab.key === 'audio') panelContent = csAudioMulti.render(multiItems) + csAudioMono.render(monoItems) + csAudioTemplates.render(audioTemplateItems);
else if (tab.key === 'value') panelContent = csValueSources.render(valueItems);
else panelContent = csStaticStreams.render(staticItems);
return `${panelContent}
`;
}).join('');
container.innerHTML = tabBar + panels;
CardSection.bindAll([csRawStreams, csRawTemplates, csProcStreams, csProcTemplates, csAudioMulti, csAudioMono, csAudioTemplates, csStaticStreams, csValueSources]);
}
}
export function onStreamTypeChange() {
const streamType = document.getElementById('stream-type').value;
document.getElementById('stream-raw-fields').style.display = streamType === 'raw' ? '' : 'none';
document.getElementById('stream-processed-fields').style.display = streamType === 'processed' ? '' : 'none';
document.getElementById('stream-static-image-fields').style.display = streamType === 'static_image' ? '' : 'none';
}
export function onStreamDisplaySelected(displayIndex, display) {
document.getElementById('stream-display-index').value = displayIndex;
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 = window.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' };
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();
}
}
_showStreamModalLoading(false);
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' };
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();
}
_showStreamModalLoading(false);
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;
async function populateStreamModalDropdowns() {
const [displaysRes, captureTemplatesRes, streamsRes, ppTemplatesRes] = await Promise.all([
fetch(`${API_BASE}/config/displays`, { headers: getHeaders() }),
fetchWithAuth('/capture-templates'),
fetchWithAuth('/picture-sources'),
fetchWithAuth('/postprocessing-templates'),
]);
// Cache desktop displays (used as default unless engine has own displays)
if (displaysRes.ok) {
const displaysData = await displaysRes.json();
displaysCache.update(displaysData.displays || []);
}
_streamModalDisplaysEngine = null;
const templateSelect = document.getElementById('stream-capture-template');
templateSelect.innerHTML = '';
if (captureTemplatesRes.ok) {
const data = await captureTemplatesRes.json();
(data.templates || []).forEach(tmpl => {
const opt = document.createElement('option');
opt.value = tmpl.id;
opt.dataset.name = tmpl.name;
opt.dataset.engineType = tmpl.engine_type;
opt.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 = '';
if (streamsRes.ok) {
const data = await streamsRes.json();
const editingId = document.getElementById('stream-id').value;
(data.streams || []).forEach(s => {
if (s.id === editingId) return;
const opt = document.createElement('option');
opt.value = s.id;
opt.dataset.name = s.name;
opt.textContent = s.name;
sourceSelect.appendChild(opt);
});
}
set_streamModalPPTemplates([]);
const ppSelect = document.getElementById('stream-pp-template');
ppSelect.innerHTML = '';
if (ppTemplatesRes.ok) {
const data = await ppTemplatesRes.json();
set_streamModalPPTemplates(data.templates || []);
_streamModalPPTemplates.forEach(tmpl => {
const opt = document.createElement('option');
opt.value = tmpl.id;
opt.textContent = tmpl.name;
ppSelect.appendChild(opt);
});
}
_autoGenerateStreamName();
}
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 };
if (!streamId) payload.stream_type = streamType;
if (streamType === 'raw') {
payload.display_index = parseInt(document.getElementById('stream-display-index').value) || 0;
payload.capture_template_id = document.getElementById('stream-capture-template').value;
payload.target_fps = parseInt(document.getElementById('stream-target-fps').value) || 30;
} else if (streamType === 'processed') {
payload.source_stream_id = document.getElementById('stream-source').value;
payload.postprocessing_template_id = document.getElementById('stream-pp-template').value;
} else if (streamType === 'static_image') {
const imageSource = document.getElementById('stream-image-source').value.trim();
if (!imageSource) { showToast(t('streams.error.required'), 'error'); return; }
payload.image_source = imageSource;
}
try {
let response;
if (streamId) {
response = await fetchWithAuth(`/picture-sources/${streamId}`, { method: 'PUT', body: JSON.stringify(payload) });
} else {
response = await fetchWithAuth('/picture-sources', { method: 'POST', body: JSON.stringify(payload) });
}
if (!response.ok) {
const error = await response.json();
throw new Error(error.detail || error.message || 'Failed to save stream');
}
showToast(streamId ? t('streams.updated') : t('streams.created'), 'success');
streamModal.forceClose();
await loadPictureSources();
} catch (error) {
console.error('Error saving stream:', error);
errorEl.textContent = error.message;
errorEl.style.display = 'block';
}
}
export async function deleteStream(streamId) {
const confirmed = await showConfirm(t('streams.delete.confirm'));
if (!confirmed) return;
try {
const response = await fetchWithAuth(`/picture-sources/${streamId}`, { method: 'DELETE' });
if (!response.ok) {
const error = await response.json();
throw new Error(error.detail || error.message || 'Failed to delete stream');
}
showToast(t('streams.deleted'), 'success');
await loadPictureSources();
} catch (error) {
console.error('Error deleting stream:', error);
showToast(t('streams.error.delete') + ': ' + error.message, 'error');
}
}
/** 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();
}
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;
}
testPPTemplateModal.open();
}
export function closeTestPPTemplateModal() {
testPPTemplateModal.forceClose();
set_currentTestPPTemplateId(null);
}
export function updatePPTestDuration(value) {
document.getElementById('test-pp-duration-value').textContent = value;
localStorage.setItem('lastPPTestDuration', value);
}
function restorePPTestDuration() {
const saved = localStorage.getItem('lastPPTestDuration') || '5';
document.getElementById('test-pp-duration').value = saved;
document.getElementById('test-pp-duration-value').textContent = saved;
}
export 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 _populateFilterSelect() {
const select = document.getElementById('pp-add-filter-select');
select.innerHTML = ``;
for (const f of _availableFilters) {
const name = _getFilterName(f.filter_id);
select.innerHTML += ``;
}
}
export function renderModalFilterList() {
const container = document.getElementById('pp-filter-list');
if (_modalFilters.length === 0) {
container.innerHTML = `${t('filters.empty')}
`;
return;
}
let html = '';
_modalFilters.forEach((fi, index) => {
const filterDef = _availableFilters.find(f => f.filter_id === fi.filter_id);
const filterName = _getFilterName(fi.filter_id);
const isExpanded = fi._expanded === true;
let summary = '';
if (filterDef && !isExpanded) {
summary = filterDef.options_schema.map(opt => {
const val = fi.options[opt.key] !== undefined ? fi.options[opt.key] : opt.default;
return val;
}).join(', ');
}
html += `
`;
if (filterDef) {
for (const opt of filterDef.options_schema) {
const currentVal = fi.options[opt.key] !== undefined ? fi.options[opt.key] : opt.default;
const inputId = `filter-${index}-${opt.key}`;
if (opt.type === 'bool') {
const checked = currentVal === true || currentVal === 'true';
html += `
`;
} else if (opt.type === 'select' && Array.isArray(opt.choices)) {
// Exclude the template being edited from filter_template choices (prevent self-reference)
const editingId = document.getElementById('pp-template-id')?.value || '';
const filteredChoices = (fi.filter_id === 'filter_template' && opt.key === 'template_id' && editingId)
? opt.choices.filter(c => c.value !== editingId)
: opt.choices;
// Auto-correct if current value doesn't match any choice
let selectVal = currentVal;
if (filteredChoices.length > 0 && !filteredChoices.some(c => c.value === selectVal)) {
selectVal = filteredChoices[0].value;
fi.options[opt.key] = selectVal;
}
const options = filteredChoices.map(c =>
`
`
).join('');
html += `
`;
} else if (opt.type === 'string') {
const maxLen = opt.max_length || 500;
html += `
`;
} else {
html += `
`;
}
}
}
html += `
`;
});
container.innerHTML = html;
_initFilterDrag();
}
/* ── PP filter drag-and-drop reordering ── */
const _FILTER_DRAG_THRESHOLD = 5;
const _FILTER_SCROLL_EDGE = 60;
const _FILTER_SCROLL_SPEED = 12;
let _filterDragState = null;
function _initFilterDrag() {
const container = document.getElementById('pp-filter-list');
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,
};
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 _modalFilters array
if (toIndex !== ds.fromIndex) {
const [item] = _modalFilters.splice(ds.fromIndex, 1);
_modalFilters.splice(toIndex, 0, item);
renderModalFilterList();
_autoGeneratePPTemplateName();
}
}
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);
}
export function addFilterFromSelect() {
const select = document.getElementById('pp-add-filter-select');
const filterId = select.value;
if (!filterId) return;
const filterDef = _availableFilters.find(f => f.filter_id === filterId);
if (!filterDef) return;
const options = {};
for (const opt of filterDef.options_schema) {
// For select options with empty default, use the first choice's value
if (opt.type === 'select' && !opt.default && Array.isArray(opt.choices) && opt.choices.length > 0) {
options[opt.key] = opt.choices[0].value;
} else {
options[opt.key] = opt.default;
}
}
_modalFilters.push({ filter_id: filterId, options, _expanded: true });
select.value = '';
renderModalFilterList();
_autoGeneratePPTemplateName();
}
export function toggleFilterExpand(index) {
if (_modalFilters[index]) {
_modalFilters[index]._expanded = !_modalFilters[index]._expanded;
renderModalFilterList();
}
}
export function removeFilter(index) {
_modalFilters.splice(index, 1);
renderModalFilterList();
_autoGeneratePPTemplateName();
}
export function moveFilter(index, direction) {
const newIndex = index + direction;
if (newIndex < 0 || newIndex >= _modalFilters.length) return;
const tmp = _modalFilters[index];
_modalFilters[index] = _modalFilters[newIndex];
_modalFilters[newIndex] = tmp;
renderModalFilterList();
_autoGeneratePPTemplateName();
}
export function updateFilterOption(filterIndex, optionKey, value) {
if (_modalFilters[filterIndex]) {
const fi = _modalFilters[filterIndex];
const filterDef = _availableFilters.find(f => f.filter_id === fi.filter_id);
if (filterDef) {
const optDef = filterDef.options_schema.find(o => o.key === optionKey);
if (optDef && optDef.type === 'bool') {
fi.options[optionKey] = !!value;
} else if (optDef && optDef.type === 'select') {
fi.options[optionKey] = String(value);
} else if (optDef && optDef.type === 'string') {
fi.options[optionKey] = String(value);
} else if (optDef && optDef.type === 'int') {
fi.options[optionKey] = parseInt(value);
} else {
fi.options[optionKey] = parseFloat(value);
}
} else {
fi.options[optionKey] = parseFloat(value);
}
}
}
function collectFilters() {
return _modalFilters.map(fi => ({
filter_id: fi.filter_id,
options: { ...fi.options },
}));
}
function _autoGeneratePPTemplateName() {
if (_ppTemplateNameManuallyEdited) return;
if (document.getElementById('pp-template-id').value) return;
const nameInput = document.getElementById('pp-template-name');
if (_modalFilters.length > 0) {
const filterNames = _modalFilters.map(f => _getFilterName(f.filter_id)).join(' + ');
nameInput.value = filterNames;
} else {
nameInput.value = '';
}
}
export async function showAddPPTemplateModal(cloneData = null) {
if (_availableFilters.length === 0) await loadAvailableFilters();
document.getElementById('pp-template-modal-title').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); };
_populateFilterSelect();
renderModalFilterList();
// Pre-fill from clone data after form is set up
if (cloneData) {
document.getElementById('pp-template-name').value = (cloneData.name || '') + ' (Copy)';
document.getElementById('pp-template-description').value = cloneData.description || '';
}
ppTemplateModal.open();
ppTemplateModal.snapshot();
}
export async function editPPTemplate(templateId) {
try {
if (_availableFilters.length === 0) await loadAvailableFilters();
const response = await fetchWithAuth(`/postprocessing-templates/${templateId}`);
if (!response.ok) throw new Error(`Failed to load template: ${response.status}`);
const tmpl = await response.json();
document.getElementById('pp-template-modal-title').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 },
})));
_populateFilterSelect();
renderModalFilterList();
ppTemplateModal.open();
ppTemplateModal.snapshot();
} catch (error) {
console.error('Error loading PP template:', error);
showToast(t('postprocessing.error.load') + ': ' + error.message, 'error');
}
}
export async function savePPTemplate() {
const templateId = document.getElementById('pp-template-id').value;
const name = document.getElementById('pp-template-name').value.trim();
const description = document.getElementById('pp-template-description').value.trim();
const errorEl = document.getElementById('pp-template-error');
if (!name) { showToast(t('postprocessing.error.required'), 'error'); return; }
const payload = { name, filters: collectFilters(), description: description || null };
try {
let response;
if (templateId) {
response = await fetchWithAuth(`/postprocessing-templates/${templateId}`, { method: 'PUT', body: JSON.stringify(payload) });
} else {
response = await fetchWithAuth('/postprocessing-templates', { method: 'POST', body: JSON.stringify(payload) });
}
if (!response.ok) {
const error = await response.json();
throw new Error(error.detail || error.message || 'Failed to save template');
}
showToast(templateId ? t('postprocessing.updated') : t('postprocessing.created'), 'success');
ppTemplateModal.forceClose();
await loadPPTemplates();
} catch (error) {
console.error('Error saving PP template:', error);
errorEl.textContent = error.message;
errorEl.style.display = 'block';
}
}
// ===== Clone functions =====
export async function cloneStream(streamId) {
try {
const resp = await fetchWithAuth(`/picture-sources/${streamId}`);
if (!resp.ok) throw new Error('Failed to load stream');
const stream = await resp.json();
showAddStreamModal(stream.stream_type, stream);
} catch (error) {
if (error.isAuth) return;
console.error('Failed to clone stream:', error);
showToast(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');
await loadPPTemplates();
} catch (error) {
console.error('Error deleting PP template:', error);
showToast(t('postprocessing.error.delete') + ': ' + error.message, 'error');
}
}
export async function closePPTemplateModal() {
await ppTemplateModal.close();
}
// Exported helpers used by other modules
export { updateCaptureDuration, buildTestStatsHtml };