- Settings modal split into 3 tabs: General, Backup, MQTT - Log viewer moved to full-screen overlay with compact toolbar - External URL setting: API endpoints + UI for configuring server domain used in webhook/WS URLs instead of auto-detected local IP - Sources tab tree restructured: Picture Source (Screen Capture/Static/ Processed sub-groups), Color Strip, Audio, Utility - TreeNav extended to support nested groups (3-level tree) - Audio tab split into Sources and Templates sub-tabs - Fix audio template test: device picker now filters by engine type (was showing WASAPI indices for sounddevice templates) - Audio template test device picker disabled during active test - Rename "Input Source" to "Source" in CSS test preview (en/ru/zh) - Fix i18n: log filter/level items deferred to avoid stale t() calls Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2969 lines
132 KiB
JavaScript
2969 lines
132 KiB
JavaScript
/**
|
||
* Streams — picture sources, capture templates, PP templates, filters.
|
||
*/
|
||
|
||
import {
|
||
_cachedDisplays,
|
||
displaysCache,
|
||
_cachedStreams,
|
||
_cachedPPTemplates,
|
||
_cachedCaptureTemplates,
|
||
_availableFilters,
|
||
availableEngines, setAvailableEngines,
|
||
currentEditingTemplateId, setCurrentEditingTemplateId,
|
||
_templateNameManuallyEdited, set_templateNameManuallyEdited,
|
||
_streamNameManuallyEdited, set_streamNameManuallyEdited,
|
||
_streamModalPPTemplates, set_streamModalPPTemplates,
|
||
_modalFilters, set_modalFilters,
|
||
_ppTemplateNameManuallyEdited, set_ppTemplateNameManuallyEdited,
|
||
currentTestingTemplate, setCurrentTestingTemplate,
|
||
_currentTestStreamId, set_currentTestStreamId,
|
||
_currentTestPPTemplateId, set_currentTestPPTemplateId,
|
||
_lastValidatedImageSource, set_lastValidatedImageSource,
|
||
_cachedAudioSources,
|
||
_cachedValueSources,
|
||
_cachedSyncClocks,
|
||
_cachedAudioTemplates,
|
||
_cachedCSPTemplates,
|
||
_csptModalFilters, set_csptModalFilters,
|
||
_csptNameManuallyEdited, set_csptNameManuallyEdited,
|
||
_stripFilters,
|
||
availableAudioEngines, setAvailableAudioEngines,
|
||
currentEditingAudioTemplateId, setCurrentEditingAudioTemplateId,
|
||
_audioTemplateNameManuallyEdited, set_audioTemplateNameManuallyEdited,
|
||
_sourcesLoading, set_sourcesLoading,
|
||
apiKey,
|
||
streamsCache, ppTemplatesCache, captureTemplatesCache,
|
||
audioSourcesCache, audioTemplatesCache, valueSourcesCache, syncClocksCache, filtersCache,
|
||
colorStripSourcesCache,
|
||
csptCache, stripFiltersCache,
|
||
} from '../core/state.js';
|
||
import { API_BASE, getHeaders, fetchWithAuth, escapeHtml } from '../core/api.js';
|
||
import { t } from '../core/i18n.js';
|
||
import { Modal } from '../core/modal.js';
|
||
import { showToast, showConfirm, openLightbox, openFullImageLightbox, showOverlaySpinner, hideOverlaySpinner, updateOverlayPreview, setTabRefreshing, setupBackdropClose } from '../core/ui.js';
|
||
import { openDisplayPicker, formatDisplayLabel } from './displays.js';
|
||
import { CardSection } from '../core/card-sections.js';
|
||
import { TreeNav } from '../core/tree-nav.js';
|
||
import { updateSubTabHash } from './tabs.js';
|
||
import { createValueSourceCard } from './value-sources.js';
|
||
import { createSyncClockCard } from './sync-clocks.js';
|
||
import { createColorStripCard } from './color-strips.js';
|
||
import {
|
||
getEngineIcon, getAudioEngineIcon, getPictureSourceIcon, getAudioSourceIcon, getColorStripIcon,
|
||
ICON_TEMPLATE, ICON_CLONE, ICON_EDIT, ICON_TEST, ICON_LINK_SOURCE,
|
||
ICON_FPS, ICON_WEB, ICON_VALUE_SOURCE, ICON_CLOCK, ICON_AUDIO_LOOPBACK, ICON_AUDIO_INPUT,
|
||
ICON_AUDIO_TEMPLATE, ICON_MONITOR, ICON_WRENCH, ICON_RADIO,
|
||
ICON_CAPTURE_TEMPLATE, ICON_PP_TEMPLATE, ICON_CSPT, ICON_HELP,
|
||
} from '../core/icons.js';
|
||
import * as P from '../core/icon-paths.js';
|
||
|
||
const _icon = (d) => `<svg class="icon" viewBox="0 0 24 24">${d}</svg>`;
|
||
import { wrapCard } from '../core/card-colors.js';
|
||
import { TagInput, renderTagChips } from '../core/tag-input.js';
|
||
import { IconSelect } from '../core/icon-select.js';
|
||
import { EntitySelect } from '../core/entity-palette.js';
|
||
import { FilterListManager } from '../core/filter-list.js';
|
||
|
||
// ── TagInput instances for modals ──
|
||
let _captureTemplateTagsInput = null;
|
||
let _streamTagsInput = null;
|
||
let _ppTemplateTagsInput = null;
|
||
let _audioTemplateTagsInput = null;
|
||
let _csptTagsInput = null;
|
||
|
||
// ── Card section instances ──
|
||
const csRawStreams = new CardSection('raw-streams', { titleKey: 'streams.section.streams', gridClass: 'templates-grid', addCardOnclick: "showAddStreamModal('raw')", keyAttr: 'data-stream-id', emptyKey: 'section.empty.picture_sources' });
|
||
const csRawTemplates = new CardSection('raw-templates', { titleKey: 'templates.title', gridClass: 'templates-grid', addCardOnclick: "showAddTemplateModal()", keyAttr: 'data-template-id', emptyKey: 'section.empty.capture_templates' });
|
||
const csProcStreams = new CardSection('proc-streams', { titleKey: 'streams.section.streams', gridClass: 'templates-grid', addCardOnclick: "showAddStreamModal('processed')", keyAttr: 'data-stream-id', emptyKey: 'section.empty.picture_sources' });
|
||
const csProcTemplates = new CardSection('proc-templates', { titleKey: 'postprocessing.title', gridClass: 'templates-grid', addCardOnclick: "showAddPPTemplateModal()", keyAttr: 'data-pp-template-id', emptyKey: 'section.empty.pp_templates' });
|
||
const csAudioMulti = new CardSection('audio-multi', { titleKey: 'audio_source.group.multichannel', gridClass: 'templates-grid', addCardOnclick: "showAudioSourceModal('multichannel')", keyAttr: 'data-id', emptyKey: 'section.empty.audio_sources' });
|
||
const csAudioMono = new CardSection('audio-mono', { titleKey: 'audio_source.group.mono', gridClass: 'templates-grid', addCardOnclick: "showAudioSourceModal('mono')", keyAttr: 'data-id', emptyKey: 'section.empty.audio_sources' });
|
||
const csStaticStreams = new CardSection('static-streams', { titleKey: 'streams.group.static_image', gridClass: 'templates-grid', addCardOnclick: "showAddStreamModal('static_image')", keyAttr: 'data-stream-id', emptyKey: 'section.empty.picture_sources' });
|
||
const csVideoStreams = new CardSection('video-streams', { titleKey: 'streams.group.video', gridClass: 'templates-grid', addCardOnclick: "showAddStreamModal('video')", keyAttr: 'data-stream-id', emptyKey: 'section.empty.picture_sources' });
|
||
const csAudioTemplates = new CardSection('audio-templates', { titleKey: 'audio_template.title', gridClass: 'templates-grid', addCardOnclick: "showAddAudioTemplateModal()", keyAttr: 'data-audio-template-id', emptyKey: 'section.empty.audio_templates' });
|
||
const csColorStrips = new CardSection('color-strips', { titleKey: 'targets.section.color_strips', gridClass: 'templates-grid', addCardOnclick: "showCSSEditor()", keyAttr: 'data-css-id', emptyKey: 'section.empty.color_strips' });
|
||
const csValueSources = new CardSection('value-sources', { titleKey: 'value_source.group.title', gridClass: 'templates-grid', addCardOnclick: "showValueSourceModal()", keyAttr: 'data-id', emptyKey: 'section.empty.value_sources' });
|
||
const csSyncClocks = new CardSection('sync-clocks', { titleKey: 'sync_clock.group.title', gridClass: 'templates-grid', addCardOnclick: "showSyncClockModal()", keyAttr: 'data-id', emptyKey: 'section.empty.sync_clocks' });
|
||
const csCSPTemplates = new CardSection('css-proc-templates', { titleKey: 'css_processing.title', gridClass: 'templates-grid', addCardOnclick: "showAddCSPTModal()", keyAttr: 'data-cspt-id', emptyKey: 'section.empty.cspt' });
|
||
|
||
// Re-render picture sources when language changes
|
||
document.addEventListener('languageChanged', () => { if (apiKey) loadPictureSources(); });
|
||
|
||
// ===== Modal instances =====
|
||
|
||
class CaptureTemplateModal extends Modal {
|
||
constructor() { super('template-modal'); }
|
||
|
||
snapshotValues() {
|
||
const vals = {
|
||
name: document.getElementById('template-name').value,
|
||
description: document.getElementById('template-description').value,
|
||
engine: document.getElementById('template-engine').value,
|
||
tags: JSON.stringify(_captureTemplateTagsInput ? _captureTemplateTagsInput.getValue() : []),
|
||
};
|
||
document.querySelectorAll('[data-config-key]').forEach(field => {
|
||
vals['cfg_' + field.dataset.configKey] = field.value;
|
||
});
|
||
return vals;
|
||
}
|
||
|
||
onForceClose() {
|
||
if (_captureTemplateTagsInput) { _captureTemplateTagsInput.destroy(); _captureTemplateTagsInput = null; }
|
||
setCurrentEditingTemplateId(null);
|
||
set_templateNameManuallyEdited(false);
|
||
}
|
||
}
|
||
|
||
class StreamEditorModal extends Modal {
|
||
constructor() { super('stream-modal'); }
|
||
|
||
snapshotValues() {
|
||
return {
|
||
name: document.getElementById('stream-name').value,
|
||
description: document.getElementById('stream-description').value,
|
||
type: document.getElementById('stream-type').value,
|
||
displayIndex: document.getElementById('stream-display-index').value,
|
||
captureTemplate: document.getElementById('stream-capture-template').value,
|
||
targetFps: document.getElementById('stream-target-fps').value,
|
||
source: document.getElementById('stream-source').value,
|
||
ppTemplate: document.getElementById('stream-pp-template').value,
|
||
imageSource: document.getElementById('stream-image-source').value,
|
||
tags: JSON.stringify(_streamTagsInput ? _streamTagsInput.getValue() : []),
|
||
};
|
||
}
|
||
|
||
onForceClose() {
|
||
if (_streamTagsInput) { _streamTagsInput.destroy(); _streamTagsInput = null; }
|
||
document.getElementById('stream-type').disabled = false;
|
||
set_streamNameManuallyEdited(false);
|
||
}
|
||
}
|
||
|
||
class PPTemplateEditorModal extends Modal {
|
||
constructor() { super('pp-template-modal'); }
|
||
|
||
snapshotValues() {
|
||
return {
|
||
name: document.getElementById('pp-template-name').value,
|
||
description: document.getElementById('pp-template-description').value,
|
||
filters: JSON.stringify(_modalFilters.map(fi => ({ filter_id: fi.filter_id, options: fi.options }))),
|
||
tags: JSON.stringify(_ppTemplateTagsInput ? _ppTemplateTagsInput.getValue() : []),
|
||
};
|
||
}
|
||
|
||
onForceClose() {
|
||
if (_ppTemplateTagsInput) { _ppTemplateTagsInput.destroy(); _ppTemplateTagsInput = null; }
|
||
set_modalFilters([]);
|
||
set_ppTemplateNameManuallyEdited(false);
|
||
}
|
||
}
|
||
|
||
class AudioTemplateModal extends Modal {
|
||
constructor() { super('audio-template-modal'); }
|
||
|
||
snapshotValues() {
|
||
const vals = {
|
||
name: document.getElementById('audio-template-name').value,
|
||
description: document.getElementById('audio-template-description').value,
|
||
engine: document.getElementById('audio-template-engine').value,
|
||
tags: JSON.stringify(_audioTemplateTagsInput ? _audioTemplateTagsInput.getValue() : []),
|
||
};
|
||
document.querySelectorAll('#audio-engine-config-fields [data-config-key]').forEach(field => {
|
||
vals['cfg_' + field.dataset.configKey] = field.value;
|
||
});
|
||
return vals;
|
||
}
|
||
|
||
onForceClose() {
|
||
if (_audioTemplateTagsInput) { _audioTemplateTagsInput.destroy(); _audioTemplateTagsInput = null; }
|
||
setCurrentEditingAudioTemplateId(null);
|
||
set_audioTemplateNameManuallyEdited(false);
|
||
}
|
||
}
|
||
|
||
class CSPTEditorModal extends Modal {
|
||
constructor() { super('cspt-modal'); }
|
||
|
||
snapshotValues() {
|
||
return {
|
||
name: document.getElementById('cspt-name').value,
|
||
description: document.getElementById('cspt-description').value,
|
||
filters: JSON.stringify(_csptModalFilters.map(fi => ({ filter_id: fi.filter_id, options: fi.options }))),
|
||
tags: JSON.stringify(_csptTagsInput ? _csptTagsInput.getValue() : []),
|
||
};
|
||
}
|
||
|
||
onForceClose() {
|
||
if (_csptTagsInput) { _csptTagsInput.destroy(); _csptTagsInput = null; }
|
||
set_csptModalFilters([]);
|
||
set_csptNameManuallyEdited(false);
|
||
}
|
||
}
|
||
|
||
const templateModal = new CaptureTemplateModal();
|
||
const testTemplateModal = new Modal('test-template-modal');
|
||
const streamModal = new StreamEditorModal();
|
||
const testStreamModal = new Modal('test-stream-modal');
|
||
const ppTemplateModal = new PPTemplateEditorModal();
|
||
const testPPTemplateModal = new Modal('test-pp-template-modal');
|
||
let _ppTestSourceEntitySelect = null;
|
||
const audioTemplateModal = new AudioTemplateModal();
|
||
const csptModal = new CSPTEditorModal();
|
||
|
||
// ===== Capture Templates =====
|
||
|
||
async function loadCaptureTemplates() {
|
||
try {
|
||
await captureTemplatesCache.fetch();
|
||
renderPictureSourcesList(_cachedStreams);
|
||
} catch (error) {
|
||
if (error.isAuth) return;
|
||
console.error('Error loading capture templates:', error);
|
||
showToast(t('streams.error.load'), 'error');
|
||
}
|
||
}
|
||
|
||
export async function showAddTemplateModal(cloneData = null) {
|
||
setCurrentEditingTemplateId(null);
|
||
document.getElementById('template-modal-title').innerHTML = `${ICON_CAPTURE_TEMPLATE} ${t('templates.add')}`;
|
||
document.getElementById('template-form').reset();
|
||
document.getElementById('template-id').value = '';
|
||
document.getElementById('engine-config-section').style.display = 'none';
|
||
document.getElementById('template-error').style.display = 'none';
|
||
|
||
set_templateNameManuallyEdited(!!cloneData);
|
||
document.getElementById('template-name').oninput = () => { set_templateNameManuallyEdited(true); };
|
||
|
||
await loadAvailableEngines();
|
||
|
||
// Pre-fill from clone data after engines are loaded
|
||
if (cloneData) {
|
||
document.getElementById('template-name').value = (cloneData.name || '') + ' (Copy)';
|
||
document.getElementById('template-description').value = cloneData.description || '';
|
||
document.getElementById('template-engine').value = cloneData.engine_type;
|
||
await onEngineChange();
|
||
populateEngineConfig(cloneData.engine_config);
|
||
}
|
||
|
||
// Tags
|
||
if (_captureTemplateTagsInput) { _captureTemplateTagsInput.destroy(); _captureTemplateTagsInput = null; }
|
||
_captureTemplateTagsInput = new TagInput(document.getElementById('capture-template-tags-container'), { placeholder: t('tags.placeholder') });
|
||
_captureTemplateTagsInput.setValue(cloneData ? (cloneData.tags || []) : []);
|
||
|
||
templateModal.open();
|
||
templateModal.snapshot();
|
||
}
|
||
|
||
export async function editTemplate(templateId) {
|
||
try {
|
||
const response = await fetchWithAuth(`/capture-templates/${templateId}`);
|
||
if (!response.ok) throw new Error(`Failed to load template: ${response.status}`);
|
||
const template = await response.json();
|
||
|
||
setCurrentEditingTemplateId(templateId);
|
||
document.getElementById('template-modal-title').innerHTML = `${ICON_CAPTURE_TEMPLATE} ${t('templates.edit')}`;
|
||
document.getElementById('template-id').value = templateId;
|
||
document.getElementById('template-name').value = template.name;
|
||
document.getElementById('template-description').value = template.description || '';
|
||
|
||
await loadAvailableEngines();
|
||
document.getElementById('template-engine').value = template.engine_type;
|
||
await onEngineChange();
|
||
populateEngineConfig(template.engine_config);
|
||
|
||
await loadDisplaysForTest();
|
||
|
||
const testResults = document.getElementById('template-test-results');
|
||
if (testResults) testResults.style.display = 'none';
|
||
document.getElementById('template-error').style.display = 'none';
|
||
|
||
// Tags
|
||
if (_captureTemplateTagsInput) { _captureTemplateTagsInput.destroy(); _captureTemplateTagsInput = null; }
|
||
_captureTemplateTagsInput = new TagInput(document.getElementById('capture-template-tags-container'), { placeholder: t('tags.placeholder') });
|
||
_captureTemplateTagsInput.setValue(template.tags || []);
|
||
|
||
templateModal.open();
|
||
templateModal.snapshot();
|
||
} catch (error) {
|
||
console.error('Error loading template:', error);
|
||
showToast(t('templates.error.load') + ': ' + error.message, 'error');
|
||
}
|
||
}
|
||
|
||
export async function closeTemplateModal() {
|
||
await templateModal.close();
|
||
}
|
||
|
||
function updateCaptureDuration(value) {
|
||
document.getElementById('test-template-duration-value').textContent = value;
|
||
localStorage.setItem('capture_duration', value);
|
||
}
|
||
|
||
function restoreCaptureDuration() {
|
||
const savedDuration = localStorage.getItem('capture_duration');
|
||
if (savedDuration) {
|
||
const durationInput = document.getElementById('test-template-duration');
|
||
const durationValue = document.getElementById('test-template-duration-value');
|
||
durationInput.value = savedDuration;
|
||
durationValue.textContent = savedDuration;
|
||
}
|
||
}
|
||
|
||
export async function showTestTemplateModal(templateId) {
|
||
try {
|
||
const templates = await captureTemplatesCache.fetch();
|
||
const template = templates.find(tp => tp.id === templateId);
|
||
|
||
if (!template) {
|
||
showToast(t('templates.error.load'), 'error');
|
||
return;
|
||
}
|
||
|
||
setCurrentTestingTemplate(template);
|
||
await loadDisplaysForTest();
|
||
restoreCaptureDuration();
|
||
|
||
testTemplateModal.open();
|
||
setupBackdropClose(testTemplateModal.el, () => closeTestTemplateModal());
|
||
} catch (error) {
|
||
if (error.isAuth) return;
|
||
showToast(t('templates.error.load'), 'error');
|
||
}
|
||
}
|
||
|
||
export function closeTestTemplateModal() {
|
||
testTemplateModal.forceClose();
|
||
setCurrentTestingTemplate(null);
|
||
}
|
||
|
||
async function loadAvailableEngines() {
|
||
try {
|
||
const response = await fetchWithAuth('/capture-engines');
|
||
if (!response.ok) throw new Error(`Failed to load engines: ${response.status}`);
|
||
const data = await response.json();
|
||
setAvailableEngines(data.engines || []);
|
||
|
||
const select = document.getElementById('template-engine');
|
||
select.innerHTML = '';
|
||
|
||
availableEngines.forEach(engine => {
|
||
const option = document.createElement('option');
|
||
option.value = engine.type;
|
||
option.textContent = engine.name;
|
||
if (!engine.available) {
|
||
option.disabled = true;
|
||
option.textContent += ` (${t('templates.engine.unavailable')})`;
|
||
}
|
||
select.appendChild(option);
|
||
});
|
||
|
||
if (!select.value) {
|
||
const firstAvailable = availableEngines.find(e => e.available);
|
||
if (firstAvailable) select.value = firstAvailable.type;
|
||
}
|
||
|
||
// Update icon-grid selector with dynamic engine list
|
||
const items = availableEngines
|
||
.filter(e => e.available)
|
||
.map(e => ({ value: e.type, icon: getEngineIcon(e.type), label: e.name, desc: t(`templates.engine.${e.type}.desc`) }));
|
||
if (_engineIconSelect) { _engineIconSelect.updateItems(items); }
|
||
else { _engineIconSelect = new IconSelect({ target: select, items, columns: 2 }); }
|
||
_engineIconSelect.setValue(select.value);
|
||
} catch (error) {
|
||
console.error('Error loading engines:', error);
|
||
showToast(t('templates.error.engines') + ': ' + error.message, 'error');
|
||
}
|
||
}
|
||
|
||
let _engineIconSelect = null;
|
||
|
||
export async function onEngineChange() {
|
||
const engineType = document.getElementById('template-engine').value;
|
||
if (_engineIconSelect) _engineIconSelect.setValue(engineType);
|
||
const configSection = document.getElementById('engine-config-section');
|
||
const configFields = document.getElementById('engine-config-fields');
|
||
|
||
if (!engineType) { configSection.style.display = 'none'; return; }
|
||
|
||
const engine = availableEngines.find(e => e.type === engineType);
|
||
if (!engine) { configSection.style.display = 'none'; return; }
|
||
|
||
if (!_templateNameManuallyEdited && !document.getElementById('template-id').value) {
|
||
document.getElementById('template-name').value = engine.name || engineType;
|
||
}
|
||
|
||
const hint = document.getElementById('engine-availability-hint');
|
||
if (!engine.available) {
|
||
hint.textContent = t('templates.engine.unavailable.hint');
|
||
hint.style.display = 'block';
|
||
hint.style.color = 'var(--error-color)';
|
||
} else {
|
||
hint.style.display = 'none';
|
||
}
|
||
|
||
configFields.innerHTML = '';
|
||
const defaultConfig = engine.default_config || {};
|
||
|
||
// Known select options for specific config keys
|
||
const CONFIG_SELECT_OPTIONS = {
|
||
camera_backend: ['auto', 'dshow', 'msmf', 'v4l2'],
|
||
};
|
||
|
||
// IconSelect definitions for specific config keys
|
||
const CONFIG_ICON_SELECT = {
|
||
camera_backend: {
|
||
columns: 2,
|
||
items: [
|
||
{ value: 'auto', icon: _icon(P.refreshCw), label: 'Auto', desc: t('templates.config.camera_backend.auto') },
|
||
{ value: 'dshow', icon: _icon(P.camera), label: 'DShow', desc: t('templates.config.camera_backend.dshow') },
|
||
{ value: 'msmf', icon: _icon(P.film), label: 'MSMF', desc: t('templates.config.camera_backend.msmf') },
|
||
{ value: 'v4l2', icon: _icon(P.monitor), label: 'V4L2', desc: t('templates.config.camera_backend.v4l2') },
|
||
],
|
||
},
|
||
};
|
||
|
||
if (Object.keys(defaultConfig).length === 0) {
|
||
configSection.style.display = 'none';
|
||
return;
|
||
} else {
|
||
let gridHtml = '<div class="config-grid">';
|
||
Object.entries(defaultConfig).forEach(([key, value]) => {
|
||
const fieldType = typeof value === 'number' ? 'number' : 'text';
|
||
const fieldValue = typeof value === 'boolean' ? (value ? 'true' : 'false') : value;
|
||
const selectOptions = CONFIG_SELECT_OPTIONS[key];
|
||
gridHtml += `
|
||
<label class="config-grid-label" for="config-${key}">${key}</label>
|
||
<div class="config-grid-value">
|
||
${typeof value === 'boolean' ? `
|
||
<select id="config-${key}" data-config-key="${key}">
|
||
<option value="true" ${value ? 'selected' : ''}>true</option>
|
||
<option value="false" ${!value ? 'selected' : ''}>false</option>
|
||
</select>
|
||
` : selectOptions ? `
|
||
<select id="config-${key}" data-config-key="${key}">
|
||
${selectOptions.map(opt => `<option value="${opt}" ${opt === String(value) ? 'selected' : ''}>${opt}</option>`).join('')}
|
||
</select>
|
||
` : `
|
||
<input type="${fieldType}" id="config-${key}" data-config-key="${key}" value="${fieldValue}">
|
||
`}
|
||
</div>
|
||
`;
|
||
});
|
||
gridHtml += '</div>';
|
||
configFields.innerHTML = gridHtml;
|
||
|
||
// Apply IconSelect to known config selects
|
||
for (const [key, cfg] of Object.entries(CONFIG_ICON_SELECT)) {
|
||
const sel = document.getElementById(`config-${key}`);
|
||
if (sel) new IconSelect({ target: sel, items: cfg.items, columns: cfg.columns });
|
||
}
|
||
}
|
||
|
||
configSection.style.display = 'block';
|
||
}
|
||
|
||
function populateEngineConfig(config) {
|
||
Object.entries(config).forEach(([key, value]) => {
|
||
const field = document.getElementById(`config-${key}`);
|
||
if (field) {
|
||
if (field.tagName === 'SELECT') {
|
||
field.value = value.toString();
|
||
} else {
|
||
field.value = value;
|
||
}
|
||
}
|
||
});
|
||
}
|
||
|
||
function collectEngineConfig() {
|
||
const config = {};
|
||
const fields = document.querySelectorAll('[data-config-key]');
|
||
fields.forEach(field => {
|
||
const key = field.dataset.configKey;
|
||
let value = field.value;
|
||
if (field.type === 'number') {
|
||
value = parseFloat(value);
|
||
} else if (field.tagName === 'SELECT' && (value === 'true' || value === 'false')) {
|
||
value = value === 'true';
|
||
}
|
||
config[key] = value;
|
||
});
|
||
return config;
|
||
}
|
||
|
||
async function loadDisplaysForTest() {
|
||
try {
|
||
// Use engine-specific display list for engines with own devices (camera, scrcpy)
|
||
const engineType = currentTestingTemplate?.engine_type;
|
||
const engineHasOwnDisplays = availableEngines.find(e => e.type === engineType)?.has_own_displays || false;
|
||
const url = engineHasOwnDisplays
|
||
? `/config/displays?engine_type=${engineType}`
|
||
: '/config/displays';
|
||
|
||
// Always refetch for engines with own displays (devices may change); use cache for desktop
|
||
if (!_cachedDisplays || engineHasOwnDisplays) {
|
||
const response = await fetchWithAuth(url);
|
||
if (!response.ok) throw new Error(`Failed to load displays: ${response.status}`);
|
||
const displaysData = await response.json();
|
||
displaysCache.update(displaysData.displays || []);
|
||
}
|
||
|
||
let selectedIndex = null;
|
||
const lastDisplay = localStorage.getItem('lastTestDisplayIndex');
|
||
|
||
if (lastDisplay !== null) {
|
||
const found = _cachedDisplays.find(d => d.index === parseInt(lastDisplay));
|
||
if (found) selectedIndex = found.index;
|
||
}
|
||
|
||
if (selectedIndex === null) {
|
||
const primary = _cachedDisplays.find(d => d.is_primary);
|
||
if (primary) selectedIndex = primary.index;
|
||
else if (_cachedDisplays.length > 0) selectedIndex = _cachedDisplays[0].index;
|
||
}
|
||
|
||
if (selectedIndex !== null) {
|
||
const display = _cachedDisplays.find(d => d.index === selectedIndex);
|
||
onTestDisplaySelected(selectedIndex, display);
|
||
}
|
||
} catch (error) {
|
||
console.error('Error loading displays:', error);
|
||
}
|
||
}
|
||
|
||
export function runTemplateTest() {
|
||
if (!currentTestingTemplate) {
|
||
showToast(t('templates.test.error.no_engine'), 'error');
|
||
return;
|
||
}
|
||
|
||
const displayIndex = document.getElementById('test-template-display').value;
|
||
const captureDuration = parseFloat(document.getElementById('test-template-duration').value);
|
||
|
||
if (displayIndex === '') {
|
||
showToast(t('templates.test.error.no_display'), 'error');
|
||
return;
|
||
}
|
||
|
||
const template = currentTestingTemplate;
|
||
localStorage.setItem('lastTestDisplayIndex', displayIndex);
|
||
|
||
const previewWidth = Math.round(Math.min(window.innerWidth * 0.8, 1920) * Math.min(window.devicePixelRatio || 1, 2));
|
||
_runTestViaWS(
|
||
'/capture-templates/test/ws',
|
||
{},
|
||
{
|
||
engine_type: template.engine_type,
|
||
engine_config: template.engine_config,
|
||
display_index: parseInt(displayIndex),
|
||
capture_duration: captureDuration,
|
||
preview_width: previewWidth,
|
||
},
|
||
captureDuration,
|
||
);
|
||
}
|
||
|
||
function buildTestStatsHtml(result) {
|
||
// Support both REST format (nested) and WS format (flat)
|
||
const p = result.performance || result;
|
||
const duration = p.capture_duration_s ?? p.elapsed_s ?? 0;
|
||
const frameCount = p.frame_count ?? 0;
|
||
const fps = p.actual_fps ?? p.fps ?? 0;
|
||
const avgMs = p.avg_capture_time_ms ?? p.avg_capture_ms ?? 0;
|
||
const w = result.full_capture?.width ?? result.width ?? 0;
|
||
const h = result.full_capture?.height ?? result.height ?? 0;
|
||
const res = `${w}x${h}`;
|
||
|
||
let html = `
|
||
<div class="stat-item"><span>${t('templates.test.results.duration')}:</span> <strong>${Number(duration).toFixed(2)}s</strong></div>
|
||
<div class="stat-item"><span>${t('templates.test.results.frame_count')}:</span> <strong>${frameCount}</strong></div>`;
|
||
if (frameCount > 1) {
|
||
html += `
|
||
<div class="stat-item"><span>${t('templates.test.results.actual_fps')}:</span> <strong>${Number(fps).toFixed(1)}</strong></div>
|
||
<div class="stat-item"><span>${t('templates.test.results.avg_capture_time')}:</span> <strong>${Number(avgMs).toFixed(1)}ms</strong></div>`;
|
||
}
|
||
html += `
|
||
<div class="stat-item"><span>${t('templates.test.results.resolution')}</span> <strong>${res}</strong></div>`;
|
||
return html;
|
||
}
|
||
|
||
// ===== Shared WebSocket test helper =====
|
||
|
||
/**
|
||
* Run a capture test via WebSocket, streaming intermediate previews into
|
||
* the overlay spinner and opening the lightbox with the final result.
|
||
*
|
||
* @param {string} wsPath Relative WS path (e.g. '/picture-sources/{id}/test/ws')
|
||
* @param {Object} queryParams Extra query params (duration, source_stream_id, etc.)
|
||
* @param {Object|null} firstMessage If non-null, sent as JSON after WS opens (for template test)
|
||
* @param {number} duration Test duration for overlay progress ring
|
||
*/
|
||
function _runTestViaWS(wsPath, queryParams = {}, firstMessage = null, duration = 5) {
|
||
const protocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:';
|
||
// Dynamic preview resolution: 80% of viewport width, scaled by DPR, capped at 1920px
|
||
const previewWidth = Math.round(Math.min(window.innerWidth * 0.8, 1920) * Math.min(window.devicePixelRatio || 1, 2));
|
||
const params = new URLSearchParams({ token: apiKey, preview_width: previewWidth, ...queryParams });
|
||
const wsUrl = `${protocol}//${window.location.host}${API_BASE}${wsPath}?${params}`;
|
||
|
||
showOverlaySpinner(t('streams.test.running'), duration);
|
||
|
||
let gotResult = false;
|
||
let ws;
|
||
|
||
try {
|
||
ws = new WebSocket(wsUrl);
|
||
} catch (e) {
|
||
hideOverlaySpinner();
|
||
showToast(t('streams.test.error.failed') + ': ' + e.message, 'error');
|
||
return;
|
||
}
|
||
|
||
// Close WS when user cancels overlay
|
||
const patchCloseBtn = () => {
|
||
const closeBtn = document.querySelector('.overlay-spinner-close');
|
||
if (closeBtn) {
|
||
const origHandler = closeBtn.onclick;
|
||
closeBtn.onclick = () => {
|
||
if (ws.readyState <= WebSocket.OPEN) ws.close();
|
||
if (origHandler) origHandler();
|
||
};
|
||
}
|
||
};
|
||
patchCloseBtn();
|
||
|
||
// Also close on ESC (overlay ESC handler calls hideOverlaySpinner which aborts)
|
||
const origAbort = window._overlayAbortController;
|
||
if (origAbort) {
|
||
origAbort.signal.addEventListener('abort', () => {
|
||
if (ws.readyState <= WebSocket.OPEN) ws.close();
|
||
}, { once: true });
|
||
}
|
||
|
||
ws.onopen = () => {
|
||
if (firstMessage) {
|
||
ws.send(JSON.stringify(firstMessage));
|
||
}
|
||
};
|
||
|
||
ws.onmessage = (event) => {
|
||
try {
|
||
const msg = JSON.parse(event.data);
|
||
if (msg.type === 'frame') {
|
||
updateOverlayPreview(msg.thumbnail, msg);
|
||
} else if (msg.type === 'result') {
|
||
gotResult = true;
|
||
hideOverlaySpinner();
|
||
openLightbox(msg.full_image, buildTestStatsHtml(msg));
|
||
ws.close();
|
||
} else if (msg.type === 'error') {
|
||
hideOverlaySpinner();
|
||
showToast(msg.detail || 'Test failed', 'error');
|
||
ws.close();
|
||
}
|
||
} catch (e) {
|
||
console.error('Error parsing test WS message:', e);
|
||
}
|
||
};
|
||
|
||
ws.onerror = () => {
|
||
if (!gotResult) {
|
||
hideOverlaySpinner();
|
||
showToast(t('streams.test.error.failed'), 'error');
|
||
}
|
||
};
|
||
|
||
ws.onclose = () => {
|
||
if (!gotResult) {
|
||
hideOverlaySpinner();
|
||
}
|
||
};
|
||
}
|
||
|
||
export async function saveTemplate() {
|
||
const templateId = document.getElementById('template-id').value;
|
||
const name = document.getElementById('template-name').value.trim();
|
||
const engineType = document.getElementById('template-engine').value;
|
||
|
||
if (!name || !engineType) {
|
||
showToast(t('templates.error.required'), 'error');
|
||
return;
|
||
}
|
||
|
||
const description = document.getElementById('template-description').value.trim();
|
||
const engineConfig = collectEngineConfig();
|
||
|
||
const payload = { name, engine_type: engineType, engine_config: engineConfig, description: description || null, tags: _captureTemplateTagsInput ? _captureTemplateTagsInput.getValue() : [] };
|
||
|
||
try {
|
||
let response;
|
||
if (templateId) {
|
||
response = await fetchWithAuth(`/capture-templates/${templateId}`, { method: 'PUT', body: JSON.stringify(payload) });
|
||
} else {
|
||
response = await fetchWithAuth('/capture-templates', { method: 'POST', body: JSON.stringify(payload) });
|
||
}
|
||
|
||
if (!response.ok) {
|
||
const error = await response.json();
|
||
throw new Error(error.detail || error.message || 'Failed to save template');
|
||
}
|
||
|
||
showToast(templateId ? t('templates.updated') : t('templates.created'), 'success');
|
||
templateModal.forceClose();
|
||
captureTemplatesCache.invalidate();
|
||
await loadCaptureTemplates();
|
||
} catch (error) {
|
||
console.error('Error saving template:', error);
|
||
document.getElementById('template-error').textContent = error.message;
|
||
document.getElementById('template-error').style.display = 'block';
|
||
}
|
||
}
|
||
|
||
export async function deleteTemplate(templateId) {
|
||
const confirmed = await showConfirm(t('templates.delete.confirm'));
|
||
if (!confirmed) return;
|
||
|
||
try {
|
||
const response = await fetchWithAuth(`/capture-templates/${templateId}`, { method: 'DELETE' });
|
||
if (!response.ok) {
|
||
const error = await response.json();
|
||
throw new Error(error.detail || error.message || 'Failed to delete template');
|
||
}
|
||
showToast(t('templates.deleted'), 'success');
|
||
captureTemplatesCache.invalidate();
|
||
await loadCaptureTemplates();
|
||
} catch (error) {
|
||
console.error('Error deleting template:', error);
|
||
showToast(t('templates.error.delete') + ': ' + error.message, 'error');
|
||
}
|
||
}
|
||
|
||
// ===== Audio Templates =====
|
||
|
||
async function loadAvailableAudioEngines() {
|
||
try {
|
||
const response = await fetchWithAuth('/audio-engines');
|
||
if (!response.ok) throw new Error(`Failed to load audio engines: ${response.status}`);
|
||
const data = await response.json();
|
||
setAvailableAudioEngines(data.engines || []);
|
||
|
||
const select = document.getElementById('audio-template-engine');
|
||
select.innerHTML = '';
|
||
|
||
availableAudioEngines.forEach(engine => {
|
||
const option = document.createElement('option');
|
||
option.value = engine.type;
|
||
option.textContent = `${engine.type.toUpperCase()}`;
|
||
if (!engine.available) {
|
||
option.disabled = true;
|
||
option.textContent += ` (${t('audio_template.engine.unavailable')})`;
|
||
}
|
||
select.appendChild(option);
|
||
});
|
||
|
||
if (!select.value) {
|
||
const firstAvailable = availableAudioEngines.find(e => e.available);
|
||
if (firstAvailable) select.value = firstAvailable.type;
|
||
}
|
||
|
||
// Update icon-grid selector with dynamic engine list
|
||
const items = availableAudioEngines
|
||
.filter(e => e.available)
|
||
.map(e => ({ value: e.type, icon: getAudioEngineIcon(e.type), label: e.type.toUpperCase(), desc: '' }));
|
||
if (_audioEngineIconSelect) { _audioEngineIconSelect.updateItems(items); }
|
||
else { _audioEngineIconSelect = new IconSelect({ target: select, items, columns: 2 }); }
|
||
_audioEngineIconSelect.setValue(select.value);
|
||
} catch (error) {
|
||
console.error('Error loading audio engines:', error);
|
||
showToast(t('audio_template.error.engines') + ': ' + error.message, 'error');
|
||
}
|
||
}
|
||
|
||
let _audioEngineIconSelect = null;
|
||
|
||
export async function onAudioEngineChange() {
|
||
const engineType = document.getElementById('audio-template-engine').value;
|
||
if (_audioEngineIconSelect) _audioEngineIconSelect.setValue(engineType);
|
||
const configSection = document.getElementById('audio-engine-config-section');
|
||
const configFields = document.getElementById('audio-engine-config-fields');
|
||
|
||
if (!engineType) { configSection.style.display = 'none'; return; }
|
||
|
||
const engine = availableAudioEngines.find(e => e.type === engineType);
|
||
if (!engine) { configSection.style.display = 'none'; return; }
|
||
|
||
if (!_audioTemplateNameManuallyEdited && !document.getElementById('audio-template-id').value) {
|
||
document.getElementById('audio-template-name').value = engine.type.toUpperCase();
|
||
}
|
||
|
||
const hint = document.getElementById('audio-engine-availability-hint');
|
||
if (!engine.available) {
|
||
hint.textContent = t('audio_template.engine.unavailable.hint');
|
||
hint.style.display = 'block';
|
||
hint.style.color = 'var(--error-color)';
|
||
} else {
|
||
hint.style.display = 'none';
|
||
}
|
||
|
||
configFields.innerHTML = '';
|
||
const defaultConfig = engine.default_config || {};
|
||
|
||
if (Object.keys(defaultConfig).length === 0) {
|
||
configSection.style.display = 'none';
|
||
return;
|
||
} else {
|
||
let gridHtml = '<div class="config-grid">';
|
||
Object.entries(defaultConfig).forEach(([key, value]) => {
|
||
const fieldType = typeof value === 'number' ? 'number' : 'text';
|
||
const fieldValue = typeof value === 'boolean' ? (value ? 'true' : 'false') : value;
|
||
gridHtml += `
|
||
<label class="config-grid-label" for="audio-config-${key}">${key}</label>
|
||
<div class="config-grid-value">
|
||
${typeof value === 'boolean' ? `
|
||
<select id="audio-config-${key}" data-config-key="${key}">
|
||
<option value="true" ${value ? 'selected' : ''}>true</option>
|
||
<option value="false" ${!value ? 'selected' : ''}>false</option>
|
||
</select>
|
||
` : `
|
||
<input type="${fieldType}" id="audio-config-${key}" data-config-key="${key}" value="${fieldValue}">
|
||
`}
|
||
</div>
|
||
`;
|
||
});
|
||
gridHtml += '</div>';
|
||
configFields.innerHTML = gridHtml;
|
||
}
|
||
|
||
configSection.style.display = 'block';
|
||
}
|
||
|
||
function populateAudioEngineConfig(config) {
|
||
Object.entries(config).forEach(([key, value]) => {
|
||
const field = document.getElementById(`audio-config-${key}`);
|
||
if (field) {
|
||
if (field.tagName === 'SELECT') {
|
||
field.value = value.toString();
|
||
} else {
|
||
field.value = value;
|
||
}
|
||
}
|
||
});
|
||
}
|
||
|
||
function collectAudioEngineConfig() {
|
||
const config = {};
|
||
document.querySelectorAll('#audio-engine-config-fields [data-config-key]').forEach(field => {
|
||
const key = field.dataset.configKey;
|
||
let value = field.value;
|
||
if (field.type === 'number') {
|
||
value = parseFloat(value);
|
||
} else if (field.tagName === 'SELECT' && (value === 'true' || value === 'false')) {
|
||
value = value === 'true';
|
||
}
|
||
config[key] = value;
|
||
});
|
||
return config;
|
||
}
|
||
|
||
async function loadAudioTemplates() {
|
||
try {
|
||
await audioTemplatesCache.fetch();
|
||
renderPictureSourcesList(_cachedStreams);
|
||
} catch (error) {
|
||
if (error.isAuth) return;
|
||
console.error('Error loading audio templates:', error);
|
||
showToast(t('audio_template.error.load'), 'error');
|
||
}
|
||
}
|
||
|
||
export async function showAddAudioTemplateModal(cloneData = null) {
|
||
setCurrentEditingAudioTemplateId(null);
|
||
document.getElementById('audio-template-modal-title').innerHTML = `${ICON_AUDIO_TEMPLATE} ${t('audio_template.add')}`;
|
||
document.getElementById('audio-template-form').reset();
|
||
document.getElementById('audio-template-id').value = '';
|
||
document.getElementById('audio-engine-config-section').style.display = 'none';
|
||
document.getElementById('audio-template-error').style.display = 'none';
|
||
|
||
set_audioTemplateNameManuallyEdited(!!cloneData);
|
||
document.getElementById('audio-template-name').oninput = () => { set_audioTemplateNameManuallyEdited(true); };
|
||
|
||
await loadAvailableAudioEngines();
|
||
|
||
if (cloneData) {
|
||
document.getElementById('audio-template-name').value = (cloneData.name || '') + ' (Copy)';
|
||
document.getElementById('audio-template-description').value = cloneData.description || '';
|
||
document.getElementById('audio-template-engine').value = cloneData.engine_type;
|
||
await onAudioEngineChange();
|
||
populateAudioEngineConfig(cloneData.engine_config);
|
||
}
|
||
|
||
// Tags
|
||
if (_audioTemplateTagsInput) { _audioTemplateTagsInput.destroy(); _audioTemplateTagsInput = null; }
|
||
_audioTemplateTagsInput = new TagInput(document.getElementById('audio-template-tags-container'), { placeholder: t('tags.placeholder') });
|
||
_audioTemplateTagsInput.setValue(cloneData ? (cloneData.tags || []) : []);
|
||
|
||
audioTemplateModal.open();
|
||
audioTemplateModal.snapshot();
|
||
}
|
||
|
||
export async function editAudioTemplate(templateId) {
|
||
try {
|
||
const response = await fetchWithAuth(`/audio-templates/${templateId}`);
|
||
if (!response.ok) throw new Error(`Failed to load audio template: ${response.status}`);
|
||
const template = await response.json();
|
||
|
||
setCurrentEditingAudioTemplateId(templateId);
|
||
document.getElementById('audio-template-modal-title').innerHTML = `${ICON_AUDIO_TEMPLATE} ${t('audio_template.edit')}`;
|
||
document.getElementById('audio-template-id').value = templateId;
|
||
document.getElementById('audio-template-name').value = template.name;
|
||
document.getElementById('audio-template-description').value = template.description || '';
|
||
|
||
await loadAvailableAudioEngines();
|
||
document.getElementById('audio-template-engine').value = template.engine_type;
|
||
await onAudioEngineChange();
|
||
populateAudioEngineConfig(template.engine_config);
|
||
|
||
document.getElementById('audio-template-error').style.display = 'none';
|
||
|
||
// Tags
|
||
if (_audioTemplateTagsInput) { _audioTemplateTagsInput.destroy(); _audioTemplateTagsInput = null; }
|
||
_audioTemplateTagsInput = new TagInput(document.getElementById('audio-template-tags-container'), { placeholder: t('tags.placeholder') });
|
||
_audioTemplateTagsInput.setValue(template.tags || []);
|
||
|
||
audioTemplateModal.open();
|
||
audioTemplateModal.snapshot();
|
||
} catch (error) {
|
||
console.error('Error loading audio template:', error);
|
||
showToast(t('audio_template.error.load') + ': ' + error.message, 'error');
|
||
}
|
||
}
|
||
|
||
export async function closeAudioTemplateModal() {
|
||
await audioTemplateModal.close();
|
||
}
|
||
|
||
export async function saveAudioTemplate() {
|
||
const templateId = currentEditingAudioTemplateId;
|
||
const name = document.getElementById('audio-template-name').value.trim();
|
||
const engineType = document.getElementById('audio-template-engine').value;
|
||
|
||
if (!name || !engineType) {
|
||
showToast(t('audio_template.error.required'), 'error');
|
||
return;
|
||
}
|
||
|
||
const description = document.getElementById('audio-template-description').value.trim();
|
||
const engineConfig = collectAudioEngineConfig();
|
||
|
||
const payload = { name, engine_type: engineType, engine_config: engineConfig, description: description || null, tags: _audioTemplateTagsInput ? _audioTemplateTagsInput.getValue() : [] };
|
||
|
||
try {
|
||
let response;
|
||
if (templateId) {
|
||
response = await fetchWithAuth(`/audio-templates/${templateId}`, { method: 'PUT', body: JSON.stringify(payload) });
|
||
} else {
|
||
response = await fetchWithAuth('/audio-templates', { method: 'POST', body: JSON.stringify(payload) });
|
||
}
|
||
|
||
if (!response.ok) {
|
||
const error = await response.json();
|
||
throw new Error(error.detail || error.message || 'Failed to save audio template');
|
||
}
|
||
|
||
showToast(templateId ? t('audio_template.updated') : t('audio_template.created'), 'success');
|
||
audioTemplateModal.forceClose();
|
||
audioTemplatesCache.invalidate();
|
||
await loadAudioTemplates();
|
||
} catch (error) {
|
||
console.error('Error saving audio template:', error);
|
||
document.getElementById('audio-template-error').textContent = error.message;
|
||
document.getElementById('audio-template-error').style.display = 'block';
|
||
}
|
||
}
|
||
|
||
export async function deleteAudioTemplate(templateId) {
|
||
const confirmed = await showConfirm(t('audio_template.delete.confirm'));
|
||
if (!confirmed) return;
|
||
|
||
try {
|
||
const response = await fetchWithAuth(`/audio-templates/${templateId}`, { method: 'DELETE' });
|
||
if (!response.ok) {
|
||
const error = await response.json();
|
||
throw new Error(error.detail || error.message || 'Failed to delete audio template');
|
||
}
|
||
showToast(t('audio_template.deleted'), 'success');
|
||
audioTemplatesCache.invalidate();
|
||
await loadAudioTemplates();
|
||
} catch (error) {
|
||
console.error('Error deleting audio template:', error);
|
||
showToast(t('audio_template.error.delete') + ': ' + error.message, 'error');
|
||
}
|
||
}
|
||
|
||
export async function cloneAudioTemplate(templateId) {
|
||
try {
|
||
const resp = await fetchWithAuth(`/audio-templates/${templateId}`);
|
||
if (!resp.ok) throw new Error('Failed to load audio template');
|
||
const tmpl = await resp.json();
|
||
showAddAudioTemplateModal(tmpl);
|
||
} catch (error) {
|
||
if (error.isAuth) return;
|
||
console.error('Failed to clone audio template:', error);
|
||
showToast(t('audio_template.error.clone_failed'), 'error');
|
||
}
|
||
}
|
||
|
||
// ===== Audio Template Test =====
|
||
|
||
const NUM_BANDS_TPL = 64;
|
||
const TPL_PEAK_DECAY = 0.02;
|
||
const TPL_BEAT_FLASH_DECAY = 0.06;
|
||
|
||
let _tplTestWs = null;
|
||
let _tplTestAnimFrame = null;
|
||
let _tplTestLatest = null;
|
||
let _tplTestPeaks = new Float32Array(NUM_BANDS_TPL);
|
||
let _tplTestBeatFlash = 0;
|
||
let _currentTestAudioTemplateId = null;
|
||
|
||
const testAudioTemplateModal = new Modal('test-audio-template-modal', { backdrop: true, lock: true });
|
||
|
||
export async function showTestAudioTemplateModal(templateId) {
|
||
_currentTestAudioTemplateId = templateId;
|
||
|
||
// Find template's engine type so we show the correct device list
|
||
const template = _cachedAudioTemplates.find(t => t.id === templateId);
|
||
const engineType = template ? template.engine_type : null;
|
||
|
||
// Load audio devices for picker — filter by engine type
|
||
const deviceSelect = document.getElementById('test-audio-template-device');
|
||
try {
|
||
const resp = await fetchWithAuth('/audio-devices');
|
||
if (resp.ok) {
|
||
const data = await resp.json();
|
||
// Use engine-specific device list if available, fall back to flat list
|
||
const devices = (engineType && data.by_engine && data.by_engine[engineType])
|
||
? data.by_engine[engineType]
|
||
: (data.devices || []);
|
||
deviceSelect.innerHTML = devices.map(d => {
|
||
const label = d.name;
|
||
const val = `${d.index}:${d.is_loopback ? '1' : '0'}`;
|
||
return `<option value="${val}">${escapeHtml(label)}</option>`;
|
||
}).join('');
|
||
if (devices.length === 0) {
|
||
deviceSelect.innerHTML = '<option value="-1:1">Default</option>';
|
||
}
|
||
}
|
||
} catch {
|
||
deviceSelect.innerHTML = '<option value="-1:1">Default</option>';
|
||
}
|
||
|
||
// Restore last used device
|
||
const lastDevice = localStorage.getItem('lastAudioTestDevice');
|
||
if (lastDevice) {
|
||
const opt = Array.from(deviceSelect.options).find(o => o.value === lastDevice);
|
||
if (opt) deviceSelect.value = lastDevice;
|
||
}
|
||
|
||
// Reset visual state
|
||
document.getElementById('audio-template-test-canvas').style.display = 'none';
|
||
document.getElementById('audio-template-test-stats').style.display = 'none';
|
||
document.getElementById('audio-template-test-status').style.display = 'none';
|
||
document.getElementById('test-audio-template-start-btn').style.display = '';
|
||
|
||
_tplCleanupTest();
|
||
|
||
testAudioTemplateModal.open();
|
||
}
|
||
|
||
export function closeTestAudioTemplateModal() {
|
||
_tplCleanupTest();
|
||
testAudioTemplateModal.forceClose();
|
||
_currentTestAudioTemplateId = null;
|
||
}
|
||
|
||
export function startAudioTemplateTest() {
|
||
if (!_currentTestAudioTemplateId) return;
|
||
|
||
const deviceVal = document.getElementById('test-audio-template-device').value || '-1:1';
|
||
const [devIdx, devLoop] = deviceVal.split(':');
|
||
localStorage.setItem('lastAudioTestDevice', deviceVal);
|
||
|
||
// Show canvas + stats, hide run button, disable device picker
|
||
document.getElementById('audio-template-test-canvas').style.display = '';
|
||
document.getElementById('audio-template-test-stats').style.display = '';
|
||
document.getElementById('test-audio-template-start-btn').style.display = 'none';
|
||
document.getElementById('test-audio-template-device').disabled = true;
|
||
|
||
const statusEl = document.getElementById('audio-template-test-status');
|
||
statusEl.textContent = t('audio_source.test.connecting');
|
||
statusEl.style.display = '';
|
||
|
||
// Reset state
|
||
_tplTestLatest = null;
|
||
_tplTestPeaks.fill(0);
|
||
_tplTestBeatFlash = 0;
|
||
document.getElementById('audio-template-test-rms').textContent = '---';
|
||
document.getElementById('audio-template-test-peak').textContent = '---';
|
||
document.getElementById('audio-template-test-beat-dot').classList.remove('active');
|
||
|
||
// Size canvas
|
||
const canvas = document.getElementById('audio-template-test-canvas');
|
||
_tplSizeCanvas(canvas);
|
||
|
||
// Connect WebSocket
|
||
const protocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:';
|
||
const wsUrl = `${protocol}//${window.location.host}${API_BASE}/audio-templates/${_currentTestAudioTemplateId}/test/ws?token=${encodeURIComponent(apiKey)}&device_index=${devIdx}&is_loopback=${devLoop === '1' ? '1' : '0'}`;
|
||
|
||
try {
|
||
_tplTestWs = new WebSocket(wsUrl);
|
||
|
||
_tplTestWs.onopen = () => {
|
||
statusEl.style.display = 'none';
|
||
};
|
||
|
||
_tplTestWs.onmessage = (event) => {
|
||
try { _tplTestLatest = JSON.parse(event.data); } catch {}
|
||
};
|
||
|
||
_tplTestWs.onclose = () => { _tplTestWs = null; };
|
||
|
||
_tplTestWs.onerror = () => {
|
||
showToast(t('audio_source.test.error'), 'error');
|
||
_tplCleanupTest();
|
||
};
|
||
} catch {
|
||
showToast(t('audio_source.test.error'), 'error');
|
||
_tplCleanupTest();
|
||
return;
|
||
}
|
||
|
||
_tplTestAnimFrame = requestAnimationFrame(_tplRenderLoop);
|
||
}
|
||
|
||
function _tplCleanupTest() {
|
||
if (_tplTestAnimFrame) {
|
||
cancelAnimationFrame(_tplTestAnimFrame);
|
||
_tplTestAnimFrame = null;
|
||
}
|
||
if (_tplTestWs) {
|
||
_tplTestWs.onclose = null;
|
||
_tplTestWs.close();
|
||
_tplTestWs = null;
|
||
}
|
||
_tplTestLatest = null;
|
||
// Re-enable device picker
|
||
const devSel = document.getElementById('test-audio-template-device');
|
||
if (devSel) devSel.disabled = false;
|
||
}
|
||
|
||
function _tplSizeCanvas(canvas) {
|
||
const rect = canvas.parentElement.getBoundingClientRect();
|
||
const dpr = window.devicePixelRatio || 1;
|
||
canvas.width = rect.width * dpr;
|
||
canvas.height = 200 * dpr;
|
||
canvas.style.height = '200px';
|
||
canvas.getContext('2d').scale(dpr, dpr);
|
||
}
|
||
|
||
function _tplRenderLoop() {
|
||
_tplRenderSpectrum();
|
||
if (testAudioTemplateModal.isOpen && _tplTestWs) {
|
||
_tplTestAnimFrame = requestAnimationFrame(_tplRenderLoop);
|
||
}
|
||
}
|
||
|
||
function _tplRenderSpectrum() {
|
||
const canvas = document.getElementById('audio-template-test-canvas');
|
||
if (!canvas) return;
|
||
|
||
const ctx = canvas.getContext('2d');
|
||
const dpr = window.devicePixelRatio || 1;
|
||
const w = canvas.width / dpr;
|
||
const h = canvas.height / dpr;
|
||
|
||
ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
|
||
ctx.clearRect(0, 0, w, h);
|
||
|
||
const data = _tplTestLatest;
|
||
if (!data || !data.spectrum) return;
|
||
|
||
const spectrum = data.spectrum;
|
||
const gap = 1;
|
||
const barWidth = (w - gap * (NUM_BANDS_TPL - 1)) / NUM_BANDS_TPL;
|
||
|
||
// Beat flash
|
||
if (data.beat) _tplTestBeatFlash = Math.min(1.0, data.beat_intensity + 0.3);
|
||
if (_tplTestBeatFlash > 0) {
|
||
ctx.fillStyle = `rgba(255, 255, 255, ${_tplTestBeatFlash * 0.08})`;
|
||
ctx.fillRect(0, 0, w, h);
|
||
_tplTestBeatFlash = Math.max(0, _tplTestBeatFlash - TPL_BEAT_FLASH_DECAY);
|
||
}
|
||
|
||
for (let i = 0; i < NUM_BANDS_TPL; i++) {
|
||
const val = Math.min(1, spectrum[i]);
|
||
const barHeight = val * h;
|
||
const x = i * (barWidth + gap);
|
||
const y = h - barHeight;
|
||
|
||
const hue = (1 - val) * 120;
|
||
ctx.fillStyle = `hsl(${hue}, 85%, 50%)`;
|
||
ctx.fillRect(x, y, barWidth, barHeight);
|
||
|
||
if (val > _tplTestPeaks[i]) {
|
||
_tplTestPeaks[i] = val;
|
||
} else {
|
||
_tplTestPeaks[i] = Math.max(0, _tplTestPeaks[i] - TPL_PEAK_DECAY);
|
||
}
|
||
const peakY = h - _tplTestPeaks[i] * h;
|
||
const peakHue = (1 - _tplTestPeaks[i]) * 120;
|
||
ctx.fillStyle = `hsl(${peakHue}, 90%, 70%)`;
|
||
ctx.fillRect(x, peakY, barWidth, 2);
|
||
}
|
||
|
||
document.getElementById('audio-template-test-rms').textContent = (data.rms * 100).toFixed(1) + '%';
|
||
document.getElementById('audio-template-test-peak').textContent = (data.peak * 100).toFixed(1) + '%';
|
||
const beatDot = document.getElementById('audio-template-test-beat-dot');
|
||
if (data.beat) {
|
||
beatDot.classList.add('active');
|
||
} else {
|
||
beatDot.classList.remove('active');
|
||
}
|
||
}
|
||
|
||
// ===== Picture Sources =====
|
||
|
||
export async function loadPictureSources() {
|
||
if (_sourcesLoading) return;
|
||
set_sourcesLoading(true);
|
||
if (!csRawStreams.isMounted()) setTabRefreshing('streams-list', true);
|
||
try {
|
||
const [streams] = await Promise.all([
|
||
streamsCache.fetch(),
|
||
ppTemplatesCache.fetch(),
|
||
captureTemplatesCache.fetch(),
|
||
audioSourcesCache.fetch(),
|
||
valueSourcesCache.fetch(),
|
||
syncClocksCache.fetch(),
|
||
audioTemplatesCache.fetch(),
|
||
colorStripSourcesCache.fetch(),
|
||
csptCache.fetch(),
|
||
filtersCache.data.length === 0 ? filtersCache.fetch() : Promise.resolve(filtersCache.data),
|
||
]);
|
||
renderPictureSourcesList(streams);
|
||
} catch (error) {
|
||
if (error.isAuth) return;
|
||
console.error('Error loading picture sources:', error);
|
||
document.getElementById('streams-list').innerHTML = `
|
||
<div class="error-message">${t('streams.error.load')}: ${error.message}</div>
|
||
`;
|
||
} finally {
|
||
set_sourcesLoading(false);
|
||
setTabRefreshing('streams-list', false);
|
||
}
|
||
}
|
||
|
||
let _streamsTreeTriggered = false;
|
||
|
||
const _streamsTree = new TreeNav('streams-tree-nav', {
|
||
onSelect: (key) => {
|
||
_streamsTreeTriggered = true;
|
||
switchStreamTab(key);
|
||
_streamsTreeTriggered = false;
|
||
}
|
||
});
|
||
|
||
export function switchStreamTab(tabKey) {
|
||
document.querySelectorAll('.stream-tab-panel[id^="stream-tab-"]').forEach(panel =>
|
||
panel.classList.toggle('active', panel.id === `stream-tab-${tabKey}`)
|
||
);
|
||
localStorage.setItem('activeStreamTab', tabKey);
|
||
updateSubTabHash('streams', tabKey);
|
||
// Update tree active state (unless the tree triggered this switch)
|
||
if (!_streamsTreeTriggered) {
|
||
_streamsTree.setActive(tabKey);
|
||
}
|
||
}
|
||
|
||
const _streamSectionMap = {
|
||
raw: [csRawStreams],
|
||
raw_templates: [csRawTemplates],
|
||
static_image: [csStaticStreams],
|
||
video: [csVideoStreams],
|
||
processed: [csProcStreams],
|
||
proc_templates: [csProcTemplates],
|
||
css_processing: [csCSPTemplates],
|
||
color_strip: [csColorStrips],
|
||
audio: [csAudioMulti, csAudioMono],
|
||
audio_templates: [csAudioTemplates],
|
||
value: [csValueSources],
|
||
sync: [csSyncClocks],
|
||
};
|
||
|
||
export function expandAllStreamSections() {
|
||
const activeTab = localStorage.getItem('activeStreamTab') || 'raw';
|
||
CardSection.expandAll(_streamSectionMap[activeTab] || []);
|
||
}
|
||
|
||
export function collapseAllStreamSections() {
|
||
const activeTab = localStorage.getItem('activeStreamTab') || 'raw';
|
||
CardSection.collapseAll(_streamSectionMap[activeTab] || []);
|
||
}
|
||
|
||
function renderPictureSourcesList(streams) {
|
||
const container = document.getElementById('streams-list');
|
||
const activeTab = localStorage.getItem('activeStreamTab') || 'raw';
|
||
|
||
const renderStreamCard = (stream) => {
|
||
const typeIcon = getPictureSourceIcon(stream.stream_type);
|
||
|
||
let detailsHtml = '';
|
||
if (stream.stream_type === 'raw') {
|
||
let capTmplName = '';
|
||
if (stream.capture_template_id) {
|
||
const capTmpl = _cachedCaptureTemplates.find(t => t.id === stream.capture_template_id);
|
||
if (capTmpl) capTmplName = escapeHtml(capTmpl.name);
|
||
}
|
||
detailsHtml = `<div class="stream-card-props">
|
||
<span class="stream-card-prop" title="${t('streams.display')}">${ICON_MONITOR} ${stream.display_index ?? 0}</span>
|
||
<span class="stream-card-prop" title="${t('streams.target_fps')}">${ICON_FPS} ${stream.target_fps ?? 30}</span>
|
||
${capTmplName ? `<span class="stream-card-prop stream-card-link" title="${t('streams.capture_template')}" onclick="event.stopPropagation(); navigateToCard('streams','raw_templates','raw-templates','data-template-id','${stream.capture_template_id}')">${ICON_TEMPLATE} ${capTmplName}</span>` : ''}
|
||
</div>`;
|
||
} else if (stream.stream_type === 'processed') {
|
||
const sourceStream = _cachedStreams.find(s => s.id === stream.source_stream_id);
|
||
const sourceName = sourceStream ? escapeHtml(sourceStream.name) : (stream.source_stream_id || '-');
|
||
const sourceSubTab = sourceStream ? (sourceStream.stream_type === 'static_image' ? 'static_image' : 'raw') : 'raw';
|
||
const sourceSection = sourceStream ? (sourceStream.stream_type === 'static_image' ? 'static-streams' : 'raw-streams') : 'raw-streams';
|
||
let ppTmplName = '';
|
||
if (stream.postprocessing_template_id) {
|
||
const ppTmpl = _cachedPPTemplates.find(p => p.id === stream.postprocessing_template_id);
|
||
if (ppTmpl) ppTmplName = escapeHtml(ppTmpl.name);
|
||
}
|
||
detailsHtml = `<div class="stream-card-props">
|
||
<span class="stream-card-prop stream-card-link" title="${t('streams.source')}" onclick="event.stopPropagation(); navigateToCard('streams','${sourceSubTab}','${sourceSection}','data-stream-id','${stream.source_stream_id}')">${ICON_LINK_SOURCE} ${sourceName}</span>
|
||
${ppTmplName ? `<span class="stream-card-prop stream-card-link" title="${t('streams.pp_template')}" onclick="event.stopPropagation(); navigateToCard('streams','proc_templates','proc-templates','data-pp-template-id','${stream.postprocessing_template_id}')">${ICON_TEMPLATE} ${ppTmplName}</span>` : ''}
|
||
</div>`;
|
||
} else if (stream.stream_type === 'static_image') {
|
||
const src = stream.image_source || '';
|
||
detailsHtml = `<div class="stream-card-props">
|
||
<span class="stream-card-prop stream-card-prop-full" title="${escapeHtml(src)}">${ICON_WEB} ${escapeHtml(src)}</span>
|
||
</div>`;
|
||
} else if (stream.stream_type === 'video') {
|
||
const url = stream.url || '';
|
||
const shortUrl = url.length > 40 ? url.slice(0, 37) + '...' : url;
|
||
detailsHtml = `<div class="stream-card-props">
|
||
<span class="stream-card-prop stream-card-prop-full" title="${escapeHtml(url)}">${ICON_WEB} ${escapeHtml(shortUrl)}</span>
|
||
<span class="stream-card-prop" title="${t('streams.target_fps')}">${ICON_FPS} ${stream.target_fps ?? 30}</span>
|
||
${stream.loop !== false ? `<span class="stream-card-prop">↻</span>` : ''}
|
||
${stream.playback_speed && stream.playback_speed !== 1.0 ? `<span class="stream-card-prop">${stream.playback_speed}×</span>` : ''}
|
||
</div>`;
|
||
}
|
||
|
||
return wrapCard({
|
||
type: 'template-card',
|
||
dataAttr: 'data-stream-id',
|
||
id: stream.id,
|
||
removeOnclick: `deleteStream('${stream.id}')`,
|
||
removeTitle: t('common.delete'),
|
||
content: `
|
||
<div class="template-card-header">
|
||
<div class="template-name">${typeIcon} ${escapeHtml(stream.name)}</div>
|
||
</div>
|
||
${detailsHtml}
|
||
${renderTagChips(stream.tags)}
|
||
${stream.description ? `<div class="template-config" style="opacity:0.7;">${escapeHtml(stream.description)}</div>` : ''}`,
|
||
actions: `
|
||
<button class="btn btn-icon btn-secondary" onclick="showTestStreamModal('${stream.id}')" title="${t('streams.test.title')}">${ICON_TEST}</button>
|
||
<button class="btn btn-icon btn-secondary" onclick="cloneStream('${stream.id}')" title="${t('common.clone')}">${ICON_CLONE}</button>
|
||
<button class="btn btn-icon btn-secondary" onclick="editStream('${stream.id}')" title="${t('common.edit')}">${ICON_EDIT}</button>`,
|
||
});
|
||
};
|
||
|
||
const renderCaptureTemplateCard = (template) => {
|
||
const engineIcon = getEngineIcon(template.engine_type);
|
||
const configEntries = Object.entries(template.engine_config);
|
||
return wrapCard({
|
||
type: 'template-card',
|
||
dataAttr: 'data-template-id',
|
||
id: template.id,
|
||
removeOnclick: `deleteTemplate('${template.id}')`,
|
||
removeTitle: t('common.delete'),
|
||
content: `
|
||
<div class="template-card-header">
|
||
<div class="template-name">${ICON_TEMPLATE} ${escapeHtml(template.name)}</div>
|
||
</div>
|
||
${template.description ? `<div class="template-config" style="opacity:0.7;">${escapeHtml(template.description)}</div>` : ''}
|
||
<div class="stream-card-props">
|
||
<span class="stream-card-prop" title="${t('templates.engine')}">${getEngineIcon(template.engine_type)} ${template.engine_type.toUpperCase()}</span>
|
||
${configEntries.length > 0 ? `<span class="stream-card-prop" title="${t('templates.config.show')}">${ICON_WRENCH} ${configEntries.length}</span>` : ''}
|
||
</div>
|
||
${renderTagChips(template.tags)}
|
||
${configEntries.length > 0 ? `
|
||
<div class="template-config-collapse">
|
||
<button type="button" class="template-config-toggle" onclick="this.parentElement.classList.toggle('open')">${t('templates.config.show')}</button>
|
||
<div class="template-config-animate">
|
||
<div class="template-config-inner">
|
||
<table class="config-table">
|
||
${configEntries.map(([key, val]) => `
|
||
<tr>
|
||
<td class="config-key">${escapeHtml(key)}</td>
|
||
<td class="config-value">${escapeHtml(String(val))}</td>
|
||
</tr>
|
||
`).join('')}
|
||
</table>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
` : ''}`,
|
||
actions: `
|
||
<button class="btn btn-icon btn-secondary" onclick="showTestTemplateModal('${template.id}')" title="${t('templates.test.title')}">${ICON_TEST}</button>
|
||
<button class="btn btn-icon btn-secondary" onclick="cloneCaptureTemplate('${template.id}')" title="${t('common.clone')}">${ICON_CLONE}</button>
|
||
<button class="btn btn-icon btn-secondary" onclick="editTemplate('${template.id}')" title="${t('common.edit')}">${ICON_EDIT}</button>`,
|
||
});
|
||
};
|
||
|
||
const renderPPTemplateCard = (tmpl) => {
|
||
let filterChainHtml = '';
|
||
if (tmpl.filters && tmpl.filters.length > 0) {
|
||
const filterNames = tmpl.filters.map(fi => `<span class="filter-chain-item">${escapeHtml(_getFilterName(fi.filter_id))}</span>`);
|
||
filterChainHtml = `<div class="filter-chain">${filterNames.join('<span class="filter-chain-arrow">→</span>')}</div>`;
|
||
}
|
||
return wrapCard({
|
||
type: 'template-card',
|
||
dataAttr: 'data-pp-template-id',
|
||
id: tmpl.id,
|
||
removeOnclick: `deletePPTemplate('${tmpl.id}')`,
|
||
removeTitle: t('common.delete'),
|
||
content: `
|
||
<div class="template-card-header">
|
||
<div class="template-name">${ICON_TEMPLATE} ${escapeHtml(tmpl.name)}</div>
|
||
</div>
|
||
${tmpl.description ? `<div class="template-config" style="opacity:0.7;">${escapeHtml(tmpl.description)}</div>` : ''}
|
||
${filterChainHtml}
|
||
${renderTagChips(tmpl.tags)}`,
|
||
actions: `
|
||
<button class="btn btn-icon btn-secondary" onclick="showTestPPTemplateModal('${tmpl.id}')" title="${t('postprocessing.test.title')}">${ICON_TEST}</button>
|
||
<button class="btn btn-icon btn-secondary" onclick="clonePPTemplate('${tmpl.id}')" title="${t('common.clone')}">${ICON_CLONE}</button>
|
||
<button class="btn btn-icon btn-secondary" onclick="editPPTemplate('${tmpl.id}')" title="${t('common.edit')}">${ICON_EDIT}</button>`,
|
||
});
|
||
};
|
||
|
||
const renderCSPTCard = (tmpl) => {
|
||
let filterChainHtml = '';
|
||
if (tmpl.filters && tmpl.filters.length > 0) {
|
||
const filterNames = tmpl.filters.map(fi => `<span class="filter-chain-item">${escapeHtml(_getStripFilterName(fi.filter_id))}</span>`);
|
||
filterChainHtml = `<div class="filter-chain">${filterNames.join('<span class="filter-chain-arrow">\u2192</span>')}</div>`;
|
||
}
|
||
return wrapCard({
|
||
type: 'template-card',
|
||
dataAttr: 'data-cspt-id',
|
||
id: tmpl.id,
|
||
removeOnclick: `deleteCSPT('${tmpl.id}')`,
|
||
removeTitle: t('common.delete'),
|
||
content: `
|
||
<div class="template-card-header">
|
||
<div class="template-name">${ICON_CSPT} ${escapeHtml(tmpl.name)}</div>
|
||
</div>
|
||
${tmpl.description ? `<div class="template-config" style="opacity:0.7;">${escapeHtml(tmpl.description)}</div>` : ''}
|
||
${filterChainHtml}
|
||
${renderTagChips(tmpl.tags)}`,
|
||
actions: `
|
||
<button class="btn btn-icon btn-secondary" onclick="event.stopPropagation(); testCSPT('${tmpl.id}')" title="${t('color_strip.test.title')}">${ICON_TEST}</button>
|
||
<button class="btn btn-icon btn-secondary" onclick="cloneCSPT('${tmpl.id}')" title="${t('common.clone')}">${ICON_CLONE}</button>
|
||
<button class="btn btn-icon btn-secondary" onclick="editCSPT('${tmpl.id}')" title="${t('common.edit')}">${ICON_EDIT}</button>`,
|
||
});
|
||
};
|
||
|
||
const rawStreams = streams.filter(s => s.stream_type === 'raw');
|
||
const processedStreams = streams.filter(s => s.stream_type === 'processed');
|
||
const staticImageStreams = streams.filter(s => s.stream_type === 'static_image');
|
||
const videoStreams = streams.filter(s => s.stream_type === 'video');
|
||
|
||
const multichannelSources = _cachedAudioSources.filter(s => s.source_type === 'multichannel');
|
||
const monoSources = _cachedAudioSources.filter(s => s.source_type === 'mono');
|
||
|
||
// CSPT templates
|
||
const csptTemplates = csptCache.data;
|
||
|
||
// Color strip sources (maps needed for card rendering)
|
||
const colorStrips = colorStripSourcesCache.data;
|
||
const pictureSourceMap = {};
|
||
streams.forEach(s => { pictureSourceMap[s.id] = s; });
|
||
const audioSourceMap = {};
|
||
_cachedAudioSources.forEach(s => { audioSourceMap[s.id] = s; });
|
||
|
||
const tabs = [
|
||
{ key: 'raw', icon: getPictureSourceIcon('raw'), titleKey: 'streams.group.raw', count: rawStreams.length },
|
||
{ key: 'raw_templates', icon: ICON_CAPTURE_TEMPLATE, titleKey: 'streams.group.raw_templates', count: _cachedCaptureTemplates.length },
|
||
{ key: 'static_image', icon: getPictureSourceIcon('static_image'), titleKey: 'streams.group.static_image', count: staticImageStreams.length },
|
||
{ key: 'video', icon: getPictureSourceIcon('video'), titleKey: 'streams.group.video', count: videoStreams.length },
|
||
{ key: 'processed', icon: getPictureSourceIcon('processed'), titleKey: 'streams.group.processed', count: processedStreams.length },
|
||
{ key: 'proc_templates', icon: ICON_PP_TEMPLATE, titleKey: 'streams.group.proc_templates', count: _cachedPPTemplates.length },
|
||
{ key: 'css_processing', icon: ICON_CSPT, titleKey: 'streams.group.css_processing', count: csptTemplates.length },
|
||
{ key: 'color_strip', icon: getColorStripIcon('static'), titleKey: 'streams.group.color_strip', count: colorStrips.length },
|
||
{ key: 'audio', icon: getAudioSourceIcon('multichannel'), titleKey: 'streams.group.audio', count: _cachedAudioSources.length },
|
||
{ key: 'audio_templates', icon: ICON_AUDIO_TEMPLATE, titleKey: 'streams.group.audio_templates', count: _cachedAudioTemplates.length },
|
||
{ key: 'value', icon: ICON_VALUE_SOURCE, titleKey: 'streams.group.value', count: _cachedValueSources.length },
|
||
{ key: 'sync', icon: ICON_CLOCK, titleKey: 'streams.group.sync', count: _cachedSyncClocks.length },
|
||
];
|
||
|
||
// Build tree navigation structure
|
||
const treeGroups = [
|
||
{
|
||
key: 'picture_group', icon: getPictureSourceIcon('raw'), titleKey: 'tree.group.picture',
|
||
children: [
|
||
{
|
||
key: 'capture_group', icon: getPictureSourceIcon('raw'), titleKey: 'tree.group.capture',
|
||
children: [
|
||
{ key: 'raw', titleKey: 'tree.leaf.sources', icon: getPictureSourceIcon('raw'), count: rawStreams.length },
|
||
{ key: 'raw_templates', titleKey: 'tree.leaf.engine_templates', icon: ICON_CAPTURE_TEMPLATE, count: _cachedCaptureTemplates.length },
|
||
]
|
||
},
|
||
{
|
||
key: 'static_group', icon: getPictureSourceIcon('static_image'), titleKey: 'tree.group.static',
|
||
children: [
|
||
{ key: 'static_image', titleKey: 'tree.leaf.images', icon: getPictureSourceIcon('static_image'), count: staticImageStreams.length },
|
||
{ key: 'video', titleKey: 'tree.leaf.video', icon: getPictureSourceIcon('video'), count: videoStreams.length },
|
||
]
|
||
},
|
||
{
|
||
key: 'processing_group', icon: getPictureSourceIcon('processed'), titleKey: 'tree.group.processing',
|
||
children: [
|
||
{ key: 'processed', titleKey: 'tree.leaf.sources', icon: getPictureSourceIcon('processed'), count: processedStreams.length },
|
||
{ key: 'proc_templates', titleKey: 'tree.leaf.filter_templates', icon: ICON_PP_TEMPLATE, count: _cachedPPTemplates.length },
|
||
]
|
||
},
|
||
]
|
||
},
|
||
{
|
||
key: 'strip_group', icon: getColorStripIcon('static'), titleKey: 'tree.group.strip',
|
||
children: [
|
||
{ key: 'color_strip', titleKey: 'tree.leaf.sources', icon: getColorStripIcon('static'), count: colorStrips.length },
|
||
{ key: 'css_processing', titleKey: 'tree.leaf.processing_templates', icon: ICON_CSPT, count: csptTemplates.length },
|
||
]
|
||
},
|
||
{
|
||
key: 'audio_group', icon: getAudioSourceIcon('multichannel'), titleKey: 'tree.group.audio',
|
||
children: [
|
||
{ key: 'audio', titleKey: 'tree.leaf.sources', icon: getAudioSourceIcon('multichannel'), count: _cachedAudioSources.length },
|
||
{ key: 'audio_templates', titleKey: 'tree.leaf.templates', icon: ICON_AUDIO_TEMPLATE, count: _cachedAudioTemplates.length },
|
||
]
|
||
},
|
||
{
|
||
key: 'utility_group', icon: ICON_WRENCH, titleKey: 'tree.group.utility',
|
||
children: [
|
||
{ key: 'value', titleKey: 'streams.group.value', icon: ICON_VALUE_SOURCE, count: _cachedValueSources.length },
|
||
{ key: 'sync', titleKey: 'streams.group.sync', icon: ICON_CLOCK, count: _cachedSyncClocks.length },
|
||
]
|
||
}
|
||
];
|
||
|
||
const renderAudioSourceCard = (src) => {
|
||
const isMono = src.source_type === 'mono';
|
||
const icon = getAudioSourceIcon(src.source_type);
|
||
|
||
let propsHtml = '';
|
||
if (isMono) {
|
||
const parent = _cachedAudioSources.find(s => s.id === src.audio_source_id);
|
||
const parentName = parent ? parent.name : src.audio_source_id;
|
||
const chLabel = src.channel === 'left' ? 'L' : src.channel === 'right' ? 'R' : 'M';
|
||
const parentBadge = parent
|
||
? `<span class="stream-card-prop stream-card-link" title="${escapeHtml(t('audio_source.parent'))}" onclick="event.stopPropagation(); navigateToCard('streams','audio','audio-multi','data-id','${src.audio_source_id}')">${ICON_AUDIO_LOOPBACK} ${escapeHtml(parentName)}</span>`
|
||
: `<span class="stream-card-prop" title="${escapeHtml(t('audio_source.parent'))}">${ICON_AUDIO_LOOPBACK} ${escapeHtml(parentName)}</span>`;
|
||
propsHtml = `
|
||
${parentBadge}
|
||
<span class="stream-card-prop" title="${escapeHtml(t('audio_source.channel'))}">${ICON_RADIO} ${chLabel}</span>
|
||
`;
|
||
} else {
|
||
const devIdx = src.device_index ?? -1;
|
||
const loopback = src.is_loopback !== false;
|
||
const devLabel = loopback ? `${ICON_AUDIO_LOOPBACK} Loopback` : `${ICON_AUDIO_INPUT} Input`;
|
||
const tpl = src.audio_template_id ? _cachedAudioTemplates.find(t => t.id === src.audio_template_id) : null;
|
||
const tplBadge = tpl ? `<span class="stream-card-prop stream-card-link" title="${escapeHtml(t('audio_source.audio_template'))}" onclick="event.stopPropagation(); navigateToCard('streams','audio_templates','audio-templates','data-audio-template-id','${src.audio_template_id}')">${ICON_AUDIO_TEMPLATE} ${escapeHtml(tpl.name)}</span>` : '';
|
||
propsHtml = `<span class="stream-card-prop">${devLabel} #${devIdx}</span>${tplBadge}`;
|
||
}
|
||
|
||
return wrapCard({
|
||
type: 'template-card',
|
||
dataAttr: 'data-id',
|
||
id: src.id,
|
||
removeOnclick: `deleteAudioSource('${src.id}')`,
|
||
removeTitle: t('common.delete'),
|
||
content: `
|
||
<div class="template-card-header">
|
||
<div class="template-name">${icon} ${escapeHtml(src.name)}</div>
|
||
</div>
|
||
<div class="stream-card-props">${propsHtml}</div>
|
||
${renderTagChips(src.tags)}
|
||
${src.description ? `<div class="template-config" style="opacity:0.7;">${escapeHtml(src.description)}</div>` : ''}`,
|
||
actions: `
|
||
<button class="btn btn-icon btn-secondary" onclick="testAudioSource('${src.id}')" title="${t('audio_source.test')}">${ICON_TEST}</button>
|
||
<button class="btn btn-icon btn-secondary" onclick="cloneAudioSource('${src.id}')" title="${t('common.clone')}">${ICON_CLONE}</button>
|
||
<button class="btn btn-icon btn-secondary" onclick="editAudioSource('${src.id}')" title="${t('common.edit')}">${ICON_EDIT}</button>`,
|
||
});
|
||
};
|
||
|
||
const renderAudioTemplateCard = (template) => {
|
||
const configEntries = Object.entries(template.engine_config || {});
|
||
return wrapCard({
|
||
type: 'template-card',
|
||
dataAttr: 'data-audio-template-id',
|
||
id: template.id,
|
||
removeOnclick: `deleteAudioTemplate('${template.id}')`,
|
||
removeTitle: t('common.delete'),
|
||
content: `
|
||
<div class="template-card-header">
|
||
<div class="template-name">${ICON_AUDIO_TEMPLATE} ${escapeHtml(template.name)}</div>
|
||
</div>
|
||
${template.description ? `<div class="template-config" style="opacity:0.7;">${escapeHtml(template.description)}</div>` : ''}
|
||
<div class="stream-card-props">
|
||
<span class="stream-card-prop" title="${t('audio_template.engine')}">${ICON_AUDIO_TEMPLATE} ${template.engine_type.toUpperCase()}</span>
|
||
${configEntries.length > 0 ? `<span class="stream-card-prop" title="${t('audio_template.config.show')}">${ICON_WRENCH} ${configEntries.length}</span>` : ''}
|
||
</div>
|
||
${renderTagChips(template.tags)}
|
||
${configEntries.length > 0 ? `
|
||
<div class="template-config-collapse">
|
||
<button type="button" class="template-config-toggle" onclick="this.parentElement.classList.toggle('open')">${t('audio_template.config.show')}</button>
|
||
<div class="template-config-animate">
|
||
<div class="template-config-inner">
|
||
<table class="config-table">
|
||
${configEntries.map(([key, val]) => `
|
||
<tr>
|
||
<td class="config-key">${escapeHtml(key)}</td>
|
||
<td class="config-value">${escapeHtml(String(val))}</td>
|
||
</tr>
|
||
`).join('')}
|
||
</table>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
` : ''}`,
|
||
actions: `
|
||
<button class="btn btn-icon btn-secondary" onclick="showTestAudioTemplateModal('${template.id}')" title="${t('audio_template.test')}">${ICON_TEST}</button>
|
||
<button class="btn btn-icon btn-secondary" onclick="cloneAudioTemplate('${template.id}')" title="${t('common.clone')}">${ICON_CLONE}</button>
|
||
<button class="btn btn-icon btn-secondary" onclick="editAudioTemplate('${template.id}')" title="${t('common.edit')}">${ICON_EDIT}</button>`,
|
||
});
|
||
};
|
||
|
||
// Build item arrays for all sections
|
||
const rawStreamItems = csRawStreams.applySortOrder(rawStreams.map(s => ({ key: s.id, html: renderStreamCard(s) })));
|
||
const rawTemplateItems = csRawTemplates.applySortOrder(_cachedCaptureTemplates.map(t => ({ key: t.id, html: renderCaptureTemplateCard(t) })));
|
||
const procStreamItems = csProcStreams.applySortOrder(processedStreams.map(s => ({ key: s.id, html: renderStreamCard(s) })));
|
||
const procTemplateItems = csProcTemplates.applySortOrder(_cachedPPTemplates.map(t => ({ key: t.id, html: renderPPTemplateCard(t) })));
|
||
const multiItems = csAudioMulti.applySortOrder(multichannelSources.map(s => ({ key: s.id, html: renderAudioSourceCard(s) })));
|
||
const monoItems = csAudioMono.applySortOrder(monoSources.map(s => ({ key: s.id, html: renderAudioSourceCard(s) })));
|
||
const audioTemplateItems = csAudioTemplates.applySortOrder(_cachedAudioTemplates.map(t => ({ key: t.id, html: renderAudioTemplateCard(t) })));
|
||
const staticItems = csStaticStreams.applySortOrder(staticImageStreams.map(s => ({ key: s.id, html: renderStreamCard(s) })));
|
||
const videoItems = csVideoStreams.applySortOrder(videoStreams.map(s => ({ key: s.id, html: renderStreamCard(s) })));
|
||
const colorStripItems = csColorStrips.applySortOrder(colorStrips.map(s => ({ key: s.id, html: createColorStripCard(s, pictureSourceMap, audioSourceMap) })));
|
||
const valueItems = csValueSources.applySortOrder(_cachedValueSources.map(s => ({ key: s.id, html: createValueSourceCard(s) })));
|
||
const syncClockItems = csSyncClocks.applySortOrder(_cachedSyncClocks.map(s => ({ key: s.id, html: createSyncClockCard(s) })));
|
||
const csptItems = csCSPTemplates.applySortOrder(csptTemplates.map(t => ({ key: t.id, html: renderCSPTCard(t) })));
|
||
|
||
if (csRawStreams.isMounted()) {
|
||
// Incremental update: reconcile cards in-place
|
||
_streamsTree.updateCounts({
|
||
raw: rawStreams.length,
|
||
raw_templates: _cachedCaptureTemplates.length,
|
||
static_image: staticImageStreams.length,
|
||
video: videoStreams.length,
|
||
processed: processedStreams.length,
|
||
proc_templates: _cachedPPTemplates.length,
|
||
css_processing: csptTemplates.length,
|
||
color_strip: colorStrips.length,
|
||
audio: _cachedAudioSources.length,
|
||
audio_templates: _cachedAudioTemplates.length,
|
||
value: _cachedValueSources.length,
|
||
sync: _cachedSyncClocks.length,
|
||
});
|
||
csRawStreams.reconcile(rawStreamItems);
|
||
csRawTemplates.reconcile(rawTemplateItems);
|
||
csProcStreams.reconcile(procStreamItems);
|
||
csProcTemplates.reconcile(procTemplateItems);
|
||
csCSPTemplates.reconcile(csptItems);
|
||
csColorStrips.reconcile(colorStripItems);
|
||
csAudioMulti.reconcile(multiItems);
|
||
csAudioMono.reconcile(monoItems);
|
||
csAudioTemplates.reconcile(audioTemplateItems);
|
||
csStaticStreams.reconcile(staticItems);
|
||
csVideoStreams.reconcile(videoItems);
|
||
csValueSources.reconcile(valueItems);
|
||
csSyncClocks.reconcile(syncClockItems);
|
||
} else {
|
||
// First render: build full HTML
|
||
const panels = tabs.map(tab => {
|
||
let panelContent = '';
|
||
if (tab.key === 'raw') panelContent = csRawStreams.render(rawStreamItems);
|
||
else if (tab.key === 'raw_templates') panelContent = csRawTemplates.render(rawTemplateItems);
|
||
else if (tab.key === 'processed') panelContent = csProcStreams.render(procStreamItems);
|
||
else if (tab.key === 'proc_templates') panelContent = csProcTemplates.render(procTemplateItems);
|
||
else if (tab.key === 'css_processing') panelContent = csCSPTemplates.render(csptItems);
|
||
else if (tab.key === 'color_strip') panelContent = csColorStrips.render(colorStripItems);
|
||
else if (tab.key === 'audio') panelContent = csAudioMulti.render(multiItems) + csAudioMono.render(monoItems);
|
||
else if (tab.key === 'audio_templates') panelContent = csAudioTemplates.render(audioTemplateItems);
|
||
else if (tab.key === 'value') panelContent = csValueSources.render(valueItems);
|
||
else if (tab.key === 'sync') panelContent = csSyncClocks.render(syncClockItems);
|
||
else if (tab.key === 'video') panelContent = csVideoStreams.render(videoItems);
|
||
else panelContent = csStaticStreams.render(staticItems);
|
||
return `<div class="stream-tab-panel${tab.key === activeTab ? ' active' : ''}" id="stream-tab-${tab.key}">${panelContent}</div>`;
|
||
}).join('');
|
||
|
||
container.innerHTML = panels;
|
||
CardSection.bindAll([csRawStreams, csRawTemplates, csProcStreams, csProcTemplates, csCSPTemplates, csColorStrips, csAudioMulti, csAudioMono, csAudioTemplates, csStaticStreams, csVideoStreams, csValueSources, csSyncClocks]);
|
||
|
||
// Render tree sidebar with expand/collapse buttons
|
||
_streamsTree.setExtraHtml(`<button class="btn-expand-collapse" onclick="expandAllStreamSections()" data-i18n-title="section.expand_all" title="${t('section.expand_all')}">⊞</button><button class="btn-expand-collapse" onclick="collapseAllStreamSections()" data-i18n-title="section.collapse_all" title="${t('section.collapse_all')}">⊟</button><button class="tutorial-trigger-btn" onclick="startSourcesTutorial()" data-i18n-title="tour.restart" title="${t('tour.restart')}">${ICON_HELP}</button>`);
|
||
_streamsTree.update(treeGroups, activeTab);
|
||
_streamsTree.observeSections('streams-list', {
|
||
'raw-streams': 'raw', 'raw-templates': 'raw_templates',
|
||
'static-streams': 'static_image',
|
||
'video-streams': 'video',
|
||
'proc-streams': 'processed', 'proc-templates': 'proc_templates',
|
||
'css-proc-templates': 'css_processing',
|
||
'color-strips': 'color_strip',
|
||
'audio-multi': 'audio', 'audio-mono': 'audio',
|
||
'audio-templates': 'audio_templates',
|
||
'value-sources': 'value',
|
||
'sync-clocks': 'sync',
|
||
});
|
||
}
|
||
}
|
||
|
||
export function onStreamTypeChange() {
|
||
const streamType = document.getElementById('stream-type').value;
|
||
document.getElementById('stream-raw-fields').style.display = streamType === 'raw' ? '' : 'none';
|
||
document.getElementById('stream-processed-fields').style.display = streamType === 'processed' ? '' : 'none';
|
||
document.getElementById('stream-static-image-fields').style.display = streamType === 'static_image' ? '' : 'none';
|
||
document.getElementById('stream-video-fields').style.display = streamType === 'video' ? '' : 'none';
|
||
}
|
||
|
||
export function onStreamDisplaySelected(displayIndex, display) {
|
||
document.getElementById('stream-display-index').value = displayIndex;
|
||
const engineType = document.getElementById('stream-capture-template').selectedOptions[0]?.dataset?.engineType || null;
|
||
document.getElementById('stream-display-picker-label').textContent = formatDisplayLabel(displayIndex, display, engineType);
|
||
_autoGenerateStreamName();
|
||
}
|
||
|
||
export function onTestDisplaySelected(displayIndex, display) {
|
||
document.getElementById('test-template-display').value = displayIndex;
|
||
const engineType = currentTestingTemplate?.engine_type || null;
|
||
document.getElementById('test-display-picker-label').textContent = formatDisplayLabel(displayIndex, display, engineType);
|
||
}
|
||
|
||
function _autoGenerateStreamName() {
|
||
if (_streamNameManuallyEdited) return;
|
||
if (document.getElementById('stream-id').value) return;
|
||
const streamType = document.getElementById('stream-type').value;
|
||
const nameInput = document.getElementById('stream-name');
|
||
|
||
if (streamType === 'raw') {
|
||
const displayIndex = document.getElementById('stream-display-index').value;
|
||
const templateSelect = document.getElementById('stream-capture-template');
|
||
const templateName = templateSelect.selectedOptions[0]?.dataset?.name || '';
|
||
if (displayIndex === '' || !templateName) return;
|
||
nameInput.value = `D${displayIndex}_${templateName}`;
|
||
} else if (streamType === 'processed') {
|
||
const sourceSelect = document.getElementById('stream-source');
|
||
const sourceName = sourceSelect.selectedOptions[0]?.dataset?.name || '';
|
||
const ppTemplateId = document.getElementById('stream-pp-template').value;
|
||
const ppTemplate = _streamModalPPTemplates.find(t => t.id === ppTemplateId);
|
||
if (!sourceName) return;
|
||
if (ppTemplate && ppTemplate.name) {
|
||
nameInput.value = `${sourceName} (${ppTemplate.name})`;
|
||
} else {
|
||
nameInput.value = sourceName;
|
||
}
|
||
}
|
||
}
|
||
|
||
export async function showAddStreamModal(presetType, cloneData = null) {
|
||
const streamType = (cloneData && cloneData.stream_type) || presetType || 'raw';
|
||
const titleKeys = { raw: 'streams.add.raw', processed: 'streams.add.processed', static_image: 'streams.add.static_image', video: 'streams.add.video' };
|
||
document.getElementById('stream-modal-title').innerHTML = `${getPictureSourceIcon(streamType)} ${t(titleKeys[streamType] || 'streams.add')}`;
|
||
document.getElementById('stream-form').reset();
|
||
document.getElementById('stream-id').value = '';
|
||
document.getElementById('stream-display-index').value = '';
|
||
document.getElementById('stream-display-picker-label').textContent = t('displays.picker.select');
|
||
document.getElementById('stream-error').style.display = 'none';
|
||
document.getElementById('stream-type').value = streamType;
|
||
set_lastValidatedImageSource('');
|
||
const imgSrcInput = document.getElementById('stream-image-source');
|
||
imgSrcInput.value = '';
|
||
document.getElementById('stream-image-preview-container').style.display = 'none';
|
||
document.getElementById('stream-image-validation-status').style.display = 'none';
|
||
imgSrcInput.onblur = () => validateStaticImage();
|
||
imgSrcInput.onkeydown = (e) => { if (e.key === 'Enter') { e.preventDefault(); validateStaticImage(); } };
|
||
imgSrcInput.onpaste = () => setTimeout(() => validateStaticImage(), 0);
|
||
onStreamTypeChange();
|
||
|
||
set_streamNameManuallyEdited(!!cloneData);
|
||
document.getElementById('stream-name').oninput = () => { set_streamNameManuallyEdited(true); };
|
||
document.getElementById('stream-capture-template').onchange = () => _autoGenerateStreamName();
|
||
document.getElementById('stream-source').onchange = () => _autoGenerateStreamName();
|
||
document.getElementById('stream-pp-template').onchange = () => _autoGenerateStreamName();
|
||
|
||
// Open modal instantly with loading indicator
|
||
_showStreamModalLoading(true);
|
||
streamModal.open();
|
||
|
||
await populateStreamModalDropdowns();
|
||
|
||
// Pre-fill from clone data after dropdowns are populated
|
||
if (cloneData) {
|
||
document.getElementById('stream-name').value = (cloneData.name || '') + ' (Copy)';
|
||
document.getElementById('stream-description').value = cloneData.description || '';
|
||
if (streamType === 'raw') {
|
||
document.getElementById('stream-capture-template').value = cloneData.capture_template_id || '';
|
||
await _onCaptureTemplateChanged();
|
||
const displayIdx = cloneData.display_index ?? 0;
|
||
const display = _cachedDisplays ? _cachedDisplays.find(d => d.index === displayIdx) : null;
|
||
onStreamDisplaySelected(displayIdx, display);
|
||
const fps = cloneData.target_fps ?? 30;
|
||
document.getElementById('stream-target-fps').value = fps;
|
||
document.getElementById('stream-target-fps-value').textContent = fps;
|
||
} else if (streamType === 'processed') {
|
||
document.getElementById('stream-source').value = cloneData.source_stream_id || '';
|
||
document.getElementById('stream-pp-template').value = cloneData.postprocessing_template_id || '';
|
||
} else if (streamType === 'static_image') {
|
||
document.getElementById('stream-image-source').value = cloneData.image_source || '';
|
||
if (cloneData.image_source) validateStaticImage();
|
||
} else if (streamType === 'video') {
|
||
document.getElementById('stream-video-url').value = cloneData.url || '';
|
||
document.getElementById('stream-video-loop').checked = cloneData.loop !== false;
|
||
document.getElementById('stream-video-speed').value = cloneData.playback_speed || 1.0;
|
||
const cloneSpeedLabel = document.getElementById('stream-video-speed-value');
|
||
if (cloneSpeedLabel) cloneSpeedLabel.textContent = cloneData.playback_speed || 1.0;
|
||
document.getElementById('stream-video-fps').value = cloneData.target_fps || 30;
|
||
document.getElementById('stream-video-start').value = cloneData.start_time || '';
|
||
document.getElementById('stream-video-end').value = cloneData.end_time || '';
|
||
document.getElementById('stream-video-resolution').value = cloneData.resolution_limit || '';
|
||
}
|
||
}
|
||
|
||
_showStreamModalLoading(false);
|
||
|
||
// Tags
|
||
if (_streamTagsInput) { _streamTagsInput.destroy(); _streamTagsInput = null; }
|
||
_streamTagsInput = new TagInput(document.getElementById('stream-tags-container'), { placeholder: t('tags.placeholder') });
|
||
_streamTagsInput.setValue(cloneData ? (cloneData.tags || []) : []);
|
||
|
||
streamModal.snapshot();
|
||
}
|
||
|
||
export async function editStream(streamId) {
|
||
try {
|
||
// Open modal instantly with loading indicator
|
||
document.getElementById('stream-modal-title').innerHTML = t('streams.edit');
|
||
document.getElementById('stream-form').reset();
|
||
document.getElementById('stream-error').style.display = 'none';
|
||
_showStreamModalLoading(true);
|
||
streamModal.open();
|
||
|
||
const response = await fetchWithAuth(`/picture-sources/${streamId}`);
|
||
if (!response.ok) throw new Error(`Failed to load stream: ${response.status}`);
|
||
const stream = await response.json();
|
||
|
||
const editTitleKeys = { raw: 'streams.edit.raw', processed: 'streams.edit.processed', static_image: 'streams.edit.static_image', video: 'streams.edit.video' };
|
||
document.getElementById('stream-modal-title').innerHTML = `${getPictureSourceIcon(stream.stream_type)} ${t(editTitleKeys[stream.stream_type] || 'streams.edit')}`;
|
||
document.getElementById('stream-id').value = streamId;
|
||
document.getElementById('stream-name').value = stream.name;
|
||
document.getElementById('stream-description').value = stream.description || '';
|
||
|
||
document.getElementById('stream-type').value = stream.stream_type;
|
||
set_lastValidatedImageSource('');
|
||
const imgSrcInput = document.getElementById('stream-image-source');
|
||
document.getElementById('stream-image-preview-container').style.display = 'none';
|
||
document.getElementById('stream-image-validation-status').style.display = 'none';
|
||
imgSrcInput.onblur = () => validateStaticImage();
|
||
imgSrcInput.onkeydown = (e) => { if (e.key === 'Enter') { e.preventDefault(); validateStaticImage(); } };
|
||
imgSrcInput.onpaste = () => setTimeout(() => validateStaticImage(), 0);
|
||
onStreamTypeChange();
|
||
|
||
await populateStreamModalDropdowns();
|
||
|
||
if (stream.stream_type === 'raw') {
|
||
document.getElementById('stream-capture-template').value = stream.capture_template_id || '';
|
||
// Ensure correct engine displays are loaded for this template
|
||
await _onCaptureTemplateChanged();
|
||
const displayIdx = stream.display_index ?? 0;
|
||
const display = _cachedDisplays ? _cachedDisplays.find(d => d.index === displayIdx) : null;
|
||
onStreamDisplaySelected(displayIdx, display);
|
||
const fps = stream.target_fps ?? 30;
|
||
document.getElementById('stream-target-fps').value = fps;
|
||
document.getElementById('stream-target-fps-value').textContent = fps;
|
||
} else if (stream.stream_type === 'processed') {
|
||
document.getElementById('stream-source').value = stream.source_stream_id || '';
|
||
document.getElementById('stream-pp-template').value = stream.postprocessing_template_id || '';
|
||
} else if (stream.stream_type === 'static_image') {
|
||
document.getElementById('stream-image-source').value = stream.image_source || '';
|
||
if (stream.image_source) validateStaticImage();
|
||
} else if (stream.stream_type === 'video') {
|
||
document.getElementById('stream-video-url').value = stream.url || '';
|
||
document.getElementById('stream-video-loop').checked = stream.loop !== false;
|
||
document.getElementById('stream-video-speed').value = stream.playback_speed || 1.0;
|
||
const speedLabel = document.getElementById('stream-video-speed-value');
|
||
if (speedLabel) speedLabel.textContent = stream.playback_speed || 1.0;
|
||
document.getElementById('stream-video-fps').value = stream.target_fps || 30;
|
||
document.getElementById('stream-video-start').value = stream.start_time || '';
|
||
document.getElementById('stream-video-end').value = stream.end_time || '';
|
||
document.getElementById('stream-video-resolution').value = stream.resolution_limit || '';
|
||
}
|
||
|
||
_showStreamModalLoading(false);
|
||
|
||
// Tags
|
||
if (_streamTagsInput) { _streamTagsInput.destroy(); _streamTagsInput = null; }
|
||
_streamTagsInput = new TagInput(document.getElementById('stream-tags-container'), { placeholder: t('tags.placeholder') });
|
||
_streamTagsInput.setValue(stream.tags || []);
|
||
|
||
streamModal.snapshot();
|
||
} catch (error) {
|
||
console.error('Error loading stream:', error);
|
||
streamModal.forceClose();
|
||
showToast(t('streams.error.load') + ': ' + error.message, 'error');
|
||
}
|
||
}
|
||
|
||
/** Track which engine type the stream-modal displays were loaded for. */
|
||
let _streamModalDisplaysEngine = null;
|
||
|
||
// ── EntitySelect instances for stream modal ──
|
||
let _captureTemplateEntitySelect = null;
|
||
let _sourceEntitySelect = null;
|
||
let _ppTemplateEntitySelect = null;
|
||
|
||
async function populateStreamModalDropdowns() {
|
||
const [captureTemplates, streams, ppTemplates] = await Promise.all([
|
||
captureTemplatesCache.fetch().catch(() => []),
|
||
streamsCache.fetch().catch(() => []),
|
||
ppTemplatesCache.fetch().catch(() => []),
|
||
displaysCache.fetch().catch(() => []),
|
||
]);
|
||
_streamModalDisplaysEngine = null;
|
||
|
||
const templateSelect = document.getElementById('stream-capture-template');
|
||
templateSelect.innerHTML = '';
|
||
captureTemplates.forEach(tmpl => {
|
||
const opt = document.createElement('option');
|
||
opt.value = tmpl.id;
|
||
opt.dataset.name = tmpl.name;
|
||
opt.dataset.engineType = tmpl.engine_type;
|
||
opt.dataset.hasOwnDisplays = availableEngines.find(e => e.type === tmpl.engine_type)?.has_own_displays ? '1' : '';
|
||
opt.textContent = `${tmpl.name} (${tmpl.engine_type})`;
|
||
templateSelect.appendChild(opt);
|
||
});
|
||
|
||
// When template changes, refresh displays if engine type switched
|
||
templateSelect.addEventListener('change', _onCaptureTemplateChanged);
|
||
|
||
// Load displays for the selected engine (engine-specific or desktop)
|
||
const firstOpt = templateSelect.selectedOptions[0];
|
||
if (firstOpt?.dataset?.hasOwnDisplays === '1') {
|
||
await _refreshStreamDisplaysForEngine(firstOpt.dataset.engineType);
|
||
} else if (!document.getElementById('stream-display-index').value && _cachedDisplays && _cachedDisplays.length > 0) {
|
||
const primary = _cachedDisplays.find(d => d.is_primary) || _cachedDisplays[0];
|
||
onStreamDisplaySelected(primary.index, primary);
|
||
}
|
||
|
||
const sourceSelect = document.getElementById('stream-source');
|
||
sourceSelect.innerHTML = '';
|
||
const editingId = document.getElementById('stream-id').value;
|
||
streams.forEach(s => {
|
||
if (s.id === editingId) return;
|
||
const opt = document.createElement('option');
|
||
opt.value = s.id;
|
||
opt.dataset.name = s.name;
|
||
opt.textContent = s.name;
|
||
sourceSelect.appendChild(opt);
|
||
});
|
||
|
||
set_streamModalPPTemplates(ppTemplates);
|
||
const ppSelect = document.getElementById('stream-pp-template');
|
||
ppSelect.innerHTML = '';
|
||
ppTemplates.forEach(tmpl => {
|
||
const opt = document.createElement('option');
|
||
opt.value = tmpl.id;
|
||
opt.textContent = tmpl.name;
|
||
ppSelect.appendChild(opt);
|
||
});
|
||
|
||
// Entity palette selectors
|
||
if (_captureTemplateEntitySelect) _captureTemplateEntitySelect.destroy();
|
||
_captureTemplateEntitySelect = new EntitySelect({
|
||
target: templateSelect,
|
||
getItems: () => captureTemplates.map(tmpl => ({
|
||
value: tmpl.id,
|
||
label: tmpl.name,
|
||
icon: getEngineIcon(tmpl.engine_type),
|
||
desc: tmpl.engine_type,
|
||
})),
|
||
placeholder: t('palette.search'),
|
||
});
|
||
|
||
if (_sourceEntitySelect) _sourceEntitySelect.destroy();
|
||
_sourceEntitySelect = new EntitySelect({
|
||
target: sourceSelect,
|
||
getItems: () => {
|
||
const editingId = document.getElementById('stream-id').value;
|
||
return streams.filter(s => s.id !== editingId).map(s => ({
|
||
value: s.id,
|
||
label: s.name,
|
||
icon: getPictureSourceIcon(s.stream_type),
|
||
}));
|
||
},
|
||
placeholder: t('palette.search'),
|
||
});
|
||
|
||
if (_ppTemplateEntitySelect) _ppTemplateEntitySelect.destroy();
|
||
_ppTemplateEntitySelect = new EntitySelect({
|
||
target: ppSelect,
|
||
getItems: () => ppTemplates.map(tmpl => ({
|
||
value: tmpl.id,
|
||
label: tmpl.name,
|
||
icon: ICON_PP_TEMPLATE,
|
||
})),
|
||
placeholder: t('palette.search'),
|
||
});
|
||
|
||
_autoGenerateStreamName();
|
||
}
|
||
|
||
async function _onCaptureTemplateChanged() {
|
||
const templateSelect = document.getElementById('stream-capture-template');
|
||
const engineType = templateSelect.selectedOptions[0]?.dataset?.engineType || null;
|
||
const hasOwnDisplays = templateSelect.selectedOptions[0]?.dataset?.hasOwnDisplays === '1';
|
||
const currentEngine = hasOwnDisplays ? engineType : null;
|
||
|
||
// Only refetch if the engine category actually changed
|
||
if (currentEngine !== _streamModalDisplaysEngine) {
|
||
await _refreshStreamDisplaysForEngine(currentEngine);
|
||
}
|
||
_autoGenerateStreamName();
|
||
}
|
||
|
||
async function _refreshStreamDisplaysForEngine(engineType) {
|
||
_streamModalDisplaysEngine = engineType;
|
||
const url = engineType ? `/config/displays?engine_type=${engineType}` : '/config/displays';
|
||
try {
|
||
const resp = await fetchWithAuth(url);
|
||
if (resp.ok) {
|
||
const data = await resp.json();
|
||
displaysCache.update(data.displays || []);
|
||
}
|
||
} catch (error) {
|
||
console.error('Error refreshing displays for engine:', error);
|
||
}
|
||
|
||
// Reset display selection and pick the first available
|
||
document.getElementById('stream-display-index').value = '';
|
||
document.getElementById('stream-display-picker-label').textContent = t('displays.picker.select');
|
||
if (_cachedDisplays && _cachedDisplays.length > 0) {
|
||
const primary = _cachedDisplays.find(d => d.is_primary) || _cachedDisplays[0];
|
||
onStreamDisplaySelected(primary.index, primary);
|
||
}
|
||
}
|
||
|
||
export async function saveStream() {
|
||
const streamId = document.getElementById('stream-id').value;
|
||
const name = document.getElementById('stream-name').value.trim();
|
||
const streamType = document.getElementById('stream-type').value;
|
||
const description = document.getElementById('stream-description').value.trim();
|
||
const errorEl = document.getElementById('stream-error');
|
||
|
||
if (!name) { showToast(t('streams.error.required'), 'error'); return; }
|
||
|
||
const payload = { name, description: description || null, tags: _streamTagsInput ? _streamTagsInput.getValue() : [] };
|
||
if (!streamId) payload.stream_type = streamType;
|
||
|
||
if (streamType === 'raw') {
|
||
payload.display_index = parseInt(document.getElementById('stream-display-index').value) || 0;
|
||
payload.capture_template_id = document.getElementById('stream-capture-template').value;
|
||
payload.target_fps = parseInt(document.getElementById('stream-target-fps').value) || 30;
|
||
} else if (streamType === 'processed') {
|
||
payload.source_stream_id = document.getElementById('stream-source').value;
|
||
payload.postprocessing_template_id = document.getElementById('stream-pp-template').value;
|
||
} else if (streamType === 'static_image') {
|
||
const imageSource = document.getElementById('stream-image-source').value.trim();
|
||
if (!imageSource) { showToast(t('streams.error.required'), 'error'); return; }
|
||
payload.image_source = imageSource;
|
||
} else if (streamType === 'video') {
|
||
const url = document.getElementById('stream-video-url').value.trim();
|
||
if (!url) { showToast(t('streams.error.required'), 'error'); return; }
|
||
payload.url = url;
|
||
payload.loop = document.getElementById('stream-video-loop').checked;
|
||
payload.playback_speed = parseFloat(document.getElementById('stream-video-speed').value) || 1.0;
|
||
payload.target_fps = parseInt(document.getElementById('stream-video-fps').value) || 30;
|
||
const startTime = parseFloat(document.getElementById('stream-video-start').value);
|
||
if (!isNaN(startTime) && startTime > 0) payload.start_time = startTime;
|
||
const endTime = parseFloat(document.getElementById('stream-video-end').value);
|
||
if (!isNaN(endTime) && endTime > 0) payload.end_time = endTime;
|
||
const resLimit = parseInt(document.getElementById('stream-video-resolution').value);
|
||
if (!isNaN(resLimit) && resLimit > 0) payload.resolution_limit = resLimit;
|
||
}
|
||
|
||
try {
|
||
let response;
|
||
if (streamId) {
|
||
response = await fetchWithAuth(`/picture-sources/${streamId}`, { method: 'PUT', body: JSON.stringify(payload) });
|
||
} else {
|
||
response = await fetchWithAuth('/picture-sources', { method: 'POST', body: JSON.stringify(payload) });
|
||
}
|
||
|
||
if (!response.ok) {
|
||
const error = await response.json();
|
||
throw new Error(error.detail || error.message || 'Failed to save stream');
|
||
}
|
||
|
||
showToast(streamId ? t('streams.updated') : t('streams.created'), 'success');
|
||
streamModal.forceClose();
|
||
streamsCache.invalidate();
|
||
await loadPictureSources();
|
||
} catch (error) {
|
||
console.error('Error saving stream:', error);
|
||
errorEl.textContent = error.message;
|
||
errorEl.style.display = 'block';
|
||
}
|
||
}
|
||
|
||
export async function deleteStream(streamId) {
|
||
const confirmed = await showConfirm(t('streams.delete.confirm'));
|
||
if (!confirmed) return;
|
||
|
||
try {
|
||
const response = await fetchWithAuth(`/picture-sources/${streamId}`, { method: 'DELETE' });
|
||
if (!response.ok) {
|
||
const error = await response.json();
|
||
throw new Error(error.detail || error.message || 'Failed to delete stream');
|
||
}
|
||
showToast(t('streams.deleted'), 'success');
|
||
streamsCache.invalidate();
|
||
await loadPictureSources();
|
||
} catch (error) {
|
||
console.error('Error deleting stream:', error);
|
||
showToast(t('streams.error.delete') + ': ' + error.message, 'error');
|
||
}
|
||
}
|
||
|
||
/** Toggle loading overlay in stream modal — hides form while data loads. */
|
||
function _showStreamModalLoading(show) {
|
||
const loading = document.getElementById('stream-modal-loading');
|
||
const form = document.getElementById('stream-form');
|
||
const footer = document.querySelector('#stream-modal .modal-footer');
|
||
if (loading) loading.style.display = show ? '' : 'none';
|
||
if (form) form.style.display = show ? 'none' : '';
|
||
if (footer) footer.style.visibility = show ? 'hidden' : '';
|
||
}
|
||
|
||
export async function closeStreamModal() {
|
||
await streamModal.close();
|
||
}
|
||
|
||
async function validateStaticImage() {
|
||
const source = document.getElementById('stream-image-source').value.trim();
|
||
const previewContainer = document.getElementById('stream-image-preview-container');
|
||
const previewImg = document.getElementById('stream-image-preview');
|
||
const infoEl = document.getElementById('stream-image-info');
|
||
const statusEl = document.getElementById('stream-image-validation-status');
|
||
|
||
if (!source) {
|
||
set_lastValidatedImageSource('');
|
||
previewContainer.style.display = 'none';
|
||
statusEl.style.display = 'none';
|
||
return;
|
||
}
|
||
|
||
if (source === _lastValidatedImageSource) return;
|
||
|
||
statusEl.textContent = t('streams.validate_image.validating');
|
||
statusEl.className = 'validation-status loading';
|
||
statusEl.style.display = 'block';
|
||
previewContainer.style.display = 'none';
|
||
|
||
try {
|
||
const response = await fetchWithAuth('/picture-sources/validate-image', {
|
||
method: 'POST',
|
||
body: JSON.stringify({ image_source: source }),
|
||
});
|
||
const data = await response.json();
|
||
|
||
set_lastValidatedImageSource(source);
|
||
if (data.valid) {
|
||
previewImg.src = data.preview;
|
||
previewImg.style.cursor = 'pointer';
|
||
previewImg.onclick = () => openFullImageLightbox(source);
|
||
infoEl.textContent = `${data.width} × ${data.height} px`;
|
||
previewContainer.style.display = '';
|
||
statusEl.textContent = t('streams.validate_image.valid');
|
||
statusEl.className = 'validation-status success';
|
||
} else {
|
||
previewContainer.style.display = 'none';
|
||
statusEl.textContent = `${t('streams.validate_image.invalid')}: ${data.error}`;
|
||
statusEl.className = 'validation-status error';
|
||
}
|
||
} catch (err) {
|
||
previewContainer.style.display = 'none';
|
||
statusEl.textContent = `${t('streams.validate_image.invalid')}: ${err.message}`;
|
||
statusEl.className = 'validation-status error';
|
||
}
|
||
}
|
||
|
||
// ===== Picture Source Test =====
|
||
|
||
export async function showTestStreamModal(streamId) {
|
||
set_currentTestStreamId(streamId);
|
||
restoreStreamTestDuration();
|
||
|
||
testStreamModal.open();
|
||
setupBackdropClose(testStreamModal.el, () => closeTestStreamModal());
|
||
}
|
||
|
||
export function closeTestStreamModal() {
|
||
testStreamModal.forceClose();
|
||
set_currentTestStreamId(null);
|
||
}
|
||
|
||
export function updateStreamTestDuration(value) {
|
||
document.getElementById('test-stream-duration-value').textContent = value;
|
||
localStorage.setItem('lastStreamTestDuration', value);
|
||
}
|
||
|
||
function restoreStreamTestDuration() {
|
||
const saved = localStorage.getItem('lastStreamTestDuration') || '5';
|
||
document.getElementById('test-stream-duration').value = saved;
|
||
document.getElementById('test-stream-duration-value').textContent = saved;
|
||
}
|
||
|
||
export function runStreamTest() {
|
||
if (!_currentTestStreamId) return;
|
||
const captureDuration = parseFloat(document.getElementById('test-stream-duration').value);
|
||
|
||
_runTestViaWS(
|
||
`/picture-sources/${_currentTestStreamId}/test/ws`,
|
||
{ duration: captureDuration },
|
||
null,
|
||
captureDuration,
|
||
);
|
||
}
|
||
|
||
// ===== PP Template Test =====
|
||
|
||
export async function showTestPPTemplateModal(templateId) {
|
||
set_currentTestPPTemplateId(templateId);
|
||
restorePPTestDuration();
|
||
|
||
const select = document.getElementById('test-pp-source-stream');
|
||
select.innerHTML = '';
|
||
if (_cachedStreams.length === 0) {
|
||
try {
|
||
await streamsCache.fetch();
|
||
} catch (e) { console.warn('Could not load streams for PP test:', e); }
|
||
}
|
||
for (const s of _cachedStreams) {
|
||
const opt = document.createElement('option');
|
||
opt.value = s.id;
|
||
opt.textContent = s.name;
|
||
select.appendChild(opt);
|
||
}
|
||
const lastStream = localStorage.getItem('lastPPTestStreamId');
|
||
if (lastStream && _cachedStreams.find(s => s.id === lastStream)) {
|
||
select.value = lastStream;
|
||
}
|
||
|
||
// EntitySelect for source stream picker
|
||
if (_ppTestSourceEntitySelect) _ppTestSourceEntitySelect.destroy();
|
||
_ppTestSourceEntitySelect = new EntitySelect({
|
||
target: select,
|
||
getItems: () => _cachedStreams.map(s => ({
|
||
value: s.id,
|
||
label: s.name,
|
||
icon: getPictureSourceIcon(s.stream_type),
|
||
})),
|
||
placeholder: t('palette.search'),
|
||
});
|
||
|
||
testPPTemplateModal.open();
|
||
setupBackdropClose(testPPTemplateModal.el, () => closeTestPPTemplateModal());
|
||
}
|
||
|
||
export function closeTestPPTemplateModal() {
|
||
testPPTemplateModal.forceClose();
|
||
set_currentTestPPTemplateId(null);
|
||
}
|
||
|
||
export function updatePPTestDuration(value) {
|
||
document.getElementById('test-pp-duration-value').textContent = value;
|
||
localStorage.setItem('lastPPTestDuration', value);
|
||
}
|
||
|
||
function restorePPTestDuration() {
|
||
const saved = localStorage.getItem('lastPPTestDuration') || '5';
|
||
document.getElementById('test-pp-duration').value = saved;
|
||
document.getElementById('test-pp-duration-value').textContent = saved;
|
||
}
|
||
|
||
export function runPPTemplateTest() {
|
||
if (!_currentTestPPTemplateId) return;
|
||
const sourceStreamId = document.getElementById('test-pp-source-stream').value;
|
||
if (!sourceStreamId) { showToast(t('postprocessing.test.error.no_stream'), 'error'); return; }
|
||
localStorage.setItem('lastPPTestStreamId', sourceStreamId);
|
||
|
||
const captureDuration = parseFloat(document.getElementById('test-pp-duration').value);
|
||
|
||
_runTestViaWS(
|
||
`/postprocessing-templates/${_currentTestPPTemplateId}/test/ws`,
|
||
{ duration: captureDuration, source_stream_id: sourceStreamId },
|
||
null,
|
||
captureDuration,
|
||
);
|
||
}
|
||
|
||
// ===== PP Templates =====
|
||
|
||
async function loadAvailableFilters() {
|
||
await filtersCache.fetch();
|
||
}
|
||
|
||
async function loadPPTemplates() {
|
||
try {
|
||
if (_availableFilters.length === 0) await filtersCache.fetch();
|
||
await ppTemplatesCache.fetch();
|
||
renderPictureSourcesList(_cachedStreams);
|
||
} catch (error) {
|
||
console.error('Error loading PP templates:', error);
|
||
}
|
||
}
|
||
|
||
function _getFilterName(filterId) {
|
||
const key = 'filters.' + filterId;
|
||
const translated = t(key);
|
||
if (translated === key) {
|
||
const def = _availableFilters.find(f => f.filter_id === filterId);
|
||
return def ? def.filter_name : filterId;
|
||
}
|
||
return translated;
|
||
}
|
||
|
||
function _getStripFilterName(filterId) {
|
||
const key = 'filters.' + filterId;
|
||
const translated = t(key);
|
||
if (translated === key) {
|
||
const def = _stripFilters.find(f => f.filter_id === filterId);
|
||
return def ? def.filter_name : filterId;
|
||
}
|
||
return translated;
|
||
}
|
||
|
||
// ── PP FilterListManager instance ──
|
||
const ppFilterManager = new FilterListManager({
|
||
getFilters: () => _modalFilters,
|
||
getFilterDefs: () => _availableFilters,
|
||
getFilterName: _getFilterName,
|
||
selectId: 'pp-add-filter-select',
|
||
containerId: 'pp-filter-list',
|
||
prefix: '',
|
||
editingIdInputId: 'pp-template-id',
|
||
selfRefFilterId: 'filter_template',
|
||
autoNameFn: () => _autoGeneratePPTemplateName(),
|
||
initDrag: _initFilterDragForContainer,
|
||
initPaletteGrids: _initFilterPaletteGrids,
|
||
});
|
||
|
||
// _renderFilterListGeneric has been replaced by FilterListManager.render()
|
||
|
||
/** Stored IconSelect instances for filter option selects (keyed by select element id). */
|
||
const _filterOptionIconSelects = {};
|
||
|
||
function _paletteSwatchHTML(hexStr) {
|
||
const hexColors = hexStr.split(',').map(s => s.trim());
|
||
if (hexColors.length === 1) {
|
||
return `<span style="display:inline-block;width:60px;height:14px;border-radius:3px;background:${hexColors[0]}"></span>`;
|
||
}
|
||
const stops = hexColors.map((c, i) => `${c} ${(i / (hexColors.length - 1) * 100).toFixed(0)}%`).join(', ');
|
||
return `<span style="display:inline-block;width:60px;height:14px;border-radius:3px;background:linear-gradient(to right,${stops})"></span>`;
|
||
}
|
||
|
||
function _initFilterPaletteGrids(container) {
|
||
// Palette-colored grids (e.g. palette quantization preset)
|
||
container.querySelectorAll('select[data-palette-grid]').forEach(sel => {
|
||
if (_filterOptionIconSelects[sel.id]) _filterOptionIconSelects[sel.id].destroy();
|
||
try {
|
||
const choices = JSON.parse(sel.dataset.paletteGrid);
|
||
const items = choices.map(c => ({
|
||
value: c.value,
|
||
label: c.label,
|
||
icon: _paletteSwatchHTML(c.colors || ''),
|
||
}));
|
||
_filterOptionIconSelects[sel.id] = new IconSelect({ target: sel, items, columns: 2 });
|
||
} catch { /* ignore parse errors */ }
|
||
});
|
||
// Template reference selects → EntitySelect (searchable palette)
|
||
container.querySelectorAll('select[data-entity-select]').forEach(sel => {
|
||
if (_filterOptionIconSelects[sel.id]) _filterOptionIconSelects[sel.id].destroy();
|
||
const icon = sel.dataset.entitySelect === 'template' ? ICON_PP_TEMPLATE : ICON_CSPT;
|
||
_filterOptionIconSelects[sel.id] = new EntitySelect({
|
||
target: sel,
|
||
getItems: () => Array.from(sel.options).map(opt => ({
|
||
value: opt.value,
|
||
label: opt.textContent,
|
||
icon,
|
||
})),
|
||
placeholder: t('palette.search'),
|
||
});
|
||
});
|
||
}
|
||
|
||
export function renderModalFilterList() {
|
||
ppFilterManager.render();
|
||
}
|
||
|
||
export function renderCSPTModalFilterList() {
|
||
csptFilterManager.render();
|
||
}
|
||
|
||
/* ── Generic filter drag-and-drop reordering ── */
|
||
|
||
const _FILTER_DRAG_THRESHOLD = 5;
|
||
const _FILTER_SCROLL_EDGE = 60;
|
||
const _FILTER_SCROLL_SPEED = 12;
|
||
let _filterDragState = null;
|
||
|
||
function _initFilterDragForContainer(containerId, filtersArr, rerenderFn) {
|
||
const container = document.getElementById(containerId);
|
||
if (!container) return;
|
||
|
||
container.addEventListener('pointerdown', (e) => {
|
||
const handle = e.target.closest('.pp-filter-drag-handle');
|
||
if (!handle) return;
|
||
const card = handle.closest('.pp-filter-card');
|
||
if (!card) return;
|
||
e.preventDefault();
|
||
e.stopPropagation();
|
||
|
||
const fromIndex = parseInt(card.dataset.filterIndex, 10);
|
||
_filterDragState = {
|
||
card,
|
||
container,
|
||
startY: e.clientY,
|
||
started: false,
|
||
clone: null,
|
||
placeholder: null,
|
||
offsetY: 0,
|
||
fromIndex,
|
||
scrollRaf: null,
|
||
filtersArr,
|
||
rerenderFn,
|
||
};
|
||
|
||
const onMove = (ev) => _onFilterDragMove(ev);
|
||
const onUp = () => {
|
||
document.removeEventListener('pointermove', onMove);
|
||
document.removeEventListener('pointerup', onUp);
|
||
_onFilterDragEnd();
|
||
};
|
||
document.addEventListener('pointermove', onMove);
|
||
document.addEventListener('pointerup', onUp);
|
||
});
|
||
}
|
||
|
||
function _onFilterDragMove(e) {
|
||
const ds = _filterDragState;
|
||
if (!ds) return;
|
||
|
||
if (!ds.started) {
|
||
if (Math.abs(e.clientY - ds.startY) < _FILTER_DRAG_THRESHOLD) return;
|
||
_startFilterDrag(ds, e);
|
||
}
|
||
|
||
// Position clone at pointer
|
||
ds.clone.style.top = (e.clientY - ds.offsetY) + 'px';
|
||
|
||
// Find drop target by vertical midpoint
|
||
const cards = ds.container.querySelectorAll('.pp-filter-card');
|
||
for (const card of cards) {
|
||
if (card.style.display === 'none') continue;
|
||
const r = card.getBoundingClientRect();
|
||
if (e.clientY >= r.top && e.clientY <= r.bottom) {
|
||
const before = e.clientY < r.top + r.height / 2;
|
||
if (card === ds.lastTarget && before === ds.lastBefore) break;
|
||
ds.lastTarget = card;
|
||
ds.lastBefore = before;
|
||
if (before) {
|
||
ds.container.insertBefore(ds.placeholder, card);
|
||
} else {
|
||
ds.container.insertBefore(ds.placeholder, card.nextSibling);
|
||
}
|
||
break;
|
||
}
|
||
}
|
||
|
||
// Auto-scroll near viewport edges
|
||
_filterAutoScroll(e.clientY, ds);
|
||
}
|
||
|
||
function _startFilterDrag(ds, e) {
|
||
ds.started = true;
|
||
const rect = ds.card.getBoundingClientRect();
|
||
|
||
// Clone for visual feedback
|
||
const clone = ds.card.cloneNode(true);
|
||
clone.className = ds.card.className + ' pp-filter-drag-clone';
|
||
clone.style.width = rect.width + 'px';
|
||
clone.style.left = rect.left + 'px';
|
||
clone.style.top = rect.top + 'px';
|
||
document.body.appendChild(clone);
|
||
ds.clone = clone;
|
||
ds.offsetY = e.clientY - rect.top;
|
||
|
||
// Placeholder
|
||
const placeholder = document.createElement('div');
|
||
placeholder.className = 'pp-filter-drag-placeholder';
|
||
placeholder.style.height = rect.height + 'px';
|
||
ds.card.parentNode.insertBefore(placeholder, ds.card);
|
||
ds.placeholder = placeholder;
|
||
|
||
// Hide original
|
||
ds.card.style.display = 'none';
|
||
document.body.classList.add('pp-filter-dragging');
|
||
}
|
||
|
||
function _onFilterDragEnd() {
|
||
const ds = _filterDragState;
|
||
_filterDragState = null;
|
||
if (!ds || !ds.started) return;
|
||
|
||
if (ds.scrollRaf) cancelAnimationFrame(ds.scrollRaf);
|
||
|
||
// Determine new index from placeholder position among children
|
||
let toIndex = 0;
|
||
for (const child of ds.container.children) {
|
||
if (child === ds.placeholder) break;
|
||
if (child.classList.contains('pp-filter-card') && child.style.display !== 'none') {
|
||
toIndex++;
|
||
}
|
||
}
|
||
|
||
// Cleanup DOM
|
||
ds.card.style.display = '';
|
||
ds.placeholder.remove();
|
||
ds.clone.remove();
|
||
document.body.classList.remove('pp-filter-dragging');
|
||
|
||
// Reorder filters array
|
||
if (toIndex !== ds.fromIndex) {
|
||
const [item] = ds.filtersArr.splice(ds.fromIndex, 1);
|
||
ds.filtersArr.splice(toIndex, 0, item);
|
||
ds.rerenderFn();
|
||
}
|
||
}
|
||
|
||
function _filterAutoScroll(clientY, ds) {
|
||
if (ds.scrollRaf) cancelAnimationFrame(ds.scrollRaf);
|
||
const modal = ds.container.closest('.modal-body');
|
||
if (!modal) return;
|
||
const rect = modal.getBoundingClientRect();
|
||
let speed = 0;
|
||
if (clientY < rect.top + _FILTER_SCROLL_EDGE) {
|
||
speed = -_FILTER_SCROLL_SPEED;
|
||
} else if (clientY > rect.bottom - _FILTER_SCROLL_EDGE) {
|
||
speed = _FILTER_SCROLL_SPEED;
|
||
}
|
||
if (speed === 0) return;
|
||
const scroll = () => {
|
||
modal.scrollTop += speed;
|
||
ds.scrollRaf = requestAnimationFrame(scroll);
|
||
};
|
||
ds.scrollRaf = requestAnimationFrame(scroll);
|
||
}
|
||
|
||
// _addFilterGeneric and _updateFilterOptionGeneric have been replaced by FilterListManager methods
|
||
|
||
// ── PP filter actions (delegate to ppFilterManager) ──
|
||
export function addFilterFromSelect() { ppFilterManager.addFromSelect(); }
|
||
export function toggleFilterExpand(index) { ppFilterManager.toggleExpand(index); }
|
||
export function removeFilter(index) { ppFilterManager.remove(index); }
|
||
export function moveFilter(index, direction) { ppFilterManager.move(index, direction); }
|
||
export function updateFilterOption(filterIndex, optionKey, value) { ppFilterManager.updateOption(filterIndex, optionKey, value); }
|
||
|
||
// ── CSPT filter actions (delegate to csptFilterManager) ──
|
||
export function csptAddFilterFromSelect() { csptFilterManager.addFromSelect(); }
|
||
export function csptToggleFilterExpand(index) { csptFilterManager.toggleExpand(index); }
|
||
export function csptRemoveFilter(index) { csptFilterManager.remove(index); }
|
||
export function csptUpdateFilterOption(filterIndex, optionKey, value) { csptFilterManager.updateOption(filterIndex, optionKey, value); }
|
||
|
||
function collectFilters() {
|
||
return ppFilterManager.collect();
|
||
}
|
||
|
||
function _autoGeneratePPTemplateName() {
|
||
if (_ppTemplateNameManuallyEdited) return;
|
||
if (document.getElementById('pp-template-id').value) return;
|
||
const nameInput = document.getElementById('pp-template-name');
|
||
if (_modalFilters.length > 0) {
|
||
const filterNames = _modalFilters.map(f => _getFilterName(f.filter_id)).join(' + ');
|
||
nameInput.value = filterNames;
|
||
} else {
|
||
nameInput.value = '';
|
||
}
|
||
}
|
||
|
||
export async function showAddPPTemplateModal(cloneData = null) {
|
||
if (_availableFilters.length === 0) await loadAvailableFilters();
|
||
|
||
document.getElementById('pp-template-modal-title').innerHTML = `${ICON_PP_TEMPLATE} ${t('postprocessing.add')}`;
|
||
document.getElementById('pp-template-form').reset();
|
||
document.getElementById('pp-template-id').value = '';
|
||
document.getElementById('pp-template-error').style.display = 'none';
|
||
|
||
if (cloneData) {
|
||
set_modalFilters((cloneData.filters || []).map(fi => ({
|
||
filter_id: fi.filter_id,
|
||
options: { ...fi.options },
|
||
})));
|
||
set_ppTemplateNameManuallyEdited(true);
|
||
} else {
|
||
set_modalFilters([]);
|
||
set_ppTemplateNameManuallyEdited(false);
|
||
}
|
||
document.getElementById('pp-template-name').oninput = () => { set_ppTemplateNameManuallyEdited(true); };
|
||
|
||
ppFilterManager.populateSelect(() => addFilterFromSelect());
|
||
renderModalFilterList();
|
||
|
||
// Pre-fill from clone data after form is set up
|
||
if (cloneData) {
|
||
document.getElementById('pp-template-name').value = (cloneData.name || '') + ' (Copy)';
|
||
document.getElementById('pp-template-description').value = cloneData.description || '';
|
||
}
|
||
|
||
// Tags
|
||
if (_ppTemplateTagsInput) { _ppTemplateTagsInput.destroy(); _ppTemplateTagsInput = null; }
|
||
_ppTemplateTagsInput = new TagInput(document.getElementById('pp-template-tags-container'), { placeholder: t('tags.placeholder') });
|
||
_ppTemplateTagsInput.setValue(cloneData ? (cloneData.tags || []) : []);
|
||
|
||
ppTemplateModal.open();
|
||
ppTemplateModal.snapshot();
|
||
}
|
||
|
||
export async function editPPTemplate(templateId) {
|
||
try {
|
||
if (_availableFilters.length === 0) await loadAvailableFilters();
|
||
|
||
const response = await fetchWithAuth(`/postprocessing-templates/${templateId}`);
|
||
if (!response.ok) throw new Error(`Failed to load template: ${response.status}`);
|
||
const tmpl = await response.json();
|
||
|
||
document.getElementById('pp-template-modal-title').innerHTML = `${ICON_PP_TEMPLATE} ${t('postprocessing.edit')}`;
|
||
document.getElementById('pp-template-id').value = templateId;
|
||
document.getElementById('pp-template-name').value = tmpl.name;
|
||
document.getElementById('pp-template-description').value = tmpl.description || '';
|
||
document.getElementById('pp-template-error').style.display = 'none';
|
||
|
||
set_modalFilters((tmpl.filters || []).map(fi => ({
|
||
filter_id: fi.filter_id,
|
||
options: { ...fi.options },
|
||
})));
|
||
|
||
ppFilterManager.populateSelect(() => addFilterFromSelect());
|
||
renderModalFilterList();
|
||
|
||
// Tags
|
||
if (_ppTemplateTagsInput) { _ppTemplateTagsInput.destroy(); _ppTemplateTagsInput = null; }
|
||
_ppTemplateTagsInput = new TagInput(document.getElementById('pp-template-tags-container'), { placeholder: t('tags.placeholder') });
|
||
_ppTemplateTagsInput.setValue(tmpl.tags || []);
|
||
|
||
ppTemplateModal.open();
|
||
ppTemplateModal.snapshot();
|
||
} catch (error) {
|
||
console.error('Error loading PP template:', error);
|
||
showToast(t('postprocessing.error.load') + ': ' + error.message, 'error');
|
||
}
|
||
}
|
||
|
||
export async function savePPTemplate() {
|
||
const templateId = document.getElementById('pp-template-id').value;
|
||
const name = document.getElementById('pp-template-name').value.trim();
|
||
const description = document.getElementById('pp-template-description').value.trim();
|
||
const errorEl = document.getElementById('pp-template-error');
|
||
|
||
if (!name) { showToast(t('postprocessing.error.required'), 'error'); return; }
|
||
|
||
const payload = { name, filters: collectFilters(), description: description || null, tags: _ppTemplateTagsInput ? _ppTemplateTagsInput.getValue() : [] };
|
||
|
||
try {
|
||
let response;
|
||
if (templateId) {
|
||
response = await fetchWithAuth(`/postprocessing-templates/${templateId}`, { method: 'PUT', body: JSON.stringify(payload) });
|
||
} else {
|
||
response = await fetchWithAuth('/postprocessing-templates', { method: 'POST', body: JSON.stringify(payload) });
|
||
}
|
||
|
||
if (!response.ok) {
|
||
const error = await response.json();
|
||
throw new Error(error.detail || error.message || 'Failed to save template');
|
||
}
|
||
|
||
showToast(templateId ? t('postprocessing.updated') : t('postprocessing.created'), 'success');
|
||
ppTemplateModal.forceClose();
|
||
ppTemplatesCache.invalidate();
|
||
await loadPPTemplates();
|
||
} catch (error) {
|
||
console.error('Error saving PP template:', error);
|
||
errorEl.textContent = error.message;
|
||
errorEl.style.display = 'block';
|
||
}
|
||
}
|
||
|
||
// ===== Clone functions =====
|
||
|
||
export async function cloneStream(streamId) {
|
||
try {
|
||
const resp = await fetchWithAuth(`/picture-sources/${streamId}`);
|
||
if (!resp.ok) throw new Error('Failed to load stream');
|
||
const stream = await resp.json();
|
||
showAddStreamModal(stream.stream_type, stream);
|
||
} catch (error) {
|
||
if (error.isAuth) return;
|
||
console.error('Failed to clone stream:', error);
|
||
showToast(t('stream.error.clone_picture_failed'), 'error');
|
||
}
|
||
}
|
||
|
||
export async function cloneCaptureTemplate(templateId) {
|
||
try {
|
||
const resp = await fetchWithAuth(`/capture-templates/${templateId}`);
|
||
if (!resp.ok) throw new Error('Failed to load template');
|
||
const tmpl = await resp.json();
|
||
showAddTemplateModal(tmpl);
|
||
} catch (error) {
|
||
if (error.isAuth) return;
|
||
console.error('Failed to clone capture template:', error);
|
||
showToast(t('stream.error.clone_capture_failed'), 'error');
|
||
}
|
||
}
|
||
|
||
export async function clonePPTemplate(templateId) {
|
||
try {
|
||
const resp = await fetchWithAuth(`/postprocessing-templates/${templateId}`);
|
||
if (!resp.ok) throw new Error('Failed to load template');
|
||
const tmpl = await resp.json();
|
||
showAddPPTemplateModal(tmpl);
|
||
} catch (error) {
|
||
if (error.isAuth) return;
|
||
console.error('Failed to clone PP template:', error);
|
||
showToast(t('stream.error.clone_pp_failed'), 'error');
|
||
}
|
||
}
|
||
|
||
export async function deletePPTemplate(templateId) {
|
||
const confirmed = await showConfirm(t('postprocessing.delete.confirm'));
|
||
if (!confirmed) return;
|
||
|
||
try {
|
||
const response = await fetchWithAuth(`/postprocessing-templates/${templateId}`, { method: 'DELETE' });
|
||
if (!response.ok) {
|
||
const error = await response.json();
|
||
throw new Error(error.detail || error.message || 'Failed to delete template');
|
||
}
|
||
showToast(t('postprocessing.deleted'), 'success');
|
||
ppTemplatesCache.invalidate();
|
||
await loadPPTemplates();
|
||
} catch (error) {
|
||
console.error('Error deleting PP template:', error);
|
||
showToast(t('postprocessing.error.delete') + ': ' + error.message, 'error');
|
||
}
|
||
}
|
||
|
||
export async function closePPTemplateModal() {
|
||
await ppTemplateModal.close();
|
||
}
|
||
|
||
// ===== Color Strip Processing Templates (CSPT) =====
|
||
|
||
// ── CSPT FilterListManager instance ──
|
||
const csptFilterManager = new FilterListManager({
|
||
getFilters: () => _csptModalFilters,
|
||
getFilterDefs: () => _stripFilters,
|
||
getFilterName: _getStripFilterName,
|
||
selectId: 'cspt-add-filter-select',
|
||
containerId: 'cspt-filter-list',
|
||
prefix: 'cspt',
|
||
editingIdInputId: 'cspt-id',
|
||
selfRefFilterId: 'css_filter_template',
|
||
autoNameFn: () => _autoGenerateCSPTName(),
|
||
initDrag: _initFilterDragForContainer,
|
||
initPaletteGrids: _initFilterPaletteGrids,
|
||
});
|
||
|
||
async function loadStripFilters() {
|
||
await stripFiltersCache.fetch();
|
||
}
|
||
|
||
async function loadCSPTemplates() {
|
||
try {
|
||
if (_stripFilters.length === 0) await stripFiltersCache.fetch();
|
||
await csptCache.fetch();
|
||
renderPictureSourcesList(_cachedStreams);
|
||
} catch (error) {
|
||
console.error('Error loading CSPT:', error);
|
||
}
|
||
}
|
||
|
||
function _autoGenerateCSPTName() {
|
||
if (_csptNameManuallyEdited) return;
|
||
if (document.getElementById('cspt-id').value) return;
|
||
const nameInput = document.getElementById('cspt-name');
|
||
if (_csptModalFilters.length > 0) {
|
||
const filterNames = _csptModalFilters.map(f => _getStripFilterName(f.filter_id)).join(' + ');
|
||
nameInput.value = filterNames;
|
||
} else {
|
||
nameInput.value = '';
|
||
}
|
||
}
|
||
|
||
function collectCSPTFilters() {
|
||
return csptFilterManager.collect();
|
||
}
|
||
|
||
export async function showAddCSPTModal(cloneData = null) {
|
||
if (_stripFilters.length === 0) await loadStripFilters();
|
||
|
||
document.getElementById('cspt-modal-title').innerHTML = `${ICON_CSPT} ${t('css_processing.add')}`;
|
||
document.getElementById('cspt-form').reset();
|
||
document.getElementById('cspt-id').value = '';
|
||
document.getElementById('cspt-error').style.display = 'none';
|
||
|
||
if (cloneData) {
|
||
set_csptModalFilters((cloneData.filters || []).map(fi => ({
|
||
filter_id: fi.filter_id,
|
||
options: { ...fi.options },
|
||
})));
|
||
set_csptNameManuallyEdited(true);
|
||
} else {
|
||
set_csptModalFilters([]);
|
||
set_csptNameManuallyEdited(false);
|
||
}
|
||
document.getElementById('cspt-name').oninput = () => { set_csptNameManuallyEdited(true); };
|
||
|
||
csptFilterManager.populateSelect(() => csptAddFilterFromSelect());
|
||
renderCSPTModalFilterList();
|
||
|
||
if (cloneData) {
|
||
document.getElementById('cspt-name').value = (cloneData.name || '') + ' (Copy)';
|
||
document.getElementById('cspt-description').value = cloneData.description || '';
|
||
}
|
||
|
||
if (_csptTagsInput) { _csptTagsInput.destroy(); _csptTagsInput = null; }
|
||
_csptTagsInput = new TagInput(document.getElementById('cspt-tags-container'), { placeholder: t('tags.placeholder') });
|
||
_csptTagsInput.setValue(cloneData ? (cloneData.tags || []) : []);
|
||
|
||
csptModal.open();
|
||
csptModal.snapshot();
|
||
}
|
||
|
||
export async function editCSPT(templateId) {
|
||
try {
|
||
if (_stripFilters.length === 0) await loadStripFilters();
|
||
|
||
const response = await fetchWithAuth(`/color-strip-processing-templates/${templateId}`);
|
||
if (!response.ok) throw new Error(`Failed to load template: ${response.status}`);
|
||
const tmpl = await response.json();
|
||
|
||
document.getElementById('cspt-modal-title').innerHTML = `${ICON_CSPT} ${t('css_processing.edit')}`;
|
||
document.getElementById('cspt-id').value = templateId;
|
||
document.getElementById('cspt-name').value = tmpl.name;
|
||
document.getElementById('cspt-description').value = tmpl.description || '';
|
||
document.getElementById('cspt-error').style.display = 'none';
|
||
|
||
set_csptModalFilters((tmpl.filters || []).map(fi => ({
|
||
filter_id: fi.filter_id,
|
||
options: { ...fi.options },
|
||
})));
|
||
|
||
csptFilterManager.populateSelect(() => csptAddFilterFromSelect());
|
||
renderCSPTModalFilterList();
|
||
|
||
if (_csptTagsInput) { _csptTagsInput.destroy(); _csptTagsInput = null; }
|
||
_csptTagsInput = new TagInput(document.getElementById('cspt-tags-container'), { placeholder: t('tags.placeholder') });
|
||
_csptTagsInput.setValue(tmpl.tags || []);
|
||
|
||
csptModal.open();
|
||
csptModal.snapshot();
|
||
} catch (error) {
|
||
console.error('Error loading CSPT:', error);
|
||
showToast(t('css_processing.error.load') + ': ' + error.message, 'error');
|
||
}
|
||
}
|
||
|
||
export async function saveCSPT() {
|
||
const templateId = document.getElementById('cspt-id').value;
|
||
const name = document.getElementById('cspt-name').value.trim();
|
||
const description = document.getElementById('cspt-description').value.trim();
|
||
const errorEl = document.getElementById('cspt-error');
|
||
|
||
if (!name) { showToast(t('css_processing.error.required'), 'error'); return; }
|
||
|
||
const payload = { name, filters: collectCSPTFilters(), description: description || null, tags: _csptTagsInput ? _csptTagsInput.getValue() : [] };
|
||
|
||
try {
|
||
let response;
|
||
if (templateId) {
|
||
response = await fetchWithAuth(`/color-strip-processing-templates/${templateId}`, { method: 'PUT', body: JSON.stringify(payload) });
|
||
} else {
|
||
response = await fetchWithAuth('/color-strip-processing-templates', { method: 'POST', body: JSON.stringify(payload) });
|
||
}
|
||
|
||
if (!response.ok) {
|
||
const error = await response.json();
|
||
throw new Error(error.detail || error.message || 'Failed to save template');
|
||
}
|
||
|
||
showToast(templateId ? t('css_processing.updated') : t('css_processing.created'), 'success');
|
||
csptModal.forceClose();
|
||
csptCache.invalidate();
|
||
await loadCSPTemplates();
|
||
} catch (error) {
|
||
console.error('Error saving CSPT:', error);
|
||
errorEl.textContent = error.message;
|
||
errorEl.style.display = 'block';
|
||
}
|
||
}
|
||
|
||
export async function cloneCSPT(templateId) {
|
||
try {
|
||
const resp = await fetchWithAuth(`/color-strip-processing-templates/${templateId}`);
|
||
if (!resp.ok) throw new Error('Failed to load template');
|
||
const tmpl = await resp.json();
|
||
showAddCSPTModal(tmpl);
|
||
} catch (error) {
|
||
if (error.isAuth) return;
|
||
console.error('Failed to clone CSPT:', error);
|
||
showToast(t('css_processing.error.clone_failed') + ': ' + error.message, 'error');
|
||
}
|
||
}
|
||
|
||
export async function deleteCSPT(templateId) {
|
||
const confirmed = await showConfirm(t('css_processing.delete.confirm'));
|
||
if (!confirmed) return;
|
||
|
||
try {
|
||
const response = await fetchWithAuth(`/color-strip-processing-templates/${templateId}`, { method: 'DELETE' });
|
||
if (!response.ok) {
|
||
const error = await response.json();
|
||
throw new Error(error.detail || error.message || 'Failed to delete template');
|
||
}
|
||
showToast(t('css_processing.deleted'), 'success');
|
||
csptCache.invalidate();
|
||
await loadCSPTemplates();
|
||
} catch (error) {
|
||
console.error('Error deleting CSPT:', error);
|
||
showToast(t('css_processing.error.delete') + ': ' + error.message, 'error');
|
||
}
|
||
}
|
||
|
||
export async function closeCSPTModal() {
|
||
await csptModal.close();
|
||
}
|
||
|
||
// Exported helpers used by other modules
|
||
export { updateCaptureDuration, buildTestStatsHtml };
|