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>
204 lines
7.9 KiB
JavaScript
204 lines
7.9 KiB
JavaScript
/**
|
||
* 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_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';
|
||
|
||
/** 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(() => {
|
||
// 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(() => {
|
||
import('../core/state.js').then(({ _cachedDisplays: displays }) => {
|
||
if (displays && displays.length > 0) {
|
||
renderDisplayPickerLayout(displays);
|
||
} else {
|
||
canvas.innerHTML = `<div class="loading">${t('displays.none')}</div>`;
|
||
}
|
||
});
|
||
});
|
||
}
|
||
});
|
||
}
|
||
|
||
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) {
|
||
// Re-read live bindings
|
||
import('../core/state.js').then(({ _displayPickerCallback: cb, _cachedDisplays: displays }) => {
|
||
if (cb) {
|
||
const display = displays ? displays.find(d => d.index === displayIndex) : null;
|
||
cb(displayIndex, display);
|
||
}
|
||
closeDisplayPicker();
|
||
});
|
||
}
|
||
|
||
export function renderDisplayPickerLayout(displays, engineType = null) {
|
||
const canvas = document.getElementById('display-picker-canvas');
|
||
|
||
if (!displays || displays.length === 0) {
|
||
canvas.innerHTML = `<div class="loading">${t('displays.none')}</div>`;
|
||
return;
|
||
}
|
||
|
||
let minX = Infinity, minY = Infinity, maxX = -Infinity, maxY = -Infinity;
|
||
displays.forEach(display => {
|
||
minX = Math.min(minX, display.x);
|
||
minY = Math.min(minY, display.y);
|
||
maxX = Math.max(maxX, display.x + display.width);
|
||
maxY = Math.max(maxY, display.y + display.height);
|
||
});
|
||
|
||
const totalWidth = maxX - minX;
|
||
const totalHeight = maxY - minY;
|
||
const aspect = totalHeight / totalWidth;
|
||
|
||
const displayElements = displays.map(display => {
|
||
const leftPct = ((display.x - minX) / totalWidth) * 100;
|
||
const topPct = ((display.y - minY) / totalHeight) * 100;
|
||
const widthPct = (display.width / totalWidth) * 100;
|
||
const heightPct = (display.height / totalHeight) * 100;
|
||
|
||
const isSelected = _displayPickerSelectedIndex !== null && display.index === _displayPickerSelectedIndex;
|
||
return `
|
||
<div class="layout-display layout-display-pickable${isSelected ? ' selected' : ''}"
|
||
style="left: ${leftPct}%; top: ${topPct}%; width: ${widthPct}%; height: ${heightPct}%;"
|
||
onclick="selectDisplay(${display.index})"
|
||
title="${t('displays.picker.click_to_select')}">
|
||
<div class="layout-position-label">(${display.x}, ${display.y})</div>
|
||
<div class="layout-index-label">#${display.index}</div>
|
||
<div class="layout-display-label">
|
||
<strong>${display.name}</strong>
|
||
<small>${display.width}×${display.height}</small>
|
||
<small>${display.refresh_rate}Hz</small>
|
||
</div>
|
||
</div>
|
||
`;
|
||
}).join('');
|
||
|
||
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) {
|
||
if (display) {
|
||
return `#${display.index}: ${display.width}×${display.height}${display.is_primary ? ' (' + t('displays.badge.primary') + ')' : ''}`;
|
||
}
|
||
return `Display ${displayIndex}`;
|
||
}
|