From 199039326bf18b61ea468356c8de46caafb654a6 Mon Sep 17 00:00:00 2001 From: "alexei.dolgolyov" Date: Mon, 23 Feb 2026 18:06:15 +0300 Subject: [PATCH] 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 --- server/restart.ps1 | 11 + .../src/wled_controller/api/routes/system.py | 94 ++++- .../wled_controller/api/routes/templates.py | 22 +- .../core/capture_engines/__init__.py | 4 + .../core/capture_engines/scrcpy_engine.py | 350 ++++++++++++++++++ .../src/wled_controller/static/js/core/api.js | 7 +- .../static/js/features/displays.js | 103 +++++- .../static/js/features/streams.js | 61 ++- .../wled_controller/static/locales/en.json | 7 + .../wled_controller/static/locales/ru.json | 7 + .../templates/modals/stream.html | 2 +- .../templates/modals/test-template.html | 2 +- 12 files changed, 644 insertions(+), 26 deletions(-) create mode 100644 server/src/wled_controller/core/capture_engines/scrcpy_engine.py diff --git a/server/restart.ps1 b/server/restart.ps1 index 2b00b90..e7c7927 100644 --- a/server/restart.ps1 +++ b/server/restart.ps1 @@ -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' ` diff --git a/server/src/wled_controller/api/routes/system.py b/server/src/wled_controller/api/routes/system.py index 3cf72fc..b4ed93a 100644 --- a/server/src/wled_controller/api/routes/system.py +++ b/server/src/wled_controller/api/routes/system.py @@ -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") diff --git a/server/src/wled_controller/api/routes/templates.py b/server/src/wled_controller/api/routes/templates.py index 914f47c..791717f 100644 --- a/server/src/wled_controller/api/routes/templates.py +++ b/server/src/wled_controller/api/routes/templates.py @@ -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 diff --git a/server/src/wled_controller/core/capture_engines/__init__.py b/server/src/wled_controller/core/capture_engines/__init__.py index 29e0a99..03cea13 100644 --- a/server/src/wled_controller/core/capture_engines/__init__.py +++ b/server/src/wled_controller/core/capture_engines/__init__.py @@ -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", ] diff --git a/server/src/wled_controller/core/capture_engines/scrcpy_engine.py b/server/src/wled_controller/core/capture_engines/scrcpy_engine.py new file mode 100644 index 0000000..2b7f46b --- /dev/null +++ b/server/src/wled_controller/core/capture_engines/scrcpy_engine.py @@ -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 ' 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) diff --git a/server/src/wled_controller/static/js/core/api.js b/server/src/wled_controller/static/js/core/api.js index 4a6bd02..72eaa5e 100644 --- a/server/src/wled_controller/static/js/core/api.js +++ b/server/src/wled_controller/static/js/core/api.js @@ -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) { diff --git a/server/src/wled_controller/static/js/features/displays.js b/server/src/wled_controller/static/js/features/displays.js index 660a3e8..ccc28be 100644 --- a/server/src/wled_controller/static/js/features/displays.js +++ b/server/src/wled_controller/static/js/features/displays.js @@ -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 = '
'; 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 = '
'; + + 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 = `
${t('displays.failed')}
`; + } +} + +function _renderEmptyAndroidPicker(canvas) { + canvas.innerHTML = ` +
${t('displays.picker.no_android')}
+ ${_buildAdbConnectHtml()} + `; +} + +function _buildAdbConnectHtml() { + return ` +
+ + +
+ `; +} + +/** 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 = `
${displayElements}
`; + + // Show ADB connect form below devices for scrcpy engine + if (engineType === 'scrcpy') { + html += _buildAdbConnectHtml(); + } + + canvas.innerHTML = html; } export function formatDisplayLabel(displayIndex, display) { diff --git a/server/src/wled_controller/static/js/features/streams.js b/server/src/wled_controller/static/js/features/streams.js index 986ddf1..57ad76c 100644 --- a/server/src/wled_controller/static/js/features/streams.js +++ b/server/src/wled_controller/static/js/features/streams.js @@ -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() { diff --git a/server/src/wled_controller/static/locales/en.json b/server/src/wled_controller/static/locales/en.json index 4d8b63e..717f225 100644 --- a/server/src/wled_controller/static/locales/en.json +++ b/server/src/wled_controller/static/locales/en.json @@ -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...", diff --git a/server/src/wled_controller/static/locales/ru.json b/server/src/wled_controller/static/locales/ru.json index 274cfb6..2206c51 100644 --- a/server/src/wled_controller/static/locales/ru.json +++ b/server/src/wled_controller/static/locales/ru.json @@ -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": "Загрузка шаблонов...", diff --git a/server/src/wled_controller/templates/modals/stream.html b/server/src/wled_controller/templates/modals/stream.html index 6878827..c8d9677 100644 --- a/server/src/wled_controller/templates/modals/stream.html +++ b/server/src/wled_controller/templates/modals/stream.html @@ -24,7 +24,7 @@ - diff --git a/server/src/wled_controller/templates/modals/test-template.html b/server/src/wled_controller/templates/modals/test-template.html index ff398bc..b21119a 100644 --- a/server/src/wled_controller/templates/modals/test-template.html +++ b/server/src/wled_controller/templates/modals/test-template.html @@ -9,7 +9,7 @@
-