Add camera/webcam capture engine with engine-aware display picker

- New CameraEngine using OpenCV VideoCapture for webcam capture
- HAS_OWN_DISPLAYS class attribute on CaptureEngine base to distinguish
  engines with their own device lists from desktop monitor engines
- Display picker renders device list for cameras/scrcpy, spatial layout
  for desktop monitors
- Engine-aware display label formatting (camera name vs monitor index)
- Stream modal properly loads engine-specific displays on template change,
  edit, and clone
- Camera backend config rendered as dropdown (auto/dshow/msmf/v4l2)
- Remove offline label from device cards (healthcheck indicator suffices)

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-03-01 12:46:28 +03:00
parent b9ec509f56
commit 8fe9c6489b
14 changed files with 405 additions and 42 deletions

View File

@@ -6,6 +6,7 @@
import {
_cachedDisplays, _displayPickerCallback, _displayPickerSelectedIndex,
set_displayPickerCallback, set_displayPickerSelectedIndex, displaysCache,
availableEngines,
} from '../core/state.js';
import { t } from '../core/i18n.js';
import { fetchWithAuth } from '../core/api.js';
@@ -14,6 +15,10 @@ import { showToast } from '../core/ui.js';
/** Currently active engine type for the picker (null = desktop monitors). */
let _pickerEngineType = null;
/** Check if an engine type has its own device list (for inline onclick use). */
window._engineHasOwnDisplays = (engineType) =>
!!(engineType && availableEngines.find(e => e.type === engineType)?.has_own_displays);
export function openDisplayPicker(callback, selectedIndex, engineType = null) {
set_displayPickerCallback(callback);
set_displayPickerSelectedIndex((selectedIndex !== undefined && selectedIndex !== null && selectedIndex !== '') ? Number(selectedIndex) : null);
@@ -57,8 +62,10 @@ async function _fetchAndRenderEngineDisplays(engineType) {
if (displays.length > 0) {
renderDisplayPickerLayout(displays, engineType);
} else {
} else if (engineType === 'scrcpy') {
_renderEmptyAndroidPicker(canvas);
} else {
canvas.innerHTML = `<div class="loading">${t('displays.none')}</div>`;
}
} catch (error) {
console.error('Error fetching engine displays:', error);
@@ -143,6 +150,34 @@ export function renderDisplayPickerLayout(displays, engineType = null) {
return;
}
// Engines with own displays (camera, scrcpy) → device list layout
if (engineType) {
const items = displays.map(display => {
const isSelected = _displayPickerSelectedIndex !== null && display.index === _displayPickerSelectedIndex;
return `
<div class="device-picker-item layout-display-pickable${isSelected ? ' selected' : ''}"
onclick="selectDisplay(${display.index})"
title="${t('displays.picker.click_to_select')}">
<div class="layout-index-label">#${display.index}</div>
<div class="layout-display-label">
<strong>${display.name}</strong>
<small>${display.width}×${display.height} · ${display.refresh_rate} FPS</small>
</div>
</div>
`;
}).join('');
let html = `<div class="device-picker-list">${items}</div>`;
if (engineType === 'scrcpy') {
html += _buildAdbConnectHtml();
}
canvas.innerHTML = html;
return;
}
// Desktop monitors → positioned rectangle layout
let minX = Infinity, minY = Infinity, maxX = -Infinity, maxY = -Infinity;
displays.forEach(display => {
minX = Math.min(minX, display.x);
@@ -178,22 +213,18 @@ export function renderDisplayPickerLayout(displays, engineType = null) {
`;
}).join('');
let html = `
canvas.innerHTML = `
<div class="layout-container" style="width: 100%; padding-bottom: ${aspect * 100}%; position: relative;">
${displayElements}
</div>
`;
// Show ADB connect form below devices for scrcpy engine
if (engineType === 'scrcpy') {
html += _buildAdbConnectHtml();
}
canvas.innerHTML = html;
}
export function formatDisplayLabel(displayIndex, display) {
export function formatDisplayLabel(displayIndex, display, engineType = null) {
if (display) {
if (engineType && window._engineHasOwnDisplays(engineType)) {
return `${display.name} (${display.width}×${display.height})`;
}
return `#${display.index}: ${display.width}×${display.height}${display.is_primary ? ' (' + t('displays.badge.primary') + ')' : ''}`;
}
return `Display ${displayIndex}`;