Files
wled-screen-controller-mixed/server/src/wled_controller/static/js/features/displays.ts
alexei.dolgolyov f2871319cb
Some checks failed
Lint & Test / test (push) Failing after 48s
refactor: comprehensive code quality, security, and release readiness improvements
Security: tighten CORS defaults, add webhook rate limiting, fix XSS in
automations, guard WebSocket JSON.parse, validate ADB address input,
seal debug exception leak, URL-encode WS tokens, CSS.escape in selectors.

Code quality: add Pydantic models for brightness/power endpoints, fix
thread safety and name uniqueness in DeviceStore, immutable update
pattern, split 6 oversized files into 16 focused modules, enable
TypeScript strictNullChecks (741→102 errors), type state variables,
add dom-utils helper, migrate 3 modules from inline onclick to event
delegation, ProcessorDependencies dataclass.

Performance: async store saves, health endpoint log level, command
palette debounce, optimized entity-events comparison, fix service
worker precache list.

Testing: expand from 45 to 293 passing tests — add store tests (141),
route tests (25), core logic tests (42), E2E flow tests (33), organize
into tests/api/, tests/storage/, tests/core/, tests/e2e/.

DevOps: CI test pipeline, pre-commit config, Dockerfile multi-stage
build with non-root user and health check, docker-compose improvements,
version bump to 0.2.0.

Docs: rewrite CLAUDE.md (202→56 lines), server/CLAUDE.md (212→76),
create contexts/server-operations.md, fix .js→.ts references, fix env
var prefix in README, rewrite INSTALLATION.md, add CONTRIBUTING.md and
.env.example.
2026-03-22 00:38:28 +03:00

239 lines
9.9 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* 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, displaysCache,
availableEngines,
} from '../core/state.ts';
import { t } from '../core/i18n.ts';
import { fetchWithAuth } from '../core/api.ts';
import { showToast } from '../core/ui.ts';
import type { Display } from '../types.ts';
/** Currently active engine type for the picker (null = desktop monitors). */
let _pickerEngineType: string | null = null;
/** Check if an engine type has its own device list (for inline onclick use). */
window._engineHasOwnDisplays = (engineType: string) =>
!!(engineType && availableEngines.find(e => e.type === engineType)?.has_own_displays);
export function openDisplayPicker(callback: (index: number, display?: Display | null) => void, selectedIndex: number | string | null | undefined, engineType: string | null = null): void {
set_displayPickerCallback(callback);
set_displayPickerSelectedIndex((selectedIndex !== undefined && selectedIndex !== null && selectedIndex !== '') ? Number(selectedIndex) : null);
_pickerEngineType = engineType || null;
const lightbox = document.getElementById('display-picker-lightbox')!;
// Use "Select a Device" title for engines with own display lists (camera, scrcpy, etc.)
const titleEl = lightbox.querySelector('.display-picker-title');
if (titleEl) {
titleEl.textContent = t(_pickerEngineType ? 'displays.picker.title.device' : 'displays.picker.title');
}
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>';
displaysCache.fetch().then(displays => {
if (displays && displays.length > 0) {
renderDisplayPickerLayout(displays);
} else {
canvas.innerHTML = `<div class="loading">${t('displays.none')}</div>`;
}
});
}
});
}
async function _fetchAndRenderEngineDisplays(engineType: string): Promise<void> {
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
displaysCache.update(displays);
if (displays.length > 0) {
renderDisplayPickerLayout(displays, engineType);
} else if (engineType === 'scrcpy') {
_renderEmptyAndroidPicker(canvas);
} else {
canvas.innerHTML = `<div class="loading">${t('displays.none')}</div>`;
}
} catch (error) {
console.error('Error fetching engine displays:', error);
canvas.innerHTML = `<div class="loading">${t('displays.failed')}</div>`;
}
}
function _renderEmptyAndroidPicker(canvas: HTMLElement): void {
canvas.innerHTML = `
<div class="loading">${t('displays.picker.no_android')}</div>
${_buildAdbConnectHtml()}
`;
}
function _buildAdbConnectHtml(): string {
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') as HTMLInputElement | null;
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?: Event): void {
if (event && event.target && (event.target as HTMLElement).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: number): void {
// 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: any[], engineType: string | null = null): void {
const canvas = document.getElementById('display-picker-canvas')!;
if (!displays || displays.length === 0) {
canvas.innerHTML = `<div class="loading">${t('displays.none')}</div>`;
return;
}
// Engines with own displays (camera, scrcpy) → device list layout
if (engineType) {
const items = displays.map(display => {
const isSelected = _displayPickerSelectedIndex !== null && display.index === _displayPickerSelectedIndex;
return `
<div class="device-picker-item layout-display-pickable${isSelected ? ' selected' : ''}"
onclick="selectDisplay(${display.index})"
title="${t('displays.picker.click_to_select')}">
<div class="layout-index-label">#${display.index}</div>
<div class="layout-display-label">
<strong>${display.name}</strong>
<small>${display.width}×${display.height} · ${display.refresh_rate} FPS</small>
</div>
</div>
`;
}).join('');
let html = `<div class="device-picker-list">${items}</div>`;
if (engineType === 'scrcpy') {
html += _buildAdbConnectHtml();
}
canvas.innerHTML = html;
return;
}
// Desktop monitors → positioned rectangle layout
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('');
canvas.innerHTML = `
<div class="layout-container" style="width: 100%; padding-bottom: ${aspect * 100}%; position: relative;">
${displayElements}
</div>
`;
}
export function formatDisplayLabel(displayIndex: number, display: any | null, engineType: string | null = null): string {
if (display) {
if (engineType && window._engineHasOwnDisplays(engineType)) {
return `${display.name} (${display.width}×${display.height})`;
}
return `#${display.index}: ${display.width}×${display.height}${display.is_primary ? ' (' + t('displays.badge.primary') + ')' : ''}`;
}
return `Display ${displayIndex}`;
}