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) => { document.addEventListener('keydown', (e) => {
if (e.key === 'Escape' && document.getElementById('image-lightbox').classList.contains('active')) { if (e.key === 'Escape') {
closeLightbox(); 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 // Locale management
let currentLocale = 'en'; let currentLocale = 'en';
let translations = {}; let translations = {};
@@ -346,23 +460,11 @@ async function loadDisplays() {
const data = await response.json(); const data = await response.json();
const container = document.getElementById('displays-list'); if (data.displays && data.displays.length > 0) {
_cachedDisplays = data.displays;
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;
} }
// Cache and render visual layout
_cachedDisplays = data.displays;
renderDisplayLayout(data.displays);
} catch (error) { } catch (error) {
console.error('Failed to load displays:', 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-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}`)); document.querySelectorAll('.tab-panel').forEach(panel => panel.classList.toggle('active', panel.id === `tab-${name}`));
localStorage.setItem('activeTab', name); localStorage.setItem('activeTab', name);
if (name === 'displays' && _cachedDisplays) {
requestAnimationFrame(() => renderDisplayLayout(_cachedDisplays));
}
if (name === 'templates') { if (name === 'templates') {
loadCaptureTemplates(); 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 // Load devices
async function loadDevices() { async function loadDevices() {
@@ -2815,33 +2854,31 @@ function collectEngineConfig() {
// Load displays for test selector // Load displays for test selector
async function loadDisplaysForTest() { async function loadDisplaysForTest() {
try { try {
const response = await fetchWithAuth('/config/displays'); if (!_cachedDisplays) {
if (!response.ok) { const response = await fetchWithAuth('/config/displays');
throw new Error(`Failed to load displays: ${response.status}`); 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 // Auto-select: last used display, or primary as fallback
let selectedIndex = null;
const lastDisplay = localStorage.getItem('lastTestDisplayIndex'); const lastDisplay = localStorage.getItem('lastTestDisplayIndex');
if (lastDisplay !== null && select.querySelector(`option[value="${lastDisplay}"]`)) {
select.value = lastDisplay; if (lastDisplay !== null) {
} else if (primaryIndex !== null) { const found = _cachedDisplays.find(d => d.index === parseInt(lastDisplay));
select.value = String(primaryIndex); 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) { } catch (error) {
console.error('Error loading displays:', 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-modal-title').textContent = t(titleKey);
document.getElementById('stream-form').reset(); document.getElementById('stream-form').reset();
document.getElementById('stream-id').value = ''; 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-error').style.display = 'none';
document.getElementById('stream-type').value = streamType; document.getElementById('stream-type').value = streamType;
onStreamTypeChange(); onStreamTypeChange();
@@ -3181,7 +3220,9 @@ async function editStream(streamId) {
await populateStreamModalDropdowns(); await populateStreamModalDropdowns();
if (stream.stream_type === 'raw') { 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 || ''; document.getElementById('stream-capture-template').value = stream.capture_template_id || '';
const fps = stream.target_fps ?? 30; const fps = stream.target_fps ?? 30;
document.getElementById('stream-target-fps').value = fps; document.getElementById('stream-target-fps').value = fps;
@@ -3210,23 +3251,16 @@ async function populateStreamModalDropdowns() {
fetchWithAuth('/postprocessing-templates'), fetchWithAuth('/postprocessing-templates'),
]); ]);
// Displays // Displays - warm cache for display picker
const displaySelect = document.getElementById('stream-display-index');
displaySelect.innerHTML = '';
if (displaysRes.ok) { if (displaysRes.ok) {
const displaysData = await displaysRes.json(); const displaysData = await displaysRes.json();
(displaysData.displays || []).forEach(d => { _cachedDisplays = displaysData.displays || [];
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);
});
} }
if (displaySelect.options.length === 0) {
const opt = document.createElement('option'); // Auto-select primary display if none selected yet
opt.value = '0'; if (!document.getElementById('stream-display-index').value && _cachedDisplays && _cachedDisplays.length > 0) {
opt.textContent = '0'; const primary = _cachedDisplays.find(d => d.is_primary) || _cachedDisplays[0];
displaySelect.appendChild(opt); onStreamDisplaySelected(primary.index, primary);
} }
// Capture templates // Capture templates

View File

@@ -35,7 +35,7 @@
<div class="tabs"> <div class="tabs">
<div class="tab-bar"> <div class="tab-bar">
<button class="tab-btn active" data-tab="devices" onclick="switchTab('devices')"><span data-i18n="devices.title">💡 Devices</span></button> <button class="tab-btn active" data-tab="devices" onclick="switchTab('devices')"><span data-i18n="devices.title">💡 Devices</span></button>
<button class="tab-btn" data-tab="displays" onclick="switchTab('displays')"><span data-i18n="displays.layout">🖥️ Displays</span></button>
<button class="tab-btn" data-tab="streams" onclick="switchTab('streams')"><span data-i18n="streams.title">📺 Picture Streams</span></button> <button class="tab-btn" data-tab="streams" onclick="switchTab('streams')"><span data-i18n="streams.title">📺 Picture Streams</span></button>
<button class="tab-btn" data-tab="templates" onclick="switchTab('templates')"><span data-i18n="templates.title">🎯 Capture Templates</span></button> <button class="tab-btn" data-tab="templates" onclick="switchTab('templates')"><span data-i18n="templates.title">🎯 Capture Templates</span></button>
<button class="tab-btn" data-tab="pp-templates" onclick="switchTab('pp-templates')"><span data-i18n="postprocessing.title">🎨 Processing Templates</span></button> <button class="tab-btn" data-tab="pp-templates" onclick="switchTab('pp-templates')"><span data-i18n="postprocessing.title">🎨 Processing Templates</span></button>
@@ -55,14 +55,6 @@
</div> </div>
</div> </div>
<div class="tab-panel" id="tab-displays">
<div class="display-layout-preview">
<div id="display-layout-canvas" class="display-layout-canvas">
<div class="loading-spinner"></div>
</div>
</div>
<div id="displays-list" style="display: none;"></div>
</div>
<div class="tab-panel" id="tab-streams"> <div class="tab-panel" id="tab-streams">
<p class="section-tip"> <p class="section-tip">
@@ -451,10 +443,11 @@
</div> </div>
<div class="modal-body"> <div class="modal-body">
<div class="form-group"> <div class="form-group">
<label for="test-template-display" data-i18n="templates.test.display">Display:</label> <label data-i18n="templates.test.display">Display:</label>
<select id="test-template-display"> <input type="hidden" id="test-template-display" value="">
<option value="" data-i18n="templates.test.display.select">Select display...</option> <button type="button" class="btn btn-display-picker" id="test-display-picker-btn" onclick="openDisplayPicker(onTestDisplaySelected)">
</select> <span id="test-display-picker-label" data-i18n="displays.picker.select">Select display...</span>
</button>
</div> </div>
<div class="form-group"> <div class="form-group">
@@ -517,8 +510,11 @@
<!-- Raw stream fields --> <!-- Raw stream fields -->
<div id="stream-raw-fields"> <div id="stream-raw-fields">
<div class="form-group"> <div class="form-group">
<label for="stream-display-index" data-i18n="streams.display">Display:</label> <label data-i18n="streams.display">Display:</label>
<select id="stream-display-index"></select> <input type="hidden" id="stream-display-index" value="">
<button type="button" class="btn btn-display-picker" id="stream-display-picker-btn" onclick="openDisplayPicker(onStreamDisplaySelected)">
<span id="stream-display-picker-label" data-i18n="displays.picker.select">Select display...</span>
</button>
</div> </div>
<div class="form-group"> <div class="form-group">
<label for="stream-capture-template" data-i18n="streams.capture_template">Capture Template:</label> <label for="stream-capture-template" data-i18n="streams.capture_template">Capture Template:</label>
@@ -627,6 +623,17 @@
</div> </div>
</div> </div>
<!-- Display Picker Lightbox -->
<div id="display-picker-lightbox" class="lightbox" onclick="closeDisplayPicker(event)">
<button class="lightbox-close" onclick="closeDisplayPicker()" title="Close">&#x2715;</button>
<div class="lightbox-content display-picker-content">
<h3 class="display-picker-title" data-i18n="displays.picker.title">Select a Display</h3>
<div id="display-picker-canvas" class="display-picker-canvas">
<div class="loading-spinner"></div>
</div>
</div>
</div>
<script src="/static/app.js"></script> <script src="/static/app.js"></script>
<script> <script>
// Initialize theme // Initialize theme
@@ -682,7 +689,6 @@
// Clear the UI // Clear the UI
document.getElementById('devices-list').innerHTML = `<div class="loading">${t('auth.please_login')} devices</div>`; document.getElementById('devices-list').innerHTML = `<div class="loading">${t('auth.please_login')} devices</div>`;
document.getElementById('displays-list').innerHTML = `<div class="loading">${t('auth.please_login')} displays</div>`;
} }
// Initialize on load // Initialize on load

View File

@@ -32,6 +32,9 @@
"displays.loading": "Loading displays...", "displays.loading": "Loading displays...",
"displays.none": "No displays available", "displays.none": "No displays available",
"displays.failed": "Failed to load displays", "displays.failed": "Failed to load displays",
"displays.picker.title": "Select a Display",
"displays.picker.select": "Select display...",
"displays.picker.click_to_select": "Click to select this display",
"templates.title": "\uD83C\uDFAF Capture Templates", "templates.title": "\uD83C\uDFAF Capture Templates",
"templates.description": "Capture templates define how the screen is captured. Each template uses a specific capture engine (MSS, DXcam, WGC) with custom settings. Assign templates to devices for optimal performance.", "templates.description": "Capture templates define how the screen is captured. Each template uses a specific capture engine (MSS, DXcam, WGC) with custom settings. Assign templates to devices for optimal performance.",
"templates.loading": "Loading templates...", "templates.loading": "Loading templates...",

View File

@@ -32,6 +32,9 @@
"displays.loading": "Загрузка дисплеев...", "displays.loading": "Загрузка дисплеев...",
"displays.none": "Нет доступных дисплеев", "displays.none": "Нет доступных дисплеев",
"displays.failed": "Не удалось загрузить дисплеи", "displays.failed": "Не удалось загрузить дисплеи",
"displays.picker.title": "Выберите Дисплей",
"displays.picker.select": "Выберите дисплей...",
"displays.picker.click_to_select": "Нажмите, чтобы выбрать этот дисплей",
"templates.title": "\uD83C\uDFAF Шаблоны Захвата", "templates.title": "\uD83C\uDFAF Шаблоны Захвата",
"templates.description": "Шаблоны захвата определяют, как захватывается экран. Каждый шаблон использует определённый движок захвата (MSS, DXcam, WGC) с настраиваемыми параметрами. Назначайте шаблоны устройствам для оптимальной производительности.", "templates.description": "Шаблоны захвата определяют, как захватывается экран. Каждый шаблон использует определённый движок захвата (MSS, DXcam, WGC) с настраиваемыми параметрами. Назначайте шаблоны устройствам для оптимальной производительности.",
"templates.loading": "Загрузка шаблонов...", "templates.loading": "Загрузка шаблонов...",

View File

@@ -487,32 +487,6 @@ section {
} }
/* Display Layout Visualization */ /* Display Layout Visualization */
.display-layout-preview {
background: var(--card-bg);
border: 1px solid var(--border-color);
border-radius: 8px;
padding: 20px;
margin-bottom: 20px;
}
.display-layout-preview h3 {
margin: 0 0 15px 0;
font-size: 1.1rem;
color: var(--text-color);
}
.display-layout-canvas {
background: var(--bg-color);
border: 2px dashed var(--border-color);
border-radius: 8px;
padding: 30px;
min-height: 280px;
display: flex;
align-items: center;
justify-content: center;
overflow: hidden;
}
.layout-container { .layout-container {
position: relative; position: relative;
background: transparent; background: transparent;
@@ -527,12 +501,10 @@ section {
align-items: center; align-items: center;
justify-content: center; justify-content: center;
box-shadow: 0 4px 6px rgba(0, 0, 0, 0.2); box-shadow: 0 4px 6px rgba(0, 0, 0, 0.2);
transition: transform 0.2s, box-shadow 0.2s; transition: box-shadow 0.2s, border-color 0.2s;
cursor: help;
} }
.layout-display:hover { .layout-display:hover {
transform: scale(1.05);
box-shadow: 0 6px 12px rgba(0, 0, 0, 0.3); box-shadow: 0 6px 12px rgba(0, 0, 0, 0.3);
z-index: 10; z-index: 10;
} }
@@ -597,39 +569,6 @@ section {
text-shadow: 0 0 4px rgba(0, 0, 0, 0.4); text-shadow: 0 0 4px rgba(0, 0, 0, 0.4);
} }
.layout-legend {
display: flex;
gap: 20px;
justify-content: center;
margin-top: 15px;
padding-top: 15px;
border-top: 1px solid var(--border-color);
}
.legend-item {
display: flex;
align-items: center;
gap: 8px;
font-size: 0.9rem;
color: var(--text-secondary);
}
.legend-dot {
width: 16px;
height: 16px;
border-radius: 3px;
border: 2px solid;
}
.legend-dot.primary {
border-color: var(--primary-color);
background: rgba(76, 175, 80, 0.2);
}
.legend-dot.secondary {
border-color: var(--border-color);
background: rgba(128, 128, 128, 0.2);
}
/* Card brightness slider */ /* Card brightness slider */
.brightness-control { .brightness-control {
@@ -2327,6 +2266,58 @@ input:-webkit-autofill:focus {
font-weight: 600; font-weight: 600;
} }
/* Display Picker Lightbox */
.display-picker-content {
max-width: 900px;
width: 90%;
text-align: center;
}
.display-picker-title {
color: white;
font-size: 1.3rem;
margin-bottom: 20px;
font-weight: 500;
}
.display-picker-canvas {
background: rgba(255, 255, 255, 0.05);
border: 2px dashed rgba(255, 255, 255, 0.15);
border-radius: 8px;
padding: 24px;
width: 100%;
box-sizing: border-box;
}
.layout-display-pickable {
cursor: pointer !important;
}
.layout-display-pickable:hover {
box-shadow: 0 0 20px rgba(76, 175, 80, 0.4);
border-color: var(--primary-color) !important;
}
/* Display picker button in forms */
.btn-display-picker {
width: 100%;
padding: 10px;
border: 1px solid var(--border-color);
border-radius: 4px;
background: var(--bg-color);
color: var(--text-color);
font-size: 1rem;
cursor: pointer;
text-align: left;
transition: border-color 0.2s, box-shadow 0.2s;
font-weight: 400;
}
.btn-display-picker:hover {
border-color: var(--primary-color);
box-shadow: 0 0 0 2px rgba(76, 175, 80, 0.15);
}
/* Responsive adjustments */ /* Responsive adjustments */
@media (max-width: 768px) { @media (max-width: 768px) {
.templates-grid { .templates-grid {