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 }
|
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
|
# Start server detached
|
||||||
Write-Host "Starting server..."
|
Write-Host "Starting server..."
|
||||||
Start-Process -FilePath python -ArgumentList '-m', 'wled_controller.main' `
|
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
|
import sys
|
||||||
from datetime import datetime
|
from datetime import datetime
|
||||||
|
from typing import Optional
|
||||||
|
|
||||||
import psutil
|
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 import __version__
|
||||||
from wled_controller.api.auth import AuthRequired
|
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"])
|
@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.
|
"""Get list of available displays.
|
||||||
|
|
||||||
Returns information about all available monitors/displays that can be captured.
|
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:
|
try:
|
||||||
# Get available displays with all metadata (name, refresh rate, etc.)
|
if engine_type:
|
||||||
display_dataclasses = get_available_displays()
|
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
|
# Convert dataclass DisplayInfo to Pydantic DisplayInfo
|
||||||
displays = [
|
displays = [
|
||||||
@@ -106,6 +119,8 @@ async def get_displays(_: AuthRequired):
|
|||||||
count=len(displays),
|
count=len(displays),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
except ValueError as e:
|
||||||
|
raise HTTPException(status_code=400, detail=str(e))
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(f"Failed to get displays: {e}")
|
logger.error(f"Failed to get displays: {e}")
|
||||||
raise HTTPException(
|
raise HTTPException(
|
||||||
@@ -175,3 +190,70 @@ def get_system_performance(_: AuthRequired):
|
|||||||
gpu=gpu,
|
gpu=gpu,
|
||||||
timestamp=datetime.utcnow(),
|
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"])
|
@router.get("/api/v1/capture-engines", response_model=EngineListResponse, tags=["Templates"])
|
||||||
async def list_engines(_auth: AuthRequired):
|
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:
|
try:
|
||||||
available_engine_types = EngineRegistry.get_available_engines()
|
available_set = set(EngineRegistry.get_available_engines())
|
||||||
|
all_engines = EngineRegistry.get_all_engines()
|
||||||
|
|
||||||
engines = []
|
engines = []
|
||||||
for engine_type in available_engine_types:
|
for engine_type, engine_class in all_engines.items():
|
||||||
engine_class = EngineRegistry.get_engine(engine_type)
|
|
||||||
engines.append(
|
engines.append(
|
||||||
EngineInfo(
|
EngineInfo(
|
||||||
type=engine_type,
|
type=engine_type,
|
||||||
name=engine_type.upper(),
|
name=engine_type.upper(),
|
||||||
default_config=engine_class.get_default_config(),
|
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"])
|
@router.post("/api/v1/capture-templates/test", response_model=TemplateTestResponse, tags=["Templates"])
|
||||||
async def test_template(
|
def test_template(
|
||||||
test_request: TemplateTestRequest,
|
test_request: TemplateTestRequest,
|
||||||
_auth: AuthRequired,
|
_auth: AuthRequired,
|
||||||
processor_manager: ProcessorManager = Depends(get_processor_manager),
|
processor_manager: ProcessorManager = Depends(get_processor_manager),
|
||||||
@@ -250,6 +251,10 @@ async def test_template(
|
|||||||
):
|
):
|
||||||
"""Test a capture template configuration.
|
"""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,
|
Temporarily instantiates an engine with the provided configuration,
|
||||||
captures frames for the specified duration, and returns actual FPS metrics.
|
captures frames for the specified duration, and returns actual FPS metrics.
|
||||||
"""
|
"""
|
||||||
@@ -301,8 +306,9 @@ async def test_template(
|
|||||||
screen_capture = stream.capture_frame()
|
screen_capture = stream.capture_frame()
|
||||||
capture_elapsed = time.perf_counter() - capture_start
|
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:
|
if screen_capture is None:
|
||||||
|
time.sleep(0.005)
|
||||||
continue
|
continue
|
||||||
|
|
||||||
total_capture_time += capture_elapsed
|
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.dxcam_engine import DXcamEngine, DXcamCaptureStream
|
||||||
from wled_controller.core.capture_engines.bettercam_engine import BetterCamEngine, BetterCamCaptureStream
|
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.wgc_engine import WGCEngine, WGCCaptureStream
|
||||||
|
from wled_controller.core.capture_engines.scrcpy_engine import ScrcpyEngine, ScrcpyCaptureStream
|
||||||
|
|
||||||
# Auto-register available engines
|
# Auto-register available engines
|
||||||
EngineRegistry.register(MSSEngine)
|
EngineRegistry.register(MSSEngine)
|
||||||
EngineRegistry.register(DXcamEngine)
|
EngineRegistry.register(DXcamEngine)
|
||||||
EngineRegistry.register(BetterCamEngine)
|
EngineRegistry.register(BetterCamEngine)
|
||||||
EngineRegistry.register(WGCEngine)
|
EngineRegistry.register(WGCEngine)
|
||||||
|
EngineRegistry.register(ScrcpyEngine)
|
||||||
|
|
||||||
__all__ = [
|
__all__ = [
|
||||||
"CaptureEngine",
|
"CaptureEngine",
|
||||||
@@ -32,4 +34,6 @@ __all__ = [
|
|||||||
"BetterCamCaptureStream",
|
"BetterCamCaptureStream",
|
||||||
"WGCEngine",
|
"WGCEngine",
|
||||||
"WGCCaptureStream",
|
"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 {
|
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();
|
const data = await response.json();
|
||||||
|
|
||||||
if (data.displays && data.displays.length > 0) {
|
if (data.displays && data.displays.length > 0) {
|
||||||
|
|||||||
@@ -1,29 +1,38 @@
|
|||||||
/**
|
/**
|
||||||
* Display picker lightbox — display selection for streams and tests.
|
* Display picker lightbox — display selection for streams and tests.
|
||||||
|
* Supports engine-specific displays (e.g. scrcpy → Android devices).
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import {
|
import {
|
||||||
_cachedDisplays, _displayPickerCallback, _displayPickerSelectedIndex,
|
_cachedDisplays, _displayPickerCallback, _displayPickerSelectedIndex,
|
||||||
set_displayPickerCallback, set_displayPickerSelectedIndex,
|
set_displayPickerCallback, set_displayPickerSelectedIndex, set_cachedDisplays,
|
||||||
} from '../core/state.js';
|
} from '../core/state.js';
|
||||||
import { t } from '../core/i18n.js';
|
import { t } from '../core/i18n.js';
|
||||||
import { loadDisplays } from '../core/api.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_displayPickerCallback(callback);
|
||||||
set_displayPickerSelectedIndex((selectedIndex !== undefined && selectedIndex !== null && selectedIndex !== '') ? Number(selectedIndex) : null);
|
set_displayPickerSelectedIndex((selectedIndex !== undefined && selectedIndex !== null && selectedIndex !== '') ? Number(selectedIndex) : null);
|
||||||
|
_pickerEngineType = engineType || null;
|
||||||
const lightbox = document.getElementById('display-picker-lightbox');
|
const lightbox = document.getElementById('display-picker-lightbox');
|
||||||
|
|
||||||
lightbox.classList.add('active');
|
lightbox.classList.add('active');
|
||||||
|
|
||||||
requestAnimationFrame(() => {
|
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);
|
renderDisplayPickerLayout(_cachedDisplays);
|
||||||
} else {
|
} else {
|
||||||
const canvas = document.getElementById('display-picker-canvas');
|
const canvas = document.getElementById('display-picker-canvas');
|
||||||
canvas.innerHTML = '<div class="loading-spinner"></div>';
|
canvas.innerHTML = '<div class="loading-spinner"></div>';
|
||||||
loadDisplays().then(() => {
|
loadDisplays().then(() => {
|
||||||
// Re-import to get updated value
|
|
||||||
import('../core/state.js').then(({ _cachedDisplays: displays }) => {
|
import('../core/state.js').then(({ _cachedDisplays: displays }) => {
|
||||||
if (displays && displays.length > 0) {
|
if (displays && displays.length > 0) {
|
||||||
renderDisplayPickerLayout(displays);
|
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) {
|
export function closeDisplayPicker(event) {
|
||||||
if (event && event.target && event.target.closest('.display-picker-content')) return;
|
if (event && event.target && event.target.closest('.display-picker-content')) return;
|
||||||
const lightbox = document.getElementById('display-picker-lightbox');
|
const lightbox = document.getElementById('display-picker-lightbox');
|
||||||
lightbox.classList.remove('active');
|
lightbox.classList.remove('active');
|
||||||
set_displayPickerCallback(null);
|
set_displayPickerCallback(null);
|
||||||
|
_pickerEngineType = null;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function selectDisplay(displayIndex) {
|
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');
|
const canvas = document.getElementById('display-picker-canvas');
|
||||||
|
|
||||||
if (!displays || displays.length === 0) {
|
if (!displays || displays.length === 0) {
|
||||||
@@ -97,11 +181,18 @@ export function renderDisplayPickerLayout(displays) {
|
|||||||
`;
|
`;
|
||||||
}).join('');
|
}).join('');
|
||||||
|
|
||||||
canvas.innerHTML = `
|
let html = `
|
||||||
<div class="layout-container" style="width: 100%; padding-bottom: ${aspect * 100}%; position: relative;">
|
<div class="layout-container" style="width: 100%; padding-bottom: ${aspect * 100}%; position: relative;">
|
||||||
${displayElements}
|
${displayElements}
|
||||||
</div>
|
</div>
|
||||||
`;
|
`;
|
||||||
|
|
||||||
|
// Show ADB connect form below devices for scrcpy engine
|
||||||
|
if (engineType === 'scrcpy') {
|
||||||
|
html += _buildAdbConnectHtml();
|
||||||
|
}
|
||||||
|
|
||||||
|
canvas.innerHTML = html;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function formatDisplayLabel(displayIndex, display) {
|
export function formatDisplayLabel(displayIndex, display) {
|
||||||
|
|||||||
@@ -53,6 +53,7 @@ async function loadCaptureTemplates() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function getEngineIcon(engineType) {
|
function getEngineIcon(engineType) {
|
||||||
|
if (engineType === 'scrcpy') return '📱';
|
||||||
return '🚀';
|
return '🚀';
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -270,8 +271,15 @@ function collectEngineConfig() {
|
|||||||
|
|
||||||
async function loadDisplaysForTest() {
|
async function loadDisplaysForTest() {
|
||||||
try {
|
try {
|
||||||
if (!_cachedDisplays) {
|
// Use engine-specific display list when testing a scrcpy template
|
||||||
const response = await fetchWithAuth('/config/displays');
|
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}`);
|
if (!response.ok) throw new Error(`Failed to load displays: ${response.status}`);
|
||||||
const displaysData = await response.json();
|
const displaysData = await response.json();
|
||||||
set_cachedDisplays(displaysData.displays || []);
|
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() {
|
async function populateStreamModalDropdowns() {
|
||||||
const [displaysRes, captureTemplatesRes, streamsRes, ppTemplatesRes] = await Promise.all([
|
const [displaysRes, captureTemplatesRes, streamsRes, ppTemplatesRes] = await Promise.all([
|
||||||
fetch(`${API_BASE}/config/displays`, { headers: getHeaders() }),
|
fetch(`${API_BASE}/config/displays`, { headers: getHeaders() }),
|
||||||
@@ -812,6 +823,7 @@ async function populateStreamModalDropdowns() {
|
|||||||
const displaysData = await displaysRes.json();
|
const displaysData = await displaysRes.json();
|
||||||
set_cachedDisplays(displaysData.displays || []);
|
set_cachedDisplays(displaysData.displays || []);
|
||||||
}
|
}
|
||||||
|
_streamModalDisplaysEngine = null; // desktop displays loaded
|
||||||
|
|
||||||
if (!document.getElementById('stream-display-index').value && _cachedDisplays && _cachedDisplays.length > 0) {
|
if (!document.getElementById('stream-display-index').value && _cachedDisplays && _cachedDisplays.length > 0) {
|
||||||
const primary = _cachedDisplays.find(d => d.is_primary) || _cachedDisplays[0];
|
const primary = _cachedDisplays.find(d => d.is_primary) || _cachedDisplays[0];
|
||||||
@@ -826,11 +838,15 @@ async function populateStreamModalDropdowns() {
|
|||||||
const opt = document.createElement('option');
|
const opt = document.createElement('option');
|
||||||
opt.value = tmpl.id;
|
opt.value = tmpl.id;
|
||||||
opt.dataset.name = tmpl.name;
|
opt.dataset.name = tmpl.name;
|
||||||
|
opt.dataset.engineType = tmpl.engine_type;
|
||||||
opt.textContent = `${getEngineIcon(tmpl.engine_type)} ${tmpl.name}`;
|
opt.textContent = `${getEngineIcon(tmpl.engine_type)} ${tmpl.name}`;
|
||||||
templateSelect.appendChild(opt);
|
templateSelect.appendChild(opt);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// When template changes, refresh displays if engine type switched
|
||||||
|
templateSelect.addEventListener('change', _onCaptureTemplateChanged);
|
||||||
|
|
||||||
const sourceSelect = document.getElementById('stream-source');
|
const sourceSelect = document.getElementById('stream-source');
|
||||||
sourceSelect.innerHTML = '';
|
sourceSelect.innerHTML = '';
|
||||||
if (streamsRes.ok) {
|
if (streamsRes.ok) {
|
||||||
@@ -863,6 +879,47 @@ async function populateStreamModalDropdowns() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
_autoGenerateStreamName();
|
_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() {
|
export async function saveStream() {
|
||||||
|
|||||||
@@ -37,6 +37,13 @@
|
|||||||
"displays.picker.title": "Select a Display",
|
"displays.picker.title": "Select a Display",
|
||||||
"displays.picker.select": "Select display...",
|
"displays.picker.select": "Select display...",
|
||||||
"displays.picker.click_to_select": "Click to select this 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.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.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...",
|
"templates.loading": "Loading templates...",
|
||||||
|
|||||||
@@ -37,6 +37,13 @@
|
|||||||
"displays.picker.title": "Выберите Дисплей",
|
"displays.picker.title": "Выберите Дисплей",
|
||||||
"displays.picker.select": "Выберите дисплей...",
|
"displays.picker.select": "Выберите дисплей...",
|
||||||
"displays.picker.click_to_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.title": "\uD83D\uDCC4 Шаблоны Движков",
|
||||||
"templates.description": "Шаблоны захвата определяют, как захватывается экран. Каждый шаблон использует определённый движок захвата (MSS, DXcam, WGC) с настраиваемыми параметрами. Назначайте шаблоны устройствам для оптимальной производительности.",
|
"templates.description": "Шаблоны захвата определяют, как захватывается экран. Каждый шаблон использует определённый движок захвата (MSS, DXcam, WGC) с настраиваемыми параметрами. Назначайте шаблоны устройствам для оптимальной производительности.",
|
||||||
"templates.loading": "Загрузка шаблонов...",
|
"templates.loading": "Загрузка шаблонов...",
|
||||||
|
|||||||
@@ -24,7 +24,7 @@
|
|||||||
</div>
|
</div>
|
||||||
<small class="input-hint" style="display:none" data-i18n="streams.display.hint">Which screen to capture</small>
|
<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="">
|
<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>
|
<span id="stream-display-picker-label" data-i18n="displays.picker.select">Select display...</span>
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -9,7 +9,7 @@
|
|||||||
<div class="form-group">
|
<div class="form-group">
|
||||||
<label data-i18n="templates.test.display">Display:</label>
|
<label data-i18n="templates.test.display">Display:</label>
|
||||||
<input type="hidden" id="test-template-display" value="">
|
<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>
|
<span id="test-display-picker-label" data-i18n="displays.picker.select">Select display...</span>
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
Reference in New Issue
Block a user