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:
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user