Replace display selection dropdowns with visual display picker lightbox

Remove the Displays tab and replace <select> dropdowns in stream and
template test modals with a lightbox overlay showing the spatial display
layout. Clicking a display selects it. Uses percentage-based positioning
so the layout always fits its container.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-02-11 12:36:21 +03:00
parent ebd6cc7d7d
commit c8ebb60f99
5 changed files with 232 additions and 195 deletions

View File

@@ -56,11 +56,125 @@ function closeLightbox(event) {
}
document.addEventListener('keydown', (e) => {
if (e.key === 'Escape' && document.getElementById('image-lightbox').classList.contains('active')) {
closeLightbox();
if (e.key === 'Escape') {
if (document.getElementById('display-picker-lightbox').classList.contains('active')) {
closeDisplayPicker();
} else if (document.getElementById('image-lightbox').classList.contains('active')) {
closeLightbox();
}
}
});
// Display picker lightbox
let _displayPickerCallback = null;
function openDisplayPicker(callback) {
_displayPickerCallback = callback;
const lightbox = document.getElementById('display-picker-lightbox');
const canvas = document.getElementById('display-picker-canvas');
lightbox.classList.add('active');
// Defer render to next frame so the lightbox has been laid out and canvas has dimensions
requestAnimationFrame(() => {
if (_cachedDisplays && _cachedDisplays.length > 0) {
renderDisplayPickerLayout(_cachedDisplays);
} else {
canvas.innerHTML = '<div class="loading-spinner"></div>';
loadDisplays().then(() => {
if (_cachedDisplays && _cachedDisplays.length > 0) {
renderDisplayPickerLayout(_cachedDisplays);
} else {
canvas.innerHTML = `<div class="loading">${t('displays.none')}</div>`;
}
});
}
});
}
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');
_displayPickerCallback = null;
}
function selectDisplay(displayIndex) {
if (_displayPickerCallback) {
const display = _cachedDisplays ? _cachedDisplays.find(d => d.index === displayIndex) : null;
_displayPickerCallback(displayIndex, display);
}
closeDisplayPicker();
}
function renderDisplayPickerLayout(displays) {
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;
// Use percentage-based positioning so layout always fits its container
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;
return `
<div class="layout-display layout-display-pickable ${display.is_primary ? 'primary' : 'secondary'}"
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>
${display.is_primary ? '<div class="primary-indicator">★</div>' : ''}
</div>
`;
}).join('');
canvas.innerHTML = `
<div class="layout-container" style="width: 100%; padding-bottom: ${aspect * 100}%; position: relative;">
${displayElements}
</div>
`;
}
function formatDisplayLabel(displayIndex, display) {
if (display) {
return `#${display.index}: ${display.width}×${display.height}${display.is_primary ? ' (' + t('displays.badge.primary') + ')' : ''}`;
}
return `Display ${displayIndex}`;
}
function onStreamDisplaySelected(displayIndex, display) {
document.getElementById('stream-display-index').value = displayIndex;
document.getElementById('stream-display-picker-label').textContent = formatDisplayLabel(displayIndex, display);
}
function onTestDisplaySelected(displayIndex, display) {
document.getElementById('test-template-display').value = displayIndex;
document.getElementById('test-display-picker-label').textContent = formatDisplayLabel(displayIndex, display);
}
// Locale management
let currentLocale = 'en';
let translations = {};
@@ -346,23 +460,11 @@ async function loadDisplays() {
const data = await response.json();
const container = document.getElementById('displays-list');
if (!data.displays || data.displays.length === 0) {
container.innerHTML = `<div class="loading">${t('displays.none')}</div>`;
document.getElementById('display-layout-canvas').innerHTML = `<div class="loading">${t('displays.none')}</div>`;
return;
if (data.displays && data.displays.length > 0) {
_cachedDisplays = data.displays;
}
// Cache and render visual layout
_cachedDisplays = data.displays;
renderDisplayLayout(data.displays);
} catch (error) {
console.error('Failed to load displays:', error);
document.getElementById('displays-list').innerHTML =
`<div class="loading">${t('displays.failed')}</div>`;
document.getElementById('display-layout-canvas').innerHTML =
`<div class="loading">${t('displays.failed')}</div>`;
}
}
@@ -372,9 +474,6 @@ function switchTab(name) {
document.querySelectorAll('.tab-btn').forEach(btn => btn.classList.toggle('active', btn.dataset.tab === name));
document.querySelectorAll('.tab-panel').forEach(panel => panel.classList.toggle('active', panel.id === `tab-${name}`));
localStorage.setItem('activeTab', name);
if (name === 'displays' && _cachedDisplays) {
requestAnimationFrame(() => renderDisplayLayout(_cachedDisplays));
}
if (name === 'templates') {
loadCaptureTemplates();
}
@@ -393,66 +492,6 @@ function initTabs() {
}
}
function renderDisplayLayout(displays) {
const canvas = document.getElementById('display-layout-canvas');
if (!displays || displays.length === 0) {
canvas.innerHTML = `<div class="loading">${t('displays.none')}</div>`;
return;
}
// Calculate bounding box for all displays
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;
// Scale factor to fit in canvas (respect available width, maintain aspect ratio)
const availableWidth = canvas.clientWidth - 60; // account for padding
const maxCanvasHeight = 350;
const scaleX = availableWidth / totalWidth;
const scaleY = maxCanvasHeight / totalHeight;
const scale = Math.min(scaleX, scaleY);
const canvasWidth = totalWidth * scale;
const canvasHeight = totalHeight * scale;
// Create display elements
const displayElements = displays.map(display => {
const left = (display.x - minX) * scale;
const top = (display.y - minY) * scale;
const width = display.width * scale;
const height = display.height * scale;
return `
<div class="layout-display ${display.is_primary ? 'primary' : 'secondary'}"
style="left: ${left}px; top: ${top}px; width: ${width}px; height: ${height}px;"
title="${display.name}\n${display.width}×${display.height}\nPosition: (${display.x}, ${display.y})">
<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>
${display.is_primary ? '<div class="primary-indicator">★</div>' : ''}
</div>
`;
}).join('');
canvas.innerHTML = `
<div class="layout-container" style="width: ${canvasWidth}px; height: ${canvasHeight}px; margin: 0 auto; position: relative;">
${displayElements}
</div>
`;
}
// Load devices
async function loadDevices() {
@@ -2815,33 +2854,31 @@ function collectEngineConfig() {
// Load displays for test selector
async function loadDisplaysForTest() {
try {
const response = await fetchWithAuth('/config/displays');
if (!response.ok) {
throw new Error(`Failed to load displays: ${response.status}`);
if (!_cachedDisplays) {
const response = await fetchWithAuth('/config/displays');
if (!response.ok) throw new Error(`Failed to load displays: ${response.status}`);
const displaysData = await response.json();
_cachedDisplays = displaysData.displays || [];
}
const displaysData = await response.json();
const select = document.getElementById('test-template-display');
select.innerHTML = '';
let primaryIndex = null;
(displaysData.displays || []).forEach(display => {
const option = document.createElement('option');
option.value = display.index;
option.textContent = `Display ${display.index} (${display.width}x${display.height})`;
if (display.is_primary) {
option.textContent += ' ★';
primaryIndex = display.index;
}
select.appendChild(option);
});
// Auto-select: last used display, or primary as fallback
let selectedIndex = null;
const lastDisplay = localStorage.getItem('lastTestDisplayIndex');
if (lastDisplay !== null && select.querySelector(`option[value="${lastDisplay}"]`)) {
select.value = lastDisplay;
} else if (primaryIndex !== null) {
select.value = String(primaryIndex);
if (lastDisplay !== null) {
const found = _cachedDisplays.find(d => d.index === parseInt(lastDisplay));
if (found) selectedIndex = found.index;
}
if (selectedIndex === null) {
const primary = _cachedDisplays.find(d => d.is_primary);
if (primary) selectedIndex = primary.index;
else if (_cachedDisplays.length > 0) selectedIndex = _cachedDisplays[0].index;
}
if (selectedIndex !== null) {
const display = _cachedDisplays.find(d => d.index === selectedIndex);
onTestDisplaySelected(selectedIndex, display);
}
} catch (error) {
console.error('Error loading displays:', error);
@@ -3147,6 +3184,8 @@ async function showAddStreamModal(presetType) {
document.getElementById('stream-modal-title').textContent = t(titleKey);
document.getElementById('stream-form').reset();
document.getElementById('stream-id').value = '';
document.getElementById('stream-display-index').value = '';
document.getElementById('stream-display-picker-label').textContent = t('displays.picker.select');
document.getElementById('stream-error').style.display = 'none';
document.getElementById('stream-type').value = streamType;
onStreamTypeChange();
@@ -3181,7 +3220,9 @@ async function editStream(streamId) {
await populateStreamModalDropdowns();
if (stream.stream_type === 'raw') {
document.getElementById('stream-display-index').value = String(stream.display_index ?? 0);
const displayIdx = stream.display_index ?? 0;
const display = _cachedDisplays ? _cachedDisplays.find(d => d.index === displayIdx) : null;
onStreamDisplaySelected(displayIdx, display);
document.getElementById('stream-capture-template').value = stream.capture_template_id || '';
const fps = stream.target_fps ?? 30;
document.getElementById('stream-target-fps').value = fps;
@@ -3210,23 +3251,16 @@ async function populateStreamModalDropdowns() {
fetchWithAuth('/postprocessing-templates'),
]);
// Displays
const displaySelect = document.getElementById('stream-display-index');
displaySelect.innerHTML = '';
// Displays - warm cache for display picker
if (displaysRes.ok) {
const displaysData = await displaysRes.json();
(displaysData.displays || []).forEach(d => {
const opt = document.createElement('option');
opt.value = d.index;
opt.textContent = `${d.index}: ${d.width}x${d.height}${d.is_primary ? ` (${t('displays.badge.primary')})` : ''}`;
displaySelect.appendChild(opt);
});
_cachedDisplays = displaysData.displays || [];
}
if (displaySelect.options.length === 0) {
const opt = document.createElement('option');
opt.value = '0';
opt.textContent = '0';
displaySelect.appendChild(opt);
// Auto-select primary display if none selected yet
if (!document.getElementById('stream-display-index').value && _cachedDisplays && _cachedDisplays.length > 0) {
const primary = _cachedDisplays.find(d => d.is_primary) || _cachedDisplays[0];
onStreamDisplaySelected(primary.index, primary);
}
// Capture templates