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:
@@ -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' `
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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",
|
||||
]
|
||||
|
||||
350
server/src/wled_controller/core/capture_engines/scrcpy_engine.py
Normal file
350
server/src/wled_controller/core/capture_engines/scrcpy_engine.py
Normal 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)
|
||||
@@ -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) {
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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() {
|
||||
|
||||
@@ -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...",
|
||||
|
||||
@@ -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": "Загрузка шаблонов...",
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user