Add OpenRGB per-zone LED control with separate/combined modes and zone preview

- Zone picker UI in device add/settings modals with per-zone checkbox selection
- Combined mode: pixels distributed sequentially across zones
- Separate mode: full effect resampled independently to each zone via linear interpolation
- Per-zone LED preview in target cards: one canvas strip per zone with hover overlay labels
- Zone badges on device cards enriched with actual LED counts from OpenRGB API
- Fix stale led_count by using device_led_count discovered at connect time

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-03-01 20:35:51 +03:00
parent aafcf83896
commit 52ee4bdeb6
19 changed files with 769 additions and 55 deletions

View File

@@ -443,6 +443,62 @@ body.cs-drag-active .card-drag-handle {
animation: spin 0.8s linear infinite;
}
/* OpenRGB zone checkboxes */
.zone-checkbox-list {
display: flex;
flex-direction: column;
gap: 4px;
max-height: 180px;
overflow-y: auto;
padding: 4px 0;
}
.zone-checkbox-list .zone-loading,
.zone-checkbox-list .zone-error {
font-size: 12px;
color: var(--text-secondary);
padding: 4px 0;
}
.zone-checkbox-list .zone-error { color: var(--danger-color, #e53935); }
.zone-checkbox-item {
display: flex;
align-items: center;
gap: 8px;
padding: 4px 6px;
border-radius: 4px;
cursor: pointer;
font-size: 13px;
transition: background 0.15s;
}
.zone-checkbox-item:hover { background: var(--hover-bg, rgba(255,255,255,0.05)); }
.zone-checkbox-item input[type="checkbox"] { margin: 0; flex-shrink: 0; }
.zone-checkbox-item .zone-led-count {
margin-left: auto;
font-size: 11px;
color: var(--text-secondary);
white-space: nowrap;
}
.zone-mode-radios {
display: flex;
gap: 16px;
}
.zone-mode-option {
display: flex;
align-items: center;
gap: 6px;
cursor: pointer;
font-size: 13px;
}
.zone-mode-option input[type="radio"] { margin: 0; }
.zone-badge {
font-size: 10px;
padding: 1px 5px;
border-radius: 3px;
background: var(--border-color);
font-weight: 600;
}
.channel-indicator {
display: inline-flex;
gap: 2px;
@@ -792,3 +848,42 @@ ul.section-tip li {
pointer-events: none;
opacity: 0.8;
}
/* Per-zone LED preview (OpenRGB separate mode) */
.led-preview-zones {
display: flex;
flex-direction: column;
gap: 2px;
}
.led-preview-zone {
position: relative;
}
.led-preview-zone-canvas {
display: block;
width: 100%;
height: 18px;
border-radius: 2px;
image-rendering: pixelated;
background: #111;
}
.led-preview-zone-label {
position: absolute;
left: 4px;
top: 50%;
transform: translateY(-50%);
font-size: 0.65rem;
font-family: var(--font-mono, monospace);
color: #fff;
text-shadow: 0 0 3px rgba(0,0,0,0.9), 0 0 6px rgba(0,0,0,0.6);
pointer-events: none;
opacity: 0;
transition: opacity 0.15s;
white-space: nowrap;
}
.led-preview-zones:hover .led-preview-zone-label {
opacity: 1;
}

View File

@@ -25,6 +25,8 @@ class AddDeviceModal extends Modal {
baudRate: document.getElementById('device-baud-rate').value,
ledType: document.getElementById('device-led-type')?.value || 'rgb',
sendLatency: document.getElementById('device-send-latency')?.value || '0',
zones: _getCheckedZones('device-zone-list'),
zoneMode: _getZoneMode(),
};
}
}
@@ -47,8 +49,14 @@ export function onDeviceTypeChanged() {
const urlLabel = document.getElementById('device-url-label');
const urlHint = document.getElementById('device-url-hint');
const zoneGroup = document.getElementById('device-zone-group');
const scanBtn = document.getElementById('scan-network-btn');
// Hide zone group + mode group by default (shown only for openrgb)
if (zoneGroup) zoneGroup.style.display = 'none';
const zoneModeGroup = document.getElementById('device-zone-mode-group');
if (zoneModeGroup) zoneModeGroup.style.display = 'none';
if (isMqttDevice(deviceType)) {
// MQTT: show URL (topic), LED count; hide serial/baud/led-type/latency/discovery
urlGroup.style.display = '';
@@ -121,6 +129,7 @@ export function onDeviceTypeChanged() {
if (ledTypeGroup) ledTypeGroup.style.display = 'none';
if (sendLatencyGroup) sendLatencyGroup.style.display = 'none';
if (scanBtn) scanBtn.style.display = '';
if (zoneGroup) zoneGroup.style.display = '';
if (urlLabel) urlLabel.textContent = t('device.openrgb.url');
if (urlHint) urlHint.textContent = t('device.openrgb.url.hint');
urlInput.placeholder = 'openrgb://localhost:6742/0';
@@ -354,6 +363,10 @@ export function selectDiscoveredDevice(device) {
} else {
document.getElementById('device-url').value = device.url;
}
// Fetch zones for OpenRGB devices
if (isOpenrgbDevice(device.device_type)) {
_fetchOpenrgbZones(device.url, 'device-zone-list');
}
showToast(t('device.scan.selected'), 'info');
}
@@ -380,12 +393,20 @@ export async function handleAddDevice(event) {
url = 'mqtt://' + url;
}
// OpenRGB: append selected zones to URL
const checkedZones = isOpenrgbDevice(deviceType) ? _getCheckedZones('device-zone-list') : [];
if (isOpenrgbDevice(deviceType) && checkedZones.length > 0) {
url = _appendZonesToUrl(url, checkedZones);
}
if (!name || (!isMockDevice(deviceType) && !isWsDevice(deviceType) && !url)) {
error.textContent = t('device_discovery.error.fill_all_fields');
error.style.display = 'block';
return;
}
const lastTemplateId = localStorage.getItem('lastCaptureTemplateId');
try {
const body = { name, url, device_type: deviceType };
const ledCountInput = document.getElementById('device-led-count');
@@ -402,10 +423,10 @@ export async function handleAddDevice(event) {
const ledType = document.getElementById('device-led-type')?.value;
body.rgbw = ledType === 'rgbw';
}
const lastTemplateId = localStorage.getItem('lastCaptureTemplateId');
if (lastTemplateId) {
body.capture_template_id = lastTemplateId;
if (isOpenrgbDevice(deviceType) && checkedZones.length >= 2) {
body.zone_mode = _getZoneMode();
}
if (lastTemplateId) body.capture_template_id = lastTemplateId;
const response = await fetchWithAuth('/devices', {
method: 'POST',
@@ -417,9 +438,7 @@ export async function handleAddDevice(event) {
console.log('Device added successfully:', result);
showToast(t('device_discovery.added'), 'success');
addDeviceModal.forceClose();
// Use window.* to avoid circular imports
if (typeof window.loadDevices === 'function') await window.loadDevices();
// Auto-start device tutorial on first device add
if (!localStorage.getItem('deviceTutorialSeen')) {
localStorage.setItem('deviceTutorialSeen', '1');
setTimeout(() => {
@@ -438,3 +457,112 @@ export async function handleAddDevice(event) {
showToast(t('device_discovery.error.add_failed'), 'error');
}
}
// ===== OpenRGB zone helpers =====
/**
* Fetch zones for an OpenRGB device URL and render checkboxes in the given container.
* @param {string} baseUrl - Base OpenRGB URL (e.g. openrgb://localhost:6742/0)
* @param {string} containerId - ID of the zone checkbox list container
* @param {string[]} [preChecked=[]] - Zone names to pre-check
*/
export async function _fetchOpenrgbZones(baseUrl, containerId, preChecked = []) {
const container = document.getElementById(containerId);
if (!container) return;
container.innerHTML = `<span class="zone-loading">${t('device.openrgb.zone.loading')}</span>`;
try {
const resp = await fetchWithAuth(`/devices/openrgb-zones?url=${encodeURIComponent(baseUrl)}`);
if (!resp.ok) {
const err = await resp.json().catch(() => ({}));
container.innerHTML = `<span class="zone-error">${err.detail || t('device.openrgb.zone.error')}</span>`;
return;
}
const data = await resp.json();
_renderZoneCheckboxes(container, data.zones, preChecked);
} catch (err) {
if (err.isAuth) return;
container.innerHTML = `<span class="zone-error">${t('device.openrgb.zone.error')}</span>`;
}
}
function _renderZoneCheckboxes(container, zones, preChecked = []) {
container.innerHTML = '';
container._zonesData = zones;
const preSet = new Set(preChecked.map(n => n.toLowerCase()));
zones.forEach(zone => {
const label = document.createElement('label');
label.className = 'zone-checkbox-item';
const cb = document.createElement('input');
cb.type = 'checkbox';
cb.value = zone.name;
if (preSet.has(zone.name.toLowerCase())) cb.checked = true;
cb.addEventListener('change', () => _updateZoneModeVisibility(container.id));
const nameSpan = document.createElement('span');
nameSpan.textContent = zone.name;
const countSpan = document.createElement('span');
countSpan.className = 'zone-led-count';
countSpan.textContent = `${zone.led_count} LEDs`;
label.appendChild(cb);
label.appendChild(nameSpan);
label.appendChild(countSpan);
container.appendChild(label);
});
_updateZoneModeVisibility(container.id);
}
export function _getCheckedZones(containerId) {
const container = document.getElementById(containerId);
if (!container) return [];
return Array.from(container.querySelectorAll('input[type="checkbox"]:checked'))
.map(cb => cb.value);
}
/**
* Split an OpenRGB URL into base URL (without zones) and zone names.
* E.g. "openrgb://localhost:6742/0/JRAINBOW1+JRAINBOW2" → { baseUrl: "openrgb://localhost:6742/0", zones: ["JRAINBOW1","JRAINBOW2"] }
*/
export function _splitOpenrgbZone(url) {
if (!url || !url.startsWith('openrgb://')) return { baseUrl: url, zones: [] };
const stripped = url.slice('openrgb://'.length);
const parts = stripped.split('/');
// parts: [host:port, device_index, ...zone_str]
if (parts.length >= 3) {
const zoneStr = parts.slice(2).join('/');
const zones = zoneStr.split('+').map(z => z.trim()).filter(Boolean);
const baseUrl = 'openrgb://' + parts[0] + '/' + parts[1];
return { baseUrl, zones };
}
return { baseUrl: url, zones: [] };
}
function _appendZonesToUrl(baseUrl, zones) {
// Strip any existing zone suffix
const { baseUrl: clean } = _splitOpenrgbZone(baseUrl);
return clean + '/' + zones.join('+');
}
/** Show/hide zone mode toggle based on how many zones are checked. */
export function _updateZoneModeVisibility(containerId) {
const modeGroupId = containerId === 'device-zone-list' ? 'device-zone-mode-group'
: containerId === 'settings-zone-list' ? 'settings-zone-mode-group'
: null;
if (!modeGroupId) return;
const modeGroup = document.getElementById(modeGroupId);
if (!modeGroup) return;
const checkedCount = _getCheckedZones(containerId).length;
modeGroup.style.display = checkedCount >= 2 ? '' : 'none';
}
/** Get the selected zone mode radio value ('combined' or 'separate'). */
export function _getZoneMode(radioName = 'device-zone-mode') {
const radio = document.querySelector(`input[name="${radioName}"]:checked`);
return radio ? radio.value : 'combined';
}

View File

@@ -6,6 +6,7 @@ import {
_deviceBrightnessCache, updateDeviceBrightness,
} from '../core/state.js';
import { API_BASE, getHeaders, fetchWithAuth, escapeHtml, isSerialDevice, isMockDevice, isMqttDevice, isWsDevice, isOpenrgbDevice } from '../core/api.js';
import { _fetchOpenrgbZones, _getCheckedZones, _splitOpenrgbZone, _getZoneMode } from './device-discovery.js';
import { t } from '../core/i18n.js';
import { showToast, showConfirm } from '../core/ui.js';
import { Modal } from '../core/modal.js';
@@ -27,6 +28,8 @@ class DeviceSettingsModal extends Modal {
led_count: this.$('settings-led-count').value,
led_type: document.getElementById('settings-led-type')?.value || 'rgb',
send_latency: document.getElementById('settings-send-latency')?.value || '0',
zones: _getCheckedZones('settings-zone-list'),
zoneMode: _getZoneMode('settings-zone-mode'),
};
}
@@ -42,7 +45,16 @@ class DeviceSettingsModal extends Modal {
if (isSerialDevice(this.deviceType)) {
return this.$('settings-serial-port').value;
}
return this.$('settings-device-url').value.trim();
let url = this.$('settings-device-url').value.trim();
// Append selected zones for OpenRGB
if (isOpenrgbDevice(this.deviceType)) {
const zones = _getCheckedZones('settings-zone-list');
if (zones.length > 0) {
const { baseUrl } = _splitOpenrgbZone(url);
url = baseUrl + '/' + zones.join('+');
}
}
return url;
}
}
@@ -78,6 +90,10 @@ export function createDeviceCard(device) {
const ledCount = state.device_led_count || device.led_count;
// Parse zone names from OpenRGB URL for badge display
const openrgbZones = isOpenrgbDevice(device.device_type)
? _splitOpenrgbZone(device.url).zones : [];
return wrapCard({
dataAttr: 'data-device-id',
id: device.id,
@@ -89,13 +105,15 @@ export function createDeviceCard(device) {
<div class="card-title">
<span class="health-dot ${healthClass}" title="${healthTitle}"></span>
${device.name || device.id}
${device.url && device.url.startsWith('http') ? `<a class="device-url-badge" href="${device.url}" target="_blank" rel="noopener" title="${t('device.button.webui')}"><span class="device-url-text">${escapeHtml(device.url.replace(/^https?:\/\//, ''))}</span><span class="device-url-icon">${ICON_WEB}</span></a>` : (device.url && !device.url.startsWith('mock://') && !device.url.startsWith('ws://') && !device.url.startsWith('http') ? `<span class="device-url-badge"><span class="device-url-text">${escapeHtml(device.url)}</span></span>` : '')}
${device.url && device.url.startsWith('http') ? `<a class="device-url-badge" href="${device.url}" target="_blank" rel="noopener" title="${t('device.button.webui')}"><span class="device-url-text">${escapeHtml(device.url.replace(/^https?:\/\//, ''))}</span><span class="device-url-icon">${ICON_WEB}</span></a>` : (device.url && !device.url.startsWith('mock://') && !device.url.startsWith('ws://') && !device.url.startsWith('openrgb://') && !device.url.startsWith('http') ? `<span class="device-url-badge"><span class="device-url-text">${escapeHtml(device.url)}</span></span>` : '')}
${healthLabel}
</div>
</div>
<div class="card-subtitle">
<span class="card-meta device-type-badge">${(device.device_type || 'wled').toUpperCase()}</span>
${ledCount ? `<span class="card-meta" title="${t('device.led_count')}">${ICON_LED} ${ledCount}</span>` : ''}
${openrgbZones.length
? openrgbZones.map(z => `<span class="card-meta zone-badge" data-zone-name="${escapeHtml(z)}">${ICON_LED} ${escapeHtml(z)}</span>`).join('')
: (ledCount ? `<span class="card-meta" title="${t('device.led_count')}">${ICON_LED} ${ledCount}</span>` : '')}
${state.device_led_type ? `<span class="card-meta">${ICON_PLUG} ${state.device_led_type.replace(/ RGBW$/, '')}</span>` : ''}
<span class="card-meta" title="${state.device_rgbw ? 'RGBW' : 'RGB'}"><span class="channel-indicator"><span class="ch" style="background:#e53935"></span><span class="ch" style="background:#43a047"></span><span class="ch" style="background:#1e88e5"></span>${state.device_rgbw ? '<span class="ch" style="background:#eee"></span>' : ''}</span></span>
</div>
@@ -207,6 +225,9 @@ export async function showSettings(deviceId) {
if (urlLabel) urlLabel.textContent = t('device.openrgb.url');
if (urlHint) urlHint.textContent = t('device.openrgb.url.hint');
urlInput.placeholder = 'openrgb://localhost:6742/0';
// Parse zone from URL and show base URL only
const { baseUrl, zones: currentZones } = _splitOpenrgbZone(device.url);
urlInput.value = baseUrl;
} else {
if (urlLabel) urlLabel.textContent = t('device.url');
if (urlHint) urlHint.textContent = t('settings.url.hint');
@@ -279,6 +300,29 @@ export async function showSettings(deviceId) {
autoShutdownGroup.style.display = caps.includes('auto_restore') ? '' : 'none';
}
document.getElementById('settings-auto-shutdown').checked = !!device.auto_shutdown;
// OpenRGB zone picker + mode toggle
const settingsZoneGroup = document.getElementById('settings-zone-group');
const settingsZoneModeGroup = document.getElementById('settings-zone-mode-group');
if (settingsZoneModeGroup) settingsZoneModeGroup.style.display = 'none';
if (settingsZoneGroup) {
if (isOpenrgbDevice(device.device_type)) {
settingsZoneGroup.style.display = '';
const { baseUrl, zones: currentZones } = _splitOpenrgbZone(device.url);
// Set zone mode radio from device
const savedMode = device.zone_mode || 'combined';
const modeRadio = document.querySelector(`input[name="settings-zone-mode"][value="${savedMode}"]`);
if (modeRadio) modeRadio.checked = true;
_fetchOpenrgbZones(baseUrl, 'settings-zone-list', currentZones).then(() => {
// Re-snapshot after zones are loaded so dirty-check baseline includes them
settingsModal.snapshot();
});
} else {
settingsZoneGroup.style.display = 'none';
document.getElementById('settings-zone-list').innerHTML = '';
}
}
settingsModal.snapshot();
settingsModal.open();
@@ -327,6 +371,9 @@ export async function saveDeviceSettings() {
const ledType = document.getElementById('settings-led-type')?.value;
body.rgbw = ledType === 'rgbw';
}
if (isOpenrgbDevice(settingsModal.deviceType)) {
body.zone_mode = _getZoneMode('settings-zone-mode');
}
const deviceResponse = await fetchWithAuth(`/devices/${deviceId}`, {
method: 'PUT',
body: JSON.stringify(body)
@@ -487,3 +534,62 @@ export function copyWsUrl() {
export async function loadDevices() {
await window.loadTargetsTab();
}
// ===== OpenRGB zone count enrichment =====
// Cache: baseUrl → { zoneName: ledCount, ... }
const _zoneCountCache = {};
/** Return cached zone LED counts for a base URL, or null if not cached. */
export function getZoneCountCache(baseUrl) {
return _zoneCountCache[baseUrl] || null;
}
const _zoneCountInFlight = new Set();
/**
* Fetch zone LED counts for an OpenRGB device and update zone badges on the card.
* Called after cards are rendered (same pattern as fetchDeviceBrightness).
*/
export async function enrichOpenrgbZoneBadges(deviceId, deviceUrl) {
const { baseUrl, zones } = _splitOpenrgbZone(deviceUrl);
if (!zones.length) return;
// Use cache if available
if (_zoneCountCache[baseUrl]) {
_applyZoneCounts(deviceId, zones, _zoneCountCache[baseUrl]);
return;
}
// Deduplicate in-flight requests per base URL
if (_zoneCountInFlight.has(baseUrl)) return;
_zoneCountInFlight.add(baseUrl);
try {
const resp = await fetchWithAuth(`/devices/openrgb-zones?url=${encodeURIComponent(baseUrl)}`);
if (!resp.ok) return;
const data = await resp.json();
const counts = {};
for (const z of data.zones) {
counts[z.name.toLowerCase()] = z.led_count;
}
_zoneCountCache[baseUrl] = counts;
_applyZoneCounts(deviceId, zones, counts);
} catch {
// Silently fail — device may be offline
} finally {
_zoneCountInFlight.delete(baseUrl);
}
}
function _applyZoneCounts(deviceId, zones, counts) {
const card = document.querySelector(`[data-device-id="${deviceId}"]`);
if (!card) return;
for (const zoneName of zones) {
const badge = card.querySelector(`[data-zone-name="${zoneName}"]`);
if (!badge) continue;
const ledCount = counts[zoneName.toLowerCase()];
if (ledCount != null) {
badge.innerHTML = `${ICON_LED} ${escapeHtml(zoneName)} · ${ledCount}`;
}
}
}

View File

@@ -10,11 +10,12 @@ import {
ledPreviewWebSockets,
_cachedValueSources, valueSourcesCache,
} from '../core/state.js';
import { API_BASE, getHeaders, fetchWithAuth, escapeHtml } from '../core/api.js';
import { API_BASE, getHeaders, fetchWithAuth, escapeHtml, isOpenrgbDevice } from '../core/api.js';
import { t } from '../core/i18n.js';
import { showToast, showConfirm, formatUptime, setTabRefreshing } from '../core/ui.js';
import { Modal } from '../core/modal.js';
import { createDeviceCard, attachDeviceListeners, fetchDeviceBrightness, _computeMaxFps } from './devices.js';
import { createDeviceCard, attachDeviceListeners, fetchDeviceBrightness, enrichOpenrgbZoneBadges, _computeMaxFps, getZoneCountCache } from './devices.js';
import { _splitOpenrgbZone } from './device-discovery.js';
import { createKCTargetCard, patchKCTargetMetrics, connectKCWebSocket, disconnectKCWebSocket } from './kc-targets.js';
import { createColorStripCard } from './color-strips.js';
import {
@@ -655,6 +656,10 @@ export async function loadTargetsTab() {
fetchDeviceBrightness(device.id);
}
}
// Enrich OpenRGB zone badges with per-zone LED counts
if (device.device_type === 'openrgb') {
enrichOpenrgbZoneBadges(device.id, device.url);
}
});
// Manage KC WebSockets: connect for processing, disconnect for stopped
@@ -915,10 +920,7 @@ export function createTargetCard(target, deviceMap, colorStripSourceMap, valueSo
</div>
` : ''}
</div>
<div id="led-preview-panel-${target.id}" class="led-preview-panel" style="display:${ledPreviewWebSockets[target.id] ? '' : 'none'}">
<canvas id="led-preview-canvas-${target.id}" class="led-preview-canvas"></canvas>
<span id="led-preview-brightness-${target.id}" class="led-preview-brightness" style="display:none"${bvsId ? ' data-has-bvs="1"' : ''}></span>
</div>`,
${_buildLedPreviewHtml(target.id, device, bvsId)}`,
actions: `
${isProcessing ? `
<button class="btn btn-icon btn-danger" onclick="stopTargetProcessing('${target.id}')" title="${t('device.button.stop')}">
@@ -1106,6 +1108,89 @@ export async function deleteTarget(targetId) {
const _ledPreviewLastFrame = {};
/**
* Build the LED preview panel HTML for a target card.
* For OpenRGB devices in "separate" zone mode with 2+ zones, renders
* one canvas per zone with labels. Otherwise, a single canvas.
*/
function _buildLedPreviewHtml(targetId, device, bvsId) {
const visible = ledPreviewWebSockets[targetId] ? '' : 'none';
const bvsAttr = bvsId ? ' data-has-bvs="1"' : '';
// Check for per-zone preview
if (device && isOpenrgbDevice(device.device_type) && device.zone_mode === 'separate') {
const { baseUrl, zones } = _splitOpenrgbZone(device.url);
if (zones.length > 1) {
const zoneCanvases = zones.map(z =>
`<div class="led-preview-zone">` +
`<canvas class="led-preview-canvas led-preview-zone-canvas" data-zone-name="${escapeHtml(z)}"></canvas>` +
`<span class="led-preview-zone-label">${escapeHtml(z)}</span>` +
`</div>`
).join('');
return `<div id="led-preview-panel-${targetId}" class="led-preview-panel led-preview-zones" data-zone-mode="separate" data-zone-base-url="${escapeHtml(baseUrl)}" style="display:${visible}">` +
zoneCanvases +
`<span id="led-preview-brightness-${targetId}" class="led-preview-brightness" style="display:none"${bvsAttr}></span>` +
`</div>`;
}
}
// Default: single canvas
return `<div id="led-preview-panel-${targetId}" class="led-preview-panel" style="display:${visible}">` +
`<canvas id="led-preview-canvas-${targetId}" class="led-preview-canvas"></canvas>` +
`<span id="led-preview-brightness-${targetId}" class="led-preview-brightness" style="display:none"${bvsAttr}></span>` +
`</div>`;
}
/**
* Resample an RGB byte array from srcCount pixels to dstCount pixels
* using linear interpolation (matches backend np.interp behavior).
*/
function _resampleStrip(srcBytes, srcCount, dstCount) {
if (dstCount === srcCount) return srcBytes;
const dst = new Uint8Array(dstCount * 3);
for (let i = 0; i < dstCount; i++) {
const t = dstCount > 1 ? i / (dstCount - 1) : 0;
const srcPos = t * (srcCount - 1);
const lo = Math.floor(srcPos);
const hi = Math.min(lo + 1, srcCount - 1);
const frac = srcPos - lo;
for (let ch = 0; ch < 3; ch++) {
dst[i * 3 + ch] = Math.round(
srcBytes[lo * 3 + ch] * (1 - frac) + srcBytes[hi * 3 + ch] * frac
);
}
}
return dst;
}
/**
* Render per-zone LED previews: resample the full frame independently
* for each zone canvas (matching the backend's separate-mode behavior).
*/
function _renderLedStripZones(panel, rgbBytes) {
const baseUrl = panel.dataset.zoneBaseUrl;
const cache = baseUrl ? getZoneCountCache(baseUrl) : null;
const srcCount = Math.floor(rgbBytes.length / 3);
if (srcCount < 1) return;
const zoneCanvases = panel.querySelectorAll('.led-preview-zone-canvas');
if (!cache) {
// Zone sizes unknown — render full frame to all canvases
for (const canvas of zoneCanvases) {
_renderLedStrip(canvas, rgbBytes);
}
return;
}
for (const canvas of zoneCanvases) {
const zoneName = canvas.dataset.zoneName;
const zoneSize = cache[zoneName.toLowerCase()];
if (!zoneSize || zoneSize < 1) continue;
const resampled = _resampleStrip(rgbBytes, srcCount, zoneSize);
_renderLedStrip(canvas, resampled);
}
}
function _renderLedStrip(canvas, rgbBytes) {
const ledCount = rgbBytes.length / 3;
if (ledCount <= 0) return;
@@ -1150,8 +1235,17 @@ function connectLedPreviewWS(targetId) {
const brightness = raw[0];
const frame = raw.subarray(1);
_ledPreviewLastFrame[targetId] = frame;
const canvas = document.getElementById(`led-preview-canvas-${targetId}`);
if (canvas) _renderLedStrip(canvas, frame);
const panel = document.getElementById(`led-preview-panel-${targetId}`);
if (panel) {
if (panel.dataset.zoneMode === 'separate') {
_renderLedStripZones(panel, frame);
} else {
const canvas = panel.querySelector('.led-preview-canvas');
if (canvas) _renderLedStrip(canvas, frame);
}
}
// Show brightness label: always when a brightness source is set, otherwise only below 100%
const bLabel = document.getElementById(`led-preview-brightness-${targetId}`);
if (bLabel) {

View File

@@ -137,6 +137,15 @@
"device.ws_url.hint": "WebSocket URL for clients to connect and receive LED data",
"device.openrgb.url": "OpenRGB URL:",
"device.openrgb.url.hint": "OpenRGB server address (e.g. openrgb://localhost:6742/0)",
"device.openrgb.zone": "Zones:",
"device.openrgb.zone.hint": "Select which LED zones to control (leave all unchecked for all zones)",
"device.openrgb.zone.loading": "Loading zones…",
"device.openrgb.zone.error": "Failed to load zones",
"device.openrgb.mode": "Zone mode:",
"device.openrgb.mode.hint": "Combined treats all zones as one continuous LED strip. Separate renders each zone independently with the full effect.",
"device.openrgb.mode.combined": "Combined strip",
"device.openrgb.mode.separate": "Independent zones",
"device.openrgb.added_multiple": "Added {count} devices",
"device.type.openrgb": "OpenRGB",
"device.url.hint": "IP address or hostname of the device (e.g. http://192.168.1.100)",
"device.name": "Device Name:",

View File

@@ -137,6 +137,15 @@
"device.ws_url.hint": "WebSocket URL для подключения клиентов и получения LED данных",
"device.openrgb.url": "OpenRGB URL:",
"device.openrgb.url.hint": "Адрес сервера OpenRGB (напр. openrgb://localhost:6742/0)",
"device.openrgb.zone": "Зоны:",
"device.openrgb.zone.hint": "Выберите зоны LED для управления (оставьте все неотмеченными для всех зон)",
"device.openrgb.zone.loading": "Загрузка зон…",
"device.openrgb.zone.error": "Не удалось загрузить зоны",
"device.openrgb.mode": "Режим зон:",
"device.openrgb.mode.hint": "Объединённый — все зоны как одна непрерывная LED-лента. Раздельный — каждая зона независимо отображает полный эффект.",
"device.openrgb.mode.combined": "Объединённая лента",
"device.openrgb.mode.separate": "Независимые зоны",
"device.openrgb.added_multiple": "Добавлено {count} устройств",
"device.type.openrgb": "OpenRGB",
"device.url.hint": "IP адрес или имя хоста устройства (напр. http://192.168.1.100)",
"device.name": "Имя Устройства:",

View File

@@ -137,6 +137,15 @@
"device.ws_url.hint": "客户端连接并接收 LED 数据的 WebSocket URL",
"device.openrgb.url": "OpenRGB URL",
"device.openrgb.url.hint": "OpenRGB 服务器地址(例如 openrgb://localhost:6742/0",
"device.openrgb.zone": "区域:",
"device.openrgb.zone.hint": "选择要控制的 LED 区域(全部不选则控制所有区域)",
"device.openrgb.zone.loading": "加载区域中…",
"device.openrgb.zone.error": "加载区域失败",
"device.openrgb.mode": "区域模式:",
"device.openrgb.mode.hint": "合并模式将所有区域作为一条连续 LED 灯带。独立模式让每个区域独立渲染完整效果。",
"device.openrgb.mode.combined": "合并灯带",
"device.openrgb.mode.separate": "独立区域",
"device.openrgb.added_multiple": "已添加 {count} 个设备",
"device.type.openrgb": "OpenRGB",
"device.url.hint": "设备的 IP 地址或主机名(例如 http://192.168.1.100",
"device.name": "设备名称:",

View File

@@ -7,7 +7,7 @@
* - Navigation: network-first with offline fallback
*/
const CACHE_NAME = 'ledgrab-v2';
const CACHE_NAME = 'ledgrab-v6';
// Only pre-cache static assets (no auth required).
// Do NOT pre-cache '/' — it requires API key auth and would cache an error page.