Files
wled-screen-controller-mixed/server/src/wled_controller/static/js/features/displays.js
alexei.dolgolyov 6366b0b317 Fix mobile color picker popup clipping and locale update for tabs/sections
Color picker popover now uses fixed positioning on small screens to
escape the header toolbar overflow container. Section titles, sub-tab
labels, and filter placeholders use data-i18n attributes so they update
automatically on language change. Display picker title switches to
"Select a Device" for engine-owned display lists.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-01 14:26:15 +03:00

238 lines
9.5 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,
availableEngines,
} 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;
/** Check if an engine type has its own device list (for inline onclick use). */
window._engineHasOwnDisplays = (engineType) =>
!!(engineType && availableEngines.find(e => e.type === engineType)?.has_own_displays);
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');
// 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) {
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) {
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;
}
// 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, display, engineType = null) {
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}`;
}