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:
2026-02-11 23:28:35 +03:00
parent 9ae93497a6
commit ebec1bd16e
13 changed files with 417 additions and 100 deletions

View File

@@ -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';

View File

@@ -238,11 +238,10 @@
<div class="form-group">
<label for="stream-selector-stream" data-i18n="device.stream_selector.label">Stream:</label>
<select id="stream-selector-stream"></select>
<div id="stream-selector-info" class="stream-info-panel" style="display: none;"></div>
<small class="input-hint" data-i18n="device.stream_selector.hint">Select a stream that defines what this device captures and processes</small>
</div>
<div id="stream-selector-info" class="stream-info-panel" style="display: none;"></div>
<div class="form-group">
<label for="stream-selector-border-width" data-i18n="device.stream_settings.border_width">Border Width (px):</label>
<input type="number" id="stream-selector-border-width" min="1" max="100" value="10">
@@ -375,6 +374,11 @@
<input type="text" id="template-name" data-i18n-placeholder="templates.name.placeholder" placeholder="My Custom Template" required>
</div>
<div class="form-group">
<label for="template-description" data-i18n="templates.description.label">Description (optional):</label>
<input type="text" id="template-description" data-i18n-placeholder="templates.description.placeholder" placeholder="Describe this template..." maxlength="500">
</div>
<div class="form-group">
<label for="template-engine" data-i18n="templates.engine">Capture Engine:</label>
<select id="template-engine" onchange="onEngineChange()" required>
@@ -384,7 +388,7 @@
</div>
<div id="engine-config-section" style="display: none;">
<h3 data-i18n="templates.config">Engine Configuration</h3>
<h3 data-i18n="templates.config">Configuration</h3>
<div id="engine-config-fields"></div>
</div>
@@ -409,7 +413,7 @@
<div class="form-group">
<label data-i18n="templates.test.display">Display:</label>
<input type="hidden" id="test-template-display" value="">
<button type="button" class="btn btn-display-picker" id="test-display-picker-btn" onclick="openDisplayPicker(onTestDisplaySelected)">
<button type="button" class="btn btn-display-picker" id="test-display-picker-btn" onclick="openDisplayPicker(onTestDisplaySelected, document.getElementById('test-template-display').value)">
<span id="test-display-picker-label" data-i18n="displays.picker.select">Select display...</span>
</button>
</div>
@@ -423,7 +427,7 @@
</div>
<button type="button" class="btn btn-primary" onclick="runTemplateTest()" style="margin-top: 16px;">
<span data-i18n="templates.test.run">🧪 Run Test</span>
<span data-i18n="templates.test.run">🧪 Run</span>
</button>
</div>
@@ -447,7 +451,7 @@
</div>
<button type="button" class="btn btn-primary" onclick="runStreamTest()" style="margin-top: 16px;">
<span data-i18n="streams.test.run">🧪 Run Test</span>
<span data-i18n="streams.test.run">🧪 Run</span>
</button>
</div>
@@ -475,7 +479,7 @@
</div>
<button type="button" class="btn btn-primary" onclick="runPPTemplateTest()" style="margin-top: 16px;">
<span data-i18n="streams.test.run">🧪 Run Test</span>
<span data-i18n="streams.test.run">🧪 Run</span>
</button>
</div>
@@ -504,7 +508,7 @@
<div class="form-group">
<label data-i18n="streams.display">Display:</label>
<input type="hidden" id="stream-display-index" value="">
<button type="button" class="btn btn-display-picker" id="stream-display-picker-btn" onclick="openDisplayPicker(onStreamDisplaySelected)">
<button type="button" class="btn btn-display-picker" id="stream-display-picker-btn" onclick="openDisplayPicker(onStreamDisplaySelected, document.getElementById('stream-display-index').value)">
<span id="stream-display-picker-label" data-i18n="displays.picker.select">Select display...</span>
</button>
</div>

View File

@@ -49,7 +49,7 @@
"templates.engine.select": "Select an engine...",
"templates.engine.unavailable": "Unavailable",
"templates.engine.unavailable.hint": "This engine is not available on your system",
"templates.config": "Engine Configuration",
"templates.config": "Configuration",
"templates.config.show": "Show configuration",
"templates.config.none": "No additional configuration",
"templates.config.default": "Default",
@@ -67,7 +67,7 @@
"templates.test.display.select": "Select display...",
"templates.test.duration": "Capture Duration (s):",
"templates.test.border_width": "Border Width (px):",
"templates.test.run": "\uD83E\uDDEA Run Test",
"templates.test.run": "\uD83E\uDDEA Run",
"templates.test.running": "Running test...",
"templates.test.results.preview": "Full Capture Preview",
"templates.test.results.borders": "Border Extraction",
@@ -229,7 +229,7 @@
"streams.error.required": "Please fill in all required fields",
"streams.error.delete": "Failed to delete stream",
"streams.test.title": "Test Stream",
"streams.test.run": "🧪 Run Test",
"streams.test.run": "🧪 Run",
"streams.test.running": "Testing stream...",
"streams.test.duration": "Capture Duration (s):",
"streams.test.error.failed": "Stream test failed",

View File

@@ -49,7 +49,7 @@
"templates.engine.select": "Выберите движок...",
"templates.engine.unavailable": "Недоступен",
"templates.engine.unavailable.hint": "Этот движок недоступен в вашей системе",
"templates.config": "Конфигурация Движка",
"templates.config": "Конфигурация",
"templates.config.show": "Показать конфигурацию",
"templates.config.none": "Нет дополнительных настроек",
"templates.config.default": "По умолчанию",
@@ -67,7 +67,7 @@
"templates.test.display.select": "Выберите дисплей...",
"templates.test.duration": "Длительность Захвата (с):",
"templates.test.border_width": "Ширина Границы (px):",
"templates.test.run": "\uD83E\uDDEA Запустить Тест",
"templates.test.run": "\uD83E\uDDEA Запустить",
"templates.test.running": "Выполняется тест...",
"templates.test.results.preview": "Полный Предпросмотр Захвата",
"templates.test.results.borders": "Извлечение Границ",
@@ -229,7 +229,7 @@
"streams.error.required": "Пожалуйста, заполните все обязательные поля",
"streams.error.delete": "Не удалось удалить поток",
"streams.test.title": "Тест Потока",
"streams.test.run": "🧪 Запустить Тест",
"streams.test.run": "🧪 Запустить",
"streams.test.running": "Тестирование потока...",
"streams.test.duration": "Длительность Захвата (с):",
"streams.test.error.failed": "Тест потока не удался",

View File

@@ -37,6 +37,7 @@ body {
html {
background: var(--bg-color);
overflow-y: scroll;
}
body {
@@ -2159,23 +2160,11 @@ input:-webkit-autofill:focus {
/* Stream info panel in stream selector modal */
.stream-info-panel {
background: var(--bg-secondary, #2a2a2a);
border: 1px solid var(--border-color);
border-radius: 8px;
padding: 12px 16px;
margin-top: 12px;
padding: 4px 0 0 0;
font-size: 14px;
line-height: 1.6;
}
.stream-info-panel div {
margin-bottom: 4px;
}
.stream-info-panel strong {
margin-right: 6px;
}
/* Stream sub-tabs */
.stream-tab-bar {
display: flex;
@@ -2359,6 +2348,8 @@ input:-webkit-autofill:focus {
.layout-display-pickable {
cursor: pointer !important;
border: 2px solid var(--border-color) !important;
background: linear-gradient(135deg, rgba(128, 128, 128, 0.08), rgba(128, 128, 128, 0.03)) !important;
}
.layout-display-pickable:hover {
@@ -2366,6 +2357,12 @@ input:-webkit-autofill:focus {
border-color: var(--primary-color) !important;
}
.layout-display-pickable.selected {
border-color: var(--primary-color) !important;
box-shadow: 0 0 16px rgba(76, 175, 80, 0.5);
background: rgba(76, 175, 80, 0.12) !important;
}
/* Display picker button in forms */
.btn-display-picker {
width: 100%;