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:
11
TODO.md
11
TODO.md
@@ -20,9 +20,7 @@ Priority: `P1` quick win · `P2` moderate · `P3` large effort
|
||||
- [ ] `P1` **Rename `picture-targets` to `output-targets`** — Rename API endpoints and internal references for clarity
|
||||
- Complexity: low — mechanical rename across routes, schemas, store, frontend fetch calls; no logic changes, but many files touched (~20+), needs care with stored JSON migration
|
||||
- Impact: low-medium — improves API clarity for future integrations (OpenRGB, Art-Net)
|
||||
- [ ] `P2` **OpenRGB** — Control PC peripherals (keyboard, mouse, RAM, fans) as ambient targets
|
||||
- Complexity: medium — new device type + client (OpenRGB SDK uses a TCP socket protocol); new target processor subclass; device discovery via OpenRGB server
|
||||
- Impact: high — extends ambient lighting beyond WLED to entire PC ecosystem
|
||||
- [x] `P2` **OpenRGB** — Control PC peripherals (keyboard, mouse, RAM, fans) as ambient targets
|
||||
- [ ] `P2` **Art-Net / sACN (E1.31)** — Stage/theatrical lighting protocols, DMX controllers
|
||||
- Complexity: medium — UDP-based protocols with well-documented specs; similar architecture to DDP client; needs DMX universe/channel mapping UI
|
||||
- Impact: medium — opens stage/theatrical use case, niche but differentiating
|
||||
@@ -53,12 +51,13 @@ Priority: `P1` quick win · `P2` moderate · `P3` large effort
|
||||
- [ ] `P3` **SCRCPY capture engine** — Implement SCRCPY-based screen capture for Android devices
|
||||
- Complexity: large — external dependency on scrcpy binary; need to manage subprocess lifecycle, parse video stream (ffmpeg/AV pipe), handle device connect/disconnect
|
||||
- Impact: medium — enables phone screen mirroring to ambient lighting; appeals to mobile gaming use case
|
||||
- [ ] `P3` **Camera / webcam** — Border-sampling from camera feed for video calls or room-reactive lighting
|
||||
- Complexity: medium — OpenCV `VideoCapture` is straightforward; needs new capture source type + calibration for camera field of view; FPS/resolution config
|
||||
- Impact: low-medium — room-reactive lighting is novel but limited practical appeal
|
||||
- [x] `P3` **Camera / webcam** — Border-sampling from camera feed for video calls or room-reactive lighting
|
||||
|
||||
## UX
|
||||
|
||||
- [ ] `P2` **Tags / groups for cards** — Assign tags to devices, targets, and sources; filter and group cards by tag
|
||||
- Complexity: medium — new `tags: List[str]` field on all card entities; tag CRUD API; filter bar UI per section; tag badge rendering on cards; persistence migration
|
||||
- Impact: medium-high — essential for setups with many devices/targets; enables quick filtering (e.g. "bedroom", "desk", "gaming")
|
||||
- [ ] `P3` **PWA / mobile layout** — Mobile-first layout + "Add to Home Screen" manifest
|
||||
- Complexity: medium-large — responsive CSS overhaul for all tabs; service worker for offline caching; manifest.json; touch-friendly controls (larger tap targets, swipe gestures)
|
||||
- Impact: high — phone control is a top user request; current desktop-first layout is unusable on mobile
|
||||
|
||||
@@ -57,6 +57,9 @@ dev = [
|
||||
"black>=24.0.0",
|
||||
"ruff>=0.6.0",
|
||||
]
|
||||
camera = [
|
||||
"opencv-python-headless>=4.8.0",
|
||||
]
|
||||
# High-performance screen capture engines (Windows only)
|
||||
perf = [
|
||||
"dxcam>=0.0.5; sys_platform == 'win32'",
|
||||
|
||||
@@ -228,6 +228,7 @@ async def list_engines(_auth: AuthRequired):
|
||||
name=engine_type.upper(),
|
||||
default_config=engine_class.get_default_config(),
|
||||
available=(engine_type in available_set),
|
||||
has_own_displays=getattr(engine_class, 'HAS_OWN_DISPLAYS', False),
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
@@ -50,6 +50,7 @@ class EngineInfo(BaseModel):
|
||||
name: str = Field(description="Human-readable engine name")
|
||||
default_config: Dict = Field(description="Default configuration for this engine")
|
||||
available: bool = Field(description="Whether engine is available on this system")
|
||||
has_own_displays: bool = Field(default=False, description="Engine has its own device list (not desktop monitors)")
|
||||
|
||||
|
||||
class EngineListResponse(BaseModel):
|
||||
|
||||
@@ -12,6 +12,7 @@ from wled_controller.core.capture_engines.dxcam_engine import DXcamEngine, DXcam
|
||||
from wled_controller.core.capture_engines.bettercam_engine import BetterCamEngine, BetterCamCaptureStream
|
||||
from wled_controller.core.capture_engines.wgc_engine import WGCEngine, WGCCaptureStream
|
||||
from wled_controller.core.capture_engines.scrcpy_engine import ScrcpyEngine, ScrcpyCaptureStream
|
||||
from wled_controller.core.capture_engines.camera_engine import CameraEngine, CameraCaptureStream
|
||||
|
||||
# Auto-register available engines
|
||||
EngineRegistry.register(MSSEngine)
|
||||
@@ -19,6 +20,7 @@ EngineRegistry.register(DXcamEngine)
|
||||
EngineRegistry.register(BetterCamEngine)
|
||||
EngineRegistry.register(WGCEngine)
|
||||
EngineRegistry.register(ScrcpyEngine)
|
||||
EngineRegistry.register(CameraEngine)
|
||||
|
||||
__all__ = [
|
||||
"CaptureEngine",
|
||||
@@ -36,4 +38,6 @@ __all__ = [
|
||||
"WGCCaptureStream",
|
||||
"ScrcpyEngine",
|
||||
"ScrcpyCaptureStream",
|
||||
"CameraEngine",
|
||||
"CameraCaptureStream",
|
||||
]
|
||||
|
||||
@@ -103,6 +103,7 @@ class CaptureEngine(ABC):
|
||||
|
||||
ENGINE_TYPE: str = "base"
|
||||
ENGINE_PRIORITY: int = 0
|
||||
HAS_OWN_DISPLAYS: bool = False # True for engines with non-desktop device lists
|
||||
|
||||
@classmethod
|
||||
@abstractmethod
|
||||
|
||||
276
server/src/wled_controller/core/capture_engines/camera_engine.py
Normal file
276
server/src/wled_controller/core/capture_engines/camera_engine.py
Normal file
@@ -0,0 +1,276 @@
|
||||
"""Camera/webcam capture engine using OpenCV.
|
||||
|
||||
Captures frames from USB/integrated webcams via ``cv2.VideoCapture``.
|
||||
Each camera is exposed as a "display" through the standard
|
||||
``CaptureEngine`` / ``CaptureStream`` architecture.
|
||||
|
||||
Prerequisites (optional dependency):
|
||||
pip install opencv-python-headless>=4.8.0
|
||||
"""
|
||||
|
||||
import platform
|
||||
import sys
|
||||
from typing import Any, Dict, List, Optional
|
||||
|
||||
import numpy as np
|
||||
|
||||
from wled_controller.core.capture_engines.base import (
|
||||
CaptureEngine,
|
||||
CaptureStream,
|
||||
DisplayInfo,
|
||||
ScreenCapture,
|
||||
)
|
||||
from wled_controller.utils import get_logger
|
||||
|
||||
logger = get_logger(__name__)
|
||||
|
||||
_MAX_CAMERA_INDEX = 10 # probe indices 0..9
|
||||
_CV2_BACKENDS = {
|
||||
"auto": None,
|
||||
"dshow": 700, # cv2.CAP_DSHOW
|
||||
"msmf": 1400, # cv2.CAP_MSMF
|
||||
"v4l2": 200, # cv2.CAP_V4L2
|
||||
}
|
||||
|
||||
|
||||
def _get_default_backend():
|
||||
"""Return the best default backend for the current platform."""
|
||||
if sys.platform == "win32":
|
||||
return "dshow" # faster open than MSMF on Windows
|
||||
return "auto"
|
||||
|
||||
|
||||
def _cv2_backend_id(backend_name: str) -> Optional[int]:
|
||||
"""Convert a backend name string to cv2 API preference constant."""
|
||||
return _CV2_BACKENDS.get(backend_name)
|
||||
|
||||
|
||||
def _get_camera_friendly_names() -> Dict[int, str]:
|
||||
"""Get friendly names for cameras from OS.
|
||||
|
||||
On Windows, queries WMI for PnP camera devices.
|
||||
Returns a dict mapping sequential index → friendly name.
|
||||
"""
|
||||
if platform.system() != "Windows":
|
||||
return {}
|
||||
|
||||
try:
|
||||
import wmi
|
||||
c = wmi.WMI()
|
||||
cameras = c.query(
|
||||
"SELECT Name FROM Win32_PnPEntity WHERE PNPClass = 'Camera'"
|
||||
)
|
||||
return {i: cam.Name for i, cam in enumerate(cameras)}
|
||||
except Exception as e:
|
||||
logger.debug(f"WMI camera enumeration failed: {e}")
|
||||
return {}
|
||||
|
||||
|
||||
def _enumerate_cameras(backend_name: str = "auto") -> List[Dict[str, Any]]:
|
||||
"""Probe camera indices and return metadata for each available camera.
|
||||
|
||||
Returns a list of dicts: {cv2_index, name, width, height, fps}.
|
||||
"""
|
||||
try:
|
||||
import cv2
|
||||
except ImportError:
|
||||
return []
|
||||
|
||||
backend_id = _cv2_backend_id(backend_name)
|
||||
friendly_names = _get_camera_friendly_names()
|
||||
|
||||
cameras: List[Dict[str, Any]] = []
|
||||
sequential_idx = 0
|
||||
|
||||
for i in range(_MAX_CAMERA_INDEX):
|
||||
if backend_id is not None:
|
||||
cap = cv2.VideoCapture(i, backend_id)
|
||||
else:
|
||||
cap = cv2.VideoCapture(i)
|
||||
|
||||
if not cap.isOpened():
|
||||
cap.release()
|
||||
continue
|
||||
|
||||
width = int(cap.get(cv2.CAP_PROP_FRAME_WIDTH))
|
||||
height = int(cap.get(cv2.CAP_PROP_FRAME_HEIGHT))
|
||||
fps = cap.get(cv2.CAP_PROP_FPS) or 30.0
|
||||
|
||||
name = friendly_names.get(sequential_idx, f"Camera {sequential_idx}")
|
||||
cap.release()
|
||||
|
||||
cameras.append({
|
||||
"cv2_index": i,
|
||||
"name": name,
|
||||
"width": width,
|
||||
"height": height,
|
||||
"fps": fps,
|
||||
})
|
||||
sequential_idx += 1
|
||||
|
||||
return cameras
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# CaptureStream
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class CameraCaptureStream(CaptureStream):
|
||||
"""OpenCV-based webcam capture stream.
|
||||
|
||||
Synchronous capture like MSSEngine — ``capture_frame()`` grabs
|
||||
one frame per call. OpenCV's internal buffering keeps latency low.
|
||||
"""
|
||||
|
||||
def __init__(self, display_index: int, config: Dict[str, Any]):
|
||||
super().__init__(display_index, config)
|
||||
self._cap = None
|
||||
|
||||
def initialize(self) -> None:
|
||||
if self._initialized:
|
||||
return
|
||||
|
||||
import cv2
|
||||
|
||||
backend_name = self.config.get("camera_backend", _get_default_backend())
|
||||
|
||||
# Enumerate to resolve display_index → actual cv2 camera index
|
||||
cameras = _enumerate_cameras(backend_name)
|
||||
if not cameras:
|
||||
raise RuntimeError(
|
||||
"No cameras found. Ensure a webcam is connected."
|
||||
)
|
||||
if self.display_index >= len(cameras):
|
||||
raise RuntimeError(
|
||||
f"Camera index {self.display_index} out of range "
|
||||
f"(found {len(cameras)} camera(s))"
|
||||
)
|
||||
|
||||
camera = cameras[self.display_index]
|
||||
cv2_index = camera["cv2_index"]
|
||||
|
||||
# Open the camera
|
||||
backend_id = _cv2_backend_id(backend_name)
|
||||
if backend_id is not None:
|
||||
self._cap = cv2.VideoCapture(cv2_index, backend_id)
|
||||
else:
|
||||
self._cap = cv2.VideoCapture(cv2_index)
|
||||
|
||||
if not self._cap.isOpened():
|
||||
raise RuntimeError(
|
||||
f"Failed to open camera {self.display_index} "
|
||||
f"(cv2 index {cv2_index})"
|
||||
)
|
||||
|
||||
# Apply optional resolution override
|
||||
res_w = self.config.get("resolution_width", 0)
|
||||
res_h = self.config.get("resolution_height", 0)
|
||||
if res_w > 0 and res_h > 0:
|
||||
self._cap.set(cv2.CAP_PROP_FRAME_WIDTH, res_w)
|
||||
self._cap.set(cv2.CAP_PROP_FRAME_HEIGHT, res_h)
|
||||
|
||||
# Test read
|
||||
ret, frame = self._cap.read()
|
||||
if not ret or frame is None:
|
||||
self._cap.release()
|
||||
self._cap = None
|
||||
raise RuntimeError(
|
||||
f"Camera {self.display_index} opened but test read failed"
|
||||
)
|
||||
|
||||
h, w = frame.shape[:2]
|
||||
self._initialized = True
|
||||
logger.info(
|
||||
f"Camera capture stream initialized "
|
||||
f"(camera={camera['name']}, cv2_idx={cv2_index}, {w}x{h})"
|
||||
)
|
||||
|
||||
def capture_frame(self) -> Optional[ScreenCapture]:
|
||||
if not self._initialized:
|
||||
self.initialize()
|
||||
|
||||
import cv2
|
||||
|
||||
ret, frame = self._cap.read()
|
||||
if not ret or frame is None:
|
||||
return None
|
||||
|
||||
# OpenCV captures BGR; convert to RGB
|
||||
rgb = cv2.cvtColor(frame, cv2.COLOR_BGR2RGB)
|
||||
h, w = rgb.shape[:2]
|
||||
|
||||
return ScreenCapture(
|
||||
image=rgb,
|
||||
width=w,
|
||||
height=h,
|
||||
display_index=self.display_index,
|
||||
)
|
||||
|
||||
def cleanup(self) -> None:
|
||||
if self._cap is not None:
|
||||
self._cap.release()
|
||||
self._cap = None
|
||||
self._initialized = False
|
||||
logger.info(f"Camera capture stream cleaned up (display={self.display_index})")
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# CaptureEngine
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class CameraEngine(CaptureEngine):
|
||||
"""Camera/webcam capture engine using OpenCV.
|
||||
|
||||
Captures frames from USB or integrated webcams.
|
||||
Each connected camera appears as a selectable "display".
|
||||
|
||||
Prerequisites:
|
||||
pip install opencv-python-headless>=4.8.0
|
||||
"""
|
||||
|
||||
ENGINE_TYPE = "camera"
|
||||
ENGINE_PRIORITY = 0
|
||||
HAS_OWN_DISPLAYS = True
|
||||
|
||||
@classmethod
|
||||
def is_available(cls) -> bool:
|
||||
try:
|
||||
import cv2 # noqa: F401
|
||||
return True
|
||||
except ImportError:
|
||||
return False
|
||||
|
||||
@classmethod
|
||||
def get_default_config(cls) -> Dict[str, Any]:
|
||||
return {
|
||||
"camera_backend": _get_default_backend(),
|
||||
"resolution_width": 0,
|
||||
"resolution_height": 0,
|
||||
}
|
||||
|
||||
@classmethod
|
||||
def get_available_displays(cls) -> List[DisplayInfo]:
|
||||
backend = _get_default_backend()
|
||||
cameras = _enumerate_cameras(backend)
|
||||
|
||||
displays = []
|
||||
for idx, cam in enumerate(cameras):
|
||||
displays.append(DisplayInfo(
|
||||
index=idx,
|
||||
name=cam["name"],
|
||||
width=cam["width"],
|
||||
height=cam["height"],
|
||||
x=idx * 500,
|
||||
y=0,
|
||||
is_primary=(idx == 0),
|
||||
refresh_rate=int(cam["fps"]),
|
||||
))
|
||||
|
||||
logger.debug(f"Camera engine detected {len(displays)} camera(s)")
|
||||
return displays
|
||||
|
||||
@classmethod
|
||||
def create_stream(
|
||||
cls, display_index: int, config: Dict[str, Any]
|
||||
) -> CameraCaptureStream:
|
||||
return CameraCaptureStream(display_index, config)
|
||||
@@ -304,6 +304,7 @@ class ScrcpyEngine(CaptureEngine):
|
||||
|
||||
ENGINE_TYPE = "scrcpy"
|
||||
ENGINE_PRIORITY = 5
|
||||
HAS_OWN_DISPLAYS = True
|
||||
|
||||
@classmethod
|
||||
def is_available(cls) -> bool:
|
||||
|
||||
@@ -723,6 +723,36 @@
|
||||
background: rgba(76, 175, 80, 0.12) !important;
|
||||
}
|
||||
|
||||
/* ── Device picker list (cameras, scrcpy) ─────────────────────── */
|
||||
|
||||
.device-picker-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.device-picker-item {
|
||||
position: relative;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.75rem;
|
||||
padding: 0.75rem 1rem;
|
||||
border: 2px solid var(--border-color);
|
||||
border-radius: 8px;
|
||||
background: linear-gradient(135deg, rgba(128, 128, 128, 0.08), rgba(128, 128, 128, 0.03));
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.device-picker-item .layout-index-label {
|
||||
position: static;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.device-picker-item .layout-display-label {
|
||||
text-align: left;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
/* ── Gradient editor ────────────────────────────────────────────── */
|
||||
|
||||
.effect-palette-preview {
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -24,7 +24,7 @@
|
||||
</div>
|
||||
<small class="input-hint" style="display:none" data-i18n="streams.display.hint">Which screen to capture</small>
|
||||
<input type="hidden" id="stream-display-index" value="">
|
||||
<button type="button" class="btn btn-display-picker" id="stream-display-picker-btn" onclick="openDisplayPicker(onStreamDisplaySelected, document.getElementById('stream-display-index').value, document.getElementById('stream-capture-template').selectedOptions[0]?.dataset?.engineType)">
|
||||
<button type="button" class="btn btn-display-picker" id="stream-display-picker-btn" onclick="openDisplayPicker(onStreamDisplaySelected, document.getElementById('stream-display-index').value, document.getElementById('stream-capture-template').selectedOptions[0]?.dataset?.hasOwnDisplays === '1' ? document.getElementById('stream-capture-template').selectedOptions[0].dataset.engineType : null)">
|
||||
<span id="stream-display-picker-label" data-i18n="displays.picker.select">Select display...</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
@@ -9,7 +9,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, document.getElementById('test-template-display').value, window.currentTestingTemplate?.engine_type)">
|
||||
<button type="button" class="btn btn-display-picker" id="test-display-picker-btn" onclick="openDisplayPicker(onTestDisplaySelected, document.getElementById('test-template-display').value, window._engineHasOwnDisplays?.(window.currentTestingTemplate?.engine_type) ? window.currentTestingTemplate.engine_type : null)">
|
||||
<span id="test-display-picker-label" data-i18n="displays.picker.select">Select display...</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
Reference in New Issue
Block a user