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:
@@ -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';
|
||||
}
|
||||
|
||||
@@ -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}`;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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