Add BetterCam engine, UI polish, and bug fixes
- Add BetterCam capture engine (DXGI Desktop Duplication, priority 4) - Fix missing picture_stream_id in get_device endpoint - Fix template delete validation to check streams instead of devices - Add description field to capture engine template UI - Default template name changed to "Default" with descriptive text - Display picker highlights selected display instead of primary - Fix modals closing when dragging text selection outside dialog - Rename "Engine Configuration" to "Configuration", hide when empty - Rename "Run Test" to "Run" across all test buttons - Always reserve space for vertical scrollbar - Redesign Stream Settings info panel with pill-style props - Fix processed stream showing internal ID instead of stream name - Update en/ru locale files Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -2,6 +2,25 @@ const API_BASE = '/api/v1';
|
||||
let refreshInterval = null;
|
||||
let apiKey = null;
|
||||
|
||||
// Backdrop click helper: only closes modal if both mousedown and mouseup were on the backdrop itself.
|
||||
// Prevents accidental close when user drags text selection outside the dialog.
|
||||
function setupBackdropClose(modal, closeFn) {
|
||||
// Guard against duplicate listeners when called on every modal open
|
||||
if (modal._backdropCloseSetup) {
|
||||
modal._backdropCloseFn = closeFn;
|
||||
return;
|
||||
}
|
||||
modal._backdropCloseFn = closeFn;
|
||||
let mouseDownTarget = null;
|
||||
modal.addEventListener('mousedown', (e) => { mouseDownTarget = e.target; });
|
||||
modal.addEventListener('mouseup', (e) => {
|
||||
if (mouseDownTarget === modal && e.target === modal && modal._backdropCloseFn) modal._backdropCloseFn();
|
||||
mouseDownTarget = null;
|
||||
});
|
||||
modal.onclick = null;
|
||||
modal._backdropCloseSetup = true;
|
||||
}
|
||||
|
||||
// Track logged errors to avoid console spam
|
||||
const loggedErrors = new Map(); // deviceId -> { errorCount, lastError }
|
||||
|
||||
@@ -90,9 +109,11 @@ document.addEventListener('keydown', (e) => {
|
||||
|
||||
// Display picker lightbox
|
||||
let _displayPickerCallback = null;
|
||||
let _displayPickerSelectedIndex = null;
|
||||
|
||||
function openDisplayPicker(callback) {
|
||||
function openDisplayPicker(callback, selectedIndex) {
|
||||
_displayPickerCallback = callback;
|
||||
_displayPickerSelectedIndex = (selectedIndex !== undefined && selectedIndex !== null && selectedIndex !== '') ? Number(selectedIndex) : null;
|
||||
const lightbox = document.getElementById('display-picker-lightbox');
|
||||
const canvas = document.getElementById('display-picker-canvas');
|
||||
|
||||
@@ -157,8 +178,9 @@ function renderDisplayPickerLayout(displays) {
|
||||
const widthPct = (display.width / totalWidth) * 100;
|
||||
const heightPct = (display.height / totalHeight) * 100;
|
||||
|
||||
const isSelected = _displayPickerSelectedIndex !== null && display.index === _displayPickerSelectedIndex;
|
||||
return `
|
||||
<div class="layout-display layout-display-pickable ${display.is_primary ? 'primary' : 'secondary'}"
|
||||
<div class="layout-display layout-display-pickable${isSelected ? ' selected' : ''}"
|
||||
style="left: ${leftPct}%; top: ${topPct}%; width: ${widthPct}%; height: ${heightPct}%;"
|
||||
onclick="selectDisplay(${display.index})"
|
||||
title="${t('displays.picker.click_to_select')}">
|
||||
@@ -169,7 +191,6 @@ function renderDisplayPickerLayout(displays) {
|
||||
<small>${display.width}×${display.height}</small>
|
||||
<small>${display.refresh_rate}Hz</small>
|
||||
</div>
|
||||
${display.is_primary ? '<div class="primary-indicator">★</div>' : ''}
|
||||
</div>
|
||||
`;
|
||||
}).join('');
|
||||
@@ -2450,13 +2471,7 @@ async function showAddTemplateModal() {
|
||||
// Show modal
|
||||
const modal = document.getElementById('template-modal');
|
||||
modal.style.display = 'flex';
|
||||
|
||||
// Add backdrop click handler to close modal
|
||||
modal.onclick = function(event) {
|
||||
if (event.target === modal) {
|
||||
closeTemplateModal();
|
||||
}
|
||||
};
|
||||
setupBackdropClose(modal, closeTemplateModal);
|
||||
}
|
||||
|
||||
// Edit template
|
||||
@@ -2473,6 +2488,7 @@ async function editTemplate(templateId) {
|
||||
document.getElementById('template-modal-title').textContent = t('templates.edit');
|
||||
document.getElementById('template-id').value = templateId;
|
||||
document.getElementById('template-name').value = template.name;
|
||||
document.getElementById('template-description').value = template.description || '';
|
||||
|
||||
// Load available engines
|
||||
await loadAvailableEngines();
|
||||
@@ -2494,13 +2510,7 @@ async function editTemplate(templateId) {
|
||||
// Show modal
|
||||
const modal = document.getElementById('template-modal');
|
||||
modal.style.display = 'flex';
|
||||
|
||||
// Add backdrop click handler to close modal
|
||||
modal.onclick = function(event) {
|
||||
if (event.target === modal) {
|
||||
closeTemplateModal();
|
||||
}
|
||||
};
|
||||
setupBackdropClose(modal, closeTemplateModal);
|
||||
} catch (error) {
|
||||
console.error('Error loading template:', error);
|
||||
showToast(t('templates.error.load') + ': ' + error.message, 'error');
|
||||
@@ -2662,12 +2672,7 @@ async function showTestTemplateModal(templateId) {
|
||||
const modal = document.getElementById('test-template-modal');
|
||||
modal.style.display = 'flex';
|
||||
|
||||
// Add backdrop click handler to close modal
|
||||
modal.onclick = function(event) {
|
||||
if (event.target === modal) {
|
||||
closeTestTemplateModal();
|
||||
}
|
||||
};
|
||||
setupBackdropClose(modal, closeTestTemplateModal);
|
||||
}
|
||||
|
||||
// Close test template modal
|
||||
@@ -2738,7 +2743,8 @@ async function onEngineChange() {
|
||||
const defaultConfig = engine.default_config || {};
|
||||
|
||||
if (Object.keys(defaultConfig).length === 0) {
|
||||
configFields.innerHTML = `<p class="text-muted">${t('templates.config.none')}</p>`;
|
||||
configSection.style.display = 'none';
|
||||
return;
|
||||
} else {
|
||||
Object.entries(defaultConfig).forEach(([key, value]) => {
|
||||
const fieldType = typeof value === 'number' ? 'number' : 'text';
|
||||
@@ -2917,12 +2923,14 @@ async function saveTemplate() {
|
||||
return;
|
||||
}
|
||||
|
||||
const description = document.getElementById('template-description').value.trim();
|
||||
const engineConfig = collectEngineConfig();
|
||||
|
||||
const payload = {
|
||||
name,
|
||||
engine_type: engineType,
|
||||
engine_config: engineConfig
|
||||
engine_config: engineConfig,
|
||||
description: description || null
|
||||
};
|
||||
|
||||
try {
|
||||
@@ -3105,6 +3113,7 @@ function renderPictureStreamsList(streams) {
|
||||
${engineIcon} ${escapeHtml(template.name)}
|
||||
</div>
|
||||
</div>
|
||||
${template.description ? `<div class="template-config" style="opacity:0.7;">${escapeHtml(template.description)}</div>` : ''}
|
||||
<div class="stream-card-props">
|
||||
<span class="stream-card-prop" title="${t('templates.engine')}">⚙️ ${template.engine_type.toUpperCase()}</span>
|
||||
${configEntries.length > 0 ? `<span class="stream-card-prop" title="${t('templates.config.show')}">🔧 ${configEntries.length}</span>` : ''}
|
||||
@@ -3271,7 +3280,7 @@ async function showAddStreamModal(presetType) {
|
||||
const modal = document.getElementById('stream-modal');
|
||||
modal.style.display = 'flex';
|
||||
lockBody();
|
||||
modal.onclick = (e) => { if (e.target === modal) closeStreamModal(); };
|
||||
setupBackdropClose(modal, closeStreamModal);
|
||||
}
|
||||
|
||||
async function editStream(streamId) {
|
||||
@@ -3324,7 +3333,7 @@ async function editStream(streamId) {
|
||||
const modal = document.getElementById('stream-modal');
|
||||
modal.style.display = 'flex';
|
||||
lockBody();
|
||||
modal.onclick = (e) => { if (e.target === modal) closeStreamModal(); };
|
||||
setupBackdropClose(modal, closeStreamModal);
|
||||
} catch (error) {
|
||||
console.error('Error loading stream:', error);
|
||||
showToast(t('streams.error.load') + ': ' + error.message, 'error');
|
||||
@@ -3550,7 +3559,7 @@ async function showTestStreamModal(streamId) {
|
||||
const modal = document.getElementById('test-stream-modal');
|
||||
modal.style.display = 'flex';
|
||||
lockBody();
|
||||
modal.onclick = (e) => { if (e.target === modal) closeTestStreamModal(); };
|
||||
setupBackdropClose(modal, closeTestStreamModal);
|
||||
}
|
||||
|
||||
function closeTestStreamModal() {
|
||||
@@ -3636,7 +3645,7 @@ async function showTestPPTemplateModal(templateId) {
|
||||
const modal = document.getElementById('test-pp-template-modal');
|
||||
modal.style.display = 'flex';
|
||||
lockBody();
|
||||
modal.onclick = (e) => { if (e.target === modal) closeTestPPTemplateModal(); };
|
||||
setupBackdropClose(modal, closeTestPPTemplateModal);
|
||||
}
|
||||
|
||||
function closeTestPPTemplateModal() {
|
||||
@@ -3888,7 +3897,7 @@ async function showAddPPTemplateModal() {
|
||||
const modal = document.getElementById('pp-template-modal');
|
||||
modal.style.display = 'flex';
|
||||
lockBody();
|
||||
modal.onclick = (e) => { if (e.target === modal) closePPTemplateModal(); };
|
||||
setupBackdropClose(modal, closePPTemplateModal);
|
||||
}
|
||||
|
||||
async function editPPTemplate(templateId) {
|
||||
@@ -3917,7 +3926,7 @@ async function editPPTemplate(templateId) {
|
||||
const modal = document.getElementById('pp-template-modal');
|
||||
modal.style.display = 'flex';
|
||||
lockBody();
|
||||
modal.onclick = (e) => { if (e.target === modal) closePPTemplateModal(); };
|
||||
setupBackdropClose(modal, closePPTemplateModal);
|
||||
} catch (error) {
|
||||
console.error('Error loading PP template:', error);
|
||||
showToast(t('postprocessing.error.load') + ': ' + error.message, 'error');
|
||||
@@ -4066,7 +4075,7 @@ async function showStreamSelector(deviceId) {
|
||||
const modal = document.getElementById('stream-selector-modal');
|
||||
modal.style.display = 'flex';
|
||||
lockBody();
|
||||
modal.onclick = (e) => { if (e.target === modal) closeStreamSelectorModal(); };
|
||||
setupBackdropClose(modal, closeStreamSelectorModal);
|
||||
} catch (error) {
|
||||
console.error('Failed to load stream settings:', error);
|
||||
showToast('Failed to load stream settings', 'error');
|
||||
@@ -4088,17 +4097,67 @@ async function updateStreamSelectorInfo(streamId) {
|
||||
}
|
||||
const stream = await response.json();
|
||||
|
||||
let infoHtml = `<div class="stream-info-type"><strong>${t('streams.type')}</strong> ${stream.stream_type === 'raw' ? t('streams.type.raw') : t('streams.type.processed')}</div>`;
|
||||
const typeIcon = stream.stream_type === 'raw' ? '🖥️' : stream.stream_type === 'static_image' ? '🖼️' : '🎨';
|
||||
const typeName = stream.stream_type === 'raw' ? t('streams.type.raw') : stream.stream_type === 'static_image' ? t('streams.type.static_image') : t('streams.type.processed');
|
||||
|
||||
let propsHtml = '';
|
||||
if (stream.stream_type === 'raw') {
|
||||
infoHtml += `<div><strong>${t('streams.display')}</strong> ${stream.display_index ?? 0}</div>`;
|
||||
infoHtml += `<div><strong>${t('streams.target_fps')}</strong> ${stream.target_fps ?? 30}</div>`;
|
||||
} else {
|
||||
const sourceStream = _cachedStreams.find(s => s.id === stream.source_stream_id);
|
||||
infoHtml += `<div><strong>${t('streams.source')}</strong> ${sourceStream ? escapeHtml(sourceStream.name) : stream.source_stream_id}</div>`;
|
||||
let capTmplName = '';
|
||||
if (stream.capture_template_id) {
|
||||
if (!_cachedCaptureTemplates || _cachedCaptureTemplates.length === 0) {
|
||||
try {
|
||||
const ctResp = await fetchWithAuth('/capture-templates');
|
||||
if (ctResp.ok) { const d = await ctResp.json(); _cachedCaptureTemplates = d.templates || []; }
|
||||
} catch {}
|
||||
}
|
||||
if (_cachedCaptureTemplates) {
|
||||
const capTmpl = _cachedCaptureTemplates.find(t => t.id === stream.capture_template_id);
|
||||
if (capTmpl) capTmplName = escapeHtml(capTmpl.name);
|
||||
}
|
||||
}
|
||||
propsHtml = `
|
||||
<span class="stream-card-prop" title="${t('streams.display')}">🖥️ ${stream.display_index ?? 0}</span>
|
||||
<span class="stream-card-prop" title="${t('streams.target_fps')}">⚡ ${stream.target_fps ?? 30}</span>
|
||||
${capTmplName ? `<span class="stream-card-prop" title="${t('streams.capture_template')}">${capTmplName}</span>` : ''}
|
||||
`;
|
||||
} else if (stream.stream_type === 'processed') {
|
||||
if ((!_cachedStreams || _cachedStreams.length === 0) && stream.source_stream_id) {
|
||||
try {
|
||||
const streamsResp = await fetchWithAuth('/picture-streams');
|
||||
if (streamsResp.ok) { const d = await streamsResp.json(); _cachedStreams = d.streams || []; }
|
||||
} catch {}
|
||||
}
|
||||
const sourceStream = _cachedStreams ? _cachedStreams.find(s => s.id === stream.source_stream_id) : null;
|
||||
const sourceName = sourceStream ? escapeHtml(sourceStream.name) : (stream.source_stream_id || '-');
|
||||
let ppTmplName = '';
|
||||
if (stream.postprocessing_template_id) {
|
||||
if (!_cachedPPTemplates || _cachedPPTemplates.length === 0) {
|
||||
try {
|
||||
const ppResp = await fetchWithAuth('/postprocessing-templates');
|
||||
if (ppResp.ok) { const d = await ppResp.json(); _cachedPPTemplates = d.templates || []; }
|
||||
} catch {}
|
||||
}
|
||||
if (_cachedPPTemplates) {
|
||||
const ppTmpl = _cachedPPTemplates.find(p => p.id === stream.postprocessing_template_id);
|
||||
if (ppTmpl) ppTmplName = escapeHtml(ppTmpl.name);
|
||||
}
|
||||
}
|
||||
propsHtml = `
|
||||
<span class="stream-card-prop" title="${t('streams.source')}">📺 ${sourceName}</span>
|
||||
${ppTmplName ? `<span class="stream-card-prop" title="${t('streams.pp_template')}">🎨 ${ppTmplName}</span>` : ''}
|
||||
`;
|
||||
} else if (stream.stream_type === 'static_image') {
|
||||
const src = stream.image_source || '';
|
||||
propsHtml = `<span class="stream-card-prop stream-card-prop-full" title="${escapeHtml(src)}">🌐 ${escapeHtml(src)}</span>`;
|
||||
}
|
||||
|
||||
infoPanel.innerHTML = infoHtml;
|
||||
infoPanel.innerHTML = `
|
||||
<div class="stream-card-props">
|
||||
<span class="stream-card-prop" title="${t('streams.type')}">${typeIcon} ${typeName}</span>
|
||||
${propsHtml}
|
||||
</div>
|
||||
${stream.description ? `<div class="template-config" style="opacity:0.7;">${escapeHtml(stream.description)}</div>` : ''}
|
||||
`;
|
||||
infoPanel.style.display = '';
|
||||
} catch {
|
||||
infoPanel.style.display = 'none';
|
||||
|
||||
Reference in New Issue
Block a user