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:
@@ -73,7 +73,7 @@ export function createDeviceCard(device) {
|
||||
healthClass = 'health-offline';
|
||||
healthTitle = t('device.health.offline');
|
||||
if (state.device_error) healthTitle += `: ${state.device_error}`;
|
||||
healthLabel = `<span class="health-latency offline">${t('device.health.offline')}</span>`;
|
||||
healthLabel = '';
|
||||
}
|
||||
|
||||
const ledCount = state.device_led_count || device.led_count;
|
||||
|
||||
@@ -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}`;
|
||||
|
||||
@@ -327,6 +327,11 @@ export async function onEngineChange() {
|
||||
configFields.innerHTML = '';
|
||||
const defaultConfig = engine.default_config || {};
|
||||
|
||||
// Known select options for specific config keys
|
||||
const CONFIG_SELECT_OPTIONS = {
|
||||
camera_backend: ['auto', 'dshow', 'msmf', 'v4l2'],
|
||||
};
|
||||
|
||||
if (Object.keys(defaultConfig).length === 0) {
|
||||
configSection.style.display = 'none';
|
||||
return;
|
||||
@@ -335,6 +340,7 @@ export async function onEngineChange() {
|
||||
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">
|
||||
@@ -343,6 +349,10 @@ export async function onEngineChange() {
|
||||
<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}">
|
||||
`}
|
||||
@@ -387,14 +397,15 @@ function collectEngineConfig() {
|
||||
|
||||
async function loadDisplaysForTest() {
|
||||
try {
|
||||
// Use engine-specific display list when testing a scrcpy template
|
||||
// Use engine-specific display list for engines with own devices (camera, scrcpy)
|
||||
const engineType = window.currentTestingTemplate?.engine_type;
|
||||
const url = engineType === 'scrcpy'
|
||||
? `/config/displays?engine_type=scrcpy`
|
||||
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 scrcpy (devices may change); use cache for desktop
|
||||
if (!_cachedDisplays || engineType === 'scrcpy') {
|
||||
// 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();
|
||||
@@ -1420,13 +1431,15 @@ export function onStreamTypeChange() {
|
||||
|
||||
export function onStreamDisplaySelected(displayIndex, display) {
|
||||
document.getElementById('stream-display-index').value = displayIndex;
|
||||
document.getElementById('stream-display-picker-label').textContent = formatDisplayLabel(displayIndex, display);
|
||||
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;
|
||||
document.getElementById('test-display-picker-label').textContent = formatDisplayLabel(displayIndex, display);
|
||||
const engineType = window.currentTestingTemplate?.engine_type || null;
|
||||
document.getElementById('test-display-picker-label').textContent = formatDisplayLabel(displayIndex, display, engineType);
|
||||
}
|
||||
|
||||
function _autoGenerateStreamName() {
|
||||
@@ -1488,10 +1501,11 @@ export async function showAddStreamModal(presetType, cloneData = null) {
|
||||
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);
|
||||
document.getElementById('stream-capture-template').value = cloneData.capture_template_id || '';
|
||||
const fps = cloneData.target_fps ?? 30;
|
||||
document.getElementById('stream-target-fps').value = fps;
|
||||
document.getElementById('stream-target-fps-value').textContent = fps;
|
||||
@@ -1534,10 +1548,12 @@ export async function editStream(streamId) {
|
||||
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);
|
||||
document.getElementById('stream-capture-template').value = stream.capture_template_id || '';
|
||||
const fps = stream.target_fps ?? 30;
|
||||
document.getElementById('stream-target-fps').value = fps;
|
||||
document.getElementById('stream-target-fps-value').textContent = fps;
|
||||
@@ -1568,16 +1584,12 @@ async function populateStreamModalDropdowns() {
|
||||
fetchWithAuth('/postprocessing-templates'),
|
||||
]);
|
||||
|
||||
// Cache desktop displays (used as default unless engine has own displays)
|
||||
if (displaysRes.ok) {
|
||||
const displaysData = await displaysRes.json();
|
||||
displaysCache.update(displaysData.displays || []);
|
||||
}
|
||||
_streamModalDisplaysEngine = null; // desktop displays loaded
|
||||
|
||||
if (!document.getElementById('stream-display-index').value && _cachedDisplays && _cachedDisplays.length > 0) {
|
||||
const primary = _cachedDisplays.find(d => d.is_primary) || _cachedDisplays[0];
|
||||
onStreamDisplaySelected(primary.index, primary);
|
||||
}
|
||||
_streamModalDisplaysEngine = null;
|
||||
|
||||
const templateSelect = document.getElementById('stream-capture-template');
|
||||
templateSelect.innerHTML = '';
|
||||
@@ -1588,6 +1600,7 @@ async function populateStreamModalDropdowns() {
|
||||
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);
|
||||
});
|
||||
@@ -1596,6 +1609,15 @@ async function populateStreamModalDropdowns() {
|
||||
// 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 = '';
|
||||
if (streamsRes.ok) {
|
||||
@@ -1626,19 +1648,13 @@ async function populateStreamModalDropdowns() {
|
||||
}
|
||||
|
||||
_autoGenerateStreamName();
|
||||
|
||||
// If the first template is an scrcpy engine, reload displays immediately
|
||||
const firstOpt = templateSelect.selectedOptions[0];
|
||||
if (firstOpt?.dataset?.engineType === 'scrcpy') {
|
||||
await _refreshStreamDisplaysForEngine('scrcpy');
|
||||
}
|
||||
}
|
||||
|
||||
async function _onCaptureTemplateChanged() {
|
||||
const templateSelect = document.getElementById('stream-capture-template');
|
||||
const engineType = templateSelect.selectedOptions[0]?.dataset?.engineType || null;
|
||||
const needsEngineDisplays = engineType === 'scrcpy';
|
||||
const currentEngine = needsEngineDisplays ? engineType : null;
|
||||
const hasOwnDisplays = templateSelect.selectedOptions[0]?.dataset?.hasOwnDisplays === '1';
|
||||
const currentEngine = hasOwnDisplays ? engineType : null;
|
||||
|
||||
// Only refetch if the engine category actually changed
|
||||
if (currentEngine !== _streamModalDisplaysEngine) {
|
||||
|
||||
Reference in New Issue
Block a user