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