Files
ledgrab/server/src/wled_controller/static/js/features/displays.js
T
alexei.dolgolyov a34edf9650 Add reusable DataCache class, unify frontend cache patterns
- Create DataCache class with fetch deduplication, invalidation, subscribers
- Instantiate 10 cache instances in state.js (streams, templates, sources, etc.)
- Replace inline fetch+parse+set patterns with cache.fetch() calls across modules
- Eliminate dual _scenesCache/_presetsCache sync via shared scenePresetsCache
- Remove 9 now-unused setter functions from state.js
- Clean up unused setter imports from audio-sources, value-sources, displays

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-28 19:35:20 +03:00

201 lines
7.7 KiB
JavaScript
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,
} from '../core/state.js';
import { t } from '../core/i18n.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>';
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) {
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 {
_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}`;
}