Add live LED strip preview via WebSocket on target cards
Stream real-time LED colors from running WLED targets to the browser via binary WebSocket (RGB bytes, throttled to ~15 fps). Toggle button on target card opens a compact canvas strip that renders each frame using ImageData. Cached last frame is re-rendered after card reconciliation to prevent flicker during auto-refresh. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -87,7 +87,7 @@ import {
|
||||
showTargetEditor, closeTargetEditorModal, forceCloseTargetEditorModal, saveTargetEditor,
|
||||
startTargetProcessing, stopTargetProcessing,
|
||||
startTargetOverlay, stopTargetOverlay, deleteTarget,
|
||||
cloneTarget,
|
||||
cloneTarget, toggleLedPreview,
|
||||
} from './features/targets.js';
|
||||
|
||||
// Layer 5: color-strip sources
|
||||
@@ -295,6 +295,7 @@ Object.assign(window, {
|
||||
stopTargetOverlay,
|
||||
deleteTarget,
|
||||
cloneTarget,
|
||||
toggleLedPreview,
|
||||
|
||||
// color-strip sources
|
||||
showCSSEditor,
|
||||
|
||||
@@ -110,6 +110,9 @@ export function set_kcNameManuallyEdited(v) { _kcNameManuallyEdited = v; }
|
||||
// KC WebSockets
|
||||
export const kcWebSockets = {};
|
||||
|
||||
// LED Preview WebSockets
|
||||
export const ledPreviewWebSockets = {};
|
||||
|
||||
// Tutorial state
|
||||
export let activeTutorial = null;
|
||||
export function setActiveTutorial(v) { activeTutorial = v; }
|
||||
|
||||
@@ -7,6 +7,7 @@ import {
|
||||
_targetEditorDevices, set_targetEditorDevices,
|
||||
_deviceBrightnessCache,
|
||||
kcWebSockets,
|
||||
ledPreviewWebSockets,
|
||||
_cachedValueSources, set_cachedValueSources,
|
||||
} from '../core/state.js';
|
||||
import { API_BASE, getHeaders, fetchWithAuth, escapeHtml } from '../core/api.js';
|
||||
@@ -494,6 +495,15 @@ export async function loadTargetsTab() {
|
||||
csPatternTemplates.reconcile(patternItems);
|
||||
changedTargetIds = new Set([...ledResult.added, ...ledResult.replaced, ...ledResult.removed,
|
||||
...kcResult.added, ...kcResult.replaced, ...kcResult.removed]);
|
||||
|
||||
// Re-render cached LED preview frames onto new canvas elements after reconciliation
|
||||
for (const id of ledResult.replaced) {
|
||||
const frame = _ledPreviewLastFrame[id];
|
||||
if (frame && ledPreviewWebSockets[id]) {
|
||||
const canvas = document.getElementById(`led-preview-canvas-${id}`);
|
||||
if (canvas) _renderLedStrip(canvas, frame);
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// ── First render: build full HTML ──
|
||||
const ledPanel = `
|
||||
@@ -545,6 +555,15 @@ export async function loadTargetsTab() {
|
||||
if (!processingKCIds.has(id)) disconnectKCWebSocket(id);
|
||||
});
|
||||
|
||||
// Auto-disconnect LED preview WebSockets for targets that stopped
|
||||
const processingLedIds = new Set();
|
||||
ledTargets.forEach(target => {
|
||||
if (target.state && target.state.processing) processingLedIds.add(target.id);
|
||||
});
|
||||
Object.keys(ledPreviewWebSockets).forEach(id => {
|
||||
if (!processingLedIds.has(id)) disconnectLedPreviewWS(id);
|
||||
});
|
||||
|
||||
// FPS charts: only destroy charts for replaced/removed cards (or all on first render)
|
||||
if (changedTargetIds) {
|
||||
// Incremental: destroy only charts whose cards were replaced or removed
|
||||
@@ -703,6 +722,9 @@ export function createTargetCard(target, deviceMap, colorStripSourceMap) {
|
||||
</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>
|
||||
</div>
|
||||
<div class="card-actions">
|
||||
${isProcessing ? `
|
||||
<button class="btn btn-icon btn-danger" onclick="stopTargetProcessing('${target.id}')" title="${t('device.button.stop')}">
|
||||
@@ -713,6 +735,11 @@ export function createTargetCard(target, deviceMap, colorStripSourceMap) {
|
||||
▶️
|
||||
</button>
|
||||
`}
|
||||
${isProcessing ? `
|
||||
<button class="btn btn-icon ${ledPreviewWebSockets[target.id] ? 'btn-warning' : 'btn-secondary'}" onclick="toggleLedPreview('${target.id}')" title="LED Preview">
|
||||
📊
|
||||
</button>
|
||||
` : ''}
|
||||
<button class="btn btn-icon btn-secondary" onclick="cloneTarget('${target.id}')" title="${t('common.clone')}">
|
||||
📋
|
||||
</button>
|
||||
@@ -828,3 +855,91 @@ export async function deleteTarget(targetId) {
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
/* ── LED Strip Preview ────────────────────────────────────────── */
|
||||
|
||||
const _ledPreviewLastFrame = {};
|
||||
|
||||
function _renderLedStrip(canvas, rgbBytes) {
|
||||
const ledCount = rgbBytes.length / 3;
|
||||
if (ledCount <= 0) return;
|
||||
|
||||
// Set canvas resolution to match LED count (1px per LED)
|
||||
canvas.width = ledCount;
|
||||
canvas.height = 1;
|
||||
|
||||
const ctx = canvas.getContext('2d');
|
||||
const imageData = ctx.createImageData(ledCount, 1);
|
||||
const data = imageData.data;
|
||||
|
||||
for (let i = 0; i < ledCount; i++) {
|
||||
const si = i * 3;
|
||||
const di = i * 4;
|
||||
data[di] = rgbBytes[si];
|
||||
data[di + 1] = rgbBytes[si + 1];
|
||||
data[di + 2] = rgbBytes[si + 2];
|
||||
data[di + 3] = 255;
|
||||
}
|
||||
|
||||
ctx.putImageData(imageData, 0, 0);
|
||||
}
|
||||
|
||||
function connectLedPreviewWS(targetId) {
|
||||
disconnectLedPreviewWS(targetId);
|
||||
|
||||
const key = localStorage.getItem('wled_api_key');
|
||||
if (!key) return;
|
||||
|
||||
const protocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:';
|
||||
const wsUrl = `${protocol}//${window.location.host}${API_BASE}/picture-targets/${targetId}/led-preview/ws?token=${encodeURIComponent(key)}`;
|
||||
|
||||
try {
|
||||
const ws = new WebSocket(wsUrl);
|
||||
ws.binaryType = 'arraybuffer';
|
||||
|
||||
ws.onmessage = (event) => {
|
||||
if (event.data instanceof ArrayBuffer) {
|
||||
const frame = new Uint8Array(event.data);
|
||||
_ledPreviewLastFrame[targetId] = frame;
|
||||
const canvas = document.getElementById(`led-preview-canvas-${targetId}`);
|
||||
if (canvas) _renderLedStrip(canvas, frame);
|
||||
}
|
||||
};
|
||||
|
||||
ws.onclose = () => {
|
||||
delete ledPreviewWebSockets[targetId];
|
||||
};
|
||||
|
||||
ws.onerror = (error) => {
|
||||
console.error(`LED preview WebSocket error for ${targetId}:`, error);
|
||||
};
|
||||
|
||||
ledPreviewWebSockets[targetId] = ws;
|
||||
} catch (error) {
|
||||
console.error(`Failed to connect LED preview WebSocket for ${targetId}:`, error);
|
||||
}
|
||||
}
|
||||
|
||||
function disconnectLedPreviewWS(targetId) {
|
||||
const ws = ledPreviewWebSockets[targetId];
|
||||
if (ws) {
|
||||
ws.close();
|
||||
delete ledPreviewWebSockets[targetId];
|
||||
}
|
||||
delete _ledPreviewLastFrame[targetId];
|
||||
const panel = document.getElementById(`led-preview-panel-${targetId}`);
|
||||
if (panel) panel.style.display = 'none';
|
||||
}
|
||||
|
||||
export function toggleLedPreview(targetId) {
|
||||
const panel = document.getElementById(`led-preview-panel-${targetId}`);
|
||||
if (!panel) return;
|
||||
|
||||
if (ledPreviewWebSockets[targetId]) {
|
||||
disconnectLedPreviewWS(targetId);
|
||||
} else {
|
||||
panel.style.display = '';
|
||||
connectLedPreviewWS(targetId);
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user