Add ADB-based Android screen capture engine with display picker integration

New scrcpy/ADB capture engine that captures Android device screens over
ADB using screencap polling. Supports USB and WiFi ADB connections with
device auto-discovery. Engine-aware display picker shows Android devices
when scrcpy engine is selected, with inline ADB connect form for WiFi
devices.

Key changes:
- New scrcpy_engine.py using adb screencap polling (~1-2 FPS over WiFi)
- Engine-aware GET /config/displays?engine_type= API
- ADB connect/disconnect API endpoints (POST /adb/connect, /adb/disconnect)
- Display picker supports engine-specific device lists
- Stream/test modals pass engine type to display picker
- Test template handler changed to sync def to prevent event loop blocking
- Restart script merges registry PATH for newly-installed tools
- All engines (including unavailable) shown in engine list with status flag

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-02-23 18:06:15 +03:00
parent cc08bb1c19
commit 199039326b
12 changed files with 644 additions and 26 deletions

View File

@@ -8,6 +8,17 @@ foreach ($p in $procs) {
}
if ($procs) { Start-Sleep -Seconds 2 }
# Merge registry PATH with current PATH so newly-installed tools (e.g. scrcpy) are visible
$regUser = [Environment]::GetEnvironmentVariable('PATH', 'User')
if ($regUser) {
$currentDirs = $env:PATH -split ';' | ForEach-Object { $_.TrimEnd('\') }
foreach ($dir in ($regUser -split ';')) {
if ($dir -and ($currentDirs -notcontains $dir.TrimEnd('\'))) {
$env:PATH = "$env:PATH;$dir"
}
}
}
# Start server detached
Write-Host "Starting server..."
Start-Process -FilePath python -ArgumentList '-m', 'wled_controller.main' `

View File

@@ -1,10 +1,13 @@
"""System routes: health, version, displays, performance."""
"""System routes: health, version, displays, performance, ADB."""
import subprocess
import sys
from datetime import datetime
from typing import Optional
import psutil
from fastapi import APIRouter, HTTPException
from fastapi import APIRouter, HTTPException, Query
from pydantic import BaseModel
from wled_controller import __version__
from wled_controller.api.auth import AuthRequired
@@ -73,16 +76,26 @@ async def get_version():
@router.get("/api/v1/config/displays", response_model=DisplayListResponse, tags=["Config"])
async def get_displays(_: AuthRequired):
async def get_displays(
_: AuthRequired,
engine_type: Optional[str] = Query(None, description="Engine type to get displays for"),
):
"""Get list of available displays.
Returns information about all available monitors/displays that can be captured.
When ``engine_type`` is provided, returns displays specific to that engine
(e.g. ``scrcpy`` returns connected Android devices instead of desktop monitors).
"""
logger.info("Listing available displays")
logger.info(f"Listing available displays (engine_type={engine_type})")
try:
# Get available displays with all metadata (name, refresh rate, etc.)
display_dataclasses = get_available_displays()
if engine_type:
from wled_controller.core.capture_engines import EngineRegistry
engine_cls = EngineRegistry.get_engine(engine_type)
display_dataclasses = engine_cls.get_available_displays()
else:
display_dataclasses = get_available_displays()
# Convert dataclass DisplayInfo to Pydantic DisplayInfo
displays = [
@@ -106,6 +119,8 @@ async def get_displays(_: AuthRequired):
count=len(displays),
)
except ValueError as e:
raise HTTPException(status_code=400, detail=str(e))
except Exception as e:
logger.error(f"Failed to get displays: {e}")
raise HTTPException(
@@ -175,3 +190,70 @@ def get_system_performance(_: AuthRequired):
gpu=gpu,
timestamp=datetime.utcnow(),
)
# ---------------------------------------------------------------------------
# ADB helpers (for Android / scrcpy engine)
# ---------------------------------------------------------------------------
class AdbConnectRequest(BaseModel):
address: str
def _get_adb_path() -> str:
"""Get the adb binary path from the scrcpy engine's resolver."""
from wled_controller.core.capture_engines.scrcpy_engine import _get_adb
return _get_adb()
@router.post("/api/v1/adb/connect", tags=["ADB"])
async def adb_connect(_: AuthRequired, request: AdbConnectRequest):
"""Connect to a WiFi ADB device by IP address.
Appends ``:5555`` if no port is specified.
"""
address = request.address.strip()
if not address:
raise HTTPException(status_code=400, detail="Address is required")
if ":" not in address:
address = f"{address}:5555"
adb = _get_adb_path()
logger.info(f"Connecting ADB device: {address}")
try:
result = subprocess.run(
[adb, "connect", address],
capture_output=True, text=True, timeout=10,
)
output = (result.stdout + result.stderr).strip()
if "connected" in output.lower():
return {"status": "connected", "address": address, "message": output}
raise HTTPException(status_code=400, detail=output or "Connection failed")
except FileNotFoundError:
raise HTTPException(
status_code=500,
detail="adb not found on PATH. Install Android SDK Platform-Tools.",
)
except subprocess.TimeoutExpired:
raise HTTPException(status_code=504, detail="ADB connect timed out")
@router.post("/api/v1/adb/disconnect", tags=["ADB"])
async def adb_disconnect(_: AuthRequired, request: AdbConnectRequest):
"""Disconnect a WiFi ADB device."""
address = request.address.strip()
if not address:
raise HTTPException(status_code=400, detail="Address is required")
adb = _get_adb_path()
logger.info(f"Disconnecting ADB device: {address}")
try:
result = subprocess.run(
[adb, "disconnect", address],
capture_output=True, text=True, timeout=10,
)
return {"status": "disconnected", "message": result.stdout.strip()}
except FileNotFoundError:
raise HTTPException(status_code=500, detail="adb not found on PATH")
except subprocess.TimeoutExpired:
raise HTTPException(status_code=504, detail="ADB disconnect timed out")

View File

@@ -215,22 +215,23 @@ async def delete_template(
@router.get("/api/v1/capture-engines", response_model=EngineListResponse, tags=["Templates"])
async def list_engines(_auth: AuthRequired):
"""List available capture engines on this system.
"""List all registered capture engines.
Returns all registered engines that are available on the current platform.
Returns every registered engine with an ``available`` flag showing
whether it can be used on the current system.
"""
try:
available_engine_types = EngineRegistry.get_available_engines()
available_set = set(EngineRegistry.get_available_engines())
all_engines = EngineRegistry.get_all_engines()
engines = []
for engine_type in available_engine_types:
engine_class = EngineRegistry.get_engine(engine_type)
for engine_type, engine_class in all_engines.items():
engines.append(
EngineInfo(
type=engine_type,
name=engine_type.upper(),
default_config=engine_class.get_default_config(),
available=True,
available=(engine_type in available_set),
)
)
@@ -242,7 +243,7 @@ async def list_engines(_auth: AuthRequired):
@router.post("/api/v1/capture-templates/test", response_model=TemplateTestResponse, tags=["Templates"])
async def test_template(
def test_template(
test_request: TemplateTestRequest,
_auth: AuthRequired,
processor_manager: ProcessorManager = Depends(get_processor_manager),
@@ -250,6 +251,10 @@ async def test_template(
):
"""Test a capture template configuration.
Uses sync ``def`` so FastAPI runs it in a thread pool — the engine
initialisation and capture loop are blocking and would stall the
event loop if run in an ``async def`` handler.
Temporarily instantiates an engine with the provided configuration,
captures frames for the specified duration, and returns actual FPS metrics.
"""
@@ -301,8 +306,9 @@ async def test_template(
screen_capture = stream.capture_frame()
capture_elapsed = time.perf_counter() - capture_start
# Skip if no new frame (screen unchanged)
# Skip if no new frame (screen unchanged); yield CPU
if screen_capture is None:
time.sleep(0.005)
continue
total_capture_time += capture_elapsed

View File

@@ -11,12 +11,14 @@ from wled_controller.core.capture_engines.mss_engine import MSSEngine, MSSCaptur
from wled_controller.core.capture_engines.dxcam_engine import DXcamEngine, DXcamCaptureStream
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
# Auto-register available engines
EngineRegistry.register(MSSEngine)
EngineRegistry.register(DXcamEngine)
EngineRegistry.register(BetterCamEngine)
EngineRegistry.register(WGCEngine)
EngineRegistry.register(ScrcpyEngine)
__all__ = [
"CaptureEngine",
@@ -32,4 +34,6 @@ __all__ = [
"BetterCamCaptureStream",
"WGCEngine",
"WGCCaptureStream",
"ScrcpyEngine",
"ScrcpyCaptureStream",
]

View File

@@ -0,0 +1,350 @@
"""ADB-based Android screen capture engine.
Captures Android device screens over ADB (USB or WiFi) by polling
``adb exec-out screencap -p`` in a background thread. Each poll returns
a PNG screenshot that is decoded into a NumPy RGB array.
Typical throughput is ~1-2 FPS over WiFi, depending on the device. For
LED ambient lighting this is usually sufficient because WLED controllers
apply smooth colour transitions between updates.
Prerequisites (system binaries, NOT Python packages):
- adb (bundled with scrcpy, or Android SDK Platform-Tools)
"""
import io
import os
import re
import shutil
import subprocess
import threading
import time
from typing import Any, Dict, List, Optional
import numpy as np
from PIL import Image
from wled_controller.core.capture_engines.base import (
CaptureEngine,
CaptureStream,
DisplayInfo,
ScreenCapture,
)
from wled_controller.utils import get_logger
logger = get_logger(__name__)
# ---------------------------------------------------------------------------
# Helpers
# ---------------------------------------------------------------------------
_ADB_TIMEOUT = 10 # seconds for ADB commands
def _find_adb() -> str:
"""Return the path to the ``adb`` binary.
Prefers the copy bundled with scrcpy (avoids ADB version mismatches),
falls back to whatever ``adb`` is on PATH.
"""
# Check for scrcpy-bundled adb next to scrcpy on PATH
scrcpy_path = shutil.which("scrcpy")
if scrcpy_path:
bundled = os.path.join(os.path.dirname(scrcpy_path), "adb.exe")
if os.path.isfile(bundled):
return bundled
bundled_unix = os.path.join(os.path.dirname(scrcpy_path), "adb")
if os.path.isfile(bundled_unix):
return bundled_unix
# Fall back to system adb
system_adb = shutil.which("adb")
if system_adb:
return system_adb
return "adb" # last resort — will fail with FileNotFoundError
_adb_path: Optional[str] = None
def _get_adb() -> str:
"""Cached lookup of the adb binary path."""
global _adb_path
if _adb_path is None:
_adb_path = _find_adb()
logger.debug(f"Using adb: {_adb_path}")
return _adb_path
def _list_adb_devices() -> List[Dict[str, Any]]:
"""Enumerate connected ADB devices with metadata.
Returns a list of dicts: {serial, model, width, height}.
"""
adb = _get_adb()
try:
result = subprocess.run(
[adb, "devices", "-l"],
capture_output=True, text=True, timeout=_ADB_TIMEOUT,
)
except (FileNotFoundError, subprocess.TimeoutExpired):
return []
devices: List[Dict[str, Any]] = []
for line in result.stdout.strip().splitlines()[1:]: # skip header
line = line.strip()
if not line:
continue
parts = line.split()
# Second token is the state: "device", "offline", "unauthorized", etc.
if len(parts) < 2 or parts[1] != "device":
continue
serial = parts[0]
# Extract model name from '-l' output (e.g. model:Pixel_6)
model = serial
for part in parts:
if part.startswith("model:"):
model = part.split(":", 1)[1].replace("_", " ")
break
# Query screen resolution
width, height = 1920, 1080 # sensible defaults
try:
size_result = subprocess.run(
[adb, "-s", serial, "shell", "wm", "size"],
capture_output=True, text=True, timeout=_ADB_TIMEOUT,
)
match = re.search(r"(\d+)x(\d+)", size_result.stdout)
if match:
width, height = int(match.group(1)), int(match.group(2))
except (FileNotFoundError, subprocess.TimeoutExpired):
pass
devices.append({
"serial": serial,
"model": model,
"width": width,
"height": height,
})
return devices
def _screencap_once(adb: str, serial: str) -> Optional[np.ndarray]:
"""Capture a single PNG screenshot and return it as an RGB NumPy array."""
try:
result = subprocess.run(
[adb, "-s", serial, "exec-out", "screencap", "-p"],
capture_output=True, timeout=_ADB_TIMEOUT,
)
if result.returncode != 0 or len(result.stdout) < 100:
return None
img = Image.open(io.BytesIO(result.stdout))
return np.asarray(img.convert("RGB"))
except Exception as e:
logger.debug(f"screencap failed for {serial}: {e}")
return None
# ---------------------------------------------------------------------------
# CaptureStream
# ---------------------------------------------------------------------------
class ScrcpyCaptureStream(CaptureStream):
"""ADB screencap-based capture stream for a specific Android device.
A background thread repeatedly calls ``adb exec-out screencap -p``
and stores the latest decoded frame. ``capture_frame()`` returns it
without blocking.
"""
def __init__(self, display_index: int, config: Dict[str, Any]):
super().__init__(display_index, config)
self._capture_thread: Optional[threading.Thread] = None
self._latest_frame: Optional[ScreenCapture] = None
self._frame_lock = threading.Lock()
self._frame_event = threading.Event()
self._running = False
self._device_serial: Optional[str] = None
def initialize(self) -> None:
if self._initialized:
return
# ── Resolve device serial from display index ──
devices = _list_adb_devices()
if not devices:
raise RuntimeError(
"No ADB devices found. Connect a device via USB or use "
"'adb connect <ip>' for WiFi."
)
if self.display_index >= len(devices):
raise RuntimeError(
f"Device index {self.display_index} out of range "
f"(found {len(devices)} device(s))"
)
device = devices[self.display_index]
self._device_serial = device["serial"]
logger.info(
f"ADB screencap: initializing capture for device "
f"{self._device_serial} ({device['model']}, "
f"{device['width']}x{device['height']})"
)
# ── Verify screencap works with a test capture ──
test_frame = _screencap_once(_get_adb(), self._device_serial)
if test_frame is None:
raise RuntimeError(
f"ADB screencap failed for device {self._device_serial}. "
"Ensure the device is connected and USB debugging is enabled."
)
h, w = test_frame.shape[:2]
with self._frame_lock:
self._latest_frame = ScreenCapture(
image=test_frame,
width=w,
height=h,
display_index=self.display_index,
)
self._frame_event.set()
# ── Start background polling thread ──
self._running = True
self._capture_thread = threading.Thread(
target=self._capture_loop, daemon=True, name="adb-screencap"
)
self._capture_thread.start()
self._initialized = True
logger.info(
f"ADB screencap stream initialized "
f"(device={self._device_serial}, first frame {w}x{h})"
)
def _capture_loop(self) -> None:
"""Background thread: poll ``adb screencap`` and store latest frame."""
adb = _get_adb()
serial = self._device_serial
poll_interval = self.config.get("poll_interval", 0.0)
while self._running:
rgb = _screencap_once(adb, serial)
if rgb is None:
if self._running:
time.sleep(1.0) # back off on failure
continue
h, w = rgb.shape[:2]
with self._frame_lock:
self._latest_frame = ScreenCapture(
image=rgb,
width=w,
height=h,
display_index=self.display_index,
)
self._frame_event.set()
if poll_interval > 0:
time.sleep(poll_interval)
def capture_frame(self) -> Optional[ScreenCapture]:
if not self._initialized:
self.initialize()
if not self._frame_event.is_set():
return None
with self._frame_lock:
if self._latest_frame is None:
return None
frame = self._latest_frame
self._frame_event.clear()
return frame
def cleanup(self) -> None:
self._running = False
if self._capture_thread and self._capture_thread.is_alive():
self._capture_thread.join(timeout=_ADB_TIMEOUT + 2)
self._capture_thread = None
self._frame_event.clear()
self._latest_frame = None
self._initialized = False
logger.info(
f"ADB screencap stream cleaned up "
f"(device={self._device_serial})"
)
# ---------------------------------------------------------------------------
# CaptureEngine
# ---------------------------------------------------------------------------
class ScrcpyEngine(CaptureEngine):
"""ADB-based Android screen capture engine.
Captures Android device screens over ADB using ``screencap``.
Supports both USB and WiFi (TCP/IP) ADB connections.
scrcpy is optional (used only for device management); only ``adb``
is required for capture.
Prerequisites:
- adb on PATH (bundled with scrcpy, or standalone)
- USB debugging enabled on Android device
"""
ENGINE_TYPE = "scrcpy"
ENGINE_PRIORITY = 5
@classmethod
def is_available(cls) -> bool:
adb = _get_adb()
try:
result = subprocess.run(
[adb, "version"],
capture_output=True, text=True, timeout=5,
)
return result.returncode == 0
except (FileNotFoundError, subprocess.TimeoutExpired):
return False
@classmethod
def get_default_config(cls) -> Dict[str, Any]:
return {
"poll_interval": 0.0,
}
@classmethod
def get_available_displays(cls) -> List[DisplayInfo]:
devices = _list_adb_devices()
displays = []
for idx, device in enumerate(devices):
displays.append(DisplayInfo(
index=idx,
name=f"{device['model']} ({device['serial']})",
width=device["width"],
height=device["height"],
x=idx * 500, # spread horizontally for visual picker
y=0,
is_primary=(idx == 0),
refresh_rate=60,
))
logger.debug(f"ADB detected {len(displays)} Android device(s)")
return displays
@classmethod
def create_stream(
cls, display_index: int, config: Dict[str, Any]
) -> ScrcpyCaptureStream:
return ScrcpyCaptureStream(display_index, config)

View File

@@ -118,9 +118,12 @@ export async function loadServerInfo() {
}
}
export async function loadDisplays() {
export async function loadDisplays(engineType = null) {
try {
const response = await fetchWithAuth('/config/displays');
const url = engineType
? `/config/displays?engine_type=${engineType}`
: '/config/displays';
const response = await fetchWithAuth(url);
const data = await response.json();
if (data.displays && data.displays.length > 0) {

View File

@@ -1,29 +1,38 @@
/**
* Display picker lightbox — display selection for streams and tests.
* Supports engine-specific displays (e.g. scrcpy → Android devices).
*/
import {
_cachedDisplays, _displayPickerCallback, _displayPickerSelectedIndex,
set_displayPickerCallback, set_displayPickerSelectedIndex,
set_displayPickerCallback, set_displayPickerSelectedIndex, set_cachedDisplays,
} from '../core/state.js';
import { t } from '../core/i18n.js';
import { loadDisplays } from '../core/api.js';
import { fetchWithAuth } from '../core/api.js';
import { showToast } from '../core/ui.js';
export function openDisplayPicker(callback, selectedIndex) {
/** Currently active engine type for the picker (null = desktop monitors). */
let _pickerEngineType = null;
export function openDisplayPicker(callback, selectedIndex, engineType = null) {
set_displayPickerCallback(callback);
set_displayPickerSelectedIndex((selectedIndex !== undefined && selectedIndex !== null && selectedIndex !== '') ? Number(selectedIndex) : null);
_pickerEngineType = engineType || null;
const lightbox = document.getElementById('display-picker-lightbox');
lightbox.classList.add('active');
requestAnimationFrame(() => {
if (_cachedDisplays && _cachedDisplays.length > 0) {
// Always fetch fresh when engine type is specified (different list each time)
if (_pickerEngineType) {
_fetchAndRenderEngineDisplays(_pickerEngineType);
} else if (_cachedDisplays && _cachedDisplays.length > 0) {
renderDisplayPickerLayout(_cachedDisplays);
} else {
const canvas = document.getElementById('display-picker-canvas');
canvas.innerHTML = '<div class="loading-spinner"></div>';
loadDisplays().then(() => {
// Re-import to get updated value
import('../core/state.js').then(({ _cachedDisplays: displays }) => {
if (displays && displays.length > 0) {
renderDisplayPickerLayout(displays);
@@ -36,11 +45,86 @@ export function openDisplayPicker(callback, selectedIndex) {
});
}
async function _fetchAndRenderEngineDisplays(engineType) {
const canvas = document.getElementById('display-picker-canvas');
canvas.innerHTML = '<div class="loading-spinner"></div>';
try {
const resp = await fetchWithAuth(`/config/displays?engine_type=${engineType}`);
if (!resp.ok) throw new Error(`${resp.status}`);
const data = await resp.json();
const displays = data.displays || [];
// Store in cache so selectDisplay() can look them up
set_cachedDisplays(displays);
if (displays.length > 0) {
renderDisplayPickerLayout(displays, engineType);
} else {
_renderEmptyAndroidPicker(canvas);
}
} catch (error) {
console.error('Error fetching engine displays:', error);
canvas.innerHTML = `<div class="loading">${t('displays.failed')}</div>`;
}
}
function _renderEmptyAndroidPicker(canvas) {
canvas.innerHTML = `
<div class="loading">${t('displays.picker.no_android')}</div>
${_buildAdbConnectHtml()}
`;
}
function _buildAdbConnectHtml() {
return `
<div class="adb-connect-form" style="margin-top: 1rem; display: flex; gap: 0.5rem; align-items: center; justify-content: center;">
<input type="text" id="adb-connect-ip"
placeholder="${t('displays.picker.adb_connect.placeholder')}"
style="width: 200px; padding: 0.35rem 0.5rem; border-radius: 4px; border: 1px solid var(--border-color);">
<button class="btn btn-primary btn-sm" onclick="window._adbConnectFromPicker()">
${t('displays.picker.adb_connect.button')}
</button>
</div>
`;
}
/** Called from the inline Connect button inside the display picker. */
window._adbConnectFromPicker = async function () {
const input = document.getElementById('adb-connect-ip');
if (!input) return;
const address = input.value.trim();
if (!address) return;
input.disabled = true;
try {
const resp = await fetchWithAuth('/adb/connect', {
method: 'POST',
body: JSON.stringify({ address }),
});
if (!resp.ok) {
const err = await resp.json().catch(() => ({}));
throw new Error(err.detail || 'Connection failed');
}
showToast(t('displays.picker.adb_connect.success'), 'success');
// Refresh the picker with updated device list
if (_pickerEngineType) {
await _fetchAndRenderEngineDisplays(_pickerEngineType);
}
} catch (error) {
showToast(`${t('displays.picker.adb_connect.error')}: ${error.message}`, 'error');
} finally {
if (input) input.disabled = false;
}
};
export function closeDisplayPicker(event) {
if (event && event.target && event.target.closest('.display-picker-content')) return;
const lightbox = document.getElementById('display-picker-lightbox');
lightbox.classList.remove('active');
set_displayPickerCallback(null);
_pickerEngineType = null;
}
export function selectDisplay(displayIndex) {
@@ -54,7 +138,7 @@ export function selectDisplay(displayIndex) {
});
}
export function renderDisplayPickerLayout(displays) {
export function renderDisplayPickerLayout(displays, engineType = null) {
const canvas = document.getElementById('display-picker-canvas');
if (!displays || displays.length === 0) {
@@ -97,11 +181,18 @@ export function renderDisplayPickerLayout(displays) {
`;
}).join('');
canvas.innerHTML = `
let html = `
<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) {

View File

@@ -53,6 +53,7 @@ async function loadCaptureTemplates() {
}
function getEngineIcon(engineType) {
if (engineType === 'scrcpy') return '📱';
return '🚀';
}
@@ -270,8 +271,15 @@ function collectEngineConfig() {
async function loadDisplaysForTest() {
try {
if (!_cachedDisplays) {
const response = await fetchWithAuth('/config/displays');
// Use engine-specific display list when testing a scrcpy template
const engineType = window.currentTestingTemplate?.engine_type;
const url = engineType === 'scrcpy'
? `/config/displays?engine_type=scrcpy`
: '/config/displays';
// Always refetch for scrcpy (devices may change); use cache for desktop
if (!_cachedDisplays || engineType === 'scrcpy') {
const response = await fetchWithAuth(url);
if (!response.ok) throw new Error(`Failed to load displays: ${response.status}`);
const displaysData = await response.json();
set_cachedDisplays(displaysData.displays || []);
@@ -800,6 +808,9 @@ export async function editStream(streamId) {
}
}
/** Track which engine type the stream-modal displays were loaded for. */
let _streamModalDisplaysEngine = null;
async function populateStreamModalDropdowns() {
const [displaysRes, captureTemplatesRes, streamsRes, ppTemplatesRes] = await Promise.all([
fetch(`${API_BASE}/config/displays`, { headers: getHeaders() }),
@@ -812,6 +823,7 @@ async function populateStreamModalDropdowns() {
const displaysData = await displaysRes.json();
set_cachedDisplays(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];
@@ -826,11 +838,15 @@ async function populateStreamModalDropdowns() {
const opt = document.createElement('option');
opt.value = tmpl.id;
opt.dataset.name = tmpl.name;
opt.dataset.engineType = tmpl.engine_type;
opt.textContent = `${getEngineIcon(tmpl.engine_type)} ${tmpl.name}`;
templateSelect.appendChild(opt);
});
}
// When template changes, refresh displays if engine type switched
templateSelect.addEventListener('change', _onCaptureTemplateChanged);
const sourceSelect = document.getElementById('stream-source');
sourceSelect.innerHTML = '';
if (streamsRes.ok) {
@@ -863,6 +879,47 @@ 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;
// Only refetch if the engine category actually changed
if (currentEngine !== _streamModalDisplaysEngine) {
await _refreshStreamDisplaysForEngine(currentEngine);
}
_autoGenerateStreamName();
}
async function _refreshStreamDisplaysForEngine(engineType) {
_streamModalDisplaysEngine = engineType;
const url = engineType ? `/config/displays?engine_type=${engineType}` : '/config/displays';
try {
const resp = await fetchWithAuth(url);
if (resp.ok) {
const data = await resp.json();
set_cachedDisplays(data.displays || []);
}
} catch (error) {
console.error('Error refreshing displays for engine:', error);
}
// Reset display selection and pick the first available
document.getElementById('stream-display-index').value = '';
document.getElementById('stream-display-picker-label').textContent = t('displays.picker.select');
if (_cachedDisplays && _cachedDisplays.length > 0) {
const primary = _cachedDisplays.find(d => d.is_primary) || _cachedDisplays[0];
onStreamDisplaySelected(primary.index, primary);
}
}
export async function saveStream() {

View File

@@ -37,6 +37,13 @@
"displays.picker.title": "Select a Display",
"displays.picker.select": "Select display...",
"displays.picker.click_to_select": "Click to select this display",
"displays.picker.adb_connect": "Connect ADB device",
"displays.picker.adb_connect.placeholder": "IP address (e.g. 192.168.2.201)",
"displays.picker.adb_connect.button": "Connect",
"displays.picker.adb_connect.success": "Device connected",
"displays.picker.adb_connect.error": "Failed to connect device",
"displays.picker.adb_disconnect": "Disconnect",
"displays.picker.no_android": "No Android devices found. Connect via USB or enter IP above.",
"templates.title": "\uD83D\uDCC4 Engine Templates",
"templates.description": "Capture templates define how the screen is captured. Each template uses a specific capture engine (MSS, DXcam, WGC) with custom settings. Assign templates to devices for optimal performance.",
"templates.loading": "Loading templates...",

View File

@@ -37,6 +37,13 @@
"displays.picker.title": "Выберите Дисплей",
"displays.picker.select": "Выберите дисплей...",
"displays.picker.click_to_select": "Нажмите, чтобы выбрать этот дисплей",
"displays.picker.adb_connect": "Подключить ADB устройство",
"displays.picker.adb_connect.placeholder": "IP адрес (напр. 192.168.2.201)",
"displays.picker.adb_connect.button": "Подключить",
"displays.picker.adb_connect.success": "Устройство подключено",
"displays.picker.adb_connect.error": "Не удалось подключить устройство",
"displays.picker.adb_disconnect": "Отключить",
"displays.picker.no_android": "Android устройства не найдены. Подключите по USB или введите IP выше.",
"templates.title": "\uD83D\uDCC4 Шаблоны Движков",
"templates.description": "Шаблоны захвата определяют, как захватывается экран. Каждый шаблон использует определённый движок захвата (MSS, DXcam, WGC) с настраиваемыми параметрами. Назначайте шаблоны устройствам для оптимальной производительности.",
"templates.loading": "Загрузка шаблонов...",

View File

@@ -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)">
<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)">
<span id="stream-display-picker-label" data-i18n="displays.picker.select">Select display...</span>
</button>
</div>

View File

@@ -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)">
<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)">
<span id="test-display-picker-label" data-i18n="displays.picker.select">Select display...</span>
</button>
</div>