Files
wled-screen-controller-mixed/server/src/wled_controller/static/js/features/streams.js
alexei.dolgolyov 199039326b Add ADB-based Android screen capture engine with display picker integration
New scrcpy/ADB capture engine that captures Android device screens over
ADB using screencap polling. Supports USB and WiFi ADB connections with
device auto-discovery. Engine-aware display picker shows Android devices
when scrcpy engine is selected, with inline ADB connect form for WiFi
devices.

Key changes:
- New scrcpy_engine.py using adb screencap polling (~1-2 FPS over WiFi)
- Engine-aware GET /config/displays?engine_type= API
- ADB connect/disconnect API endpoints (POST /adb/connect, /adb/disconnect)
- Display picker supports engine-specific device lists
- Stream/test modals pass engine type to display picker
- Test template handler changed to sync def to prevent event loop blocking
- Restart script merges registry PATH for newly-installed tools
- All engines (including unavailable) shown in engine list with status flag

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-23 18:06:15 +03:00

1547 lines
66 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, set_cachedDisplays,
_cachedStreams, set_cachedStreams,
_cachedPPTemplates, set_cachedPPTemplates,
_cachedCaptureTemplates, set_cachedCaptureTemplates,
_availableFilters, set_availableFilters,
availableEngines, setAvailableEngines,
currentEditingTemplateId, setCurrentEditingTemplateId,
_templateNameManuallyEdited, set_templateNameManuallyEdited,
_streamNameManuallyEdited, set_streamNameManuallyEdited,
_streamModalPPTemplates, set_streamModalPPTemplates,
_modalFilters, set_modalFilters,
_ppTemplateNameManuallyEdited, set_ppTemplateNameManuallyEdited,
_currentTestStreamId, set_currentTestStreamId,
_currentTestPPTemplateId, set_currentTestPPTemplateId,
_lastValidatedImageSource, set_lastValidatedImageSource,
apiKey,
} from '../core/state.js';
import { API_BASE, getHeaders, fetchWithAuth, escapeHtml } from '../core/api.js';
import { t } from '../core/i18n.js';
import { Modal } from '../core/modal.js';
import { showToast, showConfirm, openLightbox, openFullImageLightbox, showOverlaySpinner, hideOverlaySpinner } from '../core/ui.js';
import { openDisplayPicker, formatDisplayLabel } from './displays.js';
// Re-render picture sources when language changes
document.addEventListener('languageChanged', () => { if (apiKey) loadPictureSources(); });
// ===== Modal instances =====
const templateModal = new Modal('template-modal');
const testTemplateModal = new Modal('test-template-modal');
const streamModal = new Modal('stream-modal');
const testStreamModal = new Modal('test-stream-modal');
const ppTemplateModal = new Modal('pp-template-modal');
const testPPTemplateModal = new Modal('test-pp-template-modal');
// ===== Capture Templates =====
async function loadCaptureTemplates() {
try {
const response = await fetchWithAuth('/capture-templates');
if (!response.ok) throw new Error(`Failed to load templates: ${response.status}`);
const data = await response.json();
set_cachedCaptureTemplates(data.templates || []);
renderPictureSourcesList(_cachedStreams);
} catch (error) {
console.error('Error loading capture templates:', error);
}
}
function getEngineIcon(engineType) {
if (engineType === 'scrcpy') return '📱';
return '🚀';
}
export async function showAddTemplateModal(cloneData = null) {
setCurrentEditingTemplateId(null);
document.getElementById('template-modal-title').textContent = t('templates.add');
document.getElementById('template-form').reset();
document.getElementById('template-id').value = '';
document.getElementById('engine-config-section').style.display = 'none';
document.getElementById('template-error').style.display = 'none';
set_templateNameManuallyEdited(!!cloneData);
document.getElementById('template-name').oninput = () => { set_templateNameManuallyEdited(true); };
await loadAvailableEngines();
// Pre-fill from clone data after engines are loaded
if (cloneData) {
document.getElementById('template-name').value = (cloneData.name || '') + ' (Copy)';
document.getElementById('template-description').value = cloneData.description || '';
document.getElementById('template-engine').value = cloneData.engine_type;
await onEngineChange();
populateEngineConfig(cloneData.engine_config);
}
templateModal.open();
}
export async function editTemplate(templateId) {
try {
const response = await fetchWithAuth(`/capture-templates/${templateId}`);
if (!response.ok) throw new Error(`Failed to load template: ${response.status}`);
const template = await response.json();
setCurrentEditingTemplateId(templateId);
document.getElementById('template-modal-title').textContent = t('templates.edit');
document.getElementById('template-id').value = templateId;
document.getElementById('template-name').value = template.name;
document.getElementById('template-description').value = template.description || '';
await loadAvailableEngines();
document.getElementById('template-engine').value = template.engine_type;
await onEngineChange();
populateEngineConfig(template.engine_config);
await loadDisplaysForTest();
const testResults = document.getElementById('template-test-results');
if (testResults) testResults.style.display = 'none';
document.getElementById('template-error').style.display = 'none';
templateModal.open();
} catch (error) {
console.error('Error loading template:', error);
showToast(t('templates.error.load') + ': ' + error.message, 'error');
}
}
export function closeTemplateModal() {
templateModal.forceClose();
setCurrentEditingTemplateId(null);
set_templateNameManuallyEdited(false);
}
function updateCaptureDuration(value) {
document.getElementById('test-template-duration-value').textContent = value;
localStorage.setItem('capture_duration', value);
}
function restoreCaptureDuration() {
const savedDuration = localStorage.getItem('capture_duration');
if (savedDuration) {
const durationInput = document.getElementById('test-template-duration');
const durationValue = document.getElementById('test-template-duration-value');
durationInput.value = savedDuration;
durationValue.textContent = savedDuration;
}
}
export async function showTestTemplateModal(templateId) {
const templates = await fetchWithAuth('/capture-templates').then(r => r.json());
const template = templates.templates.find(t => t.id === templateId);
if (!template) {
showToast(t('templates.error.load'), 'error');
return;
}
window.currentTestingTemplate = template;
await loadDisplaysForTest();
restoreCaptureDuration();
testTemplateModal.open();
}
export function closeTestTemplateModal() {
testTemplateModal.forceClose();
window.currentTestingTemplate = null;
}
async function loadAvailableEngines() {
try {
const response = await fetchWithAuth('/capture-engines');
if (!response.ok) throw new Error(`Failed to load engines: ${response.status}`);
const data = await response.json();
setAvailableEngines(data.engines || []);
const select = document.getElementById('template-engine');
select.innerHTML = '';
availableEngines.forEach(engine => {
const option = document.createElement('option');
option.value = engine.type;
option.textContent = `${getEngineIcon(engine.type)} ${engine.name}`;
if (!engine.available) {
option.disabled = true;
option.textContent += ` (${t('templates.engine.unavailable')})`;
}
select.appendChild(option);
});
if (!select.value) {
const firstAvailable = availableEngines.find(e => e.available);
if (firstAvailable) select.value = firstAvailable.type;
}
} catch (error) {
console.error('Error loading engines:', error);
showToast(t('templates.error.engines') + ': ' + error.message, 'error');
}
}
export async function onEngineChange() {
const engineType = document.getElementById('template-engine').value;
const configSection = document.getElementById('engine-config-section');
const configFields = document.getElementById('engine-config-fields');
if (!engineType) { configSection.style.display = 'none'; return; }
const engine = availableEngines.find(e => e.type === engineType);
if (!engine) { configSection.style.display = 'none'; return; }
if (!_templateNameManuallyEdited && !document.getElementById('template-id').value) {
document.getElementById('template-name').value = engine.name || engineType;
}
const hint = document.getElementById('engine-availability-hint');
if (!engine.available) {
hint.textContent = t('templates.engine.unavailable.hint');
hint.style.display = 'block';
hint.style.color = 'var(--error-color)';
} else {
hint.style.display = 'none';
}
configFields.innerHTML = '';
const defaultConfig = engine.default_config || {};
if (Object.keys(defaultConfig).length === 0) {
configSection.style.display = 'none';
return;
} else {
let gridHtml = '<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="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>
` : `
<input type="${fieldType}" id="config-${key}" data-config-key="${key}" value="${fieldValue}">
`}
</div>
`;
});
gridHtml += '</div>';
configFields.innerHTML = gridHtml;
}
configSection.style.display = 'block';
}
function populateEngineConfig(config) {
Object.entries(config).forEach(([key, value]) => {
const field = document.getElementById(`config-${key}`);
if (field) {
if (field.tagName === 'SELECT') {
field.value = value.toString();
} else {
field.value = value;
}
}
});
}
function collectEngineConfig() {
const config = {};
const fields = document.querySelectorAll('[data-config-key]');
fields.forEach(field => {
const key = field.dataset.configKey;
let value = field.value;
if (field.type === 'number') {
value = parseFloat(value);
} else if (field.tagName === 'SELECT' && (value === 'true' || value === 'false')) {
value = value === 'true';
}
config[key] = value;
});
return config;
}
async function loadDisplaysForTest() {
try {
// Use engine-specific display list when testing a scrcpy template
const engineType = window.currentTestingTemplate?.engine_type;
const url = engineType === 'scrcpy'
? `/config/displays?engine_type=scrcpy`
: '/config/displays';
// Always refetch for scrcpy (devices may change); use cache for desktop
if (!_cachedDisplays || engineType === 'scrcpy') {
const response = await fetchWithAuth(url);
if (!response.ok) throw new Error(`Failed to load displays: ${response.status}`);
const displaysData = await response.json();
set_cachedDisplays(displaysData.displays || []);
}
let selectedIndex = null;
const lastDisplay = localStorage.getItem('lastTestDisplayIndex');
if (lastDisplay !== null) {
const found = _cachedDisplays.find(d => d.index === parseInt(lastDisplay));
if (found) selectedIndex = found.index;
}
if (selectedIndex === null) {
const primary = _cachedDisplays.find(d => d.is_primary);
if (primary) selectedIndex = primary.index;
else if (_cachedDisplays.length > 0) selectedIndex = _cachedDisplays[0].index;
}
if (selectedIndex !== null) {
const display = _cachedDisplays.find(d => d.index === selectedIndex);
onTestDisplaySelected(selectedIndex, display);
}
} catch (error) {
console.error('Error loading displays:', error);
}
}
export async function runTemplateTest() {
if (!window.currentTestingTemplate) {
showToast(t('templates.test.error.no_engine'), 'error');
return;
}
const displayIndex = document.getElementById('test-template-display').value;
const captureDuration = parseFloat(document.getElementById('test-template-duration').value);
if (displayIndex === '') {
showToast(t('templates.test.error.no_display'), 'error');
return;
}
const template = window.currentTestingTemplate;
showOverlaySpinner(t('templates.test.running'), captureDuration);
const signal = window._overlayAbortController?.signal;
try {
const response = await fetchWithAuth('/capture-templates/test', {
method: 'POST',
body: JSON.stringify({
engine_type: template.engine_type,
engine_config: template.engine_config,
display_index: parseInt(displayIndex),
capture_duration: captureDuration
}),
signal
});
if (!response.ok) {
const error = await response.json();
throw new Error(error.detail || error.message || 'Test failed');
}
const result = await response.json();
localStorage.setItem('lastTestDisplayIndex', displayIndex);
displayTestResults(result);
} catch (error) {
if (error.name === 'AbortError') return;
console.error('Error running test:', error);
hideOverlaySpinner();
showToast(t('templates.test.error.failed'), 'error');
}
}
function buildTestStatsHtml(result) {
const p = result.performance;
const res = `${result.full_capture.width}x${result.full_capture.height}`;
let html = `
<div class="stat-item"><span>${t('templates.test.results.duration')}:</span> <strong>${p.capture_duration_s.toFixed(2)}s</strong></div>
<div class="stat-item"><span>${t('templates.test.results.frame_count')}:</span> <strong>${p.frame_count}</strong></div>`;
if (p.frame_count > 1) {
html += `
<div class="stat-item"><span>${t('templates.test.results.actual_fps')}:</span> <strong>${p.actual_fps.toFixed(1)}</strong></div>
<div class="stat-item"><span>${t('templates.test.results.avg_capture_time')}:</span> <strong>${p.avg_capture_time_ms.toFixed(1)}ms</strong></div>`;
}
html += `
<div class="stat-item"><span>Resolution:</span> <strong>${res}</strong></div>`;
return html;
}
function displayTestResults(result) {
hideOverlaySpinner();
const fullImageSrc = result.full_capture.full_image || result.full_capture.image;
openLightbox(fullImageSrc, buildTestStatsHtml(result));
}
export async function saveTemplate() {
const templateId = document.getElementById('template-id').value;
const name = document.getElementById('template-name').value.trim();
const engineType = document.getElementById('template-engine').value;
if (!name || !engineType) {
showToast(t('templates.error.required'), 'error');
return;
}
const description = document.getElementById('template-description').value.trim();
const engineConfig = collectEngineConfig();
const payload = { name, engine_type: engineType, engine_config: engineConfig, description: description || null };
try {
let response;
if (templateId) {
response = await fetchWithAuth(`/capture-templates/${templateId}`, { method: 'PUT', body: JSON.stringify(payload) });
} else {
response = await fetchWithAuth('/capture-templates', { method: 'POST', body: JSON.stringify(payload) });
}
if (!response.ok) {
const error = await response.json();
throw new Error(error.detail || error.message || 'Failed to save template');
}
showToast(templateId ? t('templates.updated') : t('templates.created'), 'success');
closeTemplateModal();
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');
}
}
// ===== Picture Sources =====
export async function loadPictureSources() {
try {
const [filtersResp, ppResp, captResp, streamsResp] = await Promise.all([
_availableFilters.length === 0 ? fetchWithAuth('/filters') : Promise.resolve(null),
fetchWithAuth('/postprocessing-templates'),
fetchWithAuth('/capture-templates'),
fetchWithAuth('/picture-sources')
]);
if (filtersResp && filtersResp.ok) {
const fd = await filtersResp.json();
set_availableFilters(fd.filters || []);
}
if (ppResp.ok) {
const pd = await ppResp.json();
set_cachedPPTemplates(pd.templates || []);
}
if (captResp.ok) {
const cd = await captResp.json();
set_cachedCaptureTemplates(cd.templates || []);
}
if (!streamsResp.ok) throw new Error(`Failed to load streams: ${streamsResp.status}`);
const data = await streamsResp.json();
set_cachedStreams(data.streams || []);
renderPictureSourcesList(_cachedStreams);
} catch (error) {
console.error('Error loading picture sources:', error);
document.getElementById('streams-list').innerHTML = `
<div class="error-message">${t('streams.error.load')}: ${error.message}</div>
`;
}
}
export function switchStreamTab(tabKey) {
document.querySelectorAll('.stream-tab-btn').forEach(btn =>
btn.classList.toggle('active', btn.dataset.streamTab === tabKey)
);
document.querySelectorAll('.stream-tab-panel').forEach(panel =>
panel.classList.toggle('active', panel.id === `stream-tab-${tabKey}`)
);
localStorage.setItem('activeStreamTab', tabKey);
}
function renderPictureSourcesList(streams) {
const container = document.getElementById('streams-list');
const activeTab = localStorage.getItem('activeStreamTab') || 'raw';
const renderStreamCard = (stream) => {
const typeIcons = { raw: '🖥️', processed: '🎨', static_image: '🖼️' };
const typeIcon = typeIcons[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')}">🖥️ ${stream.display_index ?? 0}</span>
<span class="stream-card-prop" title="${t('streams.target_fps')}">⚡ ${stream.target_fps ?? 30}</span>
${capTmplName ? `<span class="stream-card-prop" title="${t('streams.capture_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 || '-');
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" title="${t('streams.source')}">📺 ${sourceName}</span>
${ppTmplName ? `<span class="stream-card-prop" title="${t('streams.pp_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)}">🌐 ${escapeHtml(src)}</span>
</div>`;
}
return `
<div class="template-card" data-stream-id="${stream.id}">
<button class="card-remove-btn" onclick="deleteStream('${stream.id}')" title="${t('common.delete')}">&#x2715;</button>
<div class="template-card-header">
<div class="template-name">${typeIcon} ${escapeHtml(stream.name)}</div>
</div>
${detailsHtml}
${stream.description ? `<div class="template-config" style="opacity:0.7;">${escapeHtml(stream.description)}</div>` : ''}
<div class="template-card-actions">
<button class="btn btn-icon btn-secondary" onclick="showTestStreamModal('${stream.id}')" title="${t('streams.test.title')}">🧪</button>
<button class="btn btn-icon btn-secondary" onclick="cloneStream('${stream.id}')" title="${t('common.clone')}">📋</button>
<button class="btn btn-icon btn-secondary" onclick="editStream('${stream.id}')" title="${t('common.edit')}">✏️</button>
</div>
</div>
`;
};
const renderCaptureTemplateCard = (template) => {
const engineIcon = getEngineIcon(template.engine_type);
const configEntries = Object.entries(template.engine_config);
return `
<div class="template-card" data-template-id="${template.id}">
<button class="card-remove-btn" onclick="deleteTemplate('${template.id}')" title="${t('common.delete')}">&#x2715;</button>
<div class="template-card-header">
<div class="template-name">📋 ${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')}">🚀 ${template.engine_type.toUpperCase()}</span>
${configEntries.length > 0 ? `<span class="stream-card-prop" title="${t('templates.config.show')}">🔧 ${configEntries.length}</span>` : ''}
</div>
${configEntries.length > 0 ? `
<details class="template-config-details">
<summary>${t('templates.config.show')}</summary>
<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>
</details>
` : ''}
<div class="template-card-actions">
<button class="btn btn-icon btn-secondary" onclick="showTestTemplateModal('${template.id}')" title="${t('templates.test.title')}">🧪</button>
<button class="btn btn-icon btn-secondary" onclick="cloneCaptureTemplate('${template.id}')" title="${t('common.clone')}">📋</button>
<button class="btn btn-icon btn-secondary" onclick="editTemplate('${template.id}')" title="${t('common.edit')}">✏️</button>
</div>
</div>
`;
};
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 `
<div class="template-card" data-pp-template-id="${tmpl.id}">
<button class="card-remove-btn" onclick="deletePPTemplate('${tmpl.id}')" title="${t('common.delete')}">&#x2715;</button>
<div class="template-card-header">
<div class="template-name">📋 ${escapeHtml(tmpl.name)}</div>
</div>
${tmpl.description ? `<div class="template-config" style="opacity:0.7;">${escapeHtml(tmpl.description)}</div>` : ''}
${filterChainHtml}
<div class="template-card-actions">
<button class="btn btn-icon btn-secondary" onclick="showTestPPTemplateModal('${tmpl.id}')" title="${t('postprocessing.test.title')}">🧪</button>
<button class="btn btn-icon btn-secondary" onclick="clonePPTemplate('${tmpl.id}')" title="${t('common.clone')}">📋</button>
<button class="btn btn-icon btn-secondary" onclick="editPPTemplate('${tmpl.id}')" title="${t('common.edit')}">✏️</button>
</div>
</div>
`;
};
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 addStreamCard = (type) => `
<div class="template-card add-template-card" onclick="showAddStreamModal('${type}')">
<div class="add-template-icon">+</div>
</div>`;
const tabs = [
{ key: 'raw', icon: '🖥️', titleKey: 'streams.group.raw', streams: rawStreams },
{ key: 'static_image', icon: '🖼️', titleKey: 'streams.group.static_image', streams: staticImageStreams },
{ key: 'processed', icon: '🎨', titleKey: 'streams.group.processed', streams: processedStreams },
];
const tabBar = `<div class="stream-tab-bar">${tabs.map(tab =>
`<button class="stream-tab-btn${tab.key === activeTab ? ' active' : ''}" data-stream-tab="${tab.key}" onclick="switchStreamTab('${tab.key}')">${tab.icon} ${t(tab.titleKey)} <span class="stream-tab-count">${tab.streams.length}</span></button>`
).join('')}</div>`;
const panels = tabs.map(tab => {
let panelContent = '';
if (tab.key === 'raw') {
panelContent = `
<div class="subtab-section">
<h3 class="subtab-section-header">${t('streams.section.streams')}</h3>
<div class="templates-grid">
${tab.streams.map(renderStreamCard).join('')}
${addStreamCard(tab.key)}
</div>
</div>
<div class="subtab-section">
<h3 class="subtab-section-header">${t('templates.title')}</h3>
<div class="templates-grid">
${_cachedCaptureTemplates.map(renderCaptureTemplateCard).join('')}
<div class="template-card add-template-card" onclick="showAddTemplateModal()">
<div class="add-template-icon">+</div>
</div>
</div>
</div>`;
} else if (tab.key === 'processed') {
panelContent = `
<div class="subtab-section">
<h3 class="subtab-section-header">${t('streams.section.streams')}</h3>
<div class="templates-grid">
${tab.streams.map(renderStreamCard).join('')}
${addStreamCard(tab.key)}
</div>
</div>
<div class="subtab-section">
<h3 class="subtab-section-header">${t('postprocessing.title')}</h3>
<div class="templates-grid">
${_cachedPPTemplates.map(renderPPTemplateCard).join('')}
<div class="template-card add-template-card" onclick="showAddPPTemplateModal()">
<div class="add-template-icon">+</div>
</div>
</div>
</div>`;
} else {
panelContent = `
<div class="templates-grid">
${tab.streams.map(renderStreamCard).join('')}
${addStreamCard(tab.key)}
</div>`;
}
return `<div class="stream-tab-panel${tab.key === activeTab ? ' active' : ''}" id="stream-tab-${tab.key}">${panelContent}</div>`;
}).join('');
container.innerHTML = tabBar + panels;
}
export function onStreamTypeChange() {
const streamType = document.getElementById('stream-type').value;
document.getElementById('stream-raw-fields').style.display = streamType === 'raw' ? '' : 'none';
document.getElementById('stream-processed-fields').style.display = streamType === 'processed' ? '' : 'none';
document.getElementById('stream-static-image-fields').style.display = streamType === 'static_image' ? '' : 'none';
}
export function onStreamDisplaySelected(displayIndex, display) {
document.getElementById('stream-display-index').value = displayIndex;
document.getElementById('stream-display-picker-label').textContent = formatDisplayLabel(displayIndex, display);
_autoGenerateStreamName();
}
export function onTestDisplaySelected(displayIndex, display) {
document.getElementById('test-template-display').value = displayIndex;
document.getElementById('test-display-picker-label').textContent = formatDisplayLabel(displayIndex, display);
}
function _autoGenerateStreamName() {
if (_streamNameManuallyEdited) return;
if (document.getElementById('stream-id').value) return;
const streamType = document.getElementById('stream-type').value;
const nameInput = document.getElementById('stream-name');
if (streamType === 'raw') {
const displayIndex = document.getElementById('stream-display-index').value;
const templateSelect = document.getElementById('stream-capture-template');
const templateName = templateSelect.selectedOptions[0]?.dataset?.name || '';
if (displayIndex === '' || !templateName) return;
nameInput.value = `D${displayIndex}_${templateName}`;
} else if (streamType === 'processed') {
const sourceSelect = document.getElementById('stream-source');
const sourceName = sourceSelect.selectedOptions[0]?.dataset?.name || '';
const ppTemplateId = document.getElementById('stream-pp-template').value;
const ppTemplate = _streamModalPPTemplates.find(t => t.id === ppTemplateId);
if (!sourceName) return;
if (ppTemplate && ppTemplate.name) {
nameInput.value = `${sourceName} (${ppTemplate.name})`;
} else {
nameInput.value = sourceName;
}
}
}
export async function showAddStreamModal(presetType, cloneData = null) {
const streamType = (cloneData && cloneData.stream_type) || presetType || 'raw';
const titleKeys = { raw: 'streams.add.raw', processed: 'streams.add.processed', static_image: 'streams.add.static_image' };
document.getElementById('stream-modal-title').textContent = t(titleKeys[streamType] || 'streams.add');
document.getElementById('stream-form').reset();
document.getElementById('stream-id').value = '';
document.getElementById('stream-display-index').value = '';
document.getElementById('stream-display-picker-label').textContent = t('displays.picker.select');
document.getElementById('stream-error').style.display = 'none';
document.getElementById('stream-type').value = streamType;
set_lastValidatedImageSource('');
const imgSrcInput = document.getElementById('stream-image-source');
imgSrcInput.value = '';
document.getElementById('stream-image-preview-container').style.display = 'none';
document.getElementById('stream-image-validation-status').style.display = 'none';
imgSrcInput.onblur = () => validateStaticImage();
imgSrcInput.onkeydown = (e) => { if (e.key === 'Enter') { e.preventDefault(); validateStaticImage(); } };
imgSrcInput.onpaste = () => setTimeout(() => validateStaticImage(), 0);
onStreamTypeChange();
set_streamNameManuallyEdited(!!cloneData);
document.getElementById('stream-name').oninput = () => { set_streamNameManuallyEdited(true); };
document.getElementById('stream-capture-template').onchange = () => _autoGenerateStreamName();
document.getElementById('stream-source').onchange = () => _autoGenerateStreamName();
document.getElementById('stream-pp-template').onchange = () => _autoGenerateStreamName();
await populateStreamModalDropdowns();
// Pre-fill from clone data after dropdowns are populated
if (cloneData) {
document.getElementById('stream-name').value = (cloneData.name || '') + ' (Copy)';
document.getElementById('stream-description').value = cloneData.description || '';
if (streamType === 'raw') {
const displayIdx = cloneData.display_index ?? 0;
const display = _cachedDisplays ? _cachedDisplays.find(d => d.index === displayIdx) : null;
onStreamDisplaySelected(displayIdx, display);
document.getElementById('stream-capture-template').value = cloneData.capture_template_id || '';
const fps = cloneData.target_fps ?? 30;
document.getElementById('stream-target-fps').value = fps;
document.getElementById('stream-target-fps-value').textContent = fps;
} else if (streamType === 'processed') {
document.getElementById('stream-source').value = cloneData.source_stream_id || '';
document.getElementById('stream-pp-template').value = cloneData.postprocessing_template_id || '';
} else if (streamType === 'static_image') {
document.getElementById('stream-image-source').value = cloneData.image_source || '';
if (cloneData.image_source) validateStaticImage();
}
}
streamModal.open();
}
export async function editStream(streamId) {
try {
const response = await fetchWithAuth(`/picture-sources/${streamId}`);
if (!response.ok) throw new Error(`Failed to load stream: ${response.status}`);
const stream = await response.json();
const editTitleKeys = { raw: 'streams.edit.raw', processed: 'streams.edit.processed', static_image: 'streams.edit.static_image' };
document.getElementById('stream-modal-title').textContent = t(editTitleKeys[stream.stream_type] || 'streams.edit');
document.getElementById('stream-id').value = streamId;
document.getElementById('stream-name').value = stream.name;
document.getElementById('stream-description').value = stream.description || '';
document.getElementById('stream-error').style.display = 'none';
document.getElementById('stream-type').value = stream.stream_type;
set_lastValidatedImageSource('');
const imgSrcInput = document.getElementById('stream-image-source');
document.getElementById('stream-image-preview-container').style.display = 'none';
document.getElementById('stream-image-validation-status').style.display = 'none';
imgSrcInput.onblur = () => validateStaticImage();
imgSrcInput.onkeydown = (e) => { if (e.key === 'Enter') { e.preventDefault(); validateStaticImage(); } };
imgSrcInput.onpaste = () => setTimeout(() => validateStaticImage(), 0);
onStreamTypeChange();
await populateStreamModalDropdowns();
if (stream.stream_type === 'raw') {
const displayIdx = stream.display_index ?? 0;
const display = _cachedDisplays ? _cachedDisplays.find(d => d.index === displayIdx) : null;
onStreamDisplaySelected(displayIdx, display);
document.getElementById('stream-capture-template').value = stream.capture_template_id || '';
const fps = stream.target_fps ?? 30;
document.getElementById('stream-target-fps').value = fps;
document.getElementById('stream-target-fps-value').textContent = fps;
} else if (stream.stream_type === 'processed') {
document.getElementById('stream-source').value = stream.source_stream_id || '';
document.getElementById('stream-pp-template').value = stream.postprocessing_template_id || '';
} else if (stream.stream_type === 'static_image') {
document.getElementById('stream-image-source').value = stream.image_source || '';
if (stream.image_source) validateStaticImage();
}
streamModal.open();
} catch (error) {
console.error('Error loading stream:', error);
showToast(t('streams.error.load') + ': ' + error.message, 'error');
}
}
/** Track which engine type the stream-modal displays were loaded for. */
let _streamModalDisplaysEngine = null;
async function populateStreamModalDropdowns() {
const [displaysRes, captureTemplatesRes, streamsRes, ppTemplatesRes] = await Promise.all([
fetch(`${API_BASE}/config/displays`, { headers: getHeaders() }),
fetchWithAuth('/capture-templates'),
fetchWithAuth('/picture-sources'),
fetchWithAuth('/postprocessing-templates'),
]);
if (displaysRes.ok) {
const displaysData = await displaysRes.json();
set_cachedDisplays(displaysData.displays || []);
}
_streamModalDisplaysEngine = null; // desktop displays loaded
if (!document.getElementById('stream-display-index').value && _cachedDisplays && _cachedDisplays.length > 0) {
const primary = _cachedDisplays.find(d => d.is_primary) || _cachedDisplays[0];
onStreamDisplaySelected(primary.index, primary);
}
const templateSelect = document.getElementById('stream-capture-template');
templateSelect.innerHTML = '';
if (captureTemplatesRes.ok) {
const data = await captureTemplatesRes.json();
(data.templates || []).forEach(tmpl => {
const opt = document.createElement('option');
opt.value = tmpl.id;
opt.dataset.name = tmpl.name;
opt.dataset.engineType = tmpl.engine_type;
opt.textContent = `${getEngineIcon(tmpl.engine_type)} ${tmpl.name}`;
templateSelect.appendChild(opt);
});
}
// When template changes, refresh displays if engine type switched
templateSelect.addEventListener('change', _onCaptureTemplateChanged);
const sourceSelect = document.getElementById('stream-source');
sourceSelect.innerHTML = '';
if (streamsRes.ok) {
const data = await streamsRes.json();
const editingId = document.getElementById('stream-id').value;
(data.streams || []).forEach(s => {
if (s.id === editingId) return;
const opt = document.createElement('option');
opt.value = s.id;
opt.dataset.name = s.name;
const typeLabels = { raw: '🖥️', processed: '🎨', static_image: '🖼️' };
const typeLabel = typeLabels[s.stream_type] || '📺';
opt.textContent = `${typeLabel} ${s.name}`;
sourceSelect.appendChild(opt);
});
}
set_streamModalPPTemplates([]);
const ppSelect = document.getElementById('stream-pp-template');
ppSelect.innerHTML = '';
if (ppTemplatesRes.ok) {
const data = await ppTemplatesRes.json();
set_streamModalPPTemplates(data.templates || []);
_streamModalPPTemplates.forEach(tmpl => {
const opt = document.createElement('option');
opt.value = tmpl.id;
opt.textContent = tmpl.name;
ppSelect.appendChild(opt);
});
}
_autoGenerateStreamName();
// If the first template is an scrcpy engine, reload displays immediately
const firstOpt = templateSelect.selectedOptions[0];
if (firstOpt?.dataset?.engineType === 'scrcpy') {
await _refreshStreamDisplaysForEngine('scrcpy');
}
}
async function _onCaptureTemplateChanged() {
const templateSelect = document.getElementById('stream-capture-template');
const engineType = templateSelect.selectedOptions[0]?.dataset?.engineType || null;
const needsEngineDisplays = engineType === 'scrcpy';
const currentEngine = needsEngineDisplays ? engineType : null;
// Only refetch if the engine category actually changed
if (currentEngine !== _streamModalDisplaysEngine) {
await _refreshStreamDisplaysForEngine(currentEngine);
}
_autoGenerateStreamName();
}
async function _refreshStreamDisplaysForEngine(engineType) {
_streamModalDisplaysEngine = engineType;
const url = engineType ? `/config/displays?engine_type=${engineType}` : '/config/displays';
try {
const resp = await fetchWithAuth(url);
if (resp.ok) {
const data = await resp.json();
set_cachedDisplays(data.displays || []);
}
} catch (error) {
console.error('Error refreshing displays for engine:', error);
}
// Reset display selection and pick the first available
document.getElementById('stream-display-index').value = '';
document.getElementById('stream-display-picker-label').textContent = t('displays.picker.select');
if (_cachedDisplays && _cachedDisplays.length > 0) {
const primary = _cachedDisplays.find(d => d.is_primary) || _cachedDisplays[0];
onStreamDisplaySelected(primary.index, primary);
}
}
export async function saveStream() {
const streamId = document.getElementById('stream-id').value;
const name = document.getElementById('stream-name').value.trim();
const streamType = document.getElementById('stream-type').value;
const description = document.getElementById('stream-description').value.trim();
const errorEl = document.getElementById('stream-error');
if (!name) { showToast(t('streams.error.required'), 'error'); return; }
const payload = { name, description: description || null };
if (!streamId) payload.stream_type = streamType;
if (streamType === 'raw') {
payload.display_index = parseInt(document.getElementById('stream-display-index').value) || 0;
payload.capture_template_id = document.getElementById('stream-capture-template').value;
payload.target_fps = parseInt(document.getElementById('stream-target-fps').value) || 30;
} else if (streamType === 'processed') {
payload.source_stream_id = document.getElementById('stream-source').value;
payload.postprocessing_template_id = document.getElementById('stream-pp-template').value;
} else if (streamType === 'static_image') {
const imageSource = document.getElementById('stream-image-source').value.trim();
if (!imageSource) { showToast(t('streams.error.required'), 'error'); return; }
payload.image_source = imageSource;
}
try {
let response;
if (streamId) {
response = await fetchWithAuth(`/picture-sources/${streamId}`, { method: 'PUT', body: JSON.stringify(payload) });
} else {
response = await fetchWithAuth('/picture-sources', { method: 'POST', body: JSON.stringify(payload) });
}
if (!response.ok) {
const error = await response.json();
throw new Error(error.detail || error.message || 'Failed to save stream');
}
showToast(streamId ? t('streams.updated') : t('streams.created'), 'success');
closeStreamModal();
await loadPictureSources();
} catch (error) {
console.error('Error saving stream:', error);
errorEl.textContent = error.message;
errorEl.style.display = 'block';
}
}
export async function deleteStream(streamId) {
const confirmed = await showConfirm(t('streams.delete.confirm'));
if (!confirmed) return;
try {
const response = await fetchWithAuth(`/picture-sources/${streamId}`, { method: 'DELETE' });
if (!response.ok) {
const error = await response.json();
throw new Error(error.detail || error.message || 'Failed to delete stream');
}
showToast(t('streams.deleted'), 'success');
await loadPictureSources();
} catch (error) {
console.error('Error deleting stream:', error);
showToast(t('streams.error.delete') + ': ' + error.message, 'error');
}
}
export function closeStreamModal() {
streamModal.forceClose();
document.getElementById('stream-type').disabled = false;
set_streamNameManuallyEdited(false);
}
async function validateStaticImage() {
const source = document.getElementById('stream-image-source').value.trim();
const previewContainer = document.getElementById('stream-image-preview-container');
const previewImg = document.getElementById('stream-image-preview');
const infoEl = document.getElementById('stream-image-info');
const statusEl = document.getElementById('stream-image-validation-status');
if (!source) {
set_lastValidatedImageSource('');
previewContainer.style.display = 'none';
statusEl.style.display = 'none';
return;
}
if (source === _lastValidatedImageSource) return;
statusEl.textContent = t('streams.validate_image.validating');
statusEl.className = 'validation-status loading';
statusEl.style.display = 'block';
previewContainer.style.display = 'none';
try {
const response = await fetchWithAuth('/picture-sources/validate-image', {
method: 'POST',
body: JSON.stringify({ image_source: source }),
});
const data = await response.json();
set_lastValidatedImageSource(source);
if (data.valid) {
previewImg.src = data.preview;
previewImg.style.cursor = 'pointer';
previewImg.onclick = () => openFullImageLightbox(source);
infoEl.textContent = `${data.width} × ${data.height} px`;
previewContainer.style.display = '';
statusEl.textContent = t('streams.validate_image.valid');
statusEl.className = 'validation-status success';
} else {
previewContainer.style.display = 'none';
statusEl.textContent = `${t('streams.validate_image.invalid')}: ${data.error}`;
statusEl.className = 'validation-status error';
}
} catch (err) {
previewContainer.style.display = 'none';
statusEl.textContent = `${t('streams.validate_image.invalid')}: ${err.message}`;
statusEl.className = 'validation-status error';
}
}
// ===== Picture Source Test =====
export async function showTestStreamModal(streamId) {
set_currentTestStreamId(streamId);
restoreStreamTestDuration();
testStreamModal.open();
}
export function closeTestStreamModal() {
testStreamModal.forceClose();
set_currentTestStreamId(null);
}
export function updateStreamTestDuration(value) {
document.getElementById('test-stream-duration-value').textContent = value;
localStorage.setItem('lastStreamTestDuration', value);
}
function restoreStreamTestDuration() {
const saved = localStorage.getItem('lastStreamTestDuration') || '5';
document.getElementById('test-stream-duration').value = saved;
document.getElementById('test-stream-duration-value').textContent = saved;
}
export async function runStreamTest() {
if (!_currentTestStreamId) return;
const captureDuration = parseFloat(document.getElementById('test-stream-duration').value);
showOverlaySpinner(t('streams.test.running'), captureDuration);
const signal = window._overlayAbortController?.signal;
try {
const response = await fetchWithAuth(`/picture-sources/${_currentTestStreamId}/test`, {
method: 'POST',
body: JSON.stringify({ capture_duration: captureDuration }),
signal
});
if (!response.ok) {
const error = await response.json();
throw new Error(error.detail || error.message || 'Test failed');
}
const result = await response.json();
hideOverlaySpinner();
const fullImageSrc = result.full_capture.full_image || result.full_capture.image;
openLightbox(fullImageSrc, buildTestStatsHtml(result));
} catch (error) {
if (error.name === 'AbortError') return;
console.error('Error running stream test:', error);
hideOverlaySpinner();
showToast(t('streams.test.error.failed') + ': ' + error.message, 'error');
}
}
// ===== PP Template Test =====
export async function showTestPPTemplateModal(templateId) {
set_currentTestPPTemplateId(templateId);
restorePPTestDuration();
const select = document.getElementById('test-pp-source-stream');
select.innerHTML = '';
if (_cachedStreams.length === 0) {
try {
const resp = await fetchWithAuth('/picture-sources');
if (resp.ok) { const d = await resp.json(); set_cachedStreams(d.streams || []); }
} catch (e) { console.warn('Could not load streams for PP test:', e); }
}
for (const s of _cachedStreams) {
const opt = document.createElement('option');
opt.value = s.id;
opt.textContent = s.name;
select.appendChild(opt);
}
const lastStream = localStorage.getItem('lastPPTestStreamId');
if (lastStream && _cachedStreams.find(s => s.id === lastStream)) {
select.value = lastStream;
}
testPPTemplateModal.open();
}
export function closeTestPPTemplateModal() {
testPPTemplateModal.forceClose();
set_currentTestPPTemplateId(null);
}
export function updatePPTestDuration(value) {
document.getElementById('test-pp-duration-value').textContent = value;
localStorage.setItem('lastPPTestDuration', value);
}
function restorePPTestDuration() {
const saved = localStorage.getItem('lastPPTestDuration') || '5';
document.getElementById('test-pp-duration').value = saved;
document.getElementById('test-pp-duration-value').textContent = saved;
}
export async function runPPTemplateTest() {
if (!_currentTestPPTemplateId) return;
const sourceStreamId = document.getElementById('test-pp-source-stream').value;
if (!sourceStreamId) { showToast(t('postprocessing.test.error.no_stream'), 'error'); return; }
localStorage.setItem('lastPPTestStreamId', sourceStreamId);
const captureDuration = parseFloat(document.getElementById('test-pp-duration').value);
showOverlaySpinner(t('postprocessing.test.running'), captureDuration);
const signal = window._overlayAbortController?.signal;
try {
const response = await fetchWithAuth(`/postprocessing-templates/${_currentTestPPTemplateId}/test`, {
method: 'POST',
body: JSON.stringify({ source_stream_id: sourceStreamId, capture_duration: captureDuration }),
signal
});
if (!response.ok) {
const error = await response.json();
throw new Error(error.detail || error.message || 'Test failed');
}
const result = await response.json();
hideOverlaySpinner();
const fullImageSrc = result.full_capture.full_image || result.full_capture.image;
openLightbox(fullImageSrc, buildTestStatsHtml(result));
} catch (error) {
if (error.name === 'AbortError') return;
console.error('Error running PP template test:', error);
hideOverlaySpinner();
showToast(t('postprocessing.test.error.failed') + ': ' + error.message, 'error');
}
}
// ===== PP Templates =====
async function loadAvailableFilters() {
try {
const response = await fetchWithAuth('/filters');
if (!response.ok) throw new Error(`Failed to load filters: ${response.status}`);
const data = await response.json();
set_availableFilters(data.filters || []);
} catch (error) {
console.error('Error loading available filters:', error);
set_availableFilters([]);
}
}
async function loadPPTemplates() {
try {
if (_availableFilters.length === 0) await loadAvailableFilters();
const response = await fetchWithAuth('/postprocessing-templates');
if (!response.ok) throw new Error(`Failed to load templates: ${response.status}`);
const data = await response.json();
set_cachedPPTemplates(data.templates || []);
renderPictureSourcesList(_cachedStreams);
} catch (error) {
console.error('Error loading PP templates:', error);
}
}
function _getFilterName(filterId) {
const key = 'filters.' + filterId;
const translated = t(key);
if (translated === key) {
const def = _availableFilters.find(f => f.filter_id === filterId);
return def ? def.filter_name : filterId;
}
return translated;
}
function _populateFilterSelect() {
const select = document.getElementById('pp-add-filter-select');
select.innerHTML = `<option value="">${t('filters.select_type')}</option>`;
for (const f of _availableFilters) {
const name = _getFilterName(f.filter_id);
select.innerHTML += `<option value="${f.filter_id}">${name}</option>`;
}
}
export function renderModalFilterList() {
const container = document.getElementById('pp-filter-list');
if (_modalFilters.length === 0) {
container.innerHTML = `<div class="pp-filter-empty">${t('filters.empty')}</div>`;
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 += `<div class="pp-filter-card${isExpanded ? ' expanded' : ''}" data-filter-index="${index}">
<div class="pp-filter-card-header" onclick="toggleFilterExpand(${index})">
<span class="pp-filter-card-chevron">${isExpanded ? '&#x25BC;' : '&#x25B6;'}</span>
<span class="pp-filter-card-name">${escapeHtml(filterName)}</span>
${summary ? `<span class="pp-filter-card-summary">${escapeHtml(summary)}</span>` : ''}
<div class="pp-filter-card-actions" onclick="event.stopPropagation()">
<button type="button" class="btn-filter-action" onclick="moveFilter(${index}, -1)" title="${t('filters.move_up')}" ${index === 0 ? 'disabled' : ''}>&#x25B2;</button>
<button type="button" class="btn-filter-action" onclick="moveFilter(${index}, 1)" title="${t('filters.move_down')}" ${index === _modalFilters.length - 1 ? 'disabled' : ''}>&#x25BC;</button>
<button type="button" class="btn-filter-action btn-filter-remove" onclick="removeFilter(${index})" title="${t('filters.remove')}">&#x2715;</button>
</div>
</div>
<div class="pp-filter-card-options"${isExpanded ? '' : ' style="display:none"'}>`;
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 += `<div class="pp-filter-option pp-filter-option-bool">
<label for="${inputId}">
<span>${escapeHtml(opt.label)}</span>
<input type="checkbox" id="${inputId}" ${checked ? 'checked' : ''}
onchange="updateFilterOption(${index}, '${opt.key}', this.checked)">
</label>
</div>`;
} 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;
const options = filteredChoices.map(c =>
`<option value="${escapeHtml(c.value)}"${c.value === currentVal ? ' selected' : ''}>${escapeHtml(c.label)}</option>`
).join('');
html += `<div class="pp-filter-option">
<label for="${inputId}"><span>${escapeHtml(opt.label)}:</span></label>
<select id="${inputId}"
onchange="updateFilterOption(${index}, '${opt.key}', this.value)">
${options}
</select>
</div>`;
} else {
html += `<div class="pp-filter-option">
<label for="${inputId}">
<span>${escapeHtml(opt.label)}:</span>
<span id="${inputId}-display">${currentVal}</span>
</label>
<input type="range" id="${inputId}"
min="${opt.min_value}" max="${opt.max_value}" step="${opt.step}" value="${currentVal}"
oninput="updateFilterOption(${index}, '${opt.key}', this.value); document.getElementById('${inputId}-display').textContent = this.value;">
</div>`;
}
}
}
html += `</div></div>`;
});
container.innerHTML = html;
}
export function addFilterFromSelect() {
const select = document.getElementById('pp-add-filter-select');
const filterId = select.value;
if (!filterId) return;
const filterDef = _availableFilters.find(f => f.filter_id === filterId);
if (!filterDef) return;
const options = {};
for (const opt of filterDef.options_schema) {
options[opt.key] = opt.default;
}
_modalFilters.push({ filter_id: filterId, options, _expanded: true });
select.value = '';
renderModalFilterList();
_autoGeneratePPTemplateName();
}
export function toggleFilterExpand(index) {
if (_modalFilters[index]) {
_modalFilters[index]._expanded = !_modalFilters[index]._expanded;
renderModalFilterList();
}
}
export function removeFilter(index) {
_modalFilters.splice(index, 1);
renderModalFilterList();
_autoGeneratePPTemplateName();
}
export function moveFilter(index, direction) {
const newIndex = index + direction;
if (newIndex < 0 || newIndex >= _modalFilters.length) return;
const tmp = _modalFilters[index];
_modalFilters[index] = _modalFilters[newIndex];
_modalFilters[newIndex] = tmp;
renderModalFilterList();
_autoGeneratePPTemplateName();
}
export function updateFilterOption(filterIndex, optionKey, value) {
if (_modalFilters[filterIndex]) {
const fi = _modalFilters[filterIndex];
const filterDef = _availableFilters.find(f => f.filter_id === fi.filter_id);
if (filterDef) {
const optDef = filterDef.options_schema.find(o => o.key === optionKey);
if (optDef && optDef.type === 'bool') {
fi.options[optionKey] = !!value;
} else if (optDef && optDef.type === 'select') {
fi.options[optionKey] = String(value);
} else if (optDef && optDef.type === 'int') {
fi.options[optionKey] = parseInt(value);
} else {
fi.options[optionKey] = parseFloat(value);
}
} else {
fi.options[optionKey] = parseFloat(value);
}
}
}
function collectFilters() {
return _modalFilters.map(fi => ({
filter_id: fi.filter_id,
options: { ...fi.options },
}));
}
function _autoGeneratePPTemplateName() {
if (_ppTemplateNameManuallyEdited) return;
if (document.getElementById('pp-template-id').value) return;
const nameInput = document.getElementById('pp-template-name');
if (_modalFilters.length > 0) {
const filterNames = _modalFilters.map(f => _getFilterName(f.filter_id)).join(' + ');
nameInput.value = filterNames;
} else {
nameInput.value = '';
}
}
export async function showAddPPTemplateModal(cloneData = null) {
if (_availableFilters.length === 0) await loadAvailableFilters();
document.getElementById('pp-template-modal-title').textContent = t('postprocessing.add');
document.getElementById('pp-template-form').reset();
document.getElementById('pp-template-id').value = '';
document.getElementById('pp-template-error').style.display = 'none';
if (cloneData) {
set_modalFilters((cloneData.filters || []).map(fi => ({
filter_id: fi.filter_id,
options: { ...fi.options },
})));
set_ppTemplateNameManuallyEdited(true);
} else {
set_modalFilters([]);
set_ppTemplateNameManuallyEdited(false);
}
document.getElementById('pp-template-name').oninput = () => { set_ppTemplateNameManuallyEdited(true); };
_populateFilterSelect();
renderModalFilterList();
// Pre-fill from clone data after form is set up
if (cloneData) {
document.getElementById('pp-template-name').value = (cloneData.name || '') + ' (Copy)';
document.getElementById('pp-template-description').value = cloneData.description || '';
}
ppTemplateModal.open();
}
export async function editPPTemplate(templateId) {
try {
if (_availableFilters.length === 0) await loadAvailableFilters();
const response = await fetchWithAuth(`/postprocessing-templates/${templateId}`);
if (!response.ok) throw new Error(`Failed to load template: ${response.status}`);
const tmpl = await response.json();
document.getElementById('pp-template-modal-title').textContent = t('postprocessing.edit');
document.getElementById('pp-template-id').value = templateId;
document.getElementById('pp-template-name').value = tmpl.name;
document.getElementById('pp-template-description').value = tmpl.description || '';
document.getElementById('pp-template-error').style.display = 'none';
set_modalFilters((tmpl.filters || []).map(fi => ({
filter_id: fi.filter_id,
options: { ...fi.options },
})));
_populateFilterSelect();
renderModalFilterList();
ppTemplateModal.open();
} 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');
closePPTemplateModal();
await loadPPTemplates();
} catch (error) {
console.error('Error saving PP template:', error);
errorEl.textContent = error.message;
errorEl.style.display = 'block';
}
}
// ===== Clone functions =====
export async function cloneStream(streamId) {
try {
const resp = await fetchWithAuth(`/picture-sources/${streamId}`);
if (!resp.ok) throw new Error('Failed to load stream');
const stream = await resp.json();
showAddStreamModal(stream.stream_type, stream);
} catch (error) {
if (error.isAuth) return;
console.error('Failed to clone stream:', error);
showToast('Failed to clone picture source', 'error');
}
}
export async function cloneCaptureTemplate(templateId) {
try {
const resp = await fetchWithAuth(`/capture-templates/${templateId}`);
if (!resp.ok) throw new Error('Failed to load template');
const tmpl = await resp.json();
showAddTemplateModal(tmpl);
} catch (error) {
if (error.isAuth) return;
console.error('Failed to clone capture template:', error);
showToast('Failed to clone capture template', 'error');
}
}
export async function clonePPTemplate(templateId) {
try {
const resp = await fetchWithAuth(`/postprocessing-templates/${templateId}`);
if (!resp.ok) throw new Error('Failed to load template');
const tmpl = await resp.json();
showAddPPTemplateModal(tmpl);
} catch (error) {
if (error.isAuth) return;
console.error('Failed to clone PP template:', error);
showToast('Failed to clone postprocessing template', 'error');
}
}
export async function deletePPTemplate(templateId) {
const confirmed = await showConfirm(t('postprocessing.delete.confirm'));
if (!confirmed) return;
try {
const response = await fetchWithAuth(`/postprocessing-templates/${templateId}`, { method: 'DELETE' });
if (!response.ok) {
const error = await response.json();
throw new Error(error.detail || error.message || 'Failed to delete template');
}
showToast(t('postprocessing.deleted'), 'success');
await loadPPTemplates();
} catch (error) {
console.error('Error deleting PP template:', error);
showToast(t('postprocessing.error.delete') + ': ' + error.message, 'error');
}
}
export function closePPTemplateModal() {
ppTemplateModal.forceClose();
set_modalFilters([]);
set_ppTemplateNameManuallyEdited(false);
}
// Exported helpers used by other modules
export { updateCaptureDuration, buildTestStatsHtml };