Introduces an engine+template abstraction for audio capture, mirroring the existing screen capture engine pattern. This enables multiple audio backends (WASAPI for Windows, sounddevice for cross-platform) with per-source engine configuration via reusable templates. Backend: - AudioCaptureEngine ABC with WasapiEngine and SounddeviceEngine implementations - AudioEngineRegistry for engine discovery and factory creation - AudioAnalyzer class decouples FFT/RMS/beat analysis from engine-specific capture - ManagedAudioStream wraps engine stream + analyzer in background thread - AudioCaptureTemplate model and AudioTemplateStore with JSON CRUD - AudioCaptureManager keyed by (engine_type, device_index, is_loopback) - Auto-migration: default template created on startup, assigned to existing sources - Full REST API: CRUD for audio templates + engine listing with availability flags - audio_template_id added to MultichannelAudioSource model and API schemas Frontend: - Audio template cards in Streams > Audio tab with engine badge and config details - Audio template editor modal with engine selector and dynamic config fields - Audio template dropdown in multichannel audio source editor - Template name crosslink badge on multichannel audio source cards - Confirm modal z-index fix (always stacks above editor modals) - i18n keys for EN and RU Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2009 lines
89 KiB
JavaScript
2009 lines
89 KiB
JavaScript
/**
|
||
* Streams — picture sources, capture templates, PP templates, filters.
|
||
*/
|
||
|
||
import {
|
||
_cachedDisplays, set_cachedDisplays,
|
||
_cachedStreams, set_cachedStreams,
|
||
_cachedPPTemplates, set_cachedPPTemplates,
|
||
_cachedCaptureTemplates, set_cachedCaptureTemplates,
|
||
_availableFilters, set_availableFilters,
|
||
availableEngines, setAvailableEngines,
|
||
currentEditingTemplateId, setCurrentEditingTemplateId,
|
||
_templateNameManuallyEdited, set_templateNameManuallyEdited,
|
||
_streamNameManuallyEdited, set_streamNameManuallyEdited,
|
||
_streamModalPPTemplates, set_streamModalPPTemplates,
|
||
_modalFilters, set_modalFilters,
|
||
_ppTemplateNameManuallyEdited, set_ppTemplateNameManuallyEdited,
|
||
_currentTestStreamId, set_currentTestStreamId,
|
||
_currentTestPPTemplateId, set_currentTestPPTemplateId,
|
||
_lastValidatedImageSource, set_lastValidatedImageSource,
|
||
_cachedAudioSources, set_cachedAudioSources,
|
||
_cachedValueSources, set_cachedValueSources,
|
||
_cachedAudioTemplates, set_cachedAudioTemplates,
|
||
availableAudioEngines, setAvailableAudioEngines,
|
||
currentEditingAudioTemplateId, setCurrentEditingAudioTemplateId,
|
||
_audioTemplateNameManuallyEdited, set_audioTemplateNameManuallyEdited,
|
||
_sourcesLoading, set_sourcesLoading,
|
||
apiKey,
|
||
} from '../core/state.js';
|
||
import { API_BASE, getHeaders, fetchWithAuth, escapeHtml } from '../core/api.js';
|
||
import { t } from '../core/i18n.js';
|
||
import { Modal } from '../core/modal.js';
|
||
import { showToast, showConfirm, openLightbox, openFullImageLightbox, showOverlaySpinner, hideOverlaySpinner, setTabRefreshing } from '../core/ui.js';
|
||
import { openDisplayPicker, formatDisplayLabel } from './displays.js';
|
||
import { CardSection } from '../core/card-sections.js';
|
||
import { updateSubTabHash } from './tabs.js';
|
||
import { createValueSourceCard } from './value-sources.js';
|
||
import {
|
||
getEngineIcon, getPictureSourceIcon, getAudioSourceIcon,
|
||
ICON_TEMPLATE, ICON_CLONE, ICON_EDIT, ICON_TEST, ICON_LINK_SOURCE,
|
||
ICON_FPS, ICON_WEB, ICON_VALUE_SOURCE, ICON_AUDIO_LOOPBACK, ICON_AUDIO_INPUT,
|
||
ICON_AUDIO_TEMPLATE,
|
||
} from '../core/icons.js';
|
||
|
||
// ── Card section instances ──
|
||
const csRawStreams = new CardSection('raw-streams', { titleKey: 'streams.section.streams', gridClass: 'templates-grid', addCardOnclick: "showAddStreamModal('raw')" });
|
||
const csRawTemplates = new CardSection('raw-templates', { titleKey: 'templates.title', gridClass: 'templates-grid', addCardOnclick: "showAddTemplateModal()" });
|
||
const csProcStreams = new CardSection('proc-streams', { titleKey: 'streams.section.streams', gridClass: 'templates-grid', addCardOnclick: "showAddStreamModal('processed')" });
|
||
const csProcTemplates = new CardSection('proc-templates', { titleKey: 'postprocessing.title', gridClass: 'templates-grid', addCardOnclick: "showAddPPTemplateModal()" });
|
||
const csAudioMulti = new CardSection('audio-multi', { titleKey: 'audio_source.group.multichannel', gridClass: 'templates-grid', addCardOnclick: "showAudioSourceModal('multichannel')" });
|
||
const csAudioMono = new CardSection('audio-mono', { titleKey: 'audio_source.group.mono', gridClass: 'templates-grid', addCardOnclick: "showAudioSourceModal('mono')" });
|
||
const csStaticStreams = new CardSection('static-streams', { titleKey: 'streams.group.static_image', gridClass: 'templates-grid', addCardOnclick: "showAddStreamModal('static_image')" });
|
||
const csAudioTemplates = new CardSection('audio-templates', { titleKey: 'audio_template.title', gridClass: 'templates-grid', addCardOnclick: "showAddAudioTemplateModal()" });
|
||
const csValueSources = new CardSection('value-sources', { titleKey: 'value_source.group.title', gridClass: 'templates-grid', addCardOnclick: "showValueSourceModal()" });
|
||
|
||
// Re-render picture sources when language changes
|
||
document.addEventListener('languageChanged', () => { if (apiKey) loadPictureSources(); });
|
||
|
||
// ===== Modal instances =====
|
||
|
||
class CaptureTemplateModal extends Modal {
|
||
constructor() { super('template-modal'); }
|
||
|
||
snapshotValues() {
|
||
const vals = {
|
||
name: document.getElementById('template-name').value,
|
||
description: document.getElementById('template-description').value,
|
||
engine: document.getElementById('template-engine').value,
|
||
};
|
||
document.querySelectorAll('[data-config-key]').forEach(field => {
|
||
vals['cfg_' + field.dataset.configKey] = field.value;
|
||
});
|
||
return vals;
|
||
}
|
||
|
||
onForceClose() {
|
||
setCurrentEditingTemplateId(null);
|
||
set_templateNameManuallyEdited(false);
|
||
}
|
||
}
|
||
|
||
class StreamEditorModal extends Modal {
|
||
constructor() { super('stream-modal'); }
|
||
|
||
snapshotValues() {
|
||
return {
|
||
name: document.getElementById('stream-name').value,
|
||
description: document.getElementById('stream-description').value,
|
||
type: document.getElementById('stream-type').value,
|
||
displayIndex: document.getElementById('stream-display-index').value,
|
||
captureTemplate: document.getElementById('stream-capture-template').value,
|
||
targetFps: document.getElementById('stream-target-fps').value,
|
||
source: document.getElementById('stream-source').value,
|
||
ppTemplate: document.getElementById('stream-pp-template').value,
|
||
imageSource: document.getElementById('stream-image-source').value,
|
||
};
|
||
}
|
||
|
||
onForceClose() {
|
||
document.getElementById('stream-type').disabled = false;
|
||
set_streamNameManuallyEdited(false);
|
||
}
|
||
}
|
||
|
||
class PPTemplateEditorModal extends Modal {
|
||
constructor() { super('pp-template-modal'); }
|
||
|
||
snapshotValues() {
|
||
return {
|
||
name: document.getElementById('pp-template-name').value,
|
||
description: document.getElementById('pp-template-description').value,
|
||
filters: JSON.stringify(_modalFilters.map(fi => ({ filter_id: fi.filter_id, options: fi.options }))),
|
||
};
|
||
}
|
||
|
||
onForceClose() {
|
||
set_modalFilters([]);
|
||
set_ppTemplateNameManuallyEdited(false);
|
||
}
|
||
}
|
||
|
||
class AudioTemplateModal extends Modal {
|
||
constructor() { super('audio-template-modal'); }
|
||
|
||
snapshotValues() {
|
||
const vals = {
|
||
name: document.getElementById('audio-template-name').value,
|
||
description: document.getElementById('audio-template-description').value,
|
||
engine: document.getElementById('audio-template-engine').value,
|
||
};
|
||
document.querySelectorAll('#audio-engine-config-fields [data-config-key]').forEach(field => {
|
||
vals['cfg_' + field.dataset.configKey] = field.value;
|
||
});
|
||
return vals;
|
||
}
|
||
|
||
onForceClose() {
|
||
setCurrentEditingAudioTemplateId(null);
|
||
set_audioTemplateNameManuallyEdited(false);
|
||
}
|
||
}
|
||
|
||
const templateModal = new CaptureTemplateModal();
|
||
const testTemplateModal = new Modal('test-template-modal');
|
||
const streamModal = new StreamEditorModal();
|
||
const testStreamModal = new Modal('test-stream-modal');
|
||
const ppTemplateModal = new PPTemplateEditorModal();
|
||
const testPPTemplateModal = new Modal('test-pp-template-modal');
|
||
const audioTemplateModal = new AudioTemplateModal();
|
||
|
||
// ===== Capture Templates =====
|
||
|
||
async function loadCaptureTemplates() {
|
||
try {
|
||
const response = await fetchWithAuth('/capture-templates');
|
||
if (!response.ok) throw new Error(`Failed to load templates: ${response.status}`);
|
||
const data = await response.json();
|
||
set_cachedCaptureTemplates(data.templates || []);
|
||
renderPictureSourcesList(_cachedStreams);
|
||
} catch (error) {
|
||
if (error.isAuth) return;
|
||
console.error('Error loading capture templates:', error);
|
||
showToast(t('streams.error.load'), 'error');
|
||
}
|
||
}
|
||
|
||
export async function showAddTemplateModal(cloneData = null) {
|
||
setCurrentEditingTemplateId(null);
|
||
document.getElementById('template-modal-title').textContent = t('templates.add');
|
||
document.getElementById('template-form').reset();
|
||
document.getElementById('template-id').value = '';
|
||
document.getElementById('engine-config-section').style.display = 'none';
|
||
document.getElementById('template-error').style.display = 'none';
|
||
|
||
set_templateNameManuallyEdited(!!cloneData);
|
||
document.getElementById('template-name').oninput = () => { set_templateNameManuallyEdited(true); };
|
||
|
||
await loadAvailableEngines();
|
||
|
||
// Pre-fill from clone data after engines are loaded
|
||
if (cloneData) {
|
||
document.getElementById('template-name').value = (cloneData.name || '') + ' (Copy)';
|
||
document.getElementById('template-description').value = cloneData.description || '';
|
||
document.getElementById('template-engine').value = cloneData.engine_type;
|
||
await onEngineChange();
|
||
populateEngineConfig(cloneData.engine_config);
|
||
}
|
||
|
||
templateModal.open();
|
||
templateModal.snapshot();
|
||
}
|
||
|
||
export async function editTemplate(templateId) {
|
||
try {
|
||
const response = await fetchWithAuth(`/capture-templates/${templateId}`);
|
||
if (!response.ok) throw new Error(`Failed to load template: ${response.status}`);
|
||
const template = await response.json();
|
||
|
||
setCurrentEditingTemplateId(templateId);
|
||
document.getElementById('template-modal-title').textContent = t('templates.edit');
|
||
document.getElementById('template-id').value = templateId;
|
||
document.getElementById('template-name').value = template.name;
|
||
document.getElementById('template-description').value = template.description || '';
|
||
|
||
await loadAvailableEngines();
|
||
document.getElementById('template-engine').value = template.engine_type;
|
||
await onEngineChange();
|
||
populateEngineConfig(template.engine_config);
|
||
|
||
await loadDisplaysForTest();
|
||
|
||
const testResults = document.getElementById('template-test-results');
|
||
if (testResults) testResults.style.display = 'none';
|
||
document.getElementById('template-error').style.display = 'none';
|
||
|
||
templateModal.open();
|
||
templateModal.snapshot();
|
||
} catch (error) {
|
||
console.error('Error loading template:', error);
|
||
showToast(t('templates.error.load') + ': ' + error.message, 'error');
|
||
}
|
||
}
|
||
|
||
export async function closeTemplateModal() {
|
||
await templateModal.close();
|
||
}
|
||
|
||
function updateCaptureDuration(value) {
|
||
document.getElementById('test-template-duration-value').textContent = value;
|
||
localStorage.setItem('capture_duration', value);
|
||
}
|
||
|
||
function restoreCaptureDuration() {
|
||
const savedDuration = localStorage.getItem('capture_duration');
|
||
if (savedDuration) {
|
||
const durationInput = document.getElementById('test-template-duration');
|
||
const durationValue = document.getElementById('test-template-duration-value');
|
||
durationInput.value = savedDuration;
|
||
durationValue.textContent = savedDuration;
|
||
}
|
||
}
|
||
|
||
export async function showTestTemplateModal(templateId) {
|
||
const templates = await fetchWithAuth('/capture-templates').then(r => r.json());
|
||
const template = templates.templates.find(t => t.id === templateId);
|
||
|
||
if (!template) {
|
||
showToast(t('templates.error.load'), 'error');
|
||
return;
|
||
}
|
||
|
||
window.currentTestingTemplate = template;
|
||
await loadDisplaysForTest();
|
||
restoreCaptureDuration();
|
||
|
||
testTemplateModal.open();
|
||
}
|
||
|
||
export function closeTestTemplateModal() {
|
||
testTemplateModal.forceClose();
|
||
window.currentTestingTemplate = null;
|
||
}
|
||
|
||
async function loadAvailableEngines() {
|
||
try {
|
||
const response = await fetchWithAuth('/capture-engines');
|
||
if (!response.ok) throw new Error(`Failed to load engines: ${response.status}`);
|
||
const data = await response.json();
|
||
setAvailableEngines(data.engines || []);
|
||
|
||
const select = document.getElementById('template-engine');
|
||
select.innerHTML = '';
|
||
|
||
availableEngines.forEach(engine => {
|
||
const option = document.createElement('option');
|
||
option.value = engine.type;
|
||
option.textContent = `${getEngineIcon(engine.type)} ${engine.name}`;
|
||
if (!engine.available) {
|
||
option.disabled = true;
|
||
option.textContent += ` (${t('templates.engine.unavailable')})`;
|
||
}
|
||
select.appendChild(option);
|
||
});
|
||
|
||
if (!select.value) {
|
||
const firstAvailable = availableEngines.find(e => e.available);
|
||
if (firstAvailable) select.value = firstAvailable.type;
|
||
}
|
||
} catch (error) {
|
||
console.error('Error loading engines:', error);
|
||
showToast(t('templates.error.engines') + ': ' + error.message, 'error');
|
||
}
|
||
}
|
||
|
||
export async function onEngineChange() {
|
||
const engineType = document.getElementById('template-engine').value;
|
||
const configSection = document.getElementById('engine-config-section');
|
||
const configFields = document.getElementById('engine-config-fields');
|
||
|
||
if (!engineType) { configSection.style.display = 'none'; return; }
|
||
|
||
const engine = availableEngines.find(e => e.type === engineType);
|
||
if (!engine) { configSection.style.display = 'none'; return; }
|
||
|
||
if (!_templateNameManuallyEdited && !document.getElementById('template-id').value) {
|
||
document.getElementById('template-name').value = engine.name || engineType;
|
||
}
|
||
|
||
const hint = document.getElementById('engine-availability-hint');
|
||
if (!engine.available) {
|
||
hint.textContent = t('templates.engine.unavailable.hint');
|
||
hint.style.display = 'block';
|
||
hint.style.color = 'var(--error-color)';
|
||
} else {
|
||
hint.style.display = 'none';
|
||
}
|
||
|
||
configFields.innerHTML = '';
|
||
const defaultConfig = engine.default_config || {};
|
||
|
||
if (Object.keys(defaultConfig).length === 0) {
|
||
configSection.style.display = 'none';
|
||
return;
|
||
} else {
|
||
let gridHtml = '<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');
|
||
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 = '<div class="config-grid">';
|
||
Object.entries(defaultConfig).forEach(([key, value]) => {
|
||
const fieldType = typeof value === 'number' ? 'number' : 'text';
|
||
const fieldValue = typeof value === 'boolean' ? (value ? 'true' : 'false') : value;
|
||
gridHtml += `
|
||
<label class="config-grid-label" for="audio-config-${key}">${key}</label>
|
||
<div class="config-grid-value">
|
||
${typeof value === 'boolean' ? `
|
||
<select id="audio-config-${key}" data-config-key="${key}">
|
||
<option value="true" ${value ? 'selected' : ''}>true</option>
|
||
<option value="false" ${!value ? 'selected' : ''}>false</option>
|
||
</select>
|
||
` : `
|
||
<input type="${fieldType}" id="audio-config-${key}" data-config-key="${key}" value="${fieldValue}">
|
||
`}
|
||
</div>
|
||
`;
|
||
});
|
||
gridHtml += '</div>';
|
||
configFields.innerHTML = gridHtml;
|
||
}
|
||
|
||
configSection.style.display = 'block';
|
||
}
|
||
|
||
function populateAudioEngineConfig(config) {
|
||
Object.entries(config).forEach(([key, value]) => {
|
||
const field = document.getElementById(`audio-config-${key}`);
|
||
if (field) {
|
||
if (field.tagName === 'SELECT') {
|
||
field.value = value.toString();
|
||
} else {
|
||
field.value = value;
|
||
}
|
||
}
|
||
});
|
||
}
|
||
|
||
function collectAudioEngineConfig() {
|
||
const config = {};
|
||
document.querySelectorAll('#audio-engine-config-fields [data-config-key]').forEach(field => {
|
||
const key = field.dataset.configKey;
|
||
let value = field.value;
|
||
if (field.type === 'number') {
|
||
value = parseFloat(value);
|
||
} else if (field.tagName === 'SELECT' && (value === 'true' || value === 'false')) {
|
||
value = value === 'true';
|
||
}
|
||
config[key] = value;
|
||
});
|
||
return config;
|
||
}
|
||
|
||
async function loadAudioTemplates() {
|
||
try {
|
||
const response = await fetchWithAuth('/audio-templates');
|
||
if (!response.ok) throw new Error(`Failed to load audio templates: ${response.status}`);
|
||
const data = await response.json();
|
||
set_cachedAudioTemplates(data.templates || []);
|
||
renderPictureSourcesList(_cachedStreams);
|
||
} catch (error) {
|
||
if (error.isAuth) return;
|
||
console.error('Error loading audio templates:', error);
|
||
showToast(t('audio_template.error.load'), 'error');
|
||
}
|
||
}
|
||
|
||
export async function showAddAudioTemplateModal(cloneData = null) {
|
||
setCurrentEditingAudioTemplateId(null);
|
||
document.getElementById('audio-template-modal-title').textContent = t('audio_template.add');
|
||
document.getElementById('audio-template-form').reset();
|
||
document.getElementById('audio-template-id').value = '';
|
||
document.getElementById('audio-engine-config-section').style.display = 'none';
|
||
document.getElementById('audio-template-error').style.display = 'none';
|
||
|
||
set_audioTemplateNameManuallyEdited(!!cloneData);
|
||
document.getElementById('audio-template-name').oninput = () => { set_audioTemplateNameManuallyEdited(true); };
|
||
|
||
await loadAvailableAudioEngines();
|
||
|
||
if (cloneData) {
|
||
document.getElementById('audio-template-name').value = (cloneData.name || '') + ' (Copy)';
|
||
document.getElementById('audio-template-description').value = cloneData.description || '';
|
||
document.getElementById('audio-template-engine').value = cloneData.engine_type;
|
||
await onAudioEngineChange();
|
||
populateAudioEngineConfig(cloneData.engine_config);
|
||
}
|
||
|
||
audioTemplateModal.open();
|
||
audioTemplateModal.snapshot();
|
||
}
|
||
|
||
export async function editAudioTemplate(templateId) {
|
||
try {
|
||
const response = await fetchWithAuth(`/audio-templates/${templateId}`);
|
||
if (!response.ok) throw new Error(`Failed to load audio template: ${response.status}`);
|
||
const template = await response.json();
|
||
|
||
setCurrentEditingAudioTemplateId(templateId);
|
||
document.getElementById('audio-template-modal-title').textContent = t('audio_template.edit');
|
||
document.getElementById('audio-template-id').value = templateId;
|
||
document.getElementById('audio-template-name').value = template.name;
|
||
document.getElementById('audio-template-description').value = template.description || '';
|
||
|
||
await loadAvailableAudioEngines();
|
||
document.getElementById('audio-template-engine').value = template.engine_type;
|
||
await onAudioEngineChange();
|
||
populateAudioEngineConfig(template.engine_config);
|
||
|
||
document.getElementById('audio-template-error').style.display = 'none';
|
||
|
||
audioTemplateModal.open();
|
||
audioTemplateModal.snapshot();
|
||
} catch (error) {
|
||
console.error('Error loading audio template:', error);
|
||
showToast(t('audio_template.error.load') + ': ' + error.message, 'error');
|
||
}
|
||
}
|
||
|
||
export async function closeAudioTemplateModal() {
|
||
await audioTemplateModal.close();
|
||
}
|
||
|
||
export async function saveAudioTemplate() {
|
||
const templateId = currentEditingAudioTemplateId;
|
||
const name = document.getElementById('audio-template-name').value.trim();
|
||
const engineType = document.getElementById('audio-template-engine').value;
|
||
|
||
if (!name || !engineType) {
|
||
showToast(t('audio_template.error.required'), 'error');
|
||
return;
|
||
}
|
||
|
||
const description = document.getElementById('audio-template-description').value.trim();
|
||
const engineConfig = collectAudioEngineConfig();
|
||
|
||
const payload = { name, engine_type: engineType, engine_config: engineConfig, description: description || null };
|
||
|
||
try {
|
||
let response;
|
||
if (templateId) {
|
||
response = await fetchWithAuth(`/audio-templates/${templateId}`, { method: 'PUT', body: JSON.stringify(payload) });
|
||
} else {
|
||
response = await fetchWithAuth('/audio-templates', { method: 'POST', body: JSON.stringify(payload) });
|
||
}
|
||
|
||
if (!response.ok) {
|
||
const error = await response.json();
|
||
throw new Error(error.detail || error.message || 'Failed to save audio template');
|
||
}
|
||
|
||
showToast(templateId ? t('audio_template.updated') : t('audio_template.created'), 'success');
|
||
audioTemplateModal.forceClose();
|
||
await loadAudioTemplates();
|
||
} catch (error) {
|
||
console.error('Error saving audio template:', error);
|
||
document.getElementById('audio-template-error').textContent = error.message;
|
||
document.getElementById('audio-template-error').style.display = 'block';
|
||
}
|
||
}
|
||
|
||
export async function deleteAudioTemplate(templateId) {
|
||
const confirmed = await showConfirm(t('audio_template.delete.confirm'));
|
||
if (!confirmed) return;
|
||
|
||
try {
|
||
const response = await fetchWithAuth(`/audio-templates/${templateId}`, { method: 'DELETE' });
|
||
if (!response.ok) {
|
||
const error = await response.json();
|
||
throw new Error(error.detail || error.message || 'Failed to delete audio template');
|
||
}
|
||
showToast(t('audio_template.deleted'), 'success');
|
||
await loadAudioTemplates();
|
||
} catch (error) {
|
||
console.error('Error deleting audio template:', error);
|
||
showToast(t('audio_template.error.delete') + ': ' + error.message, 'error');
|
||
}
|
||
}
|
||
|
||
export async function cloneAudioTemplate(templateId) {
|
||
try {
|
||
const resp = await fetchWithAuth(`/audio-templates/${templateId}`);
|
||
if (!resp.ok) throw new Error('Failed to load audio template');
|
||
const tmpl = await resp.json();
|
||
showAddAudioTemplateModal(tmpl);
|
||
} catch (error) {
|
||
if (error.isAuth) return;
|
||
console.error('Failed to clone audio template:', error);
|
||
showToast('Failed to clone audio template', 'error');
|
||
}
|
||
}
|
||
|
||
// ===== Picture Sources =====
|
||
|
||
export async function loadPictureSources() {
|
||
if (_sourcesLoading) return;
|
||
set_sourcesLoading(true);
|
||
setTabRefreshing('streams-list', true);
|
||
try {
|
||
const [filtersResp, ppResp, captResp, streamsResp, audioResp, valueResp, audioTplResp] = await Promise.all([
|
||
_availableFilters.length === 0 ? fetchWithAuth('/filters') : Promise.resolve(null),
|
||
fetchWithAuth('/postprocessing-templates'),
|
||
fetchWithAuth('/capture-templates'),
|
||
fetchWithAuth('/picture-sources'),
|
||
fetchWithAuth('/audio-sources'),
|
||
fetchWithAuth('/value-sources'),
|
||
fetchWithAuth('/audio-templates'),
|
||
]);
|
||
|
||
if (filtersResp && filtersResp.ok) {
|
||
const fd = await filtersResp.json();
|
||
set_availableFilters(fd.filters || []);
|
||
}
|
||
if (ppResp.ok) {
|
||
const pd = await ppResp.json();
|
||
set_cachedPPTemplates(pd.templates || []);
|
||
}
|
||
if (captResp.ok) {
|
||
const cd = await captResp.json();
|
||
set_cachedCaptureTemplates(cd.templates || []);
|
||
}
|
||
if (audioResp && audioResp.ok) {
|
||
const ad = await audioResp.json();
|
||
set_cachedAudioSources(ad.sources || []);
|
||
}
|
||
if (valueResp && valueResp.ok) {
|
||
const vd = await valueResp.json();
|
||
set_cachedValueSources(vd.sources || []);
|
||
}
|
||
if (audioTplResp && audioTplResp.ok) {
|
||
const atd = await audioTplResp.json();
|
||
set_cachedAudioTemplates(atd.templates || []);
|
||
}
|
||
if (!streamsResp.ok) throw new Error(`Failed to load streams: ${streamsResp.status}`);
|
||
const data = await streamsResp.json();
|
||
set_cachedStreams(data.streams || []);
|
||
renderPictureSourcesList(_cachedStreams);
|
||
} catch (error) {
|
||
if (error.isAuth) return;
|
||
console.error('Error loading picture sources:', error);
|
||
document.getElementById('streams-list').innerHTML = `
|
||
<div class="error-message">${t('streams.error.load')}: ${error.message}</div>
|
||
`;
|
||
} 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 = `<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')}">${ICON_FPS} ${stream.target_fps ?? 30}</span>
|
||
${capTmplName ? `<span class="stream-card-prop stream-card-link" title="${t('streams.capture_template')}" onclick="event.stopPropagation(); navigateToCard('streams','raw','raw-templates','data-id','${stream.capture_template_id}')">${ICON_TEMPLATE} ${capTmplName}</span>` : ''}
|
||
</div>`;
|
||
} else if (stream.stream_type === 'processed') {
|
||
const sourceStream = _cachedStreams.find(s => s.id === stream.source_stream_id);
|
||
const sourceName = sourceStream ? escapeHtml(sourceStream.name) : (stream.source_stream_id || '-');
|
||
const sourceSubTab = sourceStream ? (sourceStream.stream_type === 'static_image' ? 'static_image' : 'raw') : 'raw';
|
||
const sourceSection = sourceStream ? (sourceStream.stream_type === 'static_image' ? 'static-streams' : 'raw-streams') : 'raw-streams';
|
||
let ppTmplName = '';
|
||
if (stream.postprocessing_template_id) {
|
||
const ppTmpl = _cachedPPTemplates.find(p => p.id === stream.postprocessing_template_id);
|
||
if (ppTmpl) ppTmplName = escapeHtml(ppTmpl.name);
|
||
}
|
||
detailsHtml = `<div class="stream-card-props">
|
||
<span class="stream-card-prop stream-card-link" title="${t('streams.source')}" onclick="event.stopPropagation(); navigateToCard('streams','${sourceSubTab}','${sourceSection}','data-stream-id','${stream.source_stream_id}')">${ICON_LINK_SOURCE} ${sourceName}</span>
|
||
${ppTmplName ? `<span class="stream-card-prop stream-card-link" title="${t('streams.pp_template')}" onclick="event.stopPropagation(); navigateToCard('streams','processed','proc-templates','data-id','${stream.postprocessing_template_id}')">${ICON_TEMPLATE} ${ppTmplName}</span>` : ''}
|
||
</div>`;
|
||
} else if (stream.stream_type === 'static_image') {
|
||
const src = stream.image_source || '';
|
||
detailsHtml = `<div class="stream-card-props">
|
||
<span class="stream-card-prop stream-card-prop-full" title="${escapeHtml(src)}">${ICON_WEB} ${escapeHtml(src)}</span>
|
||
</div>`;
|
||
}
|
||
|
||
return `
|
||
<div class="template-card" data-stream-id="${stream.id}">
|
||
<button class="card-remove-btn" onclick="deleteStream('${stream.id}')" title="${t('common.delete')}">✕</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')}">${ICON_TEST}</button>
|
||
<button class="btn btn-icon btn-secondary" onclick="cloneStream('${stream.id}')" title="${t('common.clone')}">${ICON_CLONE}</button>
|
||
<button class="btn btn-icon btn-secondary" onclick="editStream('${stream.id}')" title="${t('common.edit')}">${ICON_EDIT}</button>
|
||
</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')}">✕</button>
|
||
<div class="template-card-header">
|
||
<div class="template-name">${ICON_TEMPLATE} ${escapeHtml(template.name)}</div>
|
||
</div>
|
||
${template.description ? `<div class="template-config" style="opacity:0.7;">${escapeHtml(template.description)}</div>` : ''}
|
||
<div class="stream-card-props">
|
||
<span class="stream-card-prop" title="${t('templates.engine')}">${getEngineIcon(template.engine_type)} ${template.engine_type.toUpperCase()}</span>
|
||
${configEntries.length > 0 ? `<span class="stream-card-prop" title="${t('templates.config.show')}">🔧 ${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')}">${ICON_TEST}</button>
|
||
<button class="btn btn-icon btn-secondary" onclick="cloneCaptureTemplate('${template.id}')" title="${t('common.clone')}">${ICON_CLONE}</button>
|
||
<button class="btn btn-icon btn-secondary" onclick="editTemplate('${template.id}')" title="${t('common.edit')}">${ICON_EDIT}</button>
|
||
</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')}">✕</button>
|
||
<div class="template-card-header">
|
||
<div class="template-name">${ICON_TEMPLATE} ${escapeHtml(tmpl.name)}</div>
|
||
</div>
|
||
${tmpl.description ? `<div class="template-config" style="opacity:0.7;">${escapeHtml(tmpl.description)}</div>` : ''}
|
||
${filterChainHtml}
|
||
<div class="template-card-actions">
|
||
<button class="btn btn-icon btn-secondary" onclick="showTestPPTemplateModal('${tmpl.id}')" title="${t('postprocessing.test.title')}">${ICON_TEST}</button>
|
||
<button class="btn btn-icon btn-secondary" onclick="clonePPTemplate('${tmpl.id}')" title="${t('common.clone')}">${ICON_CLONE}</button>
|
||
<button class="btn btn-icon btn-secondary" onclick="editPPTemplate('${tmpl.id}')" title="${t('common.edit')}">${ICON_EDIT}</button>
|
||
</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 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 = `<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.count}</span></button>`
|
||
).join('')}<span class="cs-expand-collapse-group"><button class="btn-expand-collapse" onclick="expandAllStreamSections()" title="${t('section.expand_all')}">⊞</button><button class="btn-expand-collapse" onclick="collapseAllStreamSections()" title="${t('section.collapse_all')}">⊟</button></span></div>`;
|
||
|
||
const renderAudioSourceCard = (src) => {
|
||
const isMono = src.source_type === 'mono';
|
||
const icon = getAudioSourceIcon(src.source_type);
|
||
|
||
let propsHtml = '';
|
||
if (isMono) {
|
||
const parent = _cachedAudioSources.find(s => s.id === src.audio_source_id);
|
||
const parentName = parent ? parent.name : src.audio_source_id;
|
||
const chLabel = src.channel === 'left' ? 'L' : src.channel === 'right' ? 'R' : 'M';
|
||
propsHtml = `
|
||
<span class="stream-card-prop" title="${escapeHtml(t('audio_source.parent'))}">${ICON_AUDIO_LOOPBACK} ${escapeHtml(parentName)}</span>
|
||
<span class="stream-card-prop" title="${escapeHtml(t('audio_source.channel'))}">📻 ${chLabel}</span>
|
||
`;
|
||
} else {
|
||
const devIdx = src.device_index ?? -1;
|
||
const loopback = src.is_loopback !== false;
|
||
const devLabel = loopback ? `${ICON_AUDIO_LOOPBACK} Loopback` : `${ICON_AUDIO_INPUT} Input`;
|
||
const tpl = src.audio_template_id ? _cachedAudioTemplates.find(t => t.id === src.audio_template_id) : null;
|
||
const tplBadge = tpl ? `<span class="stream-card-prop stream-card-link" title="${escapeHtml(t('audio_source.audio_template'))}" onclick="event.stopPropagation(); navigateToCard('streams','audio','audio-templates','data-audio-template-id','${src.audio_template_id}')">${ICON_AUDIO_TEMPLATE} ${escapeHtml(tpl.name)}</span>` : '';
|
||
propsHtml = `<span class="stream-card-prop">${devLabel} #${devIdx}</span>${tplBadge}`;
|
||
}
|
||
|
||
return `
|
||
<div class="template-card" data-id="${src.id}">
|
||
<button class="card-remove-btn" onclick="deleteAudioSource('${src.id}')" title="${t('common.delete')}">✕</button>
|
||
<div class="template-card-header">
|
||
<div class="template-name">${icon} ${escapeHtml(src.name)}</div>
|
||
</div>
|
||
<div class="stream-card-props">${propsHtml}</div>
|
||
${src.description ? `<div class="template-config" style="opacity:0.7;">${escapeHtml(src.description)}</div>` : ''}
|
||
<div class="template-card-actions">
|
||
<button class="btn btn-icon btn-secondary" onclick="cloneAudioSource('${src.id}')" title="${t('common.clone')}">${ICON_CLONE}</button>
|
||
<button class="btn btn-icon btn-secondary" onclick="editAudioSource('${src.id}')" title="${t('common.edit')}">${ICON_EDIT}</button>
|
||
</div>
|
||
</div>
|
||
`;
|
||
};
|
||
|
||
const renderAudioTemplateCard = (template) => {
|
||
const configEntries = Object.entries(template.engine_config || {});
|
||
return `
|
||
<div class="template-card" data-audio-template-id="${template.id}">
|
||
<button class="card-remove-btn" onclick="deleteAudioTemplate('${template.id}')" title="${t('common.delete')}">✕</button>
|
||
<div class="template-card-header">
|
||
<div class="template-name">${ICON_AUDIO_TEMPLATE} ${escapeHtml(template.name)}</div>
|
||
</div>
|
||
${template.description ? `<div class="template-config" style="opacity:0.7;">${escapeHtml(template.description)}</div>` : ''}
|
||
<div class="stream-card-props">
|
||
<span class="stream-card-prop" title="${t('audio_template.engine')}">${ICON_AUDIO_TEMPLATE} ${template.engine_type.toUpperCase()}</span>
|
||
${configEntries.length > 0 ? `<span class="stream-card-prop" title="${t('audio_template.config.show')}">🔧 ${configEntries.length}</span>` : ''}
|
||
</div>
|
||
${configEntries.length > 0 ? `
|
||
<details class="template-config-details">
|
||
<summary>${t('audio_template.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="cloneAudioTemplate('${template.id}')" title="${t('common.clone')}">${ICON_CLONE}</button>
|
||
<button class="btn btn-icon btn-secondary" onclick="editAudioTemplate('${template.id}')" title="${t('common.edit')}">${ICON_EDIT}</button>
|
||
</div>
|
||
</div>
|
||
`;
|
||
};
|
||
|
||
const panels = tabs.map(tab => {
|
||
let panelContent = '';
|
||
|
||
if (tab.key === 'raw') {
|
||
panelContent =
|
||
csRawStreams.render(rawStreams.map(s => ({ key: s.id, html: renderStreamCard(s) }))) +
|
||
csRawTemplates.render(_cachedCaptureTemplates.map(t => ({ key: t.id, html: renderCaptureTemplateCard(t) })));
|
||
} else if (tab.key === 'processed') {
|
||
panelContent =
|
||
csProcStreams.render(processedStreams.map(s => ({ key: s.id, html: renderStreamCard(s) }))) +
|
||
csProcTemplates.render(_cachedPPTemplates.map(t => ({ key: t.id, html: renderPPTemplateCard(t) })));
|
||
} else if (tab.key === 'audio') {
|
||
panelContent =
|
||
csAudioMulti.render(multichannelSources.map(s => ({ key: s.id, html: renderAudioSourceCard(s) }))) +
|
||
csAudioMono.render(monoSources.map(s => ({ key: s.id, html: renderAudioSourceCard(s) }))) +
|
||
csAudioTemplates.render(_cachedAudioTemplates.map(t => ({ key: t.id, html: renderAudioTemplateCard(t) })));
|
||
} else if (tab.key === 'value') {
|
||
panelContent = csValueSources.render(_cachedValueSources.map(s => ({ key: s.id, html: createValueSourceCard(s) })));
|
||
} else {
|
||
panelContent = csStaticStreams.render(staticImageStreams.map(s => ({ key: s.id, html: renderStreamCard(s) })));
|
||
}
|
||
|
||
return `<div class="stream-tab-panel${tab.key === activeTab ? ' active' : ''}" id="stream-tab-${tab.key}">${panelContent}</div>`;
|
||
}).join('');
|
||
|
||
container.innerHTML = tabBar + panels;
|
||
CardSection.bindAll([csRawStreams, csRawTemplates, csProcStreams, csProcTemplates, csAudioMulti, csAudioMono, csAudioTemplates, csStaticStreams, csValueSources]);
|
||
}
|
||
|
||
export function onStreamTypeChange() {
|
||
const streamType = document.getElementById('stream-type').value;
|
||
document.getElementById('stream-raw-fields').style.display = streamType === 'raw' ? '' : 'none';
|
||
document.getElementById('stream-processed-fields').style.display = streamType === 'processed' ? '' : 'none';
|
||
document.getElementById('stream-static-image-fields').style.display = streamType === 'static_image' ? '' : 'none';
|
||
}
|
||
|
||
export function onStreamDisplaySelected(displayIndex, display) {
|
||
document.getElementById('stream-display-index').value = displayIndex;
|
||
document.getElementById('stream-display-picker-label').textContent = formatDisplayLabel(displayIndex, display);
|
||
_autoGenerateStreamName();
|
||
}
|
||
|
||
export function onTestDisplaySelected(displayIndex, display) {
|
||
document.getElementById('test-template-display').value = displayIndex;
|
||
document.getElementById('test-display-picker-label').textContent = formatDisplayLabel(displayIndex, display);
|
||
}
|
||
|
||
function _autoGenerateStreamName() {
|
||
if (_streamNameManuallyEdited) return;
|
||
if (document.getElementById('stream-id').value) return;
|
||
const streamType = document.getElementById('stream-type').value;
|
||
const nameInput = document.getElementById('stream-name');
|
||
|
||
if (streamType === 'raw') {
|
||
const displayIndex = document.getElementById('stream-display-index').value;
|
||
const templateSelect = document.getElementById('stream-capture-template');
|
||
const templateName = templateSelect.selectedOptions[0]?.dataset?.name || '';
|
||
if (displayIndex === '' || !templateName) return;
|
||
nameInput.value = `D${displayIndex}_${templateName}`;
|
||
} else if (streamType === 'processed') {
|
||
const sourceSelect = document.getElementById('stream-source');
|
||
const sourceName = sourceSelect.selectedOptions[0]?.dataset?.name || '';
|
||
const ppTemplateId = document.getElementById('stream-pp-template').value;
|
||
const ppTemplate = _streamModalPPTemplates.find(t => t.id === ppTemplateId);
|
||
if (!sourceName) return;
|
||
if (ppTemplate && ppTemplate.name) {
|
||
nameInput.value = `${sourceName} (${ppTemplate.name})`;
|
||
} else {
|
||
nameInput.value = sourceName;
|
||
}
|
||
}
|
||
}
|
||
|
||
export async function showAddStreamModal(presetType, cloneData = null) {
|
||
const streamType = (cloneData && cloneData.stream_type) || presetType || 'raw';
|
||
const titleKeys = { raw: 'streams.add.raw', processed: 'streams.add.processed', static_image: 'streams.add.static_image' };
|
||
document.getElementById('stream-modal-title').textContent = t(titleKeys[streamType] || 'streams.add');
|
||
document.getElementById('stream-form').reset();
|
||
document.getElementById('stream-id').value = '';
|
||
document.getElementById('stream-display-index').value = '';
|
||
document.getElementById('stream-display-picker-label').textContent = t('displays.picker.select');
|
||
document.getElementById('stream-error').style.display = 'none';
|
||
document.getElementById('stream-type').value = streamType;
|
||
set_lastValidatedImageSource('');
|
||
const imgSrcInput = document.getElementById('stream-image-source');
|
||
imgSrcInput.value = '';
|
||
document.getElementById('stream-image-preview-container').style.display = 'none';
|
||
document.getElementById('stream-image-validation-status').style.display = 'none';
|
||
imgSrcInput.onblur = () => validateStaticImage();
|
||
imgSrcInput.onkeydown = (e) => { if (e.key === 'Enter') { e.preventDefault(); validateStaticImage(); } };
|
||
imgSrcInput.onpaste = () => setTimeout(() => validateStaticImage(), 0);
|
||
onStreamTypeChange();
|
||
|
||
set_streamNameManuallyEdited(!!cloneData);
|
||
document.getElementById('stream-name').oninput = () => { set_streamNameManuallyEdited(true); };
|
||
document.getElementById('stream-capture-template').onchange = () => _autoGenerateStreamName();
|
||
document.getElementById('stream-source').onchange = () => _autoGenerateStreamName();
|
||
document.getElementById('stream-pp-template').onchange = () => _autoGenerateStreamName();
|
||
|
||
await populateStreamModalDropdowns();
|
||
|
||
// Pre-fill from clone data after dropdowns are populated
|
||
if (cloneData) {
|
||
document.getElementById('stream-name').value = (cloneData.name || '') + ' (Copy)';
|
||
document.getElementById('stream-description').value = cloneData.description || '';
|
||
if (streamType === 'raw') {
|
||
const displayIdx = cloneData.display_index ?? 0;
|
||
const display = _cachedDisplays ? _cachedDisplays.find(d => d.index === displayIdx) : null;
|
||
onStreamDisplaySelected(displayIdx, display);
|
||
document.getElementById('stream-capture-template').value = cloneData.capture_template_id || '';
|
||
const fps = cloneData.target_fps ?? 30;
|
||
document.getElementById('stream-target-fps').value = fps;
|
||
document.getElementById('stream-target-fps-value').textContent = fps;
|
||
} else if (streamType === 'processed') {
|
||
document.getElementById('stream-source').value = cloneData.source_stream_id || '';
|
||
document.getElementById('stream-pp-template').value = cloneData.postprocessing_template_id || '';
|
||
} else if (streamType === 'static_image') {
|
||
document.getElementById('stream-image-source').value = cloneData.image_source || '';
|
||
if (cloneData.image_source) validateStaticImage();
|
||
}
|
||
}
|
||
|
||
streamModal.open();
|
||
streamModal.snapshot();
|
||
}
|
||
|
||
export async function editStream(streamId) {
|
||
try {
|
||
const response = await fetchWithAuth(`/picture-sources/${streamId}`);
|
||
if (!response.ok) throw new Error(`Failed to load stream: ${response.status}`);
|
||
const stream = await response.json();
|
||
|
||
const editTitleKeys = { raw: 'streams.edit.raw', processed: 'streams.edit.processed', static_image: 'streams.edit.static_image' };
|
||
document.getElementById('stream-modal-title').textContent = t(editTitleKeys[stream.stream_type] || 'streams.edit');
|
||
document.getElementById('stream-id').value = streamId;
|
||
document.getElementById('stream-name').value = stream.name;
|
||
document.getElementById('stream-description').value = stream.description || '';
|
||
document.getElementById('stream-error').style.display = 'none';
|
||
|
||
document.getElementById('stream-type').value = stream.stream_type;
|
||
set_lastValidatedImageSource('');
|
||
const imgSrcInput = document.getElementById('stream-image-source');
|
||
document.getElementById('stream-image-preview-container').style.display = 'none';
|
||
document.getElementById('stream-image-validation-status').style.display = 'none';
|
||
imgSrcInput.onblur = () => validateStaticImage();
|
||
imgSrcInput.onkeydown = (e) => { if (e.key === 'Enter') { e.preventDefault(); validateStaticImage(); } };
|
||
imgSrcInput.onpaste = () => setTimeout(() => validateStaticImage(), 0);
|
||
onStreamTypeChange();
|
||
|
||
await populateStreamModalDropdowns();
|
||
|
||
if (stream.stream_type === 'raw') {
|
||
const displayIdx = stream.display_index ?? 0;
|
||
const display = _cachedDisplays ? _cachedDisplays.find(d => d.index === displayIdx) : null;
|
||
onStreamDisplaySelected(displayIdx, display);
|
||
document.getElementById('stream-capture-template').value = stream.capture_template_id || '';
|
||
const fps = stream.target_fps ?? 30;
|
||
document.getElementById('stream-target-fps').value = fps;
|
||
document.getElementById('stream-target-fps-value').textContent = fps;
|
||
} else if (stream.stream_type === 'processed') {
|
||
document.getElementById('stream-source').value = stream.source_stream_id || '';
|
||
document.getElementById('stream-pp-template').value = stream.postprocessing_template_id || '';
|
||
} else if (stream.stream_type === 'static_image') {
|
||
document.getElementById('stream-image-source').value = stream.image_source || '';
|
||
if (stream.image_source) validateStaticImage();
|
||
}
|
||
|
||
streamModal.open();
|
||
streamModal.snapshot();
|
||
} catch (error) {
|
||
console.error('Error loading stream:', error);
|
||
showToast(t('streams.error.load') + ': ' + error.message, 'error');
|
||
}
|
||
}
|
||
|
||
/** Track which engine type the stream-modal displays were loaded for. */
|
||
let _streamModalDisplaysEngine = null;
|
||
|
||
async function populateStreamModalDropdowns() {
|
||
const [displaysRes, captureTemplatesRes, streamsRes, ppTemplatesRes] = await Promise.all([
|
||
fetch(`${API_BASE}/config/displays`, { headers: getHeaders() }),
|
||
fetchWithAuth('/capture-templates'),
|
||
fetchWithAuth('/picture-sources'),
|
||
fetchWithAuth('/postprocessing-templates'),
|
||
]);
|
||
|
||
if (displaysRes.ok) {
|
||
const displaysData = await displaysRes.json();
|
||
set_cachedDisplays(displaysData.displays || []);
|
||
}
|
||
_streamModalDisplaysEngine = null; // desktop displays loaded
|
||
|
||
if (!document.getElementById('stream-display-index').value && _cachedDisplays && _cachedDisplays.length > 0) {
|
||
const primary = _cachedDisplays.find(d => d.is_primary) || _cachedDisplays[0];
|
||
onStreamDisplaySelected(primary.index, primary);
|
||
}
|
||
|
||
const templateSelect = document.getElementById('stream-capture-template');
|
||
templateSelect.innerHTML = '';
|
||
if (captureTemplatesRes.ok) {
|
||
const data = await captureTemplatesRes.json();
|
||
(data.templates || []).forEach(tmpl => {
|
||
const opt = document.createElement('option');
|
||
opt.value = tmpl.id;
|
||
opt.dataset.name = tmpl.name;
|
||
opt.dataset.engineType = tmpl.engine_type;
|
||
opt.textContent = `${getEngineIcon(tmpl.engine_type)} ${tmpl.name}`;
|
||
templateSelect.appendChild(opt);
|
||
});
|
||
}
|
||
|
||
// When template changes, refresh displays if engine type switched
|
||
templateSelect.addEventListener('change', _onCaptureTemplateChanged);
|
||
|
||
const sourceSelect = document.getElementById('stream-source');
|
||
sourceSelect.innerHTML = '';
|
||
if (streamsRes.ok) {
|
||
const data = await streamsRes.json();
|
||
const editingId = document.getElementById('stream-id').value;
|
||
(data.streams || []).forEach(s => {
|
||
if (s.id === editingId) return;
|
||
const opt = document.createElement('option');
|
||
opt.value = s.id;
|
||
opt.dataset.name = s.name;
|
||
opt.textContent = `${getPictureSourceIcon(s.stream_type)} ${s.name}`;
|
||
sourceSelect.appendChild(opt);
|
||
});
|
||
}
|
||
|
||
set_streamModalPPTemplates([]);
|
||
const ppSelect = document.getElementById('stream-pp-template');
|
||
ppSelect.innerHTML = '';
|
||
if (ppTemplatesRes.ok) {
|
||
const data = await ppTemplatesRes.json();
|
||
set_streamModalPPTemplates(data.templates || []);
|
||
_streamModalPPTemplates.forEach(tmpl => {
|
||
const opt = document.createElement('option');
|
||
opt.value = tmpl.id;
|
||
opt.textContent = tmpl.name;
|
||
ppSelect.appendChild(opt);
|
||
});
|
||
}
|
||
|
||
_autoGenerateStreamName();
|
||
|
||
// If the first template is an scrcpy engine, reload displays immediately
|
||
const firstOpt = templateSelect.selectedOptions[0];
|
||
if (firstOpt?.dataset?.engineType === 'scrcpy') {
|
||
await _refreshStreamDisplaysForEngine('scrcpy');
|
||
}
|
||
}
|
||
|
||
async function _onCaptureTemplateChanged() {
|
||
const templateSelect = document.getElementById('stream-capture-template');
|
||
const engineType = templateSelect.selectedOptions[0]?.dataset?.engineType || null;
|
||
const needsEngineDisplays = engineType === 'scrcpy';
|
||
const currentEngine = needsEngineDisplays ? engineType : null;
|
||
|
||
// Only refetch if the engine category actually changed
|
||
if (currentEngine !== _streamModalDisplaysEngine) {
|
||
await _refreshStreamDisplaysForEngine(currentEngine);
|
||
}
|
||
_autoGenerateStreamName();
|
||
}
|
||
|
||
async function _refreshStreamDisplaysForEngine(engineType) {
|
||
_streamModalDisplaysEngine = engineType;
|
||
const url = engineType ? `/config/displays?engine_type=${engineType}` : '/config/displays';
|
||
try {
|
||
const resp = await fetchWithAuth(url);
|
||
if (resp.ok) {
|
||
const data = await resp.json();
|
||
set_cachedDisplays(data.displays || []);
|
||
}
|
||
} catch (error) {
|
||
console.error('Error refreshing displays for engine:', error);
|
||
}
|
||
|
||
// Reset display selection and pick the first available
|
||
document.getElementById('stream-display-index').value = '';
|
||
document.getElementById('stream-display-picker-label').textContent = t('displays.picker.select');
|
||
if (_cachedDisplays && _cachedDisplays.length > 0) {
|
||
const primary = _cachedDisplays.find(d => d.is_primary) || _cachedDisplays[0];
|
||
onStreamDisplaySelected(primary.index, primary);
|
||
}
|
||
}
|
||
|
||
export async function saveStream() {
|
||
const streamId = document.getElementById('stream-id').value;
|
||
const name = document.getElementById('stream-name').value.trim();
|
||
const streamType = document.getElementById('stream-type').value;
|
||
const description = document.getElementById('stream-description').value.trim();
|
||
const errorEl = document.getElementById('stream-error');
|
||
|
||
if (!name) { showToast(t('streams.error.required'), 'error'); return; }
|
||
|
||
const payload = { name, description: description || null };
|
||
if (!streamId) payload.stream_type = streamType;
|
||
|
||
if (streamType === 'raw') {
|
||
payload.display_index = parseInt(document.getElementById('stream-display-index').value) || 0;
|
||
payload.capture_template_id = document.getElementById('stream-capture-template').value;
|
||
payload.target_fps = parseInt(document.getElementById('stream-target-fps').value) || 30;
|
||
} else if (streamType === 'processed') {
|
||
payload.source_stream_id = document.getElementById('stream-source').value;
|
||
payload.postprocessing_template_id = document.getElementById('stream-pp-template').value;
|
||
} else if (streamType === 'static_image') {
|
||
const imageSource = document.getElementById('stream-image-source').value.trim();
|
||
if (!imageSource) { showToast(t('streams.error.required'), 'error'); return; }
|
||
payload.image_source = imageSource;
|
||
}
|
||
|
||
try {
|
||
let response;
|
||
if (streamId) {
|
||
response = await fetchWithAuth(`/picture-sources/${streamId}`, { method: 'PUT', body: JSON.stringify(payload) });
|
||
} else {
|
||
response = await fetchWithAuth('/picture-sources', { method: 'POST', body: JSON.stringify(payload) });
|
||
}
|
||
|
||
if (!response.ok) {
|
||
const error = await response.json();
|
||
throw new Error(error.detail || error.message || 'Failed to save stream');
|
||
}
|
||
|
||
showToast(streamId ? t('streams.updated') : t('streams.created'), 'success');
|
||
streamModal.forceClose();
|
||
await loadPictureSources();
|
||
} catch (error) {
|
||
console.error('Error saving stream:', error);
|
||
errorEl.textContent = error.message;
|
||
errorEl.style.display = 'block';
|
||
}
|
||
}
|
||
|
||
export async function deleteStream(streamId) {
|
||
const confirmed = await showConfirm(t('streams.delete.confirm'));
|
||
if (!confirmed) return;
|
||
|
||
try {
|
||
const response = await fetchWithAuth(`/picture-sources/${streamId}`, { method: 'DELETE' });
|
||
if (!response.ok) {
|
||
const error = await response.json();
|
||
throw new Error(error.detail || error.message || 'Failed to delete stream');
|
||
}
|
||
showToast(t('streams.deleted'), 'success');
|
||
await loadPictureSources();
|
||
} catch (error) {
|
||
console.error('Error deleting stream:', error);
|
||
showToast(t('streams.error.delete') + ': ' + error.message, 'error');
|
||
}
|
||
}
|
||
|
||
export async function closeStreamModal() {
|
||
await streamModal.close();
|
||
}
|
||
|
||
async function validateStaticImage() {
|
||
const source = document.getElementById('stream-image-source').value.trim();
|
||
const previewContainer = document.getElementById('stream-image-preview-container');
|
||
const previewImg = document.getElementById('stream-image-preview');
|
||
const infoEl = document.getElementById('stream-image-info');
|
||
const statusEl = document.getElementById('stream-image-validation-status');
|
||
|
||
if (!source) {
|
||
set_lastValidatedImageSource('');
|
||
previewContainer.style.display = 'none';
|
||
statusEl.style.display = 'none';
|
||
return;
|
||
}
|
||
|
||
if (source === _lastValidatedImageSource) return;
|
||
|
||
statusEl.textContent = t('streams.validate_image.validating');
|
||
statusEl.className = 'validation-status loading';
|
||
statusEl.style.display = 'block';
|
||
previewContainer.style.display = 'none';
|
||
|
||
try {
|
||
const response = await fetchWithAuth('/picture-sources/validate-image', {
|
||
method: 'POST',
|
||
body: JSON.stringify({ image_source: source }),
|
||
});
|
||
const data = await response.json();
|
||
|
||
set_lastValidatedImageSource(source);
|
||
if (data.valid) {
|
||
previewImg.src = data.preview;
|
||
previewImg.style.cursor = 'pointer';
|
||
previewImg.onclick = () => openFullImageLightbox(source);
|
||
infoEl.textContent = `${data.width} × ${data.height} px`;
|
||
previewContainer.style.display = '';
|
||
statusEl.textContent = t('streams.validate_image.valid');
|
||
statusEl.className = 'validation-status success';
|
||
} else {
|
||
previewContainer.style.display = 'none';
|
||
statusEl.textContent = `${t('streams.validate_image.invalid')}: ${data.error}`;
|
||
statusEl.className = 'validation-status error';
|
||
}
|
||
} catch (err) {
|
||
previewContainer.style.display = 'none';
|
||
statusEl.textContent = `${t('streams.validate_image.invalid')}: ${err.message}`;
|
||
statusEl.className = 'validation-status error';
|
||
}
|
||
}
|
||
|
||
// ===== Picture Source Test =====
|
||
|
||
export async function showTestStreamModal(streamId) {
|
||
set_currentTestStreamId(streamId);
|
||
restoreStreamTestDuration();
|
||
|
||
testStreamModal.open();
|
||
}
|
||
|
||
export function closeTestStreamModal() {
|
||
testStreamModal.forceClose();
|
||
set_currentTestStreamId(null);
|
||
}
|
||
|
||
export function updateStreamTestDuration(value) {
|
||
document.getElementById('test-stream-duration-value').textContent = value;
|
||
localStorage.setItem('lastStreamTestDuration', value);
|
||
}
|
||
|
||
function restoreStreamTestDuration() {
|
||
const saved = localStorage.getItem('lastStreamTestDuration') || '5';
|
||
document.getElementById('test-stream-duration').value = saved;
|
||
document.getElementById('test-stream-duration-value').textContent = saved;
|
||
}
|
||
|
||
export async function runStreamTest() {
|
||
if (!_currentTestStreamId) return;
|
||
const captureDuration = parseFloat(document.getElementById('test-stream-duration').value);
|
||
showOverlaySpinner(t('streams.test.running'), captureDuration);
|
||
const signal = window._overlayAbortController?.signal;
|
||
|
||
try {
|
||
const response = await fetchWithAuth(`/picture-sources/${_currentTestStreamId}/test`, {
|
||
method: 'POST',
|
||
body: JSON.stringify({ capture_duration: captureDuration }),
|
||
signal
|
||
});
|
||
if (!response.ok) {
|
||
const error = await response.json();
|
||
throw new Error(error.detail || error.message || 'Test failed');
|
||
}
|
||
const result = await response.json();
|
||
hideOverlaySpinner();
|
||
const fullImageSrc = result.full_capture.full_image || result.full_capture.image;
|
||
openLightbox(fullImageSrc, buildTestStatsHtml(result));
|
||
} catch (error) {
|
||
if (error.name === 'AbortError') return;
|
||
console.error('Error running stream test:', error);
|
||
hideOverlaySpinner();
|
||
showToast(t('streams.test.error.failed') + ': ' + error.message, 'error');
|
||
}
|
||
}
|
||
|
||
// ===== PP Template Test =====
|
||
|
||
export async function showTestPPTemplateModal(templateId) {
|
||
set_currentTestPPTemplateId(templateId);
|
||
restorePPTestDuration();
|
||
|
||
const select = document.getElementById('test-pp-source-stream');
|
||
select.innerHTML = '';
|
||
if (_cachedStreams.length === 0) {
|
||
try {
|
||
const resp = await fetchWithAuth('/picture-sources');
|
||
if (resp.ok) { const d = await resp.json(); set_cachedStreams(d.streams || []); }
|
||
} catch (e) { console.warn('Could not load streams for PP test:', e); }
|
||
}
|
||
for (const s of _cachedStreams) {
|
||
const opt = document.createElement('option');
|
||
opt.value = s.id;
|
||
opt.textContent = s.name;
|
||
select.appendChild(opt);
|
||
}
|
||
const lastStream = localStorage.getItem('lastPPTestStreamId');
|
||
if (lastStream && _cachedStreams.find(s => s.id === lastStream)) {
|
||
select.value = lastStream;
|
||
}
|
||
|
||
testPPTemplateModal.open();
|
||
}
|
||
|
||
export function closeTestPPTemplateModal() {
|
||
testPPTemplateModal.forceClose();
|
||
set_currentTestPPTemplateId(null);
|
||
}
|
||
|
||
export function updatePPTestDuration(value) {
|
||
document.getElementById('test-pp-duration-value').textContent = value;
|
||
localStorage.setItem('lastPPTestDuration', value);
|
||
}
|
||
|
||
function restorePPTestDuration() {
|
||
const saved = localStorage.getItem('lastPPTestDuration') || '5';
|
||
document.getElementById('test-pp-duration').value = saved;
|
||
document.getElementById('test-pp-duration-value').textContent = saved;
|
||
}
|
||
|
||
export async function runPPTemplateTest() {
|
||
if (!_currentTestPPTemplateId) return;
|
||
const sourceStreamId = document.getElementById('test-pp-source-stream').value;
|
||
if (!sourceStreamId) { showToast(t('postprocessing.test.error.no_stream'), 'error'); return; }
|
||
localStorage.setItem('lastPPTestStreamId', sourceStreamId);
|
||
|
||
const captureDuration = parseFloat(document.getElementById('test-pp-duration').value);
|
||
showOverlaySpinner(t('postprocessing.test.running'), captureDuration);
|
||
const signal = window._overlayAbortController?.signal;
|
||
|
||
try {
|
||
const response = await fetchWithAuth(`/postprocessing-templates/${_currentTestPPTemplateId}/test`, {
|
||
method: 'POST',
|
||
body: JSON.stringify({ source_stream_id: sourceStreamId, capture_duration: captureDuration }),
|
||
signal
|
||
});
|
||
if (!response.ok) {
|
||
const error = await response.json();
|
||
throw new Error(error.detail || error.message || 'Test failed');
|
||
}
|
||
const result = await response.json();
|
||
hideOverlaySpinner();
|
||
const fullImageSrc = result.full_capture.full_image || result.full_capture.image;
|
||
openLightbox(fullImageSrc, buildTestStatsHtml(result));
|
||
} catch (error) {
|
||
if (error.name === 'AbortError') return;
|
||
console.error('Error running PP template test:', error);
|
||
hideOverlaySpinner();
|
||
showToast(t('postprocessing.test.error.failed') + ': ' + error.message, 'error');
|
||
}
|
||
}
|
||
|
||
// ===== PP Templates =====
|
||
|
||
async function loadAvailableFilters() {
|
||
try {
|
||
const response = await fetchWithAuth('/filters');
|
||
if (!response.ok) throw new Error(`Failed to load filters: ${response.status}`);
|
||
const data = await response.json();
|
||
set_availableFilters(data.filters || []);
|
||
} catch (error) {
|
||
console.error('Error loading available filters:', error);
|
||
set_availableFilters([]);
|
||
}
|
||
}
|
||
|
||
async function loadPPTemplates() {
|
||
try {
|
||
if (_availableFilters.length === 0) await loadAvailableFilters();
|
||
const response = await fetchWithAuth('/postprocessing-templates');
|
||
if (!response.ok) throw new Error(`Failed to load templates: ${response.status}`);
|
||
const data = await response.json();
|
||
set_cachedPPTemplates(data.templates || []);
|
||
renderPictureSourcesList(_cachedStreams);
|
||
} catch (error) {
|
||
console.error('Error loading PP templates:', error);
|
||
}
|
||
}
|
||
|
||
function _getFilterName(filterId) {
|
||
const key = 'filters.' + filterId;
|
||
const translated = t(key);
|
||
if (translated === key) {
|
||
const def = _availableFilters.find(f => f.filter_id === filterId);
|
||
return def ? def.filter_name : filterId;
|
||
}
|
||
return translated;
|
||
}
|
||
|
||
function _populateFilterSelect() {
|
||
const select = document.getElementById('pp-add-filter-select');
|
||
select.innerHTML = `<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 ? '▼' : '▶'}</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' : ''}>▲</button>
|
||
<button type="button" class="btn-filter-action" onclick="moveFilter(${index}, 1)" title="${t('filters.move_down')}" ${index === _modalFilters.length - 1 ? 'disabled' : ''}>▼</button>
|
||
<button type="button" class="btn-filter-action btn-filter-remove" onclick="removeFilter(${index})" title="${t('filters.remove')}">✕</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;
|
||
// 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 =>
|
||
`<option value="${escapeHtml(c.value)}"${c.value === selectVal ? ' 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) {
|
||
// For select options with empty default, use the first choice's value
|
||
if (opt.type === 'select' && !opt.default && Array.isArray(opt.choices) && opt.choices.length > 0) {
|
||
options[opt.key] = opt.choices[0].value;
|
||
} else {
|
||
options[opt.key] = opt.default;
|
||
}
|
||
}
|
||
|
||
_modalFilters.push({ filter_id: filterId, options, _expanded: true });
|
||
select.value = '';
|
||
renderModalFilterList();
|
||
_autoGeneratePPTemplateName();
|
||
}
|
||
|
||
export function toggleFilterExpand(index) {
|
||
if (_modalFilters[index]) {
|
||
_modalFilters[index]._expanded = !_modalFilters[index]._expanded;
|
||
renderModalFilterList();
|
||
}
|
||
}
|
||
|
||
export function removeFilter(index) {
|
||
_modalFilters.splice(index, 1);
|
||
renderModalFilterList();
|
||
_autoGeneratePPTemplateName();
|
||
}
|
||
|
||
export function moveFilter(index, direction) {
|
||
const newIndex = index + direction;
|
||
if (newIndex < 0 || newIndex >= _modalFilters.length) return;
|
||
const tmp = _modalFilters[index];
|
||
_modalFilters[index] = _modalFilters[newIndex];
|
||
_modalFilters[newIndex] = tmp;
|
||
renderModalFilterList();
|
||
_autoGeneratePPTemplateName();
|
||
}
|
||
|
||
export function updateFilterOption(filterIndex, optionKey, value) {
|
||
if (_modalFilters[filterIndex]) {
|
||
const fi = _modalFilters[filterIndex];
|
||
const filterDef = _availableFilters.find(f => f.filter_id === fi.filter_id);
|
||
if (filterDef) {
|
||
const optDef = filterDef.options_schema.find(o => o.key === optionKey);
|
||
if (optDef && optDef.type === 'bool') {
|
||
fi.options[optionKey] = !!value;
|
||
} else if (optDef && optDef.type === 'select') {
|
||
fi.options[optionKey] = String(value);
|
||
} else if (optDef && optDef.type === 'int') {
|
||
fi.options[optionKey] = parseInt(value);
|
||
} else {
|
||
fi.options[optionKey] = parseFloat(value);
|
||
}
|
||
} else {
|
||
fi.options[optionKey] = parseFloat(value);
|
||
}
|
||
}
|
||
}
|
||
|
||
function collectFilters() {
|
||
return _modalFilters.map(fi => ({
|
||
filter_id: fi.filter_id,
|
||
options: { ...fi.options },
|
||
}));
|
||
}
|
||
|
||
function _autoGeneratePPTemplateName() {
|
||
if (_ppTemplateNameManuallyEdited) return;
|
||
if (document.getElementById('pp-template-id').value) return;
|
||
const nameInput = document.getElementById('pp-template-name');
|
||
if (_modalFilters.length > 0) {
|
||
const filterNames = _modalFilters.map(f => _getFilterName(f.filter_id)).join(' + ');
|
||
nameInput.value = filterNames;
|
||
} else {
|
||
nameInput.value = '';
|
||
}
|
||
}
|
||
|
||
export async function showAddPPTemplateModal(cloneData = null) {
|
||
if (_availableFilters.length === 0) await loadAvailableFilters();
|
||
|
||
document.getElementById('pp-template-modal-title').textContent = t('postprocessing.add');
|
||
document.getElementById('pp-template-form').reset();
|
||
document.getElementById('pp-template-id').value = '';
|
||
document.getElementById('pp-template-error').style.display = 'none';
|
||
|
||
if (cloneData) {
|
||
set_modalFilters((cloneData.filters || []).map(fi => ({
|
||
filter_id: fi.filter_id,
|
||
options: { ...fi.options },
|
||
})));
|
||
set_ppTemplateNameManuallyEdited(true);
|
||
} else {
|
||
set_modalFilters([]);
|
||
set_ppTemplateNameManuallyEdited(false);
|
||
}
|
||
document.getElementById('pp-template-name').oninput = () => { set_ppTemplateNameManuallyEdited(true); };
|
||
|
||
_populateFilterSelect();
|
||
renderModalFilterList();
|
||
|
||
// Pre-fill from clone data after form is set up
|
||
if (cloneData) {
|
||
document.getElementById('pp-template-name').value = (cloneData.name || '') + ' (Copy)';
|
||
document.getElementById('pp-template-description').value = cloneData.description || '';
|
||
}
|
||
|
||
ppTemplateModal.open();
|
||
ppTemplateModal.snapshot();
|
||
}
|
||
|
||
export async function editPPTemplate(templateId) {
|
||
try {
|
||
if (_availableFilters.length === 0) await loadAvailableFilters();
|
||
|
||
const response = await fetchWithAuth(`/postprocessing-templates/${templateId}`);
|
||
if (!response.ok) throw new Error(`Failed to load template: ${response.status}`);
|
||
const tmpl = await response.json();
|
||
|
||
document.getElementById('pp-template-modal-title').textContent = t('postprocessing.edit');
|
||
document.getElementById('pp-template-id').value = templateId;
|
||
document.getElementById('pp-template-name').value = tmpl.name;
|
||
document.getElementById('pp-template-description').value = tmpl.description || '';
|
||
document.getElementById('pp-template-error').style.display = 'none';
|
||
|
||
set_modalFilters((tmpl.filters || []).map(fi => ({
|
||
filter_id: fi.filter_id,
|
||
options: { ...fi.options },
|
||
})));
|
||
|
||
_populateFilterSelect();
|
||
renderModalFilterList();
|
||
|
||
ppTemplateModal.open();
|
||
ppTemplateModal.snapshot();
|
||
} catch (error) {
|
||
console.error('Error loading PP template:', error);
|
||
showToast(t('postprocessing.error.load') + ': ' + error.message, 'error');
|
||
}
|
||
}
|
||
|
||
export async function savePPTemplate() {
|
||
const templateId = document.getElementById('pp-template-id').value;
|
||
const name = document.getElementById('pp-template-name').value.trim();
|
||
const description = document.getElementById('pp-template-description').value.trim();
|
||
const errorEl = document.getElementById('pp-template-error');
|
||
|
||
if (!name) { showToast(t('postprocessing.error.required'), 'error'); return; }
|
||
|
||
const payload = { name, filters: collectFilters(), description: description || null };
|
||
|
||
try {
|
||
let response;
|
||
if (templateId) {
|
||
response = await fetchWithAuth(`/postprocessing-templates/${templateId}`, { method: 'PUT', body: JSON.stringify(payload) });
|
||
} else {
|
||
response = await fetchWithAuth('/postprocessing-templates', { method: 'POST', body: JSON.stringify(payload) });
|
||
}
|
||
|
||
if (!response.ok) {
|
||
const error = await response.json();
|
||
throw new Error(error.detail || error.message || 'Failed to save template');
|
||
}
|
||
|
||
showToast(templateId ? t('postprocessing.updated') : t('postprocessing.created'), 'success');
|
||
ppTemplateModal.forceClose();
|
||
await loadPPTemplates();
|
||
} catch (error) {
|
||
console.error('Error saving PP template:', error);
|
||
errorEl.textContent = error.message;
|
||
errorEl.style.display = 'block';
|
||
}
|
||
}
|
||
|
||
// ===== Clone functions =====
|
||
|
||
export async function cloneStream(streamId) {
|
||
try {
|
||
const resp = await fetchWithAuth(`/picture-sources/${streamId}`);
|
||
if (!resp.ok) throw new Error('Failed to load stream');
|
||
const stream = await resp.json();
|
||
showAddStreamModal(stream.stream_type, stream);
|
||
} catch (error) {
|
||
if (error.isAuth) return;
|
||
console.error('Failed to clone stream:', error);
|
||
showToast('Failed to clone picture source', 'error');
|
||
}
|
||
}
|
||
|
||
export async function cloneCaptureTemplate(templateId) {
|
||
try {
|
||
const resp = await fetchWithAuth(`/capture-templates/${templateId}`);
|
||
if (!resp.ok) throw new Error('Failed to load template');
|
||
const tmpl = await resp.json();
|
||
showAddTemplateModal(tmpl);
|
||
} catch (error) {
|
||
if (error.isAuth) return;
|
||
console.error('Failed to clone capture template:', error);
|
||
showToast('Failed to clone capture template', 'error');
|
||
}
|
||
}
|
||
|
||
export async function clonePPTemplate(templateId) {
|
||
try {
|
||
const resp = await fetchWithAuth(`/postprocessing-templates/${templateId}`);
|
||
if (!resp.ok) throw new Error('Failed to load template');
|
||
const tmpl = await resp.json();
|
||
showAddPPTemplateModal(tmpl);
|
||
} catch (error) {
|
||
if (error.isAuth) return;
|
||
console.error('Failed to clone PP template:', error);
|
||
showToast('Failed to clone postprocessing template', 'error');
|
||
}
|
||
}
|
||
|
||
export async function deletePPTemplate(templateId) {
|
||
const confirmed = await showConfirm(t('postprocessing.delete.confirm'));
|
||
if (!confirmed) return;
|
||
|
||
try {
|
||
const response = await fetchWithAuth(`/postprocessing-templates/${templateId}`, { method: 'DELETE' });
|
||
if (!response.ok) {
|
||
const error = await response.json();
|
||
throw new Error(error.detail || error.message || 'Failed to delete template');
|
||
}
|
||
showToast(t('postprocessing.deleted'), 'success');
|
||
await loadPPTemplates();
|
||
} catch (error) {
|
||
console.error('Error deleting PP template:', error);
|
||
showToast(t('postprocessing.error.delete') + ': ' + error.message, 'error');
|
||
}
|
||
}
|
||
|
||
export async function closePPTemplateModal() {
|
||
await ppTemplateModal.close();
|
||
}
|
||
|
||
// Exported helpers used by other modules
|
||
export { updateCaptureDuration, buildTestStatsHtml };
|