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

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

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

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

View File

@@ -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() {