Simplify calibration model, add pixel preview, and improve UI
Some checks failed
Validate / validate (push) Failing after 9s

- Replace segment-based calibration with core parameters (leds_top/right/bottom/left);
  segments are now derived at runtime via lookup tables
- Fix clockwise/counterclockwise edge traversal order for all 8 start_position/layout
  combinations (e.g. bottom_left+clockwise now correctly goes up-left first)
- Add pixel layout preview overlay with color-coded edges, LED index labels,
  direction arrows, and start position marker
- Move "Add New Device" form into a modal dialog triggered by "+" button
- Add display index selector to device settings modal
- Migrate from requirements.txt to pyproject.toml for dependency management
- Update Dockerfile and docs to use `pip install .`

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-02-08 03:05:09 +03:00
parent d4261d76d8
commit 7f613df362
15 changed files with 965 additions and 317 deletions

View File

@@ -562,6 +562,9 @@ function createDeviceCard(device) {
<button class="btn btn-icon btn-secondary" onclick="showCalibration('${device.id}')" title="${t('device.button.calibrate')}">
📐
</button>
<button class="btn btn-icon btn-secondary" onclick="showPixelPreview('${device.id}')" title="${t('preview.button')}">
👁️
</button>
<button class="btn btn-icon btn-danger" onclick="removeDevice('${device.id}')" title="${t('device.button.remove')}">
🗑️
</button>
@@ -663,34 +666,55 @@ async function removeDevice(deviceId) {
async function showSettings(deviceId) {
try {
// Fetch current device data
const response = await fetch(`${API_BASE}/devices/${deviceId}`, {
headers: getHeaders()
});
// Fetch device data and displays in parallel
const [deviceResponse, displaysResponse] = await Promise.all([
fetch(`${API_BASE}/devices/${deviceId}`, { headers: getHeaders() }),
fetch(`${API_BASE}/config/displays`, { headers: getHeaders() }),
]);
if (response.status === 401) {
if (deviceResponse.status === 401) {
handle401Error();
return;
}
if (!response.ok) {
if (!deviceResponse.ok) {
showToast('Failed to load device settings', 'error');
return;
}
const device = await response.json();
const device = await deviceResponse.json();
// Populate modal
// Populate display index select
const displaySelect = document.getElementById('settings-display-index');
displaySelect.innerHTML = '';
if (displaysResponse.ok) {
const displaysData = await displaysResponse.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);
});
}
if (displaySelect.options.length === 0) {
const opt = document.createElement('option');
opt.value = '0';
opt.textContent = '0';
displaySelect.appendChild(opt);
}
displaySelect.value = String(device.settings.display_index ?? 0);
// Populate other fields
document.getElementById('settings-device-id').value = device.id;
document.getElementById('settings-device-name').value = device.name;
document.getElementById('settings-device-url').value = device.url;
// Set health check interval
document.getElementById('settings-health-interval').value = device.settings.state_check_interval || 30;
// Snapshot initial values for dirty checking
settingsInitialValues = {
name: device.name,
url: device.url,
display_index: String(device.settings.display_index ?? 0),
state_check_interval: String(device.settings.state_check_interval || 30),
};
@@ -714,6 +738,7 @@ function isSettingsDirty() {
return (
document.getElementById('settings-device-name').value !== settingsInitialValues.name ||
document.getElementById('settings-device-url').value !== settingsInitialValues.url ||
document.getElementById('settings-display-index').value !== settingsInitialValues.display_index ||
document.getElementById('settings-health-interval').value !== settingsInitialValues.state_check_interval
);
}
@@ -739,6 +764,7 @@ async function saveDeviceSettings() {
const deviceId = document.getElementById('settings-device-id').value;
const name = document.getElementById('settings-device-name').value.trim();
const url = document.getElementById('settings-device-url').value.trim();
const display_index = parseInt(document.getElementById('settings-display-index').value) || 0;
const state_check_interval = parseInt(document.getElementById('settings-health-interval').value) || 30;
const error = document.getElementById('settings-error');
@@ -773,7 +799,7 @@ async function saveDeviceSettings() {
const settingsResponse = await fetch(`${API_BASE}/devices/${deviceId}/settings`, {
method: 'PUT',
headers: getHeaders(),
body: JSON.stringify({ state_check_interval })
body: JSON.stringify({ display_index, state_check_interval })
});
if (settingsResponse.status === 401) {
@@ -817,14 +843,36 @@ async function saveCardBrightness(deviceId, value) {
}
}
// Add device form handler
// Add device modal
function showAddDevice() {
const modal = document.getElementById('add-device-modal');
const form = document.getElementById('add-device-form');
const error = document.getElementById('add-device-error');
form.reset();
error.style.display = 'none';
modal.style.display = 'flex';
lockBody();
setTimeout(() => document.getElementById('device-name').focus(), 100);
}
function closeAddDeviceModal() {
const modal = document.getElementById('add-device-modal');
modal.style.display = 'none';
unlockBody();
}
async function handleAddDevice(event) {
event.preventDefault();
const name = document.getElementById('device-name').value;
const url = document.getElementById('device-url').value;
const name = document.getElementById('device-name').value.trim();
const url = document.getElementById('device-url').value.trim();
const error = document.getElementById('add-device-error');
console.log(`Adding device: ${name} (${url})`);
if (!name || !url) {
error.textContent = 'Please fill in all fields';
error.style.display = 'block';
return;
}
try {
const response = await fetch(`${API_BASE}/devices`, {
@@ -842,15 +890,16 @@ async function handleAddDevice(event) {
const result = await response.json();
console.log('Device added successfully:', result);
showToast('Device added successfully', 'success');
event.target.reset();
closeAddDeviceModal();
loadDevices();
} else {
const error = await response.json();
console.error('Failed to add device:', error);
showToast(`Failed to add device: ${error.detail}`, 'error');
const errorData = await response.json();
console.error('Failed to add device:', errorData);
error.textContent = `Failed to add device: ${errorData.detail}`;
error.style.display = 'block';
}
} catch (error) {
console.error('Failed to add device:', error);
} catch (err) {
console.error('Failed to add device:', err);
showToast('Failed to add device', 'error');
}
}
@@ -945,25 +994,20 @@ async function showCalibration(deviceId) {
document.getElementById('cal-offset').value = calibration.offset || 0;
// Set LED counts per edge
const edgeCounts = { top: 0, right: 0, bottom: 0, left: 0 };
calibration.segments.forEach(seg => {
edgeCounts[seg.edge] = seg.led_count;
});
document.getElementById('cal-top-leds').value = edgeCounts.top;
document.getElementById('cal-right-leds').value = edgeCounts.right;
document.getElementById('cal-bottom-leds').value = edgeCounts.bottom;
document.getElementById('cal-left-leds').value = edgeCounts.left;
document.getElementById('cal-top-leds').value = calibration.leds_top || 0;
document.getElementById('cal-right-leds').value = calibration.leds_right || 0;
document.getElementById('cal-bottom-leds').value = calibration.leds_bottom || 0;
document.getElementById('cal-left-leds').value = calibration.leds_left || 0;
// Snapshot initial values for dirty checking
calibrationInitialValues = {
start_position: calibration.start_position,
layout: calibration.layout,
offset: String(calibration.offset || 0),
top: String(edgeCounts.top),
right: String(edgeCounts.right),
bottom: String(edgeCounts.bottom),
left: String(edgeCounts.left),
top: String(calibration.leds_top || 0),
right: String(calibration.leds_right || 0),
bottom: String(calibration.leds_bottom || 0),
left: String(calibration.leds_left || 0),
};
// Initialize test mode state for this device
@@ -1167,40 +1211,16 @@ async function saveCalibration() {
// Build calibration config
const startPosition = document.getElementById('cal-start-position').value;
const layout = document.getElementById('cal-layout').value;
// Build segments based on start position and direction
const segments = [];
let ledStart = 0;
const edgeOrder = getEdgeOrder(startPosition, layout);
const edgeCounts = {
top: topLeds,
right: rightLeds,
bottom: bottomLeds,
left: leftLeds
};
edgeOrder.forEach(edge => {
const count = edgeCounts[edge];
if (count > 0) {
segments.push({
edge: edge,
led_start: ledStart,
led_count: count,
reverse: shouldReverse(edge, startPosition, layout)
});
ledStart += count;
}
});
const offset = parseInt(document.getElementById('cal-offset').value || 0);
const calibration = {
layout: layout,
start_position: startPosition,
offset: offset,
segments: segments
leds_top: topLeds,
leds_right: rightLeds,
leds_bottom: bottomLeds,
leds_left: leftLeds
};
try {
@@ -1232,43 +1252,465 @@ async function saveCalibration() {
}
function getEdgeOrder(startPosition, layout) {
const clockwise = ['bottom', 'right', 'top', 'left'];
const counterclockwise = ['bottom', 'left', 'top', 'right'];
const orders = {
'bottom_left_clockwise': clockwise,
'bottom_left_counterclockwise': counterclockwise,
'bottom_left_clockwise': ['left', 'top', 'right', 'bottom'],
'bottom_left_counterclockwise': ['bottom', 'right', 'top', 'left'],
'bottom_right_clockwise': ['bottom', 'left', 'top', 'right'],
'bottom_right_counterclockwise': ['bottom', 'right', 'top', 'left'],
'bottom_right_counterclockwise': ['right', 'top', 'left', 'bottom'],
'top_left_clockwise': ['top', 'right', 'bottom', 'left'],
'top_left_counterclockwise': ['top', 'left', 'bottom', 'right'],
'top_right_clockwise': ['top', 'left', 'bottom', 'right'],
'top_right_counterclockwise': ['top', 'right', 'bottom', 'left']
'top_left_counterclockwise': ['left', 'bottom', 'right', 'top'],
'top_right_clockwise': ['right', 'bottom', 'left', 'top'],
'top_right_counterclockwise': ['top', 'left', 'bottom', 'right']
};
return orders[`${startPosition}_${layout}`] || clockwise;
return orders[`${startPosition}_${layout}`] || ['left', 'top', 'right', 'bottom'];
}
function shouldReverse(edge, startPosition, layout) {
// Determine if this edge should be reversed based on LED strip direction
const reverseRules = {
'bottom_left_clockwise': { bottom: false, right: false, top: true, left: true },
'bottom_left_clockwise': { left: true, top: false, right: false, bottom: true },
'bottom_left_counterclockwise': { bottom: false, right: true, top: true, left: false },
'bottom_right_clockwise': { bottom: true, right: false, top: false, left: true },
'bottom_right_counterclockwise': { bottom: true, right: true, top: false, left: false },
'bottom_right_clockwise': { bottom: true, left: true, top: false, right: false },
'bottom_right_counterclockwise': { right: true, top: true, left: false, bottom: false },
'top_left_clockwise': { top: false, right: false, bottom: true, left: true },
'top_left_counterclockwise': { top: false, right: true, bottom: true, left: false },
'top_right_clockwise': { top: true, right: false, bottom: false, left: true },
'top_right_counterclockwise': { top: true, right: true, bottom: false, left: false }
'top_left_counterclockwise': { left: false, bottom: false, right: true, top: true },
'top_right_clockwise': { right: false, bottom: true, left: true, top: false },
'top_right_counterclockwise': { top: true, left: false, bottom: false, right: true }
};
const rules = reverseRules[`${startPosition}_${layout}`];
return rules ? rules[edge] : false;
}
// Close modals on backdrop click
function buildSegments(calibration) {
const edgeOrder = getEdgeOrder(calibration.start_position, calibration.layout);
const edgeCounts = {
top: calibration.leds_top || 0,
right: calibration.leds_right || 0,
bottom: calibration.leds_bottom || 0,
left: calibration.leds_left || 0
};
const segments = [];
let ledStart = 0;
edgeOrder.forEach(edge => {
const count = edgeCounts[edge];
if (count > 0) {
segments.push({
edge: edge,
led_start: ledStart,
led_count: count,
reverse: shouldReverse(edge, calibration.start_position, calibration.layout)
});
ledStart += count;
}
});
return segments;
}
// Pixel Layout Preview functions
async function showPixelPreview(deviceId) {
try {
const [deviceResponse, displaysResponse] = await Promise.all([
fetch(`${API_BASE}/devices/${deviceId}`, { headers: getHeaders() }),
fetch(`${API_BASE}/config/displays`, { headers: getHeaders() }),
]);
if (deviceResponse.status === 401) {
handle401Error();
return;
}
if (!deviceResponse.ok) {
showToast('Failed to load device data', 'error');
return;
}
const device = await deviceResponse.json();
const calibration = device.calibration;
const totalLeds = (calibration?.leds_top || 0) + (calibration?.leds_right || 0) +
(calibration?.leds_bottom || 0) + (calibration?.leds_left || 0);
if (!calibration || totalLeds === 0) {
showToast(t('preview.no_calibration'), 'error');
return;
}
// Derive segments from core parameters
calibration.segments = buildSegments(calibration);
let displayWidth = 1920;
let displayHeight = 1080;
if (displaysResponse.ok) {
const displaysData = await displaysResponse.json();
const displayIndex = device.settings.display_index || 0;
const display = (displaysData.displays || []).find(d => d.index === displayIndex);
if (display) {
displayWidth = display.width;
displayHeight = display.height;
}
}
const overlay = document.getElementById('pixel-preview-overlay');
overlay.style.display = 'flex';
lockBody();
document.getElementById('pixel-preview-device-name').textContent =
`${device.name} (${device.led_count} LEDs)`;
buildPreviewLegend(calibration);
// Render after layout settles
requestAnimationFrame(() => {
renderPixelPreview(calibration, displayWidth, displayHeight);
});
overlay._resizeHandler = () => {
renderPixelPreview(calibration, displayWidth, displayHeight);
};
window.addEventListener('resize', overlay._resizeHandler);
} catch (error) {
console.error('Failed to show pixel preview:', error);
showToast('Failed to load pixel preview', 'error');
}
}
function closePixelPreview() {
const overlay = document.getElementById('pixel-preview-overlay');
overlay.style.display = 'none';
unlockBody();
if (overlay._resizeHandler) {
window.removeEventListener('resize', overlay._resizeHandler);
overlay._resizeHandler = null;
}
}
function buildPreviewLegend(calibration) {
const legendContainer = document.getElementById('pixel-preview-legend');
const edgeNames = {
top: t('preview.edge.top'),
right: t('preview.edge.right'),
bottom: t('preview.edge.bottom'),
left: t('preview.edge.left')
};
const items = calibration.segments.map(seg => {
const [r, g, b] = EDGE_TEST_COLORS[seg.edge] || [128, 128, 128];
const last = seg.led_start + seg.led_count - 1;
return `<div class="pixel-preview-legend-item">
<div class="pixel-preview-legend-swatch" style="background: rgb(${r},${g},${b})"></div>
${edgeNames[seg.edge] || seg.edge}: ${seg.led_count} LEDs (#${seg.led_start}\u2013${last})
</div>`;
});
const dirText = calibration.layout === 'clockwise' ? t('preview.direction.cw') : t('preview.direction.ccw');
items.push(`<div class="pixel-preview-legend-item">
${calibration.layout === 'clockwise' ? '\u21BB' : '\u21BA'} ${dirText}
</div>`);
if (calibration.offset > 0) {
items.push(`<div class="pixel-preview-legend-item">
\u2194 ${t('preview.offset_leds', { count: calibration.offset })}
</div>`);
}
legendContainer.innerHTML = items.join('');
}
function renderPixelPreview(calibration, displayWidth, displayHeight) {
const canvas = document.getElementById('pixel-preview-canvas');
const ctx = canvas.getContext('2d');
const rect = canvas.getBoundingClientRect();
const dpr = window.devicePixelRatio || 1;
canvas.width = rect.width * dpr;
canvas.height = rect.height * dpr;
ctx.scale(dpr, dpr);
const W = rect.width;
const H = rect.height;
// Clear
ctx.fillStyle = '#111111';
ctx.fillRect(0, 0, W, H);
// Calculate screen rectangle with proper aspect ratio
const padding = 80;
const maxScreenW = W - padding * 2;
const maxScreenH = H - padding * 2;
if (maxScreenW <= 0 || maxScreenH <= 0) return;
const displayAspect = displayWidth / displayHeight;
let screenW, screenH;
if (maxScreenW / maxScreenH > displayAspect) {
screenH = maxScreenH;
screenW = screenH * displayAspect;
} else {
screenW = maxScreenW;
screenH = screenW / displayAspect;
}
const screenX = (W - screenW) / 2;
const screenY = (H - screenH) / 2;
// Draw screen rectangle
ctx.fillStyle = '#1a1a2e';
ctx.fillRect(screenX, screenY, screenW, screenH);
ctx.strokeStyle = '#444';
ctx.lineWidth = 2;
ctx.strokeRect(screenX, screenY, screenW, screenH);
// Screen label
ctx.fillStyle = '#555';
ctx.font = `${Math.min(24, screenH * 0.06)}px -apple-system, BlinkMacSystemFont, sans-serif`;
ctx.textAlign = 'center';
ctx.textBaseline = 'middle';
ctx.fillText(`${displayWidth}\u00D7${displayHeight}`, screenX + screenW / 2, screenY + screenH / 2);
// LED rendering config
const maxEdgeLeds = Math.max(...calibration.segments.map(s => s.led_count), 1);
const ledMarkerSize = Math.max(2, Math.min(8, 500 / maxEdgeLeds));
const stripOffset = ledMarkerSize + 10;
// Edge geometry
const edgeGeometry = {
top: { x1: screenX, y1: screenY, x2: screenX + screenW, y2: screenY, horizontal: true, outside: -1 },
bottom: { x1: screenX, y1: screenY + screenH, x2: screenX + screenW, y2: screenY + screenH, horizontal: true, outside: 1 },
left: { x1: screenX, y1: screenY, x2: screenX, y2: screenY + screenH, horizontal: false, outside: -1 },
right: { x1: screenX + screenW, y1: screenY, x2: screenX + screenW, y2: screenY + screenH, horizontal: false, outside: 1 },
};
// Draw each segment's LEDs
calibration.segments.forEach(seg => {
const [r, g, b] = EDGE_TEST_COLORS[seg.edge] || [128, 128, 128];
const geo = edgeGeometry[seg.edge];
if (!geo) return;
const positions = [];
for (let i = 0; i < seg.led_count; i++) {
const fraction = seg.led_count > 1 ? i / (seg.led_count - 1) : 0.5;
const displayFraction = seg.reverse ? (1 - fraction) : fraction;
let cx, cy;
if (geo.horizontal) {
cx = geo.x1 + displayFraction * (geo.x2 - geo.x1);
cy = geo.y1 + geo.outside * stripOffset;
} else {
cx = geo.x1 + geo.outside * stripOffset;
cy = geo.y1 + displayFraction * (geo.y2 - geo.y1);
}
positions.push({ cx, cy, ledIndex: seg.led_start + i });
// Draw LED marker
ctx.fillStyle = `rgba(${r},${g},${b},0.85)`;
ctx.beginPath();
ctx.arc(cx, cy, ledMarkerSize / 2, 0, Math.PI * 2);
ctx.fill();
}
// Draw LED index labels
drawPreviewLedLabels(ctx, positions, ledMarkerSize, geo);
});
// Draw start position marker
drawPreviewStartPosition(ctx, calibration, screenX, screenY, screenW, screenH);
// Draw direction arrows
drawPreviewDirectionArrows(ctx, calibration, edgeGeometry);
// Draw offset indicator
if (calibration.offset > 0) {
drawPreviewOffsetIndicator(ctx, calibration, screenX, screenY, screenW, screenH);
}
}
function drawPreviewLedLabels(ctx, positions, markerSize, geo) {
if (positions.length === 0) return;
const labelFontSize = Math.max(9, Math.min(12, 200 / Math.sqrt(positions.length)));
ctx.font = `${labelFontSize}px -apple-system, BlinkMacSystemFont, sans-serif`;
ctx.fillStyle = '#ccc';
ctx.textBaseline = 'middle';
// Adaptive label interval
const count = positions.length;
const labelInterval = count <= 20 ? 1
: count <= 50 ? 5
: count <= 100 ? 10
: count <= 200 ? 25
: 50;
const labelsToShow = new Set();
labelsToShow.add(0);
labelsToShow.add(count - 1);
for (let i = labelInterval; i < count - 1; i += labelInterval) {
labelsToShow.add(i);
}
const labelOffset = markerSize / 2 + labelFontSize;
labelsToShow.forEach(i => {
const pos = positions[i];
const label = String(pos.ledIndex);
if (geo.horizontal) {
ctx.textAlign = 'center';
ctx.fillText(label, pos.cx, pos.cy + geo.outside * labelOffset);
} else {
ctx.textAlign = geo.outside < 0 ? 'right' : 'left';
ctx.fillText(label, pos.cx + geo.outside * labelOffset, pos.cy);
}
});
}
function drawPreviewStartPosition(ctx, calibration, screenX, screenY, screenW, screenH) {
const corners = {
top_left: { x: screenX, y: screenY },
top_right: { x: screenX + screenW, y: screenY },
bottom_left: { x: screenX, y: screenY + screenH },
bottom_right: { x: screenX + screenW, y: screenY + screenH },
};
const corner = corners[calibration.start_position];
if (!corner) return;
// Green diamond
const size = 10;
ctx.save();
ctx.translate(corner.x, corner.y);
ctx.rotate(Math.PI / 4);
ctx.fillStyle = '#4CAF50';
ctx.shadowColor = 'rgba(76, 175, 80, 0.6)';
ctx.shadowBlur = 8;
ctx.fillRect(-size / 2, -size / 2, size, size);
ctx.restore();
// START label
ctx.save();
ctx.font = 'bold 11px -apple-system, BlinkMacSystemFont, sans-serif';
ctx.fillStyle = '#4CAF50';
ctx.textAlign = 'center';
ctx.textBaseline = 'middle';
const lx = calibration.start_position.includes('left') ? -28 : 28;
const ly = calibration.start_position.includes('top') ? -18 : 18;
ctx.fillText(t('preview.start'), corner.x + lx, corner.y + ly);
ctx.restore();
}
function drawPreviewDirectionArrows(ctx, calibration, edgeGeometry) {
const arrowSize = 8;
ctx.save();
ctx.fillStyle = 'rgba(255, 255, 255, 0.5)';
calibration.segments.forEach(seg => {
const geo = edgeGeometry[seg.edge];
if (!geo) return;
// Midpoint of edge, shifted outside
const midFraction = 0.5;
let mx, my;
if (geo.horizontal) {
mx = geo.x1 + midFraction * (geo.x2 - geo.x1);
my = geo.y1 + geo.outside * (arrowSize + 20);
} else {
mx = geo.x1 + geo.outside * (arrowSize + 20);
my = geo.y1 + midFraction * (geo.y2 - geo.y1);
}
// Direction based on edge and reverse
let angle;
if (geo.horizontal) {
angle = seg.reverse ? Math.PI : 0; // left or right
} else {
angle = seg.reverse ? -Math.PI / 2 : Math.PI / 2; // up or down
}
ctx.save();
ctx.translate(mx, my);
ctx.rotate(angle);
ctx.beginPath();
ctx.moveTo(arrowSize, 0);
ctx.lineTo(-arrowSize / 2, -arrowSize / 2);
ctx.lineTo(-arrowSize / 2, arrowSize / 2);
ctx.closePath();
ctx.fill();
ctx.restore();
});
ctx.restore();
}
function drawPreviewOffsetIndicator(ctx, calibration, screenX, screenY, screenW, screenH) {
const corners = {
top_left: { x: screenX, y: screenY },
top_right: { x: screenX + screenW, y: screenY },
bottom_left: { x: screenX, y: screenY + screenH },
bottom_right: { x: screenX + screenW, y: screenY + screenH },
};
const corner = corners[calibration.start_position];
if (!corner) return;
ctx.save();
ctx.font = '10px -apple-system, BlinkMacSystemFont, sans-serif';
ctx.fillStyle = '#ff9800';
ctx.textAlign = 'center';
ctx.textBaseline = 'middle';
const ox = calibration.start_position.includes('left') ? -45 : 45;
const oy = calibration.start_position.includes('top') ? -35 : 35;
ctx.fillText(
t('preview.offset_leds', { count: calibration.offset }),
corner.x + ox,
corner.y + oy
);
// Dashed arc
ctx.strokeStyle = '#ff9800';
ctx.lineWidth = 1.5;
ctx.setLineDash([3, 3]);
ctx.beginPath();
ctx.arc(corner.x, corner.y, 18, 0, Math.PI * 0.5);
ctx.stroke();
ctx.setLineDash([]);
ctx.restore();
}
// Close pixel preview on canvas click
document.getElementById('pixel-preview-canvas').addEventListener('click', () => {
closePixelPreview();
});
// Close pixel preview on Escape key
document.addEventListener('keydown', (e) => {
if (e.key === 'Escape') {
const overlay = document.getElementById('pixel-preview-overlay');
if (overlay.style.display !== 'none') {
closePixelPreview();
return;
}
}
});
// Close modals on backdrop click (only if mousedown also started on backdrop)
let backdropMouseDownTarget = null;
document.addEventListener('mousedown', (e) => {
backdropMouseDownTarget = e.target;
});
document.addEventListener('click', (e) => {
if (!e.target.classList.contains('modal')) return;
if (backdropMouseDownTarget !== e.target) return;
const modalId = e.target.id;
@@ -1298,6 +1740,12 @@ document.addEventListener('click', (e) => {
closeCalibrationModal();
return;
}
// Add device modal: close on backdrop
if (modalId === 'add-device-modal') {
closeAddDeviceModal();
return;
}
});
// Cleanup on page unload

View File

@@ -45,32 +45,15 @@
</section>
<section class="devices-section">
<h2 data-i18n="devices.title">WLED Devices</h2>
<div class="section-header">
<h2 data-i18n="devices.title">WLED Devices</h2>
<button class="btn btn-icon btn-primary" onclick="showAddDevice()" data-i18n-title="devices.add" title="Add New Device">+</button>
</div>
<div id="devices-list" class="devices-grid">
<div class="loading" data-i18n="devices.loading">Loading devices...</div>
</div>
</section>
<section class="add-device-section">
<h2 data-i18n="devices.add">Add New Device</h2>
<div class="info-banner" style="margin-bottom: 20px; padding: 12px; background: rgba(33, 150, 243, 0.1); border-left: 4px solid #2196F3; border-radius: 4px;">
<strong><span data-i18n="devices.wled_config">📱 WLED Configuration:</span></strong> <span data-i18n="devices.wled_note">Configure your WLED device (effects, segments, color order, power limits, etc.) using the</span>
<a href="https://kno.wled.ge/" target="_blank" rel="noopener" style="color: #2196F3; text-decoration: underline;" data-i18n="devices.wled_link">official WLED app</a>.
<span data-i18n="devices.wled_note2">This controller sends pixel color data and controls brightness per device.</span>
</div>
<form id="add-device-form">
<div class="form-group">
<label for="device-name" data-i18n="device.name">Device Name:</label>
<input type="text" id="device-name" data-i18n-placeholder="device.name.placeholder" placeholder="Living Room TV" required>
</div>
<div class="form-group">
<label for="device-url" data-i18n="device.url">WLED URL:</label>
<input type="url" id="device-url" data-i18n-placeholder="device.url.placeholder" placeholder="http://192.168.1.100" required>
</div>
<button type="submit" class="btn btn-primary" data-i18n="device.button.add">Add Device</button>
</form>
</section>
<footer class="app-footer">
<div class="footer-content">
<p>
@@ -192,6 +175,11 @@
<small class="input-hint" data-i18n="settings.url.hint">IP address or hostname of your WLED device</small>
</div>
<div class="form-group">
<label for="settings-display-index" data-i18n="settings.display_index">Display:</label>
<select id="settings-display-index"></select>
<small class="input-hint" data-i18n="settings.display_index.hint">Which screen to capture for this device</small>
</div>
<div class="form-group">
<label for="settings-health-interval" data-i18n="settings.health_interval">Health Check Interval (s):</label>
@@ -262,6 +250,48 @@
</div>
</div>
<!-- Add Device Modal -->
<div id="add-device-modal" class="modal">
<div class="modal-content">
<div class="modal-header">
<h2 data-i18n="devices.add">Add New Device</h2>
</div>
<div class="modal-body">
<div class="info-banner" style="margin-bottom: 16px; padding: 12px; background: rgba(33, 150, 243, 0.1); border-left: 4px solid #2196F3; border-radius: 4px;">
<strong><span data-i18n="devices.wled_config">WLED Configuration:</span></strong> <span data-i18n="devices.wled_note">Configure your WLED device (effects, segments, color order, power limits, etc.) using the</span>
<a href="https://kno.wled.ge/" target="_blank" rel="noopener" style="color: #2196F3; text-decoration: underline;" data-i18n="devices.wled_link">official WLED app</a>.
<span data-i18n="devices.wled_note2">This controller sends pixel color data and controls brightness per device.</span>
</div>
<form id="add-device-form">
<div class="form-group">
<label for="device-name" data-i18n="device.name">Device Name:</label>
<input type="text" id="device-name" data-i18n-placeholder="device.name.placeholder" placeholder="Living Room TV" required>
</div>
<div class="form-group">
<label for="device-url" data-i18n="device.url">WLED URL:</label>
<input type="url" id="device-url" data-i18n-placeholder="device.url.placeholder" placeholder="http://192.168.1.100" required>
</div>
<div id="add-device-error" class="error-message" style="display: none;"></div>
</form>
</div>
<div class="modal-footer">
<button class="btn btn-secondary" onclick="closeAddDeviceModal()" data-i18n="calibration.button.cancel">Cancel</button>
<button class="btn btn-primary" onclick="document.getElementById('add-device-form').requestSubmit()" data-i18n="device.button.add">Add Device</button>
</div>
</div>
</div>
<!-- Pixel Layout Preview Overlay -->
<div id="pixel-preview-overlay" class="pixel-preview-overlay" style="display: none;">
<div class="pixel-preview-header">
<span class="pixel-preview-title" data-i18n="preview.title">Pixel Layout Preview</span>
<span id="pixel-preview-device-name" class="pixel-preview-device-name"></span>
<button class="pixel-preview-close" onclick="closePixelPreview()" title="Close">&#x2715;</button>
</div>
<canvas id="pixel-preview-canvas"></canvas>
<div class="pixel-preview-legend" id="pixel-preview-legend"></div>
</div>
<script src="/static/app.js"></script>
<script>
// Initialize theme

View File

@@ -77,6 +77,8 @@
"settings.brightness": "Brightness:",
"settings.brightness.hint": "Global brightness for this WLED device (0-100%)",
"settings.url.hint": "IP address or hostname of your WLED device",
"settings.display_index": "Display:",
"settings.display_index.hint": "Which screen to capture for this device",
"settings.button.cancel": "Cancel",
"settings.health_interval": "Health Check Interval (s):",
"settings.health_interval.hint": "How often to check the WLED device status (5-600 seconds)",
@@ -106,6 +108,17 @@
"calibration.button.save": "Save",
"calibration.saved": "Calibration saved successfully",
"calibration.failed": "Failed to save calibration",
"preview.title": "Pixel Layout Preview",
"preview.button": "Preview",
"preview.start": "START",
"preview.offset_leds": "Offset: {count} LEDs",
"preview.direction.cw": "Clockwise",
"preview.direction.ccw": "Counterclockwise",
"preview.edge.top": "Top",
"preview.edge.right": "Right",
"preview.edge.bottom": "Bottom",
"preview.edge.left": "Left",
"preview.no_calibration": "No calibration data. Please calibrate the device first.",
"server.healthy": "Server online",
"server.offline": "Server offline",
"error.unauthorized": "Unauthorized - please login",

View File

@@ -77,6 +77,8 @@
"settings.brightness": "Яркость:",
"settings.brightness.hint": "Общая яркость для этого WLED устройства (0-100%)",
"settings.url.hint": "IP адрес или имя хоста вашего WLED устройства",
"settings.display_index": "Дисплей:",
"settings.display_index.hint": "Какой экран захватывать для этого устройства",
"settings.button.cancel": "Отмена",
"settings.health_interval": "Интервал Проверки (с):",
"settings.health_interval.hint": "Как часто проверять статус WLED устройства (5-600 секунд)",
@@ -106,6 +108,17 @@
"calibration.button.save": "Сохранить",
"calibration.saved": "Калибровка успешно сохранена",
"calibration.failed": "Не удалось сохранить калибровку",
"preview.title": "Предпросмотр Расположения Пикселей",
"preview.button": "Предпросмотр",
"preview.start": "СТАРТ",
"preview.offset_leds": "Смещение: {count} LED",
"preview.direction.cw": "По часовой",
"preview.direction.ccw": "Против часовой",
"preview.edge.top": "Верх",
"preview.edge.right": "Право",
"preview.edge.bottom": "Низ",
"preview.edge.left": "Лево",
"preview.no_calibration": "Нет данных калибровки. Сначала откалибруйте устройство.",
"server.healthy": "Сервер онлайн",
"server.offline": "Сервер офлайн",
"error.unauthorized": "Не авторизован - пожалуйста, войдите",

View File

@@ -164,6 +164,10 @@ section {
gap: 20px;
}
.devices-grid > .loading {
grid-column: 1 / -1;
}
.card {
background: var(--card-bg);
border: 1px solid var(--border-color);
@@ -487,11 +491,15 @@ section {
width: 100%;
}
.add-device-section {
background: var(--card-bg);
border: 1px solid var(--border-color);
border-radius: 8px;
padding: 20px;
.section-header {
display: flex;
align-items: center;
gap: 10px;
margin-bottom: 15px;
}
.section-header h2 {
margin-bottom: 0;
}
.form-group {
@@ -981,3 +989,92 @@ input:-webkit-autofill:focus {
width: 100%;
}
}
/* Pixel Layout Preview Overlay */
.pixel-preview-overlay {
position: fixed;
top: 0;
left: 0;
width: 100%;
height: 100%;
background: #111111;
z-index: 3000;
display: flex;
flex-direction: column;
animation: fadeIn 0.2s ease-out;
}
.pixel-preview-header {
display: flex;
align-items: center;
padding: 12px 20px;
background: rgba(0, 0, 0, 0.6);
border-bottom: 1px solid #333;
flex-shrink: 0;
}
.pixel-preview-title {
font-size: 1.1rem;
font-weight: 600;
color: #e0e0e0;
}
.pixel-preview-device-name {
font-size: 0.9rem;
color: #999;
margin-left: 12px;
}
.pixel-preview-close {
margin-left: auto;
background: none;
border: 1px solid #555;
color: #e0e0e0;
font-size: 1.2rem;
width: 36px;
height: 36px;
border-radius: 4px;
cursor: pointer;
display: flex;
align-items: center;
justify-content: center;
transition: background 0.2s, border-color 0.2s;
}
.pixel-preview-close:hover {
background: rgba(244, 67, 54, 0.3);
border-color: #f44336;
}
#pixel-preview-canvas {
flex: 1;
width: 100%;
min-height: 0;
}
.pixel-preview-legend {
display: flex;
align-items: center;
justify-content: center;
gap: 24px;
padding: 10px 20px;
background: rgba(0, 0, 0, 0.6);
border-top: 1px solid #333;
flex-shrink: 0;
flex-wrap: wrap;
}
.pixel-preview-legend-item {
display: flex;
align-items: center;
gap: 6px;
font-size: 0.85rem;
color: #ccc;
}
.pixel-preview-legend-swatch {
width: 14px;
height: 14px;
border-radius: 3px;
border: 1px solid rgba(255, 255, 255, 0.3);
}