refactor: comprehensive code quality, security, and release readiness improvements
Some checks failed
Lint & Test / test (push) Failing after 48s

Security: tighten CORS defaults, add webhook rate limiting, fix XSS in
automations, guard WebSocket JSON.parse, validate ADB address input,
seal debug exception leak, URL-encode WS tokens, CSS.escape in selectors.

Code quality: add Pydantic models for brightness/power endpoints, fix
thread safety and name uniqueness in DeviceStore, immutable update
pattern, split 6 oversized files into 16 focused modules, enable
TypeScript strictNullChecks (741→102 errors), type state variables,
add dom-utils helper, migrate 3 modules from inline onclick to event
delegation, ProcessorDependencies dataclass.

Performance: async store saves, health endpoint log level, command
palette debounce, optimized entity-events comparison, fix service
worker precache list.

Testing: expand from 45 to 293 passing tests — add store tests (141),
route tests (25), core logic tests (42), E2E flow tests (33), organize
into tests/api/, tests/storage/, tests/core/, tests/e2e/.

DevOps: CI test pipeline, pre-commit config, Dockerfile multi-stage
build with non-root user and health check, docker-compose improvements,
version bump to 0.2.0.

Docs: rewrite CLAUDE.md (202→56 lines), server/CLAUDE.md (212→76),
create contexts/server-operations.md, fix .js→.ts references, fix env
var prefix in README, rewrite INSTALLATION.md, add CONTRIBUTING.md and
.env.example.
This commit is contained in:
2026-03-22 00:38:28 +03:00
parent 07bb89e9b7
commit f2871319cb
115 changed files with 9808 additions and 5818 deletions

View File

@@ -280,7 +280,7 @@ export function selectCalibrationLine(idx: number): void {
const prev = _state.selectedLine;
_state.selectedLine = idx;
// Update selection in-place without rebuilding the list DOM
const container = document.getElementById('advcal-line-list');
const container = document.getElementById('advcal-line-list')!;
const items = container.querySelectorAll('.advcal-line-item');
if (prev >= 0 && prev < items.length) items[prev].classList.remove('selected');
if (idx >= 0 && idx < items.length) items[idx].classList.add('selected');
@@ -361,14 +361,14 @@ function _buildMonitorLayout(psList: any[], cssId: string | null): void {
// Load saved positions from localStorage
const savedKey = `advcal_positions_${cssId}`;
let saved = {};
try { saved = JSON.parse(localStorage.getItem(savedKey)) || {}; } catch { /* ignore */ }
try { saved = JSON.parse(localStorage.getItem(savedKey) ?? '{}') || {}; } catch { /* ignore */ }
const canvas = document.getElementById('advcal-canvas') as HTMLCanvasElement;
const canvasW = canvas.width;
const canvasH = canvas.height;
// Default layout: arrange monitors in a row
const monitors = [];
const monitors: any[] = [];
const padding = 20;
const maxMonW = (canvasW - padding * 2) / Math.max(psList.length, 1) - 10;
const monH = canvasH * 0.6;
@@ -423,7 +423,7 @@ function _placeNewMonitor(): void {
function _updateTotalLeds(): void {
const used = _state.lines.reduce((s, l) => s + l.led_count, 0);
const el = document.getElementById('advcal-total-leds');
const el = document.getElementById('advcal-total-leds')!;
if (_state.totalLedCount > 0) {
el.textContent = `${used}/${_state.totalLedCount}`;
el.style.color = used > _state.totalLedCount ? 'var(--danger-color, #ff5555)' : '';
@@ -436,7 +436,7 @@ function _updateTotalLeds(): void {
/* ── Line list rendering ────────────────────────────────────── */
function _renderLineList(): void {
const container = document.getElementById('advcal-line-list');
const container = document.getElementById('advcal-line-list')!;
container.innerHTML = '';
_state.lines.forEach((line, i) => {
@@ -470,7 +470,7 @@ function _renderLineList(): void {
}
function _showLineProps(): void {
const propsEl = document.getElementById('advcal-line-props');
const propsEl = document.getElementById('advcal-line-props')!;
const idx = _state.selectedLine;
if (idx < 0 || idx >= _state.lines.length) {
propsEl.style.display = 'none';
@@ -553,7 +553,7 @@ function _fitView(): void {
function _renderCanvas(): void {
const canvas = document.getElementById('advcal-canvas') as HTMLCanvasElement | null;
if (!canvas) return;
const ctx = canvas.getContext('2d');
const ctx = canvas.getContext('2d')!;
const W = canvas.width;
const H = canvas.height;
@@ -679,7 +679,7 @@ function _drawLineTicks(ctx: CanvasRenderingContext2D, line: CalibrationLine, x1
return (line.reverse ? (1 - f) : f) * edgeLen;
};
const placed = [];
const placed: number[] = [];
// Place intermediate labels at nice steps
for (let i = 0; i < count; i++) {

View File

@@ -439,7 +439,7 @@ function _sizeCanvas(canvas: HTMLCanvasElement) {
canvas.width = rect.width * dpr;
canvas.height = 200 * dpr;
canvas.style.height = '200px';
canvas.getContext('2d').scale(dpr, dpr);
canvas.getContext('2d')!.scale(dpr, dpr);
}
function _renderLoop() {
@@ -449,11 +449,40 @@ function _renderLoop() {
}
}
// ── Event delegation for audio source card actions ──
const _audioSourceActions: Record<string, (id: string) => void> = {
'test-audio': testAudioSource,
'clone-audio': cloneAudioSource,
'edit-audio': editAudioSource,
};
export function initAudioSourceDelegation(container: HTMLElement): void {
container.addEventListener('click', (e: MouseEvent) => {
const btn = (e.target as HTMLElement).closest<HTMLElement>('[data-action]');
if (!btn) return;
const action = btn.dataset.action;
const id = btn.dataset.id;
if (!action || !id) return;
// Only handle audio-source actions (prefixed with audio-)
const handler = _audioSourceActions[action];
if (handler) {
// Verify we're inside an audio source section
const section = btn.closest<HTMLElement>('[data-card-section="audio-multi"], [data-card-section="audio-mono"]');
if (!section) return;
e.stopPropagation();
handler(id);
}
});
}
function _renderAudioSpectrum() {
const canvas = document.getElementById('audio-test-canvas') as HTMLCanvasElement | null;
if (!canvas) return;
const ctx = canvas.getContext('2d');
const ctx = canvas.getContext('2d')!;
const dpr = window.devicePixelRatio || 1;
const w = canvas.width / dpr;
const h = canvas.height / dpr;

View File

@@ -18,7 +18,7 @@ import { IconSelect } from '../core/icon-select.ts';
import { EntitySelect } from '../core/entity-palette.ts';
import { attachProcessPicker } from '../core/process-picker.ts';
import { TreeNav } from '../core/tree-nav.ts';
import { csScenes, createSceneCard } from './scene-presets.ts';
import { csScenes, createSceneCard, initScenePresetDelegation } from './scene-presets.ts';
import type { Automation } from '../types.ts';
let _automationTagsInput: any = null;
@@ -158,7 +158,7 @@ export async function loadAutomations() {
} catch (error: any) {
if (error.isAuth) return;
console.error('Failed to load automations:', error);
container.innerHTML = `<p class="error-message">${error.message}</p>`;
container.innerHTML = `<p class="error-message">${escapeHtml(error.message)}</p>`;
} finally {
set_automationsLoading(false);
setTabRefreshing('automations-content', false);
@@ -194,6 +194,9 @@ function renderAutomations(automations: any, sceneMap: any) {
container.innerHTML = panels;
CardSection.bindAll([csAutomations, csScenes]);
// Event delegation for scene preset card actions
initScenePresetDelegation(container);
_automationsTree.setExtraHtml(`<button class="tutorial-trigger-btn" onclick="startAutomationsTutorial()" data-i18n-title="tour.restart" title="${t('tour.restart')}">${ICON_HELP}</button>`);
_automationsTree.update(treeItems, activeTab);
_automationsTree.observeSections('automations-content', {

View File

@@ -138,7 +138,7 @@ export async function showCalibration(deviceId: any) {
try {
const [response, displays] = await Promise.all([
fetchWithAuth(`/devices/${deviceId}`),
displaysCache.fetch().catch(() => []),
displaysCache.fetch().catch((): any[] => []),
]);
if (!response.ok) { showToast(t('calibration.error.load_failed'), 'error'); return; }
@@ -235,7 +235,7 @@ export async function showCSSCalibration(cssId: any) {
try {
const [cssSources, devices] = await Promise.all([
colorStripSourcesCache.fetch(),
devicesCache.fetch().catch(() => []),
devicesCache.fetch().catch((): any[] => []),
]);
const source = cssSources.find((s: any) => s.id === cssId);
if (!source) { showToast(t('calibration.error.css_load_failed'), 'error'); return; }

View File

@@ -0,0 +1,413 @@
/**
* Color Strip Sources — Composite layer helpers.
* Extracted from color-strips.ts to reduce file size.
*/
import { escapeHtml } from '../core/api.ts';
import { _cachedValueSources, _cachedCSPTemplates } from '../core/state.ts';
import { t } from '../core/i18n.ts';
import {
getColorStripIcon, getValueSourceIcon,
ICON_SPARKLES,
} from '../core/icons.ts';
import * as P from '../core/icon-paths.ts';
import { IconSelect } from '../core/icon-select.ts';
import { EntitySelect } from '../core/entity-palette.ts';
const _icon = (d: any) => `<svg class="icon" viewBox="0 0 24 24">${d}</svg>`;
/* ── Composite layer state ────────────────────────────────────── */
let _compositeLayers: any[] = [];
let _compositeAvailableSources: any[] = []; // non-composite sources for layer dropdowns
let _compositeSourceEntitySelects: any[] = [];
let _compositeBrightnessEntitySelects: any[] = [];
let _compositeBlendIconSelects: any[] = [];
let _compositeCSPTEntitySelects: any[] = [];
/** Return current composite layers array (for dirty-check snapshot). */
export function compositeGetRawLayers() {
return _compositeLayers;
}
export function compositeSetAvailableSources(sources: any[]) {
_compositeAvailableSources = sources;
}
export function compositeGetAvailableSources() {
return _compositeAvailableSources;
}
export function compositeDestroyEntitySelects() {
_compositeSourceEntitySelects.forEach(es => es.destroy());
_compositeSourceEntitySelects = [];
_compositeBrightnessEntitySelects.forEach(es => es.destroy());
_compositeBrightnessEntitySelects = [];
_compositeBlendIconSelects.forEach(is => is.destroy());
_compositeBlendIconSelects = [];
_compositeCSPTEntitySelects.forEach(es => es.destroy());
_compositeCSPTEntitySelects = [];
}
function _getCompositeBlendItems() {
return [
{ value: 'normal', icon: _icon(P.square), label: t('color_strip.composite.blend_mode.normal'), desc: t('color_strip.composite.blend_mode.normal.desc') },
{ value: 'add', icon: _icon(P.sun), label: t('color_strip.composite.blend_mode.add'), desc: t('color_strip.composite.blend_mode.add.desc') },
{ value: 'multiply', icon: _icon(P.eye), label: t('color_strip.composite.blend_mode.multiply'), desc: t('color_strip.composite.blend_mode.multiply.desc') },
{ value: 'screen', icon: _icon(P.monitor), label: t('color_strip.composite.blend_mode.screen'), desc: t('color_strip.composite.blend_mode.screen.desc') },
{ value: 'override', icon: _icon(P.zap), label: t('color_strip.composite.blend_mode.override'), desc: t('color_strip.composite.blend_mode.override.desc') },
];
}
function _getCompositeSourceItems() {
return _compositeAvailableSources.map(s => ({
value: s.id,
label: s.name,
icon: getColorStripIcon(s.source_type),
}));
}
function _getCompositeBrightnessItems() {
return (_cachedValueSources || []).map(v => ({
value: v.id,
label: v.name,
icon: getValueSourceIcon(v.source_type),
}));
}
function _getCompositeCSPTItems() {
return (_cachedCSPTemplates || []).map(tmpl => ({
value: tmpl.id,
label: tmpl.name,
icon: ICON_SPARKLES,
}));
}
export function compositeRenderList() {
const list = document.getElementById('composite-layers-list') as HTMLElement | null;
if (!list) return;
compositeDestroyEntitySelects();
const vsList = _cachedValueSources || [];
list.innerHTML = _compositeLayers.map((layer, i) => {
const srcOptions = _compositeAvailableSources.map(s =>
`<option value="${s.id}"${layer.source_id === s.id ? ' selected' : ''}>${escapeHtml(s.name)}</option>`
).join('');
const vsOptions = `<option value="">${t('color_strip.composite.brightness.none')}</option>` +
vsList.map(v =>
`<option value="${v.id}"${layer.brightness_source_id === v.id ? ' selected' : ''}>${escapeHtml(v.name)}</option>`
).join('');
const csptList = _cachedCSPTemplates || [];
const csptOptions = `<option value="">${t('common.none_no_cspt')}</option>` +
csptList.map(tmpl =>
`<option value="${tmpl.id}"${layer.processing_template_id === tmpl.id ? ' selected' : ''}>${escapeHtml(tmpl.name)}</option>`
).join('');
const canRemove = _compositeLayers.length > 1;
return `
<div class="composite-layer-item" data-layer-index="${i}">
<div class="composite-layer-row">
<span class="composite-layer-drag-handle" title="${t('filters.drag_to_reorder')}">&#x2807;</span>
<select class="composite-layer-source" data-idx="${i}">${srcOptions}</select>
<select class="composite-layer-blend" data-idx="${i}">
<option value="normal"${layer.blend_mode === 'normal' ? ' selected' : ''}>${t('color_strip.composite.blend_mode.normal')}</option>
<option value="add"${layer.blend_mode === 'add' ? ' selected' : ''}>${t('color_strip.composite.blend_mode.add')}</option>
<option value="multiply"${layer.blend_mode === 'multiply' ? ' selected' : ''}>${t('color_strip.composite.blend_mode.multiply')}</option>
<option value="screen"${layer.blend_mode === 'screen' ? ' selected' : ''}>${t('color_strip.composite.blend_mode.screen')}</option>
<option value="override"${layer.blend_mode === 'override' ? ' selected' : ''}>${t('color_strip.composite.blend_mode.override')}</option>
</select>
</div>
<div class="composite-layer-row">
<label class="composite-layer-opacity-label">
<span>${t('color_strip.composite.opacity')}:</span>
<span class="composite-opacity-val">${parseFloat(layer.opacity).toFixed(2)}</span>
</label>
<input type="range" class="composite-layer-opacity" data-idx="${i}"
min="0" max="1" step="0.05" value="${layer.opacity}">
<label class="settings-toggle composite-layer-toggle">
<input type="checkbox" class="composite-layer-enabled" data-idx="${i}"${layer.enabled ? ' checked' : ''}>
<span class="settings-toggle-slider"></span>
</label>
${canRemove
? `<button type="button" class="btn btn-secondary composite-layer-remove-btn"
onclick="compositeRemoveLayer(${i})">&#x2715;</button>`
: ''}
</div>
<div class="composite-layer-row">
<label class="composite-layer-brightness-label">
<span>${t('color_strip.composite.brightness')}:</span>
</label>
<select class="composite-layer-brightness" data-idx="${i}">${vsOptions}</select>
</div>
<div class="composite-layer-row">
<label class="composite-layer-brightness-label">
<span>${t('color_strip.composite.processing')}:</span>
</label>
<select class="composite-layer-cspt" data-idx="${i}">${csptOptions}</select>
</div>
</div>
`;
}).join('');
// Wire up live opacity display
list.querySelectorAll<HTMLInputElement>('.composite-layer-opacity').forEach(el => {
el.addEventListener('input', () => {
const val = parseFloat(el.value);
(el.closest('.composite-layer-row')!.querySelector('.composite-opacity-val') as HTMLElement).textContent = val.toFixed(2);
});
});
// Attach IconSelect to each layer's blend mode dropdown
const blendItems = _getCompositeBlendItems();
list.querySelectorAll<HTMLSelectElement>('.composite-layer-blend').forEach(sel => {
const is = new IconSelect({ target: sel, items: blendItems, columns: 2 });
_compositeBlendIconSelects.push(is);
});
// Attach EntitySelect to each layer's source dropdown
list.querySelectorAll<HTMLSelectElement>('.composite-layer-source').forEach(sel => {
_compositeSourceEntitySelects.push(new EntitySelect({
target: sel,
getItems: _getCompositeSourceItems,
placeholder: t('palette.search'),
}));
});
// Attach EntitySelect to each layer's brightness dropdown
list.querySelectorAll<HTMLSelectElement>('.composite-layer-brightness').forEach(sel => {
_compositeBrightnessEntitySelects.push(new EntitySelect({
target: sel,
getItems: _getCompositeBrightnessItems,
placeholder: t('palette.search'),
allowNone: true,
noneLabel: t('color_strip.composite.brightness.none'),
}));
});
// Attach EntitySelect to each layer's CSPT dropdown
list.querySelectorAll<HTMLSelectElement>('.composite-layer-cspt').forEach(sel => {
_compositeCSPTEntitySelects.push(new EntitySelect({
target: sel,
getItems: _getCompositeCSPTItems,
placeholder: t('palette.search'),
allowNone: true,
noneLabel: t('common.none_no_cspt'),
}));
});
_initCompositeLayerDrag(list);
}
export function compositeAddLayer() {
_compositeLayersSyncFromDom();
_compositeLayers.push({
source_id: _compositeAvailableSources.length > 0 ? _compositeAvailableSources[0].id : '',
blend_mode: 'normal',
opacity: 1.0,
enabled: true,
brightness_source_id: null,
processing_template_id: null,
});
compositeRenderList();
}
export function compositeRemoveLayer(i: number) {
_compositeLayersSyncFromDom();
if (_compositeLayers.length <= 1) return;
_compositeLayers.splice(i, 1);
compositeRenderList();
}
function _compositeLayersSyncFromDom() {
const list = document.getElementById('composite-layers-list') as HTMLElement | null;
if (!list) return;
const srcs = list.querySelectorAll<HTMLSelectElement>('.composite-layer-source');
const blends = list.querySelectorAll<HTMLSelectElement>('.composite-layer-blend');
const opacities = list.querySelectorAll<HTMLInputElement>('.composite-layer-opacity');
const enableds = list.querySelectorAll<HTMLInputElement>('.composite-layer-enabled');
const briSrcs = list.querySelectorAll<HTMLSelectElement>('.composite-layer-brightness');
const csptSels = list.querySelectorAll<HTMLSelectElement>('.composite-layer-cspt');
if (srcs.length === _compositeLayers.length) {
for (let i = 0; i < srcs.length; i++) {
_compositeLayers[i].source_id = srcs[i].value;
_compositeLayers[i].blend_mode = blends[i].value;
_compositeLayers[i].opacity = parseFloat(opacities[i].value);
_compositeLayers[i].enabled = enableds[i].checked;
_compositeLayers[i].brightness_source_id = briSrcs[i] ? (briSrcs[i].value || null) : null;
_compositeLayers[i].processing_template_id = csptSels[i] ? (csptSels[i].value || null) : null;
}
}
}
/* ── Composite layer drag-to-reorder ── */
const _COMPOSITE_DRAG_THRESHOLD = 5;
let _compositeLayerDragState: any = null;
function _initCompositeLayerDrag(list: any) {
// Guard against stacking listeners across re-renders (the list DOM node persists).
if (list._compositeDragBound) return;
list._compositeDragBound = true;
list.addEventListener('pointerdown', (e: any) => {
const handle = e.target.closest('.composite-layer-drag-handle');
if (!handle) return;
const item = handle.closest('.composite-layer-item');
if (!item) return;
e.preventDefault();
e.stopPropagation();
const fromIndex = parseInt(item.dataset.layerIndex, 10);
_compositeLayerDragState = {
item,
list,
startY: e.clientY,
started: false,
clone: null,
placeholder: null,
offsetY: 0,
fromIndex,
scrollRaf: null,
};
const onMove = (ev: any) => _onCompositeLayerDragMove(ev);
const cleanup = () => {
document.removeEventListener('pointermove', onMove);
document.removeEventListener('pointerup', cleanup);
document.removeEventListener('pointercancel', cleanup);
_onCompositeLayerDragEnd();
};
document.addEventListener('pointermove', onMove);
document.addEventListener('pointerup', cleanup);
document.addEventListener('pointercancel', cleanup);
}, { capture: false });
}
function _onCompositeLayerDragMove(e: any) {
const ds = _compositeLayerDragState;
if (!ds) return;
if (!ds.started) {
if (Math.abs(e.clientY - ds.startY) < _COMPOSITE_DRAG_THRESHOLD) return;
_startCompositeLayerDrag(ds, e);
}
ds.clone.style.top = (e.clientY - ds.offsetY) + 'px';
const items = ds.list.querySelectorAll('.composite-layer-item');
for (const it of items) {
if (it.style.display === 'none') continue;
const r = it.getBoundingClientRect();
if (e.clientY >= r.top && e.clientY <= r.bottom) {
const before = e.clientY < r.top + r.height / 2;
if (it === ds.lastTarget && before === ds.lastBefore) break;
ds.lastTarget = it;
ds.lastBefore = before;
if (before) {
ds.list.insertBefore(ds.placeholder, it);
} else {
ds.list.insertBefore(ds.placeholder, it.nextSibling);
}
break;
}
}
// Auto-scroll near modal edges
if (ds.scrollRaf) cancelAnimationFrame(ds.scrollRaf);
const modal = ds.list.closest('.modal-body');
if (modal) {
const EDGE = 60, SPEED = 12;
const mr = modal.getBoundingClientRect();
let speed = 0;
if (e.clientY < mr.top + EDGE) speed = -SPEED;
else if (e.clientY > mr.bottom - EDGE) speed = SPEED;
if (speed !== 0) {
const scroll = () => { modal.scrollTop += speed; ds.scrollRaf = requestAnimationFrame(scroll); };
ds.scrollRaf = requestAnimationFrame(scroll);
}
}
}
function _startCompositeLayerDrag(ds: any, e: any) {
ds.started = true;
const rect = ds.item.getBoundingClientRect();
const clone = ds.item.cloneNode(true);
clone.className = ds.item.className + ' composite-layer-drag-clone';
clone.style.width = rect.width + 'px';
clone.style.left = rect.left + 'px';
clone.style.top = rect.top + 'px';
document.body.appendChild(clone);
ds.clone = clone;
ds.offsetY = e.clientY - rect.top;
const placeholder = document.createElement('div');
placeholder.className = 'composite-layer-drag-placeholder';
placeholder.style.height = rect.height + 'px';
ds.item.parentNode.insertBefore(placeholder, ds.item);
ds.placeholder = placeholder;
ds.item.style.display = 'none';
document.body.classList.add('composite-layer-dragging');
}
function _onCompositeLayerDragEnd() {
const ds = _compositeLayerDragState;
_compositeLayerDragState = null;
if (!ds || !ds.started) return;
if (ds.scrollRaf) cancelAnimationFrame(ds.scrollRaf);
// Determine new index from placeholder position
let toIndex = 0;
for (const child of ds.list.children) {
if (child === ds.placeholder) break;
if (child.classList.contains('composite-layer-item') && child.style.display !== 'none') {
toIndex++;
}
}
// Cleanup DOM
ds.item.style.display = '';
ds.placeholder.remove();
ds.clone.remove();
document.body.classList.remove('composite-layer-dragging');
// Sync current DOM values before reordering
_compositeLayersSyncFromDom();
// Reorder array and re-render
if (toIndex !== ds.fromIndex) {
const [moved] = _compositeLayers.splice(ds.fromIndex, 1);
_compositeLayers.splice(toIndex, 0, moved);
compositeRenderList();
}
}
export function compositeGetLayers() {
_compositeLayersSyncFromDom();
return _compositeLayers.map(l => {
const layer: any = {
source_id: l.source_id,
blend_mode: l.blend_mode,
opacity: l.opacity,
enabled: l.enabled,
};
if (l.brightness_source_id) layer.brightness_source_id = l.brightness_source_id;
if (l.processing_template_id) layer.processing_template_id = l.processing_template_id;
return layer;
});
}
export function loadCompositeState(css: any) {
const raw = css && css.layers;
_compositeLayers = (raw && raw.length > 0)
? raw.map((l: any) => ({
source_id: l.source_id || '',
blend_mode: l.blend_mode || 'normal',
opacity: l.opacity != null ? l.opacity : 1.0,
enabled: l.enabled != null ? l.enabled : true,
brightness_source_id: l.brightness_source_id || null,
processing_template_id: l.processing_template_id || null,
}))
: [{ source_id: '', blend_mode: 'normal', opacity: 1.0, enabled: true, brightness_source_id: null, processing_template_id: null }];
compositeRenderList();
}

View File

@@ -0,0 +1,273 @@
/**
* Color Strip Sources — Notification helpers.
* Extracted from color-strips.ts to reduce file size.
*/
import { fetchWithAuth, escapeHtml } from '../core/api.ts';
import { t } from '../core/i18n.ts';
import { showToast } from '../core/ui.ts';
import {
ICON_SEARCH,
} from '../core/icons.ts';
import * as P from '../core/icon-paths.ts';
import { IconSelect } from '../core/icon-select.ts';
import { attachNotificationAppPicker, NotificationAppPalette } from '../core/process-picker.ts';
import { getBaseOrigin } from './settings.ts';
const _icon = (d: any) => `<svg class="icon" viewBox="0 0 24 24">${d}</svg>`;
/* ── Notification state ───────────────────────────────────────── */
let _notificationAppColors: any[] = []; // [{app: '', color: '#...'}]
/** Return current app colors array (for dirty-check snapshot). */
export function notificationGetRawAppColors() {
return _notificationAppColors;
}
let _notificationEffectIconSelect: any = null;
let _notificationFilterModeIconSelect: any = null;
export function ensureNotificationEffectIconSelect() {
const sel = document.getElementById('css-editor-notification-effect') as HTMLSelectElement | null;
if (!sel) return;
const items = [
{ value: 'flash', icon: _icon(P.zap), label: t('color_strip.notification.effect.flash'), desc: t('color_strip.notification.effect.flash.desc') },
{ value: 'pulse', icon: _icon(P.activity), label: t('color_strip.notification.effect.pulse'), desc: t('color_strip.notification.effect.pulse.desc') },
{ value: 'sweep', icon: _icon(P.fastForward), label: t('color_strip.notification.effect.sweep'), desc: t('color_strip.notification.effect.sweep.desc') },
];
if (_notificationEffectIconSelect) { _notificationEffectIconSelect.updateItems(items); return; }
_notificationEffectIconSelect = new IconSelect({ target: sel, items, columns: 3 });
}
export function ensureNotificationFilterModeIconSelect() {
const sel = document.getElementById('css-editor-notification-filter-mode') as HTMLSelectElement | null;
if (!sel) return;
const items = [
{ value: 'off', icon: _icon(P.globe), label: t('color_strip.notification.filter_mode.off'), desc: t('color_strip.notification.filter_mode.off.desc') },
{ value: 'whitelist', icon: _icon(P.circleCheck), label: t('color_strip.notification.filter_mode.whitelist'), desc: t('color_strip.notification.filter_mode.whitelist.desc') },
{ value: 'blacklist', icon: _icon(P.eyeOff), label: t('color_strip.notification.filter_mode.blacklist'), desc: t('color_strip.notification.filter_mode.blacklist.desc') },
];
if (_notificationFilterModeIconSelect) { _notificationFilterModeIconSelect.updateItems(items); return; }
_notificationFilterModeIconSelect = new IconSelect({ target: sel, items, columns: 3 });
}
export function onNotificationFilterModeChange() {
const mode = (document.getElementById('css-editor-notification-filter-mode') as HTMLInputElement).value;
(document.getElementById('css-editor-notification-filter-list-group') as HTMLElement).style.display = mode === 'off' ? 'none' : '';
}
function _notificationAppColorsRenderList() {
const list = document.getElementById('notification-app-colors-list') as HTMLElement | null;
if (!list) return;
list.innerHTML = _notificationAppColors.map((entry, i) => `
<div class="notif-app-color-row">
<input type="text" class="notif-app-name" data-idx="${i}" value="${escapeHtml(entry.app)}" placeholder="App name">
<button type="button" class="notif-app-browse" data-idx="${i}"
title="${t('automations.condition.application.browse')}">${ICON_SEARCH}</button>
<input type="color" class="notif-app-color" data-idx="${i}" value="${entry.color}">
<button type="button" class="notif-app-color-remove"
onclick="notificationRemoveAppColor(${i})">&#x2715;</button>
</div>
`).join('');
// Wire up browse buttons to open process palette
list.querySelectorAll<HTMLButtonElement>('.notif-app-browse').forEach(btn => {
btn.addEventListener('click', async () => {
const idx = parseInt(btn.dataset.idx!);
const nameInput = list.querySelector<HTMLInputElement>(`.notif-app-name[data-idx="${idx}"]`);
if (!nameInput) return;
const picked = await NotificationAppPalette.pick({
current: nameInput.value,
placeholder: t('color_strip.notification.search_apps') || 'Search notification apps...',
});
if (picked !== undefined) {
nameInput.value = picked;
_notificationAppColorsSyncFromDom();
}
});
});
}
export function notificationAddAppColor() {
_notificationAppColorsSyncFromDom();
_notificationAppColors.push({ app: '', color: '#ffffff' });
_notificationAppColorsRenderList();
}
export function notificationRemoveAppColor(i: number) {
_notificationAppColorsSyncFromDom();
_notificationAppColors.splice(i, 1);
_notificationAppColorsRenderList();
}
export async function testNotification(sourceId: string) {
try {
const resp = (await fetchWithAuth(`/color-strip-sources/${sourceId}/notify`, { method: 'POST' }))!;
if (!resp.ok) {
const err = await resp.json().catch(() => ({}));
showToast(err.detail || t('color_strip.notification.test.error'), 'error');
return;
}
const data = await resp.json();
if (data.streams_notified > 0) {
showToast(t('color_strip.notification.test.ok'), 'success');
} else {
showToast(t('color_strip.notification.test.no_streams'), 'warning');
}
} catch {
showToast(t('color_strip.notification.test.error'), 'error');
}
}
// ── OS Notification History Modal ─────────────────────────────────────────
export function showNotificationHistory() {
const modal = document.getElementById('notification-history-modal') as HTMLElement | null;
if (!modal) return;
modal.style.display = 'flex';
modal.onclick = (e) => { if (e.target === modal) closeNotificationHistory(); };
_loadNotificationHistory();
}
export function closeNotificationHistory() {
const modal = document.getElementById('notification-history-modal') as HTMLElement | null;
if (modal) modal.style.display = 'none';
}
export async function refreshNotificationHistory() {
await _loadNotificationHistory();
}
async function _loadNotificationHistory() {
const list = document.getElementById('notification-history-list') as HTMLElement | null;
const status = document.getElementById('notification-history-status') as HTMLElement | null;
if (!list) return;
try {
const resp = (await fetchWithAuth('/color-strip-sources/os-notifications/history'))!;
if (!resp.ok) throw new Error(`HTTP ${resp.status}`);
const data = await resp.json();
if (!data.available) {
list.innerHTML = '';
if (status) {
status.textContent = t('color_strip.notification.history.unavailable');
status.style.display = '';
}
return;
}
if (status) status.style.display = 'none';
const history = data.history || [];
if (history.length === 0) {
list.innerHTML = `<div class="notif-history-empty">${t('color_strip.notification.history.empty')}</div>`;
return;
}
list.innerHTML = history.map((entry: any) => {
const appName = entry.app || t('color_strip.notification.history.unknown_app');
const timeStr = new Date(entry.time * 1000).toLocaleString();
const fired = entry.fired ?? 0;
const filtered = entry.filtered ?? 0;
const firedBadge = fired > 0
? `<span class="notif-history-badge notif-history-badge--fired" title="${t('color_strip.notification.history.fired')}">${fired}</span>`
: '';
const filteredBadge = filtered > 0
? `<span class="notif-history-badge notif-history-badge--filtered" title="${t('color_strip.notification.history.filtered')}">${filtered}</span>`
: '';
return `<div class="notif-history-row">
<div class="notif-history-app" title="${escapeHtml(appName)}">${escapeHtml(appName)}</div>
<div class="notif-history-time">${timeStr}</div>
<div class="notif-history-badges">${firedBadge}${filteredBadge}</div>
</div>`;
}).join('');
} catch (err: any) {
console.error('Failed to load notification history:', err);
if (status) {
status.textContent = t('color_strip.notification.history.error');
status.style.display = '';
}
list.innerHTML = '';
}
}
function _notificationAppColorsSyncFromDom() {
const list = document.getElementById('notification-app-colors-list') as HTMLElement | null;
if (!list) return;
const names = list.querySelectorAll<HTMLInputElement>('.notif-app-name');
const colors = list.querySelectorAll<HTMLInputElement>('.notif-app-color');
if (names.length === _notificationAppColors.length) {
for (let i = 0; i < names.length; i++) {
_notificationAppColors[i].app = names[i].value;
_notificationAppColors[i].color = colors[i].value;
}
}
}
export function notificationGetAppColorsDict() {
_notificationAppColorsSyncFromDom();
const dict: Record<string, any> = {};
for (const entry of _notificationAppColors) {
if (entry.app.trim()) dict[entry.app.trim()] = entry.color;
}
return dict;
}
export function loadNotificationState(css: any) {
(document.getElementById('css-editor-notification-effect') as HTMLInputElement).value = css.notification_effect || 'flash';
if (_notificationEffectIconSelect) _notificationEffectIconSelect.setValue(css.notification_effect || 'flash');
const dur = css.duration_ms ?? 1500;
(document.getElementById('css-editor-notification-duration') as HTMLInputElement).value = dur;
(document.getElementById('css-editor-notification-duration-val') as HTMLElement).textContent = dur;
(document.getElementById('css-editor-notification-default-color') as HTMLInputElement).value = css.default_color || '#ffffff';
(document.getElementById('css-editor-notification-filter-mode') as HTMLInputElement).value = css.app_filter_mode || 'off';
if (_notificationFilterModeIconSelect) _notificationFilterModeIconSelect.setValue(css.app_filter_mode || 'off');
(document.getElementById('css-editor-notification-filter-list') as HTMLInputElement).value = (css.app_filter_list || []).join('\n');
onNotificationFilterModeChange();
_attachNotificationProcessPicker();
// App colors dict -> list
const ac = css.app_colors || {};
_notificationAppColors = Object.entries(ac).map(([app, color]) => ({ app, color }));
_notificationAppColorsRenderList();
showNotificationEndpoint(css.id);
}
export function resetNotificationState() {
(document.getElementById('css-editor-notification-effect') as HTMLInputElement).value = 'flash';
if (_notificationEffectIconSelect) _notificationEffectIconSelect.setValue('flash');
(document.getElementById('css-editor-notification-duration') as HTMLInputElement).value = 1500 as any;
(document.getElementById('css-editor-notification-duration-val') as HTMLElement).textContent = '1500';
(document.getElementById('css-editor-notification-default-color') as HTMLInputElement).value = '#ffffff';
(document.getElementById('css-editor-notification-filter-mode') as HTMLInputElement).value = 'off';
if (_notificationFilterModeIconSelect) _notificationFilterModeIconSelect.setValue('off');
(document.getElementById('css-editor-notification-filter-list') as HTMLInputElement).value = '';
onNotificationFilterModeChange();
_attachNotificationProcessPicker();
_notificationAppColors = [];
_notificationAppColorsRenderList();
showNotificationEndpoint(null);
}
function _attachNotificationProcessPicker() {
const container = document.getElementById('css-editor-notification-filter-picker-container') as HTMLElement | null;
const textarea = document.getElementById('css-editor-notification-filter-list') as HTMLTextAreaElement | null;
if (container && textarea) attachNotificationAppPicker(container, textarea);
}
export function showNotificationEndpoint(cssId: any) {
const el = document.getElementById('css-editor-notification-endpoint') as HTMLElement | null;
if (!el) return;
if (!cssId) {
el.innerHTML = `<em data-i18n="color_strip.notification.save_first">${t('color_strip.notification.save_first')}</em>`;
return;
}
const base = `${getBaseOrigin()}/api/v1`;
const url = `${base}/color-strip-sources/${cssId}/notify`;
el.innerHTML = `
<small class="endpoint-label">POST</small>
<div class="ws-url-row"><input type="text" value="${url}" readonly style="font-size:0.85em"><button type="button" class="btn btn-sm btn-secondary" onclick="copyEndpointUrl(this)" title="Copy">&#x1F4CB;</button></div>
`;
}

View File

@@ -0,0 +1,907 @@
/**
* Color Strip Sources — Test / Preview modal (WebSocket strip renderer).
* Extracted from color-strips.ts to reduce file size.
*/
import { fetchWithAuth, escapeHtml } from '../core/api.ts';
import { colorStripSourcesCache } from '../core/state.ts';
import { t } from '../core/i18n.ts';
import { showToast } from '../core/ui.ts';
import { createFpsSparkline } from '../core/chart-utils.ts';
import {
getColorStripIcon,
ICON_WARNING, ICON_SUN_DIM,
} from '../core/icons.ts';
import { EntitySelect } from '../core/entity-palette.ts';
import { hexToRgbArray, getGradientStops } from './css-gradient-editor.ts';
import { testNotification, _getAnimationPayload, _colorCycleGetColors } from './color-strips.ts';
/* ── Preview config builder ───────────────────────────────────── */
const _PREVIEW_TYPES = new Set([
'static', 'gradient', 'color_cycle', 'effect', 'daylight', 'candlelight',
]);
function _collectPreviewConfig() {
const sourceType = (document.getElementById('css-editor-type') as HTMLInputElement).value;
if (!_PREVIEW_TYPES.has(sourceType)) return null;
let config: any;
if (sourceType === 'static') {
config = { source_type: 'static', color: hexToRgbArray((document.getElementById('css-editor-color') as HTMLInputElement).value), animation: _getAnimationPayload() };
} else if (sourceType === 'gradient') {
const stops = getGradientStops();
if (stops.length < 2) return null;
config = { source_type: 'gradient', stops: stops.map(s => ({ position: s.position, color: s.color, ...(s.colorRight ? { color_right: s.colorRight } : {}) })), animation: _getAnimationPayload() };
} else if (sourceType === 'color_cycle') {
const colors = _colorCycleGetColors();
if (colors.length < 2) return null;
config = { source_type: 'color_cycle', colors };
} else if (sourceType === 'effect') {
config = { source_type: 'effect', effect_type: (document.getElementById('css-editor-effect-type') as HTMLInputElement).value, palette: (document.getElementById('css-editor-effect-palette') as HTMLInputElement).value, intensity: parseFloat((document.getElementById('css-editor-effect-intensity') as HTMLInputElement).value), scale: parseFloat((document.getElementById('css-editor-effect-scale') as HTMLInputElement).value), mirror: (document.getElementById('css-editor-effect-mirror') as HTMLInputElement).checked };
if (config.effect_type === 'meteor') { const hex = (document.getElementById('css-editor-effect-color') as HTMLInputElement).value; config.color = [parseInt(hex.slice(1, 3), 16), parseInt(hex.slice(3, 5), 16), parseInt(hex.slice(5, 7), 16)]; }
} else if (sourceType === 'daylight') {
config = { source_type: 'daylight', speed: parseFloat((document.getElementById('css-editor-daylight-speed') as HTMLInputElement).value), use_real_time: (document.getElementById('css-editor-daylight-real-time') as HTMLInputElement).checked, latitude: parseFloat((document.getElementById('css-editor-daylight-latitude') as HTMLInputElement).value) };
} else if (sourceType === 'candlelight') {
config = { source_type: 'candlelight', color: hexToRgbArray((document.getElementById('css-editor-candlelight-color') as HTMLInputElement).value), intensity: parseFloat((document.getElementById('css-editor-candlelight-intensity') as HTMLInputElement).value), num_candles: parseInt((document.getElementById('css-editor-candlelight-num-candles') as HTMLInputElement).value) || 3, speed: parseFloat((document.getElementById('css-editor-candlelight-speed') as HTMLInputElement).value) };
}
const clockEl = document.getElementById('css-editor-clock') as HTMLSelectElement | null;
if (clockEl && clockEl.value) config.clock_id = clockEl.value;
return config;
}
/**
* Open the existing Test Preview modal from the CSS editor.
* For saved sources, uses the normal test endpoint.
* For unsaved/self-contained types, uses the transient preview endpoint.
*/
export function previewCSSFromEditor() {
// Always use transient preview with current form values
const config = _collectPreviewConfig();
if (!config) {
// Non-previewable type (picture, composite, etc.) — fall back to saved source test
const cssId = (document.getElementById('css-editor-id') as HTMLInputElement).value;
if (cssId) { testColorStrip(cssId); return; }
showToast(t('color_strip.preview.unsupported'), 'info');
return;
}
_cssTestCSPTMode = false;
_cssTestCSPTId = null;
_cssTestTransientConfig = config;
const csptGroup = document.getElementById('css-test-cspt-input-group') as HTMLElement | null;
if (csptGroup) csptGroup.style.display = 'none';
_openTestModal('__preview__');
}
/** Store transient config so _cssTestConnect and applyCssTestSettings can use it. */
let _cssTestTransientConfig: any = null;
/* ── Test / Preview ───────────────────────────────────────────── */
const _CSS_TEST_LED_KEY = 'css_test_led_count';
const _CSS_TEST_FPS_KEY = 'css_test_fps';
let _cssTestWs: WebSocket | null = null;
let _cssTestRaf: number | null = null;
let _cssTestLatestRgb: Uint8Array | null = null;
let _cssTestMeta: any = null;
let _cssTestSourceId: string | null = null;
let _cssTestIsComposite: boolean = false;
let _cssTestLayerData: any = null; // { layerCount, ledCount, layers: [Uint8Array], composite: Uint8Array }
let _cssTestGeneration: number = 0; // bumped on each connect to ignore stale WS messages
let _cssTestNotificationIds: string[] = []; // notification source IDs to fire (self or composite layers)
let _cssTestCSPTMode: boolean = false; // true when testing a CSPT template
let _cssTestCSPTId: string | null = null; // CSPT template ID when in CSPT mode
let _cssTestIsApiInput: boolean = false;
let _cssTestFpsTimestamps: number[] = []; // raw timestamps for current-second FPS calculation
let _cssTestFpsActualHistory: number[] = []; // rolling FPS samples for sparkline
let _cssTestFpsChart: any = null;
const _CSS_TEST_FPS_MAX_SAMPLES = 30;
let _csptTestInputEntitySelect: any = null;
function _getCssTestLedCount() {
const stored = parseInt(localStorage.getItem(_CSS_TEST_LED_KEY) ?? '', 10);
return (stored > 0 && stored <= 2000) ? stored : 100;
}
function _getCssTestFps() {
const stored = parseInt(localStorage.getItem(_CSS_TEST_FPS_KEY) ?? '', 10);
return (stored >= 1 && stored <= 60) ? stored : 20;
}
function _populateCssTestSourceSelector(preselectId: any) {
const sources = (colorStripSourcesCache.data || []) as any[];
const nonProcessed = sources.filter(s => s.source_type !== 'processed');
const sel = document.getElementById('css-test-cspt-input-select') as HTMLSelectElement;
sel.innerHTML = nonProcessed.map(s =>
`<option value="${s.id}"${s.id === preselectId ? ' selected' : ''}>${escapeHtml(s.name)}</option>`
).join('');
if (_csptTestInputEntitySelect) _csptTestInputEntitySelect.destroy();
_csptTestInputEntitySelect = new EntitySelect({
target: sel,
getItems: () => ((colorStripSourcesCache.data || []) as any[])
.filter(s => s.source_type !== 'processed')
.map(s => ({ value: s.id, label: s.name, icon: getColorStripIcon(s.source_type) })),
placeholder: t('palette.search'),
});
}
export function testColorStrip(sourceId: string) {
_cssTestCSPTMode = false;
_cssTestCSPTId = null;
// Detect api_input type
const sources = (colorStripSourcesCache.data || []) as any[];
const src = sources.find(s => s.id === sourceId);
_cssTestIsApiInput = src?.source_type === 'api_input';
// Populate input source selector with current source preselected
_populateCssTestSourceSelector(sourceId);
_openTestModal(sourceId);
}
export async function testCSPT(templateId: string) {
_cssTestCSPTMode = true;
_cssTestCSPTId = templateId;
// Populate input source selector
await colorStripSourcesCache.fetch();
_populateCssTestSourceSelector(null);
const sel = document.getElementById('css-test-cspt-input-select') as HTMLSelectElement;
const inputId = sel.value;
if (!inputId) {
showToast(t('color_strip.processed.error.no_input'), 'error');
return;
}
_openTestModal(inputId);
}
function _openTestModal(sourceId: string) {
// Clean up any previous session fully
if (_cssTestWs) { _cssTestWs.close(); _cssTestWs = null; }
if (_cssTestRaf) { cancelAnimationFrame(_cssTestRaf); _cssTestRaf = null; }
_cssTestLatestRgb = null;
_cssTestMeta = null;
_cssTestIsComposite = false;
_cssTestLayerData = null;
const modal = document.getElementById('test-css-source-modal') as HTMLElement | null;
if (!modal) return;
modal.style.display = 'flex';
modal.onclick = (e) => { if (e.target === modal) closeTestCssSourceModal(); };
_cssTestSourceId = sourceId;
// Reset views and clear stale canvas content
(document.getElementById('css-test-strip-view') as HTMLElement).style.display = 'none';
(document.getElementById('css-test-rect-view') as HTMLElement).style.display = 'none';
(document.getElementById('css-test-layers-view') as HTMLElement).style.display = 'none';
// Clear all test canvases to prevent stale frames from previous sessions
modal.querySelectorAll('canvas').forEach(c => {
const ctx = c.getContext('2d');
if (ctx) ctx.clearRect(0, 0, c.width, c.height);
});
(document.getElementById('css-test-led-group') as HTMLElement).style.display = '';
// Input source selector: shown for both CSS test and CSPT test, hidden for api_input
const csptGroup = document.getElementById('css-test-cspt-input-group') as HTMLElement | null;
if (csptGroup) csptGroup.style.display = _cssTestIsApiInput ? 'none' : '';
const layersContainer = document.getElementById('css-test-layers') as HTMLElement | null;
if (layersContainer) layersContainer.innerHTML = '';
(document.getElementById('css-test-status') as HTMLElement).style.display = '';
(document.getElementById('css-test-status') as HTMLElement).textContent = t('color_strip.test.connecting');
// Reset FPS tracking
_cssTestFpsActualHistory = [];
// For api_input: hide LED/FPS controls, show FPS chart
const ledControlGroup = document.getElementById('css-test-led-fps-group') as HTMLElement | null;
const fpsChartGroup = document.getElementById('css-test-fps-chart-group') as HTMLElement | null;
if (_cssTestIsApiInput) {
if (ledControlGroup) ledControlGroup.style.display = 'none';
if (fpsChartGroup) fpsChartGroup.style.display = '';
_cssTestStartFpsSampling();
// Use large LED count (buffer auto-sizes) and high poll FPS
_cssTestConnect(sourceId, 1000, 60);
} else {
if (ledControlGroup) ledControlGroup.style.display = '';
if (fpsChartGroup) fpsChartGroup.style.display = 'none';
// Restore LED count + FPS + Enter key handlers
const ledCount = _getCssTestLedCount();
const ledInput = document.getElementById('css-test-led-input') as HTMLInputElement | null;
ledInput!.value = ledCount as any;
ledInput!.onkeydown = (e) => { if (e.key === 'Enter') { e.preventDefault(); applyCssTestSettings(); } };
const fpsVal = _getCssTestFps();
const fpsInput = document.getElementById('css-test-fps-input') as HTMLInputElement | null;
fpsInput!.value = fpsVal as any;
fpsInput!.onkeydown = (e) => { if (e.key === 'Enter') { e.preventDefault(); applyCssTestSettings(); } };
_cssTestConnect(sourceId, ledCount, fpsVal);
}
}
function _cssTestConnect(sourceId: string, ledCount: number, fps?: number) {
// Close existing connection if any
if (_cssTestWs) { _cssTestWs.close(); _cssTestWs = null; }
// Bump generation so any late messages from old WS are ignored
const gen = ++_cssTestGeneration;
if (!fps) fps = _getCssTestFps();
const protocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:';
const apiKey = localStorage.getItem('wled_api_key') || '';
const isTransient = sourceId === '__preview__' && _cssTestTransientConfig;
let wsUrl;
if (isTransient) {
wsUrl = `${protocol}//${window.location.host}/api/v1/color-strip-sources/preview/ws?token=${encodeURIComponent(apiKey)}&led_count=${ledCount}&fps=${fps}`;
} else if (_cssTestCSPTMode && _cssTestCSPTId) {
wsUrl = `${protocol}//${window.location.host}/api/v1/color-strip-processing-templates/${_cssTestCSPTId}/test/ws?token=${encodeURIComponent(apiKey)}&input_source_id=${encodeURIComponent(sourceId)}&led_count=${ledCount}&fps=${fps}`;
} else {
wsUrl = `${protocol}//${window.location.host}/api/v1/color-strip-sources/${sourceId}/test/ws?token=${encodeURIComponent(apiKey)}&led_count=${ledCount}&fps=${fps}`;
}
_cssTestWs = new WebSocket(wsUrl);
_cssTestWs.binaryType = 'arraybuffer';
if (isTransient) {
_cssTestWs.onopen = () => {
if (gen !== _cssTestGeneration) return;
_cssTestWs!.send(JSON.stringify(_cssTestTransientConfig));
};
}
_cssTestWs.onmessage = (event) => {
// Ignore messages from a stale connection
if (gen !== _cssTestGeneration) return;
if (typeof event.data === 'string') {
let msg: any;
try {
msg = JSON.parse(event.data);
} catch (e) {
console.error('CSS test WS parse error:', e);
return;
}
// Handle brightness updates for composite layers
if (msg.type === 'brightness') {
_cssTestUpdateBrightness(msg.values);
return;
}
// Handle frame dimensions — render border-width overlay
if (msg.type === 'frame_dims' && _cssTestMeta) {
_cssTestRenderBorderOverlay(msg.width, msg.height);
return;
}
// Initial metadata
_cssTestMeta = msg;
const isPicture = _cssTestMeta.edges && _cssTestMeta.edges.length > 0;
_cssTestIsComposite = _cssTestMeta.layers && _cssTestMeta.layers.length > 0;
// Reset FPS timestamps so the initial bootstrap frame
// (sent right after metadata for api_input) isn't counted
if (_cssTestIsApiInput) _cssTestFpsTimestamps = [];
// Show correct view
(document.getElementById('css-test-strip-view') as HTMLElement).style.display = (isPicture || _cssTestIsComposite) ? 'none' : '';
(document.getElementById('css-test-rect-view') as HTMLElement).style.display = isPicture ? '' : 'none';
(document.getElementById('css-test-layers-view') as HTMLElement).style.display = _cssTestIsComposite ? '' : 'none';
(document.getElementById('css-test-status') as HTMLElement).style.display = 'none';
// Widen modal for picture sources to show the screen rectangle larger
const modalContent = document.querySelector('#test-css-source-modal .modal-content') as HTMLElement | null;
if (modalContent) modalContent.style.maxWidth = isPicture ? '900px' : '';
// Hide LED count control for picture sources (LED count is fixed by calibration)
(document.getElementById('css-test-led-group') as HTMLElement).style.display = isPicture ? 'none' : '';
// Show fire button for notification sources (direct only; composite has per-layer buttons)
const isNotify = _cssTestMeta.source_type === 'notification';
const layerInfos = _cssTestMeta.layer_infos || [];
_cssTestNotificationIds = isNotify
? [_cssTestSourceId]
: layerInfos.filter((li: any) => li.is_notification).map((li: any) => li.id);
const fireBtn = document.getElementById('css-test-fire-btn') as HTMLElement | null;
if (fireBtn) fireBtn.style.display = (isNotify && !_cssTestIsComposite) ? '' : 'none';
// Populate rect screen labels for picture sources
if (isPicture) {
const nameEl = document.getElementById('css-test-rect-name') as HTMLElement | null;
const ledsEl = document.getElementById('css-test-rect-leds') as HTMLElement | null;
if (nameEl) nameEl.textContent = _cssTestMeta.source_name || '';
if (ledsEl) ledsEl.textContent = `${_cssTestMeta.led_count} LEDs`;
// Render tick marks after layout settles
requestAnimationFrame(() => _cssTestRenderTicks(_cssTestMeta.edges));
}
// Build composite layer canvases
if (_cssTestIsComposite) {
_cssTestBuildLayers(_cssTestMeta.layers, _cssTestMeta.source_type, _cssTestMeta.layer_infos);
requestAnimationFrame(() => _cssTestRenderStripAxis('css-test-layers-axis', _cssTestMeta.led_count));
}
// Render strip axis for non-picture, non-composite views
if (!isPicture && !_cssTestIsComposite) {
requestAnimationFrame(() => _cssTestRenderStripAxis('css-test-strip-axis', _cssTestMeta.led_count));
}
} else {
const raw = new Uint8Array(event.data);
// Check JPEG frame preview: [0xFD] [jpeg_bytes]
if (raw.length > 1 && raw[0] === 0xFD) {
const jpegBlob = new Blob([raw.subarray(1)], { type: 'image/jpeg' });
const url = URL.createObjectURL(jpegBlob);
const screen = document.getElementById('css-test-rect-screen') as HTMLElement | null;
if (screen) {
// Preload image to avoid flicker on swap
const img = new Image();
img.onload = () => {
const oldUrl = (screen as any)._blobUrl;
(screen as any)._blobUrl = url;
screen.style.backgroundImage = `url(${url})`;
screen.style.backgroundSize = 'cover';
screen.style.backgroundPosition = 'center';
if (oldUrl) URL.revokeObjectURL(oldUrl);
// Set aspect ratio from first decoded frame
const rect = document.getElementById('css-test-rect') as HTMLElement | null;
if (rect && !(rect as any)._aspectSet && img.naturalWidth && img.naturalHeight) {
(rect as any).style.aspectRatio = `${img.naturalWidth} / ${img.naturalHeight}`;
rect.style.height = 'auto';
(rect as any)._aspectSet = true;
requestAnimationFrame(() => _cssTestRenderTicks(_cssTestMeta?.edges));
}
};
img.onerror = () => URL.revokeObjectURL(url);
img.src = url;
}
return;
}
// Check composite wire format: [0xFE] [layer_count] [led_count_hi] [led_count_lo] [layers...] [composite...]
if (raw.length > 3 && raw[0] === 0xFE && _cssTestIsComposite) {
const layerCount = raw[1];
const ledCount = (raw[2] << 8) | raw[3];
const rgbSize = ledCount * 3;
let offset = 4;
const layers: Uint8Array[] = [];
for (let i = 0; i < layerCount; i++) {
layers.push(raw.subarray(offset, offset + rgbSize));
offset += rgbSize;
}
const composite = raw.subarray(offset, offset + rgbSize);
_cssTestLayerData = { layerCount, ledCount, layers, composite };
_cssTestLatestRgb = composite;
} else {
// Standard format: raw RGB
_cssTestLatestRgb = raw;
}
// Track FPS for api_input sources
if (_cssTestIsApiInput) {
_cssTestFpsTimestamps.push(performance.now());
}
}
};
_cssTestWs.onerror = () => {
if (gen !== _cssTestGeneration) return;
(document.getElementById('css-test-status') as HTMLElement).textContent = t('color_strip.test.error');
};
_cssTestWs.onclose = () => {
if (gen === _cssTestGeneration) _cssTestWs = null;
};
// Start render loop (only once)
if (!_cssTestRaf) _cssTestRenderLoop();
}
const _BELL_SVG = '<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M6 8a6 6 0 0 1 12 0c0 7 3 9 3 9H3s3-2 3-9"/><path d="M10.3 21a1.94 1.94 0 0 0 3.4 0"/><path d="M4 2C2.8 3.7 2 5.7 2 8"/><path d="M22 8c0-2.3-.8-4.3-2-6"/></svg>';
function _cssTestBuildLayers(layerNames: any[], sourceType: any, layerInfos: any[]) {
const container = document.getElementById('css-test-layers') as HTMLElement | null;
if (!container) return;
// Composite result first, then individual layers
let html = `<div class="css-test-layer css-test-layer-composite">` +
`<canvas class="css-test-layer-canvas" data-layer-idx="composite"></canvas>` +
`<span class="css-test-layer-label">${sourceType === 'composite' ? t('color_strip.test.composite') : ''}</span>` +
`</div>`;
for (let i = 0; i < layerNames.length; i++) {
const info = layerInfos && layerInfos[i];
const isNotify = info && info.is_notification;
const hasBri = info && info.has_brightness;
const fireBtn = isNotify
? `<button class="css-test-fire-btn" onclick="event.stopPropagation(); fireCssTestNotificationLayer('${info.id}')" title="${t('color_strip.notification.test')}">${_BELL_SVG}</button>`
: '';
const briLabel = hasBri
? `<span class="css-test-layer-brightness" data-layer-idx="${i}" style="display:none"></span>`
: '';
let calLabel = '';
if (info && info.is_picture && info.calibration_led_count) {
const mismatch = _cssTestMeta.led_count !== info.calibration_led_count;
calLabel = `<span class="css-test-layer-cal${mismatch ? ' css-test-layer-cal-warn' : ''}" data-layer-idx="${i}">${mismatch ? ICON_WARNING + ' ' : ''}${_cssTestMeta.led_count}/${info.calibration_led_count}</span>`;
}
html += `<div class="css-test-layer css-test-strip-wrap">` +
`<canvas class="css-test-layer-canvas" data-layer-idx="${i}"></canvas>` +
`<span class="css-test-layer-label">${escapeHtml(layerNames[i])}</span>` +
calLabel +
briLabel +
fireBtn +
`</div>`;
}
container.innerHTML = html;
}
function _cssTestUpdateBrightness(values: any) {
if (!values) return;
const container = document.getElementById('css-test-layers') as HTMLElement | null;
if (!container) return;
for (let i = 0; i < values.length; i++) {
const el = container.querySelector(`.css-test-layer-brightness[data-layer-idx="${i}"]`) as HTMLElement | null;
if (!el) continue;
const v = values[i];
if (v != null) {
el.innerHTML = `${ICON_SUN_DIM} ${v}%`;
el.style.display = '';
} else {
el.style.display = 'none';
}
}
}
export function applyCssTestSettings() {
if (!_cssTestSourceId) return;
const ledInput = document.getElementById('css-test-led-input') as HTMLInputElement | null;
let leds = parseInt(ledInput?.value ?? '', 10);
if (isNaN(leds) || leds < 1) leds = 1;
if (leds > 2000) leds = 2000;
if (ledInput) ledInput.value = leds as any;
localStorage.setItem(_CSS_TEST_LED_KEY, String(leds));
const fpsInput = document.getElementById('css-test-fps-input') as HTMLInputElement | null;
let fps = parseInt(fpsInput?.value ?? '', 10);
if (isNaN(fps) || fps < 1) fps = 1;
if (fps > 60) fps = 60;
if (fpsInput) fpsInput.value = fps as any;
localStorage.setItem(_CSS_TEST_FPS_KEY, String(fps));
// Clear frame data but keep views/layout intact to avoid size jump
_cssTestLatestRgb = null;
_cssTestMeta = null;
_cssTestLayerData = null;
// Read selected input source from selector (both CSS and CSPT modes)
const inputSel = document.getElementById('css-test-cspt-input-select') as HTMLSelectElement | null;
if (inputSel && inputSel.value) {
_cssTestSourceId = inputSel.value;
// Re-detect api_input when source changes
const sources = (colorStripSourcesCache.data || []) as any[];
const src = sources.find(s => s.id === _cssTestSourceId);
_cssTestIsApiInput = src?.source_type === 'api_input';
}
// Reconnect (generation counter ignores stale frames from old WS)
_cssTestConnect(_cssTestSourceId, leds, fps);
}
function _cssTestRenderLoop() {
_cssTestRaf = requestAnimationFrame(_cssTestRenderLoop);
if (!_cssTestMeta) return;
const isPicture = _cssTestMeta.edges && _cssTestMeta.edges.length > 0;
if (_cssTestIsComposite && _cssTestLayerData) {
_cssTestRenderLayers(_cssTestLayerData);
} else if (isPicture && _cssTestLatestRgb) {
_cssTestRenderRect(_cssTestLatestRgb, _cssTestMeta.edges);
} else if (_cssTestLatestRgb) {
_cssTestRenderStrip(_cssTestLatestRgb);
}
}
function _cssTestRenderStrip(rgbBytes: Uint8Array) {
const canvas = document.getElementById('css-test-strip-canvas') as HTMLCanvasElement | null;
if (!canvas) return;
const ledCount = rgbBytes.length / 3;
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 _cssTestRenderLayers(data: any) {
const container = document.getElementById('css-test-layers') as HTMLElement | null;
if (!container) return;
// Composite canvas is first
const compositeCanvas = container.querySelector('[data-layer-idx="composite"]') as HTMLCanvasElement | null;
if (compositeCanvas) _cssTestRenderStripCanvas(compositeCanvas, data.composite);
// Individual layer canvases
for (let i = 0; i < data.layers.length; i++) {
const canvas = container.querySelector(`[data-layer-idx="${i}"]`) as HTMLCanvasElement | null;
if (canvas) _cssTestRenderStripCanvas(canvas, data.layers[i]);
}
}
function _cssTestRenderStripCanvas(canvas: HTMLCanvasElement, rgbBytes: Uint8Array) {
const ledCount = rgbBytes.length / 3;
if (ledCount <= 0) return;
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 _cssTestRenderRect(rgbBytes: Uint8Array, edges: any[]) {
// edges: [{ edge: "top"|..., indices: [outputIdx, ...] }, ...]
// indices are pre-computed on server: reverse + offset already applied
const edgeMap: Record<string, number[]> = { top: [], right: [], bottom: [], left: [] };
for (const e of edges) {
if (edgeMap[e.edge]) edgeMap[e.edge].push(...e.indices);
}
for (const [edge, indices] of Object.entries(edgeMap)) {
const canvas = document.getElementById(`css-test-edge-${edge}`) as HTMLCanvasElement | null;
if (!canvas) continue;
const count = indices.length;
if (count === 0) { canvas.width = 0; continue; }
const isH = edge === 'top' || edge === 'bottom';
canvas.width = isH ? count : 1;
canvas.height = isH ? 1 : count;
const ctx = canvas.getContext('2d')!;
const imageData = ctx.createImageData(canvas.width, canvas.height);
const px = imageData.data;
for (let i = 0; i < count; i++) {
const si = indices[i] * 3;
const di = i * 4;
px[di] = rgbBytes[si] || 0;
px[di + 1] = rgbBytes[si + 1] || 0;
px[di + 2] = rgbBytes[si + 2] || 0;
px[di + 3] = 255;
}
ctx.putImageData(imageData, 0, 0);
}
}
function _cssTestRenderBorderOverlay(frameW: number, frameH: number) {
const screen = document.getElementById('css-test-rect-screen') as HTMLElement | null;
if (!screen || !_cssTestMeta) return;
// Remove any previous border overlay
screen.querySelectorAll('.css-test-border-overlay').forEach(el => el.remove());
const bw = _cssTestMeta.border_width;
if (!bw || bw <= 0) return;
const edges = _cssTestMeta.edges || [];
const activeEdges = new Set(edges.map((e: any) => e.edge));
// Compute border as percentage of frame dimensions
const bwPctH = (bw / frameH * 100).toFixed(2); // % for top/bottom
const bwPctW = (bw / frameW * 100).toFixed(2); // % for left/right
const overlayStyle = 'position:absolute;pointer-events:none;background:rgba(var(--primary-color-rgb, 76,175,80),0.18);border:1px solid rgba(var(--primary-color-rgb, 76,175,80),0.4);';
if (activeEdges.has('top')) {
const el = document.createElement('div');
el.className = 'css-test-border-overlay';
el.style.cssText = `${overlayStyle}top:0;left:0;right:0;height:${bwPctH}%;`;
el.title = `${t('calibration.border_width')} ${bw}px`;
screen.appendChild(el);
}
if (activeEdges.has('bottom')) {
const el = document.createElement('div');
el.className = 'css-test-border-overlay';
el.style.cssText = `${overlayStyle}bottom:0;left:0;right:0;height:${bwPctH}%;`;
el.title = `${t('calibration.border_width')} ${bw}px`;
screen.appendChild(el);
}
if (activeEdges.has('left')) {
const el = document.createElement('div');
el.className = 'css-test-border-overlay';
el.style.cssText = `${overlayStyle}top:0;bottom:0;left:0;width:${bwPctW}%;`;
el.title = `${t('calibration.border_width')} ${bw}px`;
screen.appendChild(el);
}
if (activeEdges.has('right')) {
const el = document.createElement('div');
el.className = 'css-test-border-overlay';
el.style.cssText = `${overlayStyle}top:0;bottom:0;right:0;width:${bwPctW}%;`;
el.title = `${t('calibration.border_width')} ${bw}px`;
screen.appendChild(el);
}
// Show border width label
const label = document.createElement('div');
label.className = 'css-test-border-overlay css-test-border-label';
label.textContent = `${bw}px`;
screen.appendChild(label);
}
function _cssTestRenderTicks(edges: any[]) {
const canvas = document.getElementById('css-test-rect-ticks') as HTMLCanvasElement | null;
const rectEl = document.getElementById('css-test-rect') as HTMLElement | null;
if (!canvas || !rectEl) return;
const outer = canvas.parentElement!;
const outerRect = outer.getBoundingClientRect();
const gridRect = rectEl.getBoundingClientRect();
const dpr = window.devicePixelRatio || 1;
canvas.width = outerRect.width * dpr;
canvas.height = outerRect.height * dpr;
const ctx = canvas.getContext('2d')!;
ctx.scale(dpr, dpr);
ctx.clearRect(0, 0, outerRect.width, outerRect.height);
// Grid offset within outer container (the padding area)
const gx = gridRect.left - outerRect.left;
const gy = gridRect.top - outerRect.top;
const gw = gridRect.width;
const gh = gridRect.height;
const edgeThick = 14; // matches CSS grid-template
// Build edge map with indices
const edgeMap: Record<string, number[]> = { top: [], right: [], bottom: [], left: [] };
for (const e of edges) {
if (edgeMap[e.edge]) edgeMap[e.edge].push(...e.indices);
}
const isDark = document.documentElement.getAttribute('data-theme') !== 'light';
const tickStroke = isDark ? 'rgba(255, 255, 255, 0.5)' : 'rgba(0, 0, 0, 0.35)';
const tickFill = isDark ? 'rgba(255, 255, 255, 0.75)' : 'rgba(0, 0, 0, 0.65)';
ctx.strokeStyle = tickStroke;
ctx.fillStyle = tickFill;
ctx.lineWidth = 1;
ctx.font = '10px -apple-system, BlinkMacSystemFont, sans-serif';
const edgeGeom: Record<string, any> = {
top: { x1: gx + edgeThick, x2: gx + gw - edgeThick, y: gy, dir: -1, horizontal: true },
bottom: { x1: gx + edgeThick, x2: gx + gw - edgeThick, y: gy + gh, dir: 1, horizontal: true },
left: { y1: gy + edgeThick, y2: gy + gh - edgeThick, x: gx, dir: -1, horizontal: false },
right: { y1: gy + edgeThick, y2: gy + gh - edgeThick, x: gx + gw, dir: 1, horizontal: false },
};
for (const [edge, indices] of Object.entries(edgeMap)) {
const count = indices.length;
if (count === 0) continue;
const geo = edgeGeom[edge];
// Determine which ticks to label
const labelsToShow = new Set([0]);
if (count > 1) labelsToShow.add(count - 1);
if (count > 2) {
const edgeLen = geo.horizontal ? (geo.x2 - geo.x1) : (geo.y2 - geo.y1);
const maxDigits = String(Math.max(...indices)).length;
const minSpacing = geo.horizontal ? maxDigits * 7 + 8 : 20;
const niceSteps = [5, 10, 25, 50, 100, 250, 500];
let step = niceSteps[niceSteps.length - 1];
for (const s of niceSteps) {
if (Math.floor(count / s) <= 4) { step = s; break; }
}
const tickPx = (i: number) => (i / (count - 1)) * edgeLen;
const placed: number[] = [];
// Place boundary ticks first
labelsToShow.forEach(i => placed.push(tickPx(i)));
for (let i = 1; i < count - 1; i++) {
if (indices[i] % step === 0) {
const px = tickPx(i);
if (!placed.some(p => Math.abs(px - p) < minSpacing)) {
labelsToShow.add(i);
placed.push(px);
}
}
}
}
const tickLen = 6;
labelsToShow.forEach(idx => {
const label = String(indices[idx]);
const fraction = count > 1 ? idx / (count - 1) : 0.5;
if (geo.horizontal) {
const tx = geo.x1 + fraction * (geo.x2 - geo.x1);
const ty = geo.y;
const tickDir = geo.dir; // -1 for top (tick goes up), +1 for bottom (tick goes down)
ctx.beginPath();
ctx.moveTo(tx, ty);
ctx.lineTo(tx, ty + tickDir * tickLen);
ctx.stroke();
ctx.textAlign = 'center';
ctx.textBaseline = tickDir < 0 ? 'bottom' : 'top';
ctx.fillText(label, tx, ty + tickDir * (tickLen + 2));
} else {
const ty = geo.y1 + fraction * (geo.y2 - geo.y1);
const tx = geo.x;
const tickDir = geo.dir; // -1 for left (tick goes left), +1 for right (tick goes right)
ctx.beginPath();
ctx.moveTo(tx, ty);
ctx.lineTo(tx + tickDir * tickLen, ty);
ctx.stroke();
ctx.textBaseline = 'middle';
ctx.textAlign = tickDir < 0 ? 'right' : 'left';
ctx.fillText(label, tx + tickDir * (tickLen + 2), ty);
}
});
}
}
function _cssTestRenderStripAxis(canvasId: string, ledCount: number) {
const canvas = document.getElementById(canvasId) as HTMLCanvasElement | null;
if (!canvas || ledCount <= 0) return;
const dpr = window.devicePixelRatio || 1;
const w = canvas.clientWidth;
const h = canvas.clientHeight;
canvas.width = w * dpr;
canvas.height = h * dpr;
const ctx = canvas.getContext('2d')!;
ctx.scale(dpr, dpr);
ctx.clearRect(0, 0, w, h);
const isDark = document.documentElement.getAttribute('data-theme') !== 'light';
const tickStroke = isDark ? 'rgba(255, 255, 255, 0.5)' : 'rgba(0, 0, 0, 0.35)';
const tickFill = isDark ? 'rgba(255, 255, 255, 0.75)' : 'rgba(0, 0, 0, 0.65)';
ctx.strokeStyle = tickStroke;
ctx.fillStyle = tickFill;
ctx.lineWidth = 1;
ctx.font = '10px -apple-system, BlinkMacSystemFont, sans-serif';
ctx.textBaseline = 'top';
const tickLen = 5;
// Determine which ticks to label
const labelsToShow = new Set([0]);
if (ledCount > 1) labelsToShow.add(ledCount - 1);
if (ledCount > 2) {
const maxDigits = String(ledCount - 1).length;
const minSpacing = maxDigits * 7 + 8;
const niceSteps = [5, 10, 25, 50, 100, 250, 500];
let step = niceSteps[niceSteps.length - 1];
for (const s of niceSteps) {
if (Math.floor(ledCount / s) <= Math.floor(w / minSpacing)) { step = s; break; }
}
const tickPx = (i: number) => (i / (ledCount - 1)) * w;
const placed: number[] = [];
labelsToShow.forEach(i => placed.push(tickPx(i)));
for (let i = 1; i < ledCount - 1; i++) {
if (i % step === 0) {
const px = tickPx(i);
if (!placed.some(p => Math.abs(px - p) < minSpacing)) {
labelsToShow.add(i);
placed.push(px);
}
}
}
}
labelsToShow.forEach(idx => {
const fraction = ledCount > 1 ? idx / (ledCount - 1) : 0.5;
const tx = fraction * w;
ctx.beginPath();
ctx.moveTo(tx, 0);
ctx.lineTo(tx, tickLen);
ctx.stroke();
// Align first tick left, last tick right, others center
if (idx === 0) ctx.textAlign = 'left';
else if (idx === ledCount - 1) ctx.textAlign = 'right';
else ctx.textAlign = 'center';
ctx.fillText(String(idx), tx, tickLen + 1);
});
}
export function fireCssTestNotification() {
for (const id of _cssTestNotificationIds) {
testNotification(id);
}
}
export function fireCssTestNotificationLayer(sourceId: string) {
testNotification(sourceId);
}
let _cssTestFpsSampleInterval: ReturnType<typeof setInterval> | null = null;
function _cssTestStartFpsSampling() {
_cssTestStopFpsSampling();
_cssTestFpsTimestamps = [];
_cssTestFpsActualHistory = [];
if (_cssTestFpsChart) { _cssTestFpsChart.destroy(); _cssTestFpsChart = null; }
// Sample FPS every 1 second
_cssTestFpsSampleInterval = setInterval(() => {
const now = performance.now();
// Count frames in the last 1 second
const cutoff = now - 1000;
_cssTestFpsTimestamps = _cssTestFpsTimestamps.filter(t => t >= cutoff);
const fps = _cssTestFpsTimestamps.length;
_cssTestFpsActualHistory.push(fps);
if (_cssTestFpsActualHistory.length > _CSS_TEST_FPS_MAX_SAMPLES)
_cssTestFpsActualHistory.shift();
// Update numeric display (match target card format)
const valueEl = document.getElementById('css-test-fps-value') as HTMLElement | null;
if (valueEl) valueEl.textContent = String(fps);
const avgEl = document.getElementById('css-test-fps-avg') as HTMLElement | null;
if (avgEl && _cssTestFpsActualHistory.length > 1) {
const avg = _cssTestFpsActualHistory.reduce((a, b) => a + b, 0) / _cssTestFpsActualHistory.length;
avgEl.textContent = `avg ${avg.toFixed(1)}`;
}
// Create or update chart
if (!_cssTestFpsChart) {
_cssTestFpsChart = createFpsSparkline(
'css-test-fps-chart',
_cssTestFpsActualHistory,
[], // no "current" dataset, just actual
60, // y-axis max
);
}
if (_cssTestFpsChart) {
const ds = _cssTestFpsChart.data.datasets[0].data;
ds.length = 0;
ds.push(..._cssTestFpsActualHistory);
while (_cssTestFpsChart.data.labels.length < ds.length) _cssTestFpsChart.data.labels.push('');
_cssTestFpsChart.data.labels.length = ds.length;
_cssTestFpsChart.update('none');
}
}, 1000);
}
function _cssTestStopFpsSampling() {
if (_cssTestFpsSampleInterval) { clearInterval(_cssTestFpsSampleInterval); _cssTestFpsSampleInterval = null; }
if (_cssTestFpsChart) { _cssTestFpsChart.destroy(); _cssTestFpsChart = null; }
}
export function closeTestCssSourceModal() {
if (_cssTestWs) { _cssTestWs.close(); _cssTestWs = null; }
if (_cssTestRaf) { cancelAnimationFrame(_cssTestRaf); _cssTestRaf = null; }
_cssTestLatestRgb = null;
_cssTestMeta = null;
_cssTestSourceId = null;
_cssTestIsComposite = false;
_cssTestLayerData = null;
_cssTestNotificationIds = [];
_cssTestIsApiInput = false;
_cssTestStopFpsSampling();
_cssTestFpsTimestamps = [];
_cssTestFpsActualHistory = [];
// Revoke blob URL for frame preview
const screen = document.getElementById('css-test-rect-screen') as HTMLElement | null;
if (screen && (screen as any)._blobUrl) { URL.revokeObjectURL((screen as any)._blobUrl); (screen as any)._blobUrl = null; screen.style.backgroundImage = ''; }
// Reset aspect ratio for next open
const rect = document.getElementById('css-test-rect') as HTMLElement | null;
if (rect) { (rect as any).style.aspectRatio = ''; rect.style.height = ''; (rect as any)._aspectSet = false; }
// Reset modal width
const modalContent = document.querySelector('#test-css-source-modal .modal-content') as HTMLElement | null;
if (modalContent) modalContent.style.maxWidth = '';
const modal = document.getElementById('test-css-source-modal') as HTMLElement | null;
if (modal) { modal.style.display = 'none'; }
}

File diff suppressed because it is too large Load Diff

View File

@@ -222,7 +222,7 @@ function _gradientRenderCanvas(): void {
const W = Math.max(1, Math.round(canvas.offsetWidth || 300));
if (canvas.width !== W) canvas.width = W;
const ctx = canvas.getContext('2d');
const ctx = canvas.getContext('2d')!;
const H = canvas.height;
const imgData = ctx.createImageData(W, H);

View File

@@ -12,7 +12,7 @@ import {
ICON_TARGET, ICON_AUTOMATION, ICON_CLOCK, ICON_WARNING, ICON_OK,
ICON_STOP, ICON_STOP_PLAIN, ICON_START, ICON_PAUSE, ICON_HELP, ICON_SCENE,
} from '../core/icons.ts';
import { loadScenePresets, renderScenePresetsSection } from './scene-presets.ts';
import { loadScenePresets, renderScenePresetsSection, initScenePresetDelegation } from './scene-presets.ts';
import { cardColorStyle } from '../core/card-colors.ts';
import { createFpsSparkline } from '../core/chart-utils.ts';
import type { Device, OutputTarget, ColorStripSource, ScenePreset, SyncClock, Automation } from '../types.ts';
@@ -57,7 +57,7 @@ function _getInterpolatedUptime(targetId: string): number | null {
function _cacheUptimeElements(): void {
_uptimeElements = {};
for (const id of _lastRunningIds) {
const el = document.querySelector(`[data-uptime-text="${id}"]`);
const el = document.querySelector(`[data-uptime-text="${CSS.escape(id)}"]`);
if (el) _uptimeElements[id] = el;
}
}
@@ -126,7 +126,7 @@ async function _initFpsCharts(runningTargetIds: string[]): Promise<void> {
if (!canvas) continue;
const actualH = _fpsHistory[id] || [];
const currentH = _fpsCurrentHistory[id] || [];
const fpsTarget = parseFloat(canvas.dataset.fpsTarget) || 30;
const fpsTarget = parseFloat(canvas.dataset.fpsTarget ?? '30') || 30;
_fpsCharts[id] = _createFpsChart(`dashboard-fps-${id}`, actualH, currentH, fpsTarget);
}
@@ -137,9 +137,9 @@ function _cacheMetricsElements(runningIds: string[]): void {
_metricsElements.clear();
for (const id of runningIds) {
_metricsElements.set(id, {
fps: document.querySelector(`[data-fps-text="${id}"]`),
errors: document.querySelector(`[data-errors-text="${id}"]`),
row: document.querySelector(`[data-target-id="${id}"]`),
fps: document.querySelector(`[data-fps-text="${CSS.escape(id)}"]`),
errors: document.querySelector(`[data-errors-text="${CSS.escape(id)}"]`),
row: document.querySelector(`[data-target-id="${CSS.escape(id)}"]`),
});
}
}
@@ -181,7 +181,7 @@ function _updateRunningMetrics(enrichedRunning: any[]): void {
// Update text values (use cached refs, fallback to querySelector)
const cached = _metricsElements.get(target.id);
const fpsEl = cached?.fps || document.querySelector(`[data-fps-text="${target.id}"]`);
const fpsEl = cached?.fps || document.querySelector(`[data-fps-text="${CSS.escape(target.id)}"]`);
if (fpsEl) {
const effFps = state.fps_effective;
const fpsTargetLabel = (effFps != null && effFps < fpsTarget)
@@ -192,13 +192,13 @@ function _updateRunningMetrics(enrichedRunning: any[]): void {
+ `<span class="dashboard-fps-avg">avg ${fpsActual}</span>`;
}
const errorsEl = cached?.errors || document.querySelector(`[data-errors-text="${target.id}"]`);
const errorsEl = cached?.errors || document.querySelector(`[data-errors-text="${CSS.escape(target.id)}"]`);
if (errorsEl) { errorsEl.innerHTML = `${errors > 0 ? ICON_WARNING : ICON_OK} ${formatCompact(errors)}`; (errorsEl as HTMLElement).title = String(errors); }
// Update health dot — prefer streaming reachability when processing
const isLed = target.target_type === 'led' || target.target_type === 'wled';
if (isLed) {
const row = cached?.row || document.querySelector(`[data-target-id="${target.id}"]`);
const row = cached?.row || document.querySelector(`[data-target-id="${CSS.escape(target.id)}"]`);
if (row) {
const dot = row.querySelector('.health-dot');
if (dot) {
@@ -217,7 +217,7 @@ function _updateRunningMetrics(enrichedRunning: any[]): void {
function _updateAutomationsInPlace(automations: Automation[]): void {
for (const a of automations) {
const card = document.querySelector(`[data-automation-id="${a.id}"]`);
const card = document.querySelector(`[data-automation-id="${CSS.escape(a.id)}"]`);
if (!card) continue;
const badge = card.querySelector('.dashboard-badge-active, .dashboard-badge-stopped');
if (badge) {
@@ -243,7 +243,7 @@ function _updateAutomationsInPlace(automations: Automation[]): void {
function _updateSyncClocksInPlace(syncClocks: SyncClock[]): void {
for (const c of syncClocks) {
const card = document.querySelector(`[data-sync-clock-id="${c.id}"]`);
const card = document.querySelector(`[data-sync-clock-id="${CSS.escape(c.id)}"]`);
if (!card) continue;
const speedEl = card.querySelector('.dashboard-clock-speed');
if (speedEl) speedEl.textContent = `${c.speed}x`;
@@ -292,7 +292,7 @@ function _renderPollIntervalSelect(): string {
return `<span class="dashboard-poll-wrap"><input type="range" class="dashboard-poll-slider" min="1" max="10" value="${sec}" oninput="changeDashboardPollInterval(this.value)" title="${t('dashboard.poll_interval')}"><span class="dashboard-poll-value">${sec}s</span></span>`;
}
let _pollDebounce: ReturnType<typeof setTimeout> | null = null;
let _pollDebounce: ReturnType<typeof setTimeout> | undefined = undefined;
export function changeDashboardPollInterval(value: string | number): void {
const label = document.querySelector('.dashboard-poll-value');
if (label) label.textContent = `${value}s`;
@@ -307,7 +307,7 @@ export function changeDashboardPollInterval(value: string | number): void {
}
function _getCollapsedSections(): Record<string, boolean> {
try { return JSON.parse(localStorage.getItem(DASHBOARD_COLLAPSED_KEY)) || {}; }
try { return JSON.parse(localStorage.getItem(DASHBOARD_COLLAPSED_KEY) ?? '{}') || {}; }
catch { return {}; }
}
@@ -315,7 +315,7 @@ export function toggleDashboardSection(sectionKey: string): void {
const collapsed = _getCollapsedSections();
collapsed[sectionKey] = !collapsed[sectionKey];
localStorage.setItem(DASHBOARD_COLLAPSED_KEY, JSON.stringify(collapsed));
const header = document.querySelector(`[data-dashboard-section="${sectionKey}"]`);
const header = document.querySelector(`[data-dashboard-section="${CSS.escape(sectionKey)}"]`);
if (!header) return;
const content = header.nextElementSibling;
const chevron = header.querySelector('.dashboard-section-chevron');
@@ -379,10 +379,10 @@ export async function loadDashboard(forceFullRender: boolean = false): Promise<v
try {
// Fire all requests in a single batch to avoid sequential RTTs
const [targets, automationsResp, devicesArr, cssArr, batchStatesResp, batchMetricsResp, scenePresets, syncClocksResp] = await Promise.all([
outputTargetsCache.fetch().catch(() => []),
outputTargetsCache.fetch().catch((): any[] => []),
fetchWithAuth('/automations').catch(() => null),
devicesCache.fetch().catch(() => []),
colorStripSourcesCache.fetch().catch(() => []),
devicesCache.fetch().catch((): any[] => []),
colorStripSourcesCache.fetch().catch((): any[] => []),
fetchWithAuth('/output-targets/batch/states').catch(() => null),
fetchWithAuth('/output-targets/batch/metrics').catch(() => null),
loadScenePresets(),
@@ -403,7 +403,7 @@ export async function loadDashboard(forceFullRender: boolean = false): Promise<v
// Build dynamic HTML (targets, automations)
let dynamicHtml = '';
let runningIds = [];
let runningIds: any[] = [];
if (targets.length === 0 && automations.length === 0 && scenePresets.length === 0 && syncClocks.length === 0) {
dynamicHtml = `<div class="dashboard-no-targets">${t('dashboard.no_targets')}</div>`;
} else {
@@ -518,9 +518,11 @@ export async function loadDashboard(forceFullRender: boolean = false): Promise<v
</div>
<div class="dashboard-dynamic">${dynamicHtml}</div>`;
await initPerfCharts();
// Event delegation for scene preset cards (attached once, works across innerHTML refreshes)
initScenePresetDelegation(container);
} else {
const dynamic = container.querySelector('.dashboard-dynamic');
if (dynamic.innerHTML !== dynamicHtml) {
if (dynamic && dynamic.innerHTML !== dynamicHtml) {
dynamic.innerHTML = dynamicHtml;
}
}
@@ -743,7 +745,7 @@ export async function dashboardStopAll(): Promise<void> {
if (!confirmed) return;
try {
const [allTargets, statesResp] = await Promise.all([
outputTargetsCache.fetch().catch(() => []),
outputTargetsCache.fetch().catch((): any[] => []),
fetchWithAuth('/output-targets/batch/states'),
]);
const statesData = statesResp.ok ? await statesResp.json() : { states: {} };
@@ -804,7 +806,7 @@ function _isDashboardActive(): boolean {
return (localStorage.getItem('activeTab') || 'dashboard') === 'dashboard';
}
let _eventDebounceTimer: ReturnType<typeof setTimeout> | null = null;
let _eventDebounceTimer: ReturnType<typeof setTimeout> | undefined = undefined;
function _debouncedDashboardReload(forceFullRender: boolean = false): void {
if (!_isDashboardActive()) return;
clearTimeout(_eventDebounceTimer);

View File

@@ -215,7 +215,7 @@ export async function turnOffDevice(deviceId: any) {
}
export async function pingDevice(deviceId: any) {
const btn = document.querySelector(`[data-device-id="${deviceId}"] .card-ping-btn`) as HTMLElement | null;
const btn = document.querySelector(`[data-device-id="${CSS.escape(deviceId)}"] .card-ping-btn`) as HTMLElement | null;
if (btn) btn.classList.add('spinning');
try {
const resp = await fetchWithAuth(`/devices/${deviceId}/ping`, { method: 'POST' });
@@ -403,7 +403,7 @@ export async function showSettings(deviceId: any) {
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}"]`) as HTMLInputElement | null;
const modeRadio = document.querySelector(`input[name="settings-zone-mode"][value="${CSS.escape(savedMode)}"]`) as HTMLInputElement | null;
if (modeRadio) modeRadio.checked = true;
_fetchOpenrgbZones(baseUrl, 'settings-zone-list', currentZones).then(() => {
// Re-snapshot after zones are loaded so dirty-check baseline includes them
@@ -536,7 +536,7 @@ export async function saveDeviceSettings() {
// Brightness
export function updateBrightnessLabel(deviceId: any, value: any) {
const slider = document.querySelector(`[data-device-brightness="${deviceId}"]`) as HTMLElement | null;
const slider = document.querySelector(`[data-device-brightness="${CSS.escape(deviceId)}"]`) as HTMLElement | null;
if (slider) slider.title = Math.round(parseInt(value) / 255 * 100) + '%';
}
@@ -569,13 +569,13 @@ export async function fetchDeviceBrightness(deviceId: any) {
if (!resp.ok) return;
const data = await resp.json();
updateDeviceBrightness(deviceId, data.brightness);
const slider = document.querySelector(`[data-device-brightness="${deviceId}"]`) as HTMLInputElement | null;
const slider = document.querySelector(`[data-device-brightness="${CSS.escape(deviceId)}"]`) as HTMLInputElement | null;
if (slider) {
slider.value = data.brightness;
slider.title = Math.round(data.brightness / 255 * 100) + '%';
slider.disabled = false;
}
const wrap = document.querySelector(`[data-brightness-wrap="${deviceId}"]`) as HTMLElement | null;
const wrap = document.querySelector(`[data-brightness-wrap="${CSS.escape(deviceId)}"]`) as HTMLElement | null;
if (wrap) wrap.classList.remove('brightness-loading');
} catch (err) {
// Silently fail — device may be offline
@@ -731,10 +731,10 @@ export async function enrichOpenrgbZoneBadges(deviceId: any, deviceUrl: any) {
}
function _applyZoneCounts(deviceId: any, zones: any, counts: any) {
const card = document.querySelector(`[data-device-id="${deviceId}"]`);
const card = document.querySelector(`[data-device-id="${CSS.escape(deviceId)}"]`);
if (!card) return;
for (const zoneName of zones) {
const badge = card.querySelector(`[data-zone-name="${zoneName}"]`);
const badge = card.querySelector(`[data-zone-name="${CSS.escape(zoneName)}"]`);
if (!badge) continue;
const ledCount = counts[zoneName.toLowerCase()];
if (ledCount != null) {

View File

@@ -24,7 +24,7 @@ export function openDisplayPicker(callback: (index: number, display?: Display |
set_displayPickerCallback(callback);
set_displayPickerSelectedIndex((selectedIndex !== undefined && selectedIndex !== null && selectedIndex !== '') ? Number(selectedIndex) : null);
_pickerEngineType = engineType || null;
const lightbox = document.getElementById('display-picker-lightbox');
const lightbox = document.getElementById('display-picker-lightbox')!;
// Use "Select a Device" title for engines with own display lists (camera, scrcpy, etc.)
const titleEl = lightbox.querySelector('.display-picker-title');
@@ -41,7 +41,7 @@ export function openDisplayPicker(callback: (index: number, display?: Display |
} else if (_cachedDisplays && _cachedDisplays.length > 0) {
renderDisplayPickerLayout(_cachedDisplays);
} else {
const canvas = document.getElementById('display-picker-canvas');
const canvas = document.getElementById('display-picker-canvas')!;
canvas.innerHTML = '<div class="loading-spinner"></div>';
displaysCache.fetch().then(displays => {
if (displays && displays.length > 0) {
@@ -55,7 +55,7 @@ export function openDisplayPicker(callback: (index: number, display?: Display |
}
async function _fetchAndRenderEngineDisplays(engineType: string): Promise<void> {
const canvas = document.getElementById('display-picker-canvas');
const canvas = document.getElementById('display-picker-canvas')!;
canvas.innerHTML = '<div class="loading-spinner"></div>';
try {
@@ -133,7 +133,7 @@ window._adbConnectFromPicker = async function () {
export function closeDisplayPicker(event?: Event): void {
if (event && event.target && (event.target as HTMLElement).closest('.display-picker-content')) return;
const lightbox = document.getElementById('display-picker-lightbox');
lightbox.classList.remove('active');
lightbox?.classList.remove('active');
set_displayPickerCallback(null);
_pickerEngineType = null;
}
@@ -150,7 +150,7 @@ export function selectDisplay(displayIndex: number): void {
}
export function renderDisplayPickerLayout(displays: any[], engineType: string | null = null): void {
const canvas = document.getElementById('display-picker-canvas');
const canvas = document.getElementById('display-picker-canvas')!;
if (!displays || displays.length === 0) {
canvas.innerHTML = `<div class="loading">${t('displays.none')}</div>`;

View File

@@ -269,7 +269,7 @@ function _makeDraggable(el: HTMLElement, handle: HTMLElement, { loadFn, saveFn }
_clampElementInContainer(el, container);
}
let dragStart = null, dragStartPos = null;
let dragStart: { x: number; y: number } | null = null, dragStartPos: { left: number; top: number } | null = null;
handle.addEventListener('pointerdown', (e) => {
e.preventDefault();
@@ -279,7 +279,7 @@ function _makeDraggable(el: HTMLElement, handle: HTMLElement, { loadFn, saveFn }
handle.setPointerCapture(e.pointerId);
});
handle.addEventListener('pointermove', (e) => {
if (!dragStart) return;
if (!dragStart || !dragStartPos) return;
const cr = container.getBoundingClientRect();
const ew = el.offsetWidth, eh = el.offsetHeight;
let l = dragStartPos.left + (e.clientX - dragStart.x);
@@ -470,10 +470,10 @@ function _applyFilter(query?: string): void {
// Parse structured filters: type:device, tag:foo, running:true
let textPart = q;
const parsedKinds = new Set();
const parsedTags = [];
const parsedKinds = new Set<string>();
const parsedTags: string[] = [];
const tokens = q.split(/\s+/);
const plainTokens = [];
const plainTokens: string[] = [];
for (const tok of tokens) {
if (tok.startsWith('type:')) { parsedKinds.add(tok.slice(5)); }
else if (tok.startsWith('tag:')) { parsedTags.push(tok.slice(4)); }
@@ -720,8 +720,8 @@ function _renderGraph(container: HTMLElement): void {
const nodeGroup = svgEl.querySelector('.graph-nodes') as SVGGElement;
const edgeGroup = svgEl.querySelector('.graph-edges') as SVGGElement;
renderEdges(edgeGroup, _edges);
renderNodes(nodeGroup, _nodeMap, {
renderEdges(edgeGroup, _edges!);
renderNodes(nodeGroup, _nodeMap!, {
onNodeClick: _onNodeClick,
onNodeDblClick: _onNodeDblClick,
onEditNode: _onEditNode,
@@ -732,14 +732,14 @@ function _renderGraph(container: HTMLElement): void {
onCloneNode: _onCloneNode,
onActivatePreset: _onActivatePreset,
});
markOrphans(nodeGroup, _nodeMap, _edges);
markOrphans(nodeGroup, _nodeMap!, _edges!);
// Animated flow dots for running nodes
const runningIds = new Set<string>();
for (const node of _nodeMap.values()) {
for (const node of _nodeMap!.values()) {
if (node.running) runningIds.add(node.id);
}
renderFlowDots(edgeGroup, _edges, runningIds);
renderFlowDots(edgeGroup, _edges!, runningIds);
// Set bounds for view clamping, then fit
if (_bounds) _canvas.setBounds(_bounds);
@@ -889,6 +889,8 @@ function _renderGraph(container: HTMLElement): void {
}
});
// Remove previous keydown listener to prevent leaks on re-render
container.removeEventListener('keydown', _onKeydown);
container.addEventListener('keydown', _onKeydown);
container.setAttribute('tabindex', '0');
container.style.outline = 'none';
@@ -1039,8 +1041,9 @@ function _initLegendDrag(legendEl: Element | null): void {
/* ── Minimap (draggable header & resize handle) ── */
function _initMinimap(mmEl: HTMLElement | null): void {
if (!mmEl || !_nodeMap || !_bounds) return;
function _initMinimap(mmElArg: HTMLElement | null): void {
if (!mmElArg || !_nodeMap || !_bounds) return;
const mmEl: HTMLElement = mmElArg;
const svg = mmEl.querySelector('svg') as SVGSVGElement | null;
if (!svg) return;
const container = mmEl.closest('.graph-container') as HTMLElement;
@@ -1108,7 +1111,7 @@ function _initMinimap(mmEl: HTMLElement | null): void {
function _initResizeHandle(rh: HTMLElement | null, corner: string): void {
if (!rh) return;
let rs = null, rss = null;
let rs: { x: number; y: number } | null = null, rss: { w: number; h: number; left: number } | null = null;
rh.addEventListener('pointerdown', (e) => {
e.preventDefault(); e.stopPropagation();
rs = { x: e.clientX, y: e.clientY };
@@ -1116,7 +1119,7 @@ function _initMinimap(mmEl: HTMLElement | null): void {
rh.setPointerCapture(e.pointerId);
});
rh.addEventListener('pointermove', (e) => {
if (!rs) return;
if (!rs || !rss) return;
const cr = container.getBoundingClientRect();
const dy = e.clientY - rs.y;
const newH = Math.max(80, Math.min(cr.height - 20, rss.h + dy));
@@ -1274,7 +1277,7 @@ function _initToolbarDrag(tbEl: HTMLElement | null): void {
const dock = saved?.dock || 'tl';
_applyToolbarDock(tbEl, container, dock, false);
let dragStart = null, dragStartPos = null;
let dragStart: { x: number; y: number } | null = null, dragStartPos: { left: number; top: number } | null = null;
handle.addEventListener('pointerdown', (e) => {
e.preventDefault();
@@ -1289,7 +1292,7 @@ function _initToolbarDrag(tbEl: HTMLElement | null): void {
});
});
handle.addEventListener('pointermove', (e) => {
if (!dragStart) return;
if (!dragStart || !dragStartPos) return;
const cr = container.getBoundingClientRect();
const ew = tbEl.offsetWidth, eh = tbEl.offsetHeight;
let l = dragStartPos.left + (e.clientX - dragStart.x);
@@ -1410,7 +1413,7 @@ async function _bulkDeleteSelected(): Promise<void> {
);
if (!ok) return;
for (const id of _selectedIds) {
const node = _nodeMap.get(id);
const node = _nodeMap?.get(id);
if (node) _onDeleteNode(node);
}
_selectedIds.clear();
@@ -1506,10 +1509,12 @@ function _updateNodeRunning(nodeId: string, running: boolean): void {
// Update flow dots since running set changed
if (edgeGroup) {
const runningIds = new Set<string>();
for (const n of _nodeMap.values()) {
if (n.running) runningIds.add(n.id);
if (_nodeMap) {
for (const n of _nodeMap.values()) {
if (n.running) runningIds.add(n.id);
}
}
renderFlowDots(edgeGroup, _edges, runningIds);
renderFlowDots(edgeGroup, _edges!, runningIds);
}
}
@@ -1559,7 +1564,7 @@ function _onKeydown(e: KeyboardEvent): void {
_detachSelectedEdge();
} else if (_selectedIds.size === 1) {
const nodeId = [..._selectedIds][0];
const node = _nodeMap.get(nodeId);
const node = _nodeMap?.get(nodeId);
if (node) _onDeleteNode(node);
} else if (_selectedIds.size > 1) {
_bulkDeleteSelected();
@@ -1614,13 +1619,13 @@ function _navigateDirection(dir: string): void {
if (!_nodeMap || _nodeMap.size === 0) return;
// Get current anchor node
let anchor = null;
let anchor: any = null;
if (_selectedIds.size === 1) {
anchor = _nodeMap.get([..._selectedIds][0]);
}
if (!anchor) {
// Select first visible node (topmost-leftmost)
let best = null;
let best: any = null;
for (const n of _nodeMap.values()) {
if (!best || n.y < best.y || (n.y === best.y && n.x < best.x)) best = n;
}
@@ -1638,7 +1643,7 @@ function _navigateDirection(dir: string): void {
const cx = anchor.x + anchor.width / 2;
const cy = anchor.y + anchor.height / 2;
let bestNode = null;
let bestNode: any = null;
let bestDist = Infinity;
for (const n of _nodeMap.values()) {
@@ -1702,8 +1707,8 @@ function _selectAll(): void {
/* ── Edge click ── */
function _onEdgeClick(edgePath: Element, nodeGroup: SVGGElement | null, edgeGroup: SVGGElement | null): void {
const fromId = edgePath.getAttribute('data-from');
const toId = edgePath.getAttribute('data-to');
const fromId = edgePath.getAttribute('data-from') ?? '';
const toId = edgePath.getAttribute('data-to') ?? '';
const field = edgePath.getAttribute('data-field') || '';
// Track selected edge for Delete key detach
@@ -1819,10 +1824,10 @@ function _onDragPointerMove(e: PointerEvent): void {
node.x = item.startX + gdx;
node.y = item.startY + gdy;
if (item.el) item.el.setAttribute('transform', `translate(${node.x}, ${node.y})`);
if (edgeGroup) updateEdgesForNode(edgeGroup, item.id, _nodeMap, _edges);
if (edgeGroup) updateEdgesForNode(edgeGroup, item.id, _nodeMap!, _edges!);
_updateMinimapNode(item.id, node);
}
if (edgeGroup) updateFlowDotsForNode(edgeGroup, null, _nodeMap, _edges);
if (edgeGroup) updateFlowDotsForNode(edgeGroup, null as any, _nodeMap!, _edges!);
} else {
const ds = _dragState as DragStateSingle;
const node = _nodeMap!.get(ds.nodeId);
@@ -1831,8 +1836,8 @@ function _onDragPointerMove(e: PointerEvent): void {
node.y = ds.startNode.y + gdy;
ds.el.setAttribute('transform', `translate(${node.x}, ${node.y})`);
if (edgeGroup) {
updateEdgesForNode(edgeGroup, ds.nodeId, _nodeMap, _edges);
updateFlowDotsForNode(edgeGroup, ds.nodeId, _nodeMap, _edges);
updateEdgesForNode(edgeGroup, ds.nodeId, _nodeMap!, _edges!);
updateFlowDotsForNode(edgeGroup, ds.nodeId, _nodeMap!, _edges!);
}
_updateMinimapNode(ds.nodeId, node);
}
@@ -1867,7 +1872,7 @@ function _onDragPointerUp(): void {
if (edgeGroup && _edges && _nodeMap) {
const runningIds = new Set<string>();
for (const n of _nodeMap.values()) { if (n.running) runningIds.add(n.id); }
renderFlowDots(edgeGroup, _edges, runningIds);
renderFlowDots(edgeGroup, _edges!, runningIds);
}
}
@@ -1886,7 +1891,7 @@ function _initRubberBand(svgEl: SVGSVGElement): void {
e.preventDefault();
_rubberBand = {
startGraph: _canvas.screenToGraph(e.clientX, e.clientY),
startGraph: _canvas!.screenToGraph(e.clientX, e.clientY),
startClient: { x: e.clientX, y: e.clientY },
active: false,
};
@@ -1930,10 +1935,10 @@ function _onRubberBandUp(): void {
const rect = document.querySelector('.graph-selection-rect') as SVGElement | null;
if (_rubberBand.active && rect && _nodeMap) {
const rx = parseFloat(rect.getAttribute('x'));
const ry = parseFloat(rect.getAttribute('y'));
const rw = parseFloat(rect.getAttribute('width'));
const rh = parseFloat(rect.getAttribute('height'));
const rx = parseFloat(rect.getAttribute('x') ?? '0');
const ry = parseFloat(rect.getAttribute('y') ?? '0');
const rw = parseFloat(rect.getAttribute('width') ?? '0');
const rh = parseFloat(rect.getAttribute('height') ?? '0');
_selectedIds.clear();
for (const node of _nodeMap.values()) {
@@ -2014,9 +2019,9 @@ function _initPortDrag(svgEl: SVGSVGElement, nodeGroup: SVGGElement, _edgeGroup:
e.stopPropagation();
e.preventDefault();
const sourceNodeId = port.getAttribute('data-node-id');
const sourceKind = port.getAttribute('data-node-kind');
const portType = port.getAttribute('data-port-type');
const sourceNodeId = port.getAttribute('data-node-id') ?? '';
const sourceKind = port.getAttribute('data-node-kind') ?? '';
const portType = port.getAttribute('data-port-type') ?? '';
const sourceNode = _nodeMap?.get(sourceNodeId);
if (!sourceNode) return;
@@ -2029,7 +2034,7 @@ function _initPortDrag(svgEl: SVGSVGElement, nodeGroup: SVGGElement, _edgeGroup:
const dragPath = document.createElementNS(SVG_NS, 'path');
dragPath.setAttribute('class', 'graph-drag-edge');
dragPath.setAttribute('d', `M ${startX} ${startY} L ${startX} ${startY}`);
const root = svgEl.querySelector('.graph-root');
const root = svgEl.querySelector('.graph-root')!;
root.appendChild(dragPath);
_connectState = { sourceNodeId, sourceKind, portType, startX, startY, dragPath };
@@ -2108,9 +2113,9 @@ function _onConnectPointerUp(e: PointerEvent): void {
const elem = document.elementFromPoint(e.clientX, e.clientY);
const targetPort = elem?.closest?.('.graph-port-in');
if (targetPort) {
const targetNodeId = targetPort.getAttribute('data-node-id');
const targetKind = targetPort.getAttribute('data-node-kind');
const targetPortType = targetPort.getAttribute('data-port-type');
const targetNodeId = targetPort.getAttribute('data-node-id') ?? '';
const targetKind = targetPort.getAttribute('data-node-kind') ?? '';
const targetPortType = targetPort.getAttribute('data-port-type') ?? '';
if (targetNodeId !== sourceNodeId) {
// Find the matching connection
@@ -2143,8 +2148,8 @@ async function _doConnect(targetId: string, targetKind: string, field: string, s
/* ── Undo / Redo ── */
const _undoStack = [];
const _redoStack = [];
const _undoStack: UndoAction[] = [];
const _redoStack: UndoAction[] = [];
const _MAX_UNDO = 30;
/** Record an undoable action. action = { undo: async fn, redo: async fn, label: string } */
@@ -2167,7 +2172,7 @@ export async function graphRedo(): Promise<void> { await _redo(); }
async function _undo(): Promise<void> {
if (_undoStack.length === 0) { showToast(t('graph.nothing_to_undo') || 'Nothing to undo', 'info'); return; }
const action = _undoStack.pop();
const action = _undoStack.pop()!;
try {
await action.undo();
_redoStack.push(action);
@@ -2182,7 +2187,7 @@ async function _undo(): Promise<void> {
async function _redo(): Promise<void> {
if (_redoStack.length === 0) { showToast(t('graph.nothing_to_redo') || 'Nothing to redo', 'info'); return; }
const action = _redoStack.pop();
const action = _redoStack.pop()!;
try {
await action.redo();
_undoStack.push(action);
@@ -2201,7 +2206,7 @@ let _helpVisible = false;
function _loadHelpPos(): AnchoredRect | null {
try {
const saved = JSON.parse(localStorage.getItem('graph_help_pos'));
const saved = JSON.parse(localStorage.getItem('graph_help_pos')!);
return saved || { anchor: 'br', offsetX: 12, offsetY: 12, width: 0, height: 0 };
} catch { return { anchor: 'br', offsetX: 12, offsetY: 12, width: 0, height: 0 }; }
}
@@ -2265,7 +2270,7 @@ function _onEdgeContextMenu(edgePath: Element, e: MouseEvent, container: HTMLEle
const field = edgePath.getAttribute('data-field') || '';
if (!isEditableEdge(field)) return; // nested fields can't be detached from graph
const toId = edgePath.getAttribute('data-to');
const toId = edgePath.getAttribute('data-to') ?? '';
const toNode = _nodeMap?.get(toId);
if (!toNode) return;
@@ -2289,7 +2294,7 @@ function _onEdgeContextMenu(edgePath: Element, e: MouseEvent, container: HTMLEle
});
menu.appendChild(btn);
container.querySelector('.graph-container').appendChild(menu);
container.querySelector('.graph-container')!.appendChild(menu);
_edgeContextMenu = menu;
}
@@ -2318,11 +2323,11 @@ async function _detachSelectedEdge(): Promise<void> {
let _hoverTooltip: HTMLDivElement | null = null; // the <div> element, created once per graph render
let _hoverTooltipChart: any = null; // Chart.js instance
let _hoverTimer: ReturnType<typeof setTimeout> | null = null; // 300ms delay timer
let _hoverPollInterval: ReturnType<typeof setInterval> | null = null; // 1s polling interval
let _hoverTimer: ReturnType<typeof setTimeout> | undefined = undefined; // 300ms delay timer
let _hoverPollInterval: ReturnType<typeof setInterval> | undefined = undefined; // 1s polling interval
let _hoverNodeId: string | null = null; // currently shown node id
let _hoverFpsHistory = []; // rolling fps_actual samples
let _hoverFpsCurrentHistory = []; // rolling fps_current samples
let _hoverFpsHistory: number[] = []; // rolling fps_actual samples
let _hoverFpsCurrentHistory: number[] = []; // rolling fps_current samples
const HOVER_DELAY_MS = 300;
const HOVER_HISTORY_LEN = 20;
@@ -2374,7 +2379,7 @@ function _initNodeHoverTooltip(nodeGroup: SVGGElement, container: HTMLElement):
if (related && nodeEl.contains(related)) return;
clearTimeout(_hoverTimer);
_hoverTimer = null;
_hoverTimer = undefined;
const nodeId = nodeEl.getAttribute('data-id');
if (nodeId === _hoverNodeId) {
@@ -2384,7 +2389,7 @@ function _initNodeHoverTooltip(nodeGroup: SVGGElement, container: HTMLElement):
}
function _positionTooltip(_nodeEl: Element | null, container: HTMLElement): void {
if (!_canvas || !_hoverTooltip) return;
if (!_canvas || !_hoverTooltip || !_hoverNodeId) return;
const node = _nodeMap?.get(_hoverNodeId);
if (!node) return;
@@ -2467,7 +2472,7 @@ async function _showNodeTooltip(nodeId: string, nodeEl: Element, container: HTML
function _hideNodeTooltip(): void {
clearInterval(_hoverPollInterval);
_hoverPollInterval = null;
_hoverPollInterval = undefined;
_hoverNodeId = null;
if (_hoverTooltipChart) {
@@ -2478,7 +2483,7 @@ function _hideNodeTooltip(): void {
_hoverTooltip.classList.remove('gnt-fade-in');
_hoverTooltip.classList.add('gnt-fade-out');
_hoverTooltip.addEventListener('animationend', () => {
if (_hoverTooltip.classList.contains('gnt-fade-out')) {
if (_hoverTooltip?.classList.contains('gnt-fade-out')) {
_hoverTooltip.style.display = 'none';
}
}, { once: true });
@@ -2506,6 +2511,7 @@ async function _fetchTooltipMetrics(nodeId: string, container: HTMLElement, node
const uptimeSec = metrics.uptime_seconds ?? 0;
// Update text rows
if (!_hoverTooltip) return;
const fpsEl = _hoverTooltip.querySelector('[data-gnt="fps"]');
const errorsEl = _hoverTooltip.querySelector('[data-gnt="errors"]');
const uptimeEl = _hoverTooltip.querySelector('[data-gnt="uptime"]');

View File

@@ -141,7 +141,7 @@ function _ensureBrightnessEntitySelect() {
}
export function patchKCTargetMetrics(target: any) {
const card = document.querySelector(`[data-kc-target-id="${target.id}"]`);
const card = document.querySelector(`[data-kc-target-id="${CSS.escape(target.id)}"]`);
if (!card) return;
const state = target.state || {};
const metrics = target.metrics || {};
@@ -523,8 +523,8 @@ export async function showKCEditor(targetId: any = null, cloneData: any = null)
try {
// Load sources, pattern templates, and value sources in parallel
const [sources, patTemplates, valueSources] = await Promise.all([
streamsCache.fetch().catch(() => []),
patternTemplatesCache.fetch().catch(() => []),
streamsCache.fetch().catch((): any[] => []),
patternTemplatesCache.fetch().catch((): any[] => []),
valueSourcesCache.fetch(),
]);
@@ -751,7 +751,7 @@ export async function deleteKCTarget(targetId: any) {
// ===== KC BRIGHTNESS =====
export function updateKCBrightnessLabel(targetId: any, value: any) {
const slider = document.querySelector(`[data-kc-brightness="${targetId}"]`) as HTMLElement;
const slider = document.querySelector(`[data-kc-brightness="${CSS.escape(targetId)}"]`) as HTMLElement;
if (slider) slider.title = Math.round(parseInt(value) / 255 * 100) + '%';
}

View File

@@ -87,7 +87,7 @@ export function createPatternTemplateCard(pt: PatternTemplate) {
export async function showPatternTemplateEditor(templateId: string | null = null, cloneData: PatternTemplate | null = null): Promise<void> {
try {
// Load sources for background capture
const sources = await streamsCache.fetch().catch(() => []);
const sources = await streamsCache.fetch().catch((): any[] => []);
const bgSelect = document.getElementById('pattern-bg-source') as HTMLSelectElement;
bgSelect.innerHTML = '';
@@ -116,7 +116,7 @@ export async function showPatternTemplateEditor(templateId: string | null = null
setPatternEditorSelectedIdx(-1);
setPatternCanvasDragMode(null);
let _editorTags = [];
let _editorTags: string[] = [];
if (templateId) {
const resp = await fetch(`${API_BASE}/pattern-templates/${templateId}`, { headers: getHeaders() });
@@ -340,7 +340,7 @@ export function removePatternRect(index: number): void {
export function renderPatternCanvas(): void {
const canvas = document.getElementById('pattern-canvas') as HTMLCanvasElement;
if (!canvas) return;
const ctx = canvas.getContext('2d');
const ctx = canvas.getContext('2d')!;
const w = canvas.width;
const h = canvas.height;
@@ -396,8 +396,8 @@ export function renderPatternCanvas(): void {
ctx.strokeRect(rx, ry, rw, rh);
// Edge highlight
let edgeDir = null;
if (isDragging && patternCanvasDragMode.startsWith('resize-')) {
let edgeDir: string | null = null;
if (isDragging && patternCanvasDragMode?.startsWith('resize-')) {
edgeDir = patternCanvasDragMode.replace('resize-', '');
} else if (isHovered && patternEditorHoverHit && patternEditorHoverHit !== 'move') {
edgeDir = patternEditorHoverHit;
@@ -586,16 +586,16 @@ function _patternCanvasDragMove(e: MouseEvent | { clientX: number; clientY: numb
const mx = (e.clientX - canvasRect.left) * scaleX;
const my = (e.clientY - canvasRect.top) * scaleY;
const dx = (mx - patternCanvasDragStart.mx) / w;
const dy = (my - patternCanvasDragStart.my) / h;
const orig = patternCanvasDragOrigRect;
const dx = (mx - patternCanvasDragStart!.mx!) / w;
const dy = (my - patternCanvasDragStart!.my!) / h;
const orig = patternCanvasDragOrigRect!;
const r = patternEditorRects[patternEditorSelectedIdx];
if (patternCanvasDragMode === 'move') {
r.x = Math.max(0, Math.min(1 - r.width, orig.x + dx));
r.y = Math.max(0, Math.min(1 - r.height, orig.y + dy));
} else if (patternCanvasDragMode.startsWith('resize-')) {
const dir = patternCanvasDragMode.replace('resize-', '');
} else if (patternCanvasDragMode?.startsWith('resize-')) {
const dir = patternCanvasDragMode!.replace('resize-', '');
let nx = orig.x, ny = orig.y, nw = orig.width, nh = orig.height;
if (dir.includes('w')) { nx = orig.x + dx; nw = orig.width - dx; }
if (dir.includes('e')) { nw = orig.width + dx; }
@@ -631,7 +631,7 @@ function _patternCanvasDragEnd(e: MouseEvent): void {
const my = (e.clientY - canvasRect.top) * scaleY;
let cursor = 'default';
let newHoverIdx = -1;
let newHoverHit = null;
let newHoverHit: string | null = null;
if (e.clientX >= canvasRect.left && e.clientX <= canvasRect.right &&
e.clientY >= canvasRect.top && e.clientY <= canvasRect.bottom) {
for (let i = patternEditorRects.length - 1; i >= 0; i--) {
@@ -717,8 +717,8 @@ function _patternCanvasMouseDown(e: MouseEvent | { offsetX?: number; offsetY?: n
const rect = canvas.getBoundingClientRect();
const scaleX = w / rect.width;
const scaleY = h / rect.height;
const mx = (e.offsetX !== undefined ? e.offsetX : e.clientX - rect.left) * scaleX;
const my = (e.offsetY !== undefined ? e.offsetY : e.clientY - rect.top) * scaleY;
const mx = (e.offsetX !== undefined ? e.offsetX : (e.clientX ?? 0) - rect.left) * scaleX;
const my = (e.offsetY !== undefined ? e.offsetY : (e.clientY ?? 0) - rect.top) * scaleY;
// Check delete button on hovered or selected rects first
for (const idx of [patternEditorHoveredIdx, patternEditorSelectedIdx]) {
@@ -739,7 +739,7 @@ function _patternCanvasMouseDown(e: MouseEvent | { offsetX?: number; offsetY?: n
// Test all rects; selected rect takes priority so it stays interactive
// even when overlapping with others.
const selIdx = patternEditorSelectedIdx;
const testOrder = [];
const testOrder: number[] = [];
if (selIdx >= 0 && selIdx < patternEditorRects.length) testOrder.push(selIdx);
for (let i = patternEditorRects.length - 1; i >= 0; i--) {
if (i !== selIdx) testOrder.push(i);
@@ -795,15 +795,15 @@ function _patternCanvasMouseMove(e: MouseEvent | { offsetX?: number; offsetY?: n
const rect = canvas.getBoundingClientRect();
const scaleX = w / rect.width;
const scaleY = h / rect.height;
const mx = (e.offsetX !== undefined ? e.offsetX : e.clientX - rect.left) * scaleX;
const my = (e.offsetY !== undefined ? e.offsetY : e.clientY - rect.top) * scaleY;
const mx = (e.offsetX !== undefined ? e.offsetX : (e.clientX ?? 0) - rect.left) * scaleX;
const my = (e.offsetY !== undefined ? e.offsetY : (e.clientY ?? 0) - rect.top) * scaleY;
let cursor = 'default';
let newHoverIdx = -1;
let newHoverHit = null;
let newHoverHit: string | null = null;
// Selected rect takes priority for hover so edges stay reachable under overlaps
const selIdx = patternEditorSelectedIdx;
const hoverOrder = [];
const hoverOrder: number[] = [];
if (selIdx >= 0 && selIdx < patternEditorRects.length) hoverOrder.push(selIdx);
for (let i = patternEditorRects.length - 1; i >= 0; i--) {
if (i !== selIdx) hoverOrder.push(i);

View File

@@ -55,21 +55,21 @@ export function renderPerfSection(): string {
return `<div class="perf-charts-grid">
<div class="perf-chart-card">
<div class="perf-chart-header">
<span class="perf-chart-label">${t('dashboard.perf.cpu')} ${createColorPicker({ id: 'perf-cpu', currentColor: _getColor('cpu'), onPick: null, anchor: 'left', showReset: true })}</span>
<span class="perf-chart-label">${t('dashboard.perf.cpu')} ${createColorPicker({ id: 'perf-cpu', currentColor: _getColor('cpu'), onPick: undefined, anchor: 'left', showReset: true })}</span>
<span class="perf-chart-value" id="perf-cpu-value">-</span>
</div>
<div class="perf-chart-wrap"><span class="perf-chart-subtitle" id="perf-cpu-name"></span><canvas id="perf-chart-cpu"></canvas></div>
</div>
<div class="perf-chart-card">
<div class="perf-chart-header">
<span class="perf-chart-label">${t('dashboard.perf.ram')} ${createColorPicker({ id: 'perf-ram', currentColor: _getColor('ram'), onPick: null, anchor: 'left', showReset: true })}</span>
<span class="perf-chart-label">${t('dashboard.perf.ram')} ${createColorPicker({ id: 'perf-ram', currentColor: _getColor('ram'), onPick: undefined, anchor: 'left', showReset: true })}</span>
<span class="perf-chart-value" id="perf-ram-value">-</span>
</div>
<div class="perf-chart-wrap"><canvas id="perf-chart-ram"></canvas></div>
</div>
<div class="perf-chart-card" id="perf-gpu-card">
<div class="perf-chart-header">
<span class="perf-chart-label">${t('dashboard.perf.gpu')} ${createColorPicker({ id: 'perf-gpu', currentColor: _getColor('gpu'), onPick: null, anchor: 'left', showReset: true })}</span>
<span class="perf-chart-label">${t('dashboard.perf.gpu')} ${createColorPicker({ id: 'perf-gpu', currentColor: _getColor('gpu'), onPick: undefined, anchor: 'left', showReset: true })}</span>
<span class="perf-chart-value" id="perf-gpu-value">-</span>
</div>
<div class="perf-chart-wrap"><span class="perf-chart-subtitle" id="perf-gpu-name"></span><canvas id="perf-chart-gpu"></canvas></div>

View File

@@ -15,10 +15,11 @@ import { scenePresetsCache, outputTargetsCache, automationsCacheObj } from '../c
import { TagInput, renderTagChips } from '../core/tag-input.ts';
import { cardColorStyle, cardColorButton } from '../core/card-colors.ts';
import { EntityPalette } from '../core/entity-palette.ts';
import { navigateToCard } from '../core/navigation.ts';
import type { ScenePreset } from '../types.ts';
let _editingId: string | null = null;
let _allTargets = []; // fetched on capture open
let _allTargets: any[] = []; // fetched on capture open
let _sceneTagsInput: TagInput | null = null;
class ScenePresetEditorModal extends Modal {
@@ -76,7 +77,7 @@ export function createSceneCard(preset: ScenePreset) {
const colorStyle = cardColorStyle(preset.id);
return `<div class="card" data-scene-id="${preset.id}"${colorStyle ? ` style="${colorStyle}"` : ''}>
<div class="card-top-actions">
<button class="card-remove-btn" onclick="deleteScenePreset('${preset.id}', '${escapeHtml(preset.name)}')" title="${t('scenes.delete')}">&#x2715;</button>
<button class="card-remove-btn" data-action="delete-scene" data-id="${preset.id}" title="${t('scenes.delete')}">&#x2715;</button>
</div>
<div class="card-header">
<div class="card-title" title="${escapeHtml(preset.name)}">${escapeHtml(preset.name)}</div>
@@ -88,10 +89,10 @@ export function createSceneCard(preset: ScenePreset) {
</div>
${renderTagChips(preset.tags)}
<div class="card-actions">
<button class="btn btn-icon btn-secondary" onclick="cloneScenePreset('${preset.id}')" title="${t('common.clone')}">${ICON_CLONE}</button>
<button class="btn btn-icon btn-secondary" onclick="editScenePreset('${preset.id}')" title="${t('scenes.edit')}">${ICON_EDIT}</button>
<button class="btn btn-icon btn-secondary" onclick="recaptureScenePreset('${preset.id}')" title="${t('scenes.recapture')}">${ICON_REFRESH}</button>
<button class="btn btn-icon btn-success" onclick="activateScenePreset('${preset.id}')" title="${t('scenes.activate')}">${ICON_START}</button>
<button class="btn btn-icon btn-secondary" data-action="clone-scene" data-id="${preset.id}" title="${t('common.clone')}">${ICON_CLONE}</button>
<button class="btn btn-icon btn-secondary" data-action="edit-scene" data-id="${preset.id}" title="${t('scenes.edit')}">${ICON_EDIT}</button>
<button class="btn btn-icon btn-secondary" data-action="recapture-scene" data-id="${preset.id}" title="${t('scenes.recapture')}">${ICON_REFRESH}</button>
<button class="btn btn-icon btn-success" data-action="activate-scene" data-id="${preset.id}" title="${t('scenes.activate')}">${ICON_START}</button>
${cardColorButton(preset.id, 'data-scene-id')}
</div>
</div>`;
@@ -106,7 +107,7 @@ export async function loadScenePresets(): Promise<ScenePreset[]> {
export function renderScenePresetsSection(presets: ScenePreset[]): string | { headerExtra: string; content: string } {
if (!presets || presets.length === 0) return '';
const captureBtn = `<button class="btn btn-sm btn-primary dashboard-stop-all" onclick="event.stopPropagation(); openScenePresetCapture()" title="${t('scenes.capture')}">${ICON_CAPTURE} ${t('scenes.capture')}</button>`;
const captureBtn = `<button class="btn btn-sm btn-primary dashboard-stop-all" data-action="capture-scene" title="${t('scenes.capture')}">${ICON_CAPTURE} ${t('scenes.capture')}</button>`;
const cards = presets.map(p => _renderDashboardPresetCard(p)).join('');
return { headerExtra: captureBtn, content: `<div class="dashboard-autostart-grid">${cards}</div>` };
@@ -120,7 +121,7 @@ function _renderDashboardPresetCard(preset: ScenePreset): string {
].filter(Boolean).join(' \u00b7 ');
const pStyle = cardColorStyle(preset.id);
return `<div class="dashboard-target dashboard-scene-preset dashboard-card-link" data-scene-id="${preset.id}" onclick="if(!event.target.closest('button')){navigateToCard('automations',null,'scenes','data-scene-id','${preset.id}')}"${pStyle ? ` style="${pStyle}"` : ''}>
return `<div class="dashboard-target dashboard-scene-preset dashboard-card-link" data-scene-id="${preset.id}" data-action="navigate-scene" data-id="${preset.id}"${pStyle ? ` style="${pStyle}"` : ''}>
<div class="dashboard-target-info">
<span class="dashboard-target-icon">${ICON_SCENE}</span>
<div>
@@ -130,7 +131,7 @@ function _renderDashboardPresetCard(preset: ScenePreset): string {
</div>
</div>
<div class="dashboard-target-actions">
<button class="dashboard-action-btn start" onclick="activateScenePreset('${preset.id}')" title="${t('scenes.activate')}">${ICON_START}</button>
<button class="dashboard-action-btn start" data-action="activate-scene" data-id="${preset.id}" title="${t('scenes.activate')}">${ICON_START}</button>
</div>
</div>`;
}
@@ -155,7 +156,7 @@ export async function openScenePresetCapture(): Promise<void> {
selectorGroup.style.display = '';
targetList.innerHTML = '';
try {
_allTargets = await outputTargetsCache.fetch().catch(() => []);
_allTargets = await outputTargetsCache.fetch().catch((): any[] => []);
_refreshTargetSelect();
} catch { /* ignore */ }
}
@@ -190,7 +191,7 @@ export async function editScenePreset(presetId: string): Promise<void> {
selectorGroup.style.display = '';
targetList.innerHTML = '';
try {
_allTargets = await outputTargetsCache.fetch().catch(() => []);
_allTargets = await outputTargetsCache.fetch().catch((): any[] => []);
// Pre-add targets already in the preset
const presetTargetIds = (preset.targets || []).map(pt => pt.target_id || pt.id);
@@ -200,7 +201,7 @@ export async function editScenePreset(presetId: string): Promise<void> {
const item = document.createElement('div');
item.className = 'scene-target-item';
item.dataset.targetId = tid;
item.innerHTML = `<span>${escapeHtml(tgt.name)}</span><button type="button" class="btn-remove-condition" onclick="removeSceneTarget(this)" title="Remove">&#x2715;</button>`;
item.innerHTML = `<span>${escapeHtml(tgt.name)}</span><button type="button" class="btn-remove-condition" data-action="remove-scene-target" title="Remove">&#x2715;</button>`;
targetList.appendChild(item);
}
_refreshTargetSelect();
@@ -294,7 +295,7 @@ function _addTargetToList(targetId: string, targetName: string): void {
const item = document.createElement('div');
item.className = 'scene-target-item';
item.dataset.targetId = targetId;
item.innerHTML = `<span>${ICON_TARGET} ${escapeHtml(targetName)}</span><button type="button" class="btn-remove-condition" onclick="removeSceneTarget(this)" title="Remove">&#x2715;</button>`;
item.innerHTML = `<span>${ICON_TARGET} ${escapeHtml(targetName)}</span><button type="button" class="btn-remove-condition" data-action="remove-scene-target" title="Remove">&#x2715;</button>`;
list.appendChild(item);
_refreshTargetSelect();
}
@@ -320,10 +321,7 @@ export async function addSceneTarget(): Promise<void> {
if (tgt) _addTargetToList(tgt.id, tgt.name);
}
export function removeSceneTarget(btn: HTMLElement): void {
btn.closest('.scene-target-item').remove();
_refreshTargetSelect();
}
// removeSceneTarget is now handled via event delegation on the modal
// ===== Activate =====
@@ -403,7 +401,7 @@ export async function cloneScenePreset(presetId: string): Promise<void> {
selectorGroup.style.display = '';
targetList.innerHTML = '';
try {
_allTargets = await outputTargetsCache.fetch().catch(() => []);
_allTargets = await outputTargetsCache.fetch().catch((): any[] => []);
// Pre-add targets from the cloned preset
const clonedTargetIds = (preset.targets || []).map(pt => pt.target_id || pt.id);
@@ -413,7 +411,7 @@ export async function cloneScenePreset(presetId: string): Promise<void> {
const item = document.createElement('div');
item.className = 'scene-target-item';
item.dataset.targetId = tid;
item.innerHTML = `<span>${escapeHtml(tgt.name)}</span><button type="button" class="btn-remove-condition" onclick="removeSceneTarget(this)" title="Remove">&#x2715;</button>`;
item.innerHTML = `<span>${escapeHtml(tgt.name)}</span><button type="button" class="btn-remove-condition" data-action="remove-scene-target" title="Remove">&#x2715;</button>`;
targetList.appendChild(item);
}
_refreshTargetSelect();
@@ -456,6 +454,57 @@ export async function deleteScenePreset(presetId: string): Promise<void> {
}
}
// ===== Event delegation for scene preset card actions =====
const _sceneCardActions: Record<string, (id: string) => void> = {
'delete-scene': deleteScenePreset,
'clone-scene': cloneScenePreset,
'edit-scene': editScenePreset,
'recapture-scene': recaptureScenePreset,
'activate-scene': activateScenePreset,
};
export function initScenePresetDelegation(container: HTMLElement): void {
container.addEventListener('click', (e: MouseEvent) => {
const btn = (e.target as HTMLElement).closest<HTMLElement>('[data-action]');
if (!btn) return;
const action = btn.dataset.action;
const id = btn.dataset.id;
if (!action) return;
if (action === 'capture-scene') {
e.stopPropagation();
openScenePresetCapture();
return;
}
if (action === 'navigate-scene') {
// Only navigate if click wasn't on a child button
if ((e.target as HTMLElement).closest('button')) return;
navigateToCard('automations', null, 'scenes', 'data-scene-id', id);
return;
}
if (action === 'remove-scene-target') {
const item = btn.closest('.scene-target-item');
if (item) {
item.remove();
_refreshTargetSelect();
}
return;
}
if (!id) return;
const handler = _sceneCardActions[action];
if (handler) {
e.stopPropagation();
handler(id);
}
});
}
// ===== Helpers =====
function _reloadScenesTab(): void {
@@ -466,3 +515,18 @@ function _reloadScenesTab(): void {
// Also refresh dashboard (scene presets section)
if (typeof window.loadDashboard === 'function') window.loadDashboard(true);
}
// ===== Modal event delegation (for target list remove buttons) =====
const _sceneEditorModal = document.getElementById('scene-preset-editor-modal');
if (_sceneEditorModal) {
_sceneEditorModal.addEventListener('click', (e: MouseEvent) => {
const btn = (e.target as HTMLElement).closest<HTMLElement>('[data-action="remove-scene-target"]');
if (!btn) return;
const item = btn.closest('.scene-target-item');
if (item) {
item.remove();
_refreshTargetSelect();
}
});
}

View File

@@ -0,0 +1,548 @@
/**
* Streams — Audio template CRUD, engine config, test modal.
* Extracted from streams.ts to reduce file size.
*/
import {
availableAudioEngines, setAvailableAudioEngines,
currentEditingAudioTemplateId, setCurrentEditingAudioTemplateId,
_audioTemplateNameManuallyEdited, set_audioTemplateNameManuallyEdited,
_cachedAudioTemplates,
audioTemplatesCache,
apiKey,
} from '../core/state.ts';
import { API_BASE, fetchWithAuth, escapeHtml } from '../core/api.ts';
import { t } from '../core/i18n.ts';
import { Modal } from '../core/modal.ts';
import { showToast, showConfirm, setupBackdropClose } from '../core/ui.ts';
import {
getAudioEngineIcon,
ICON_AUDIO_TEMPLATE,
} from '../core/icons.ts';
import * as P from '../core/icon-paths.ts';
import { TagInput } from '../core/tag-input.ts';
import { IconSelect } from '../core/icon-select.ts';
import { loadPictureSources } from './streams.ts';
const _icon = (d: string) => `<svg class="icon" viewBox="0 0 24 24">${d}</svg>`;
// ── TagInput instance for audio template modal ──
let _audioTemplateTagsInput: TagInput | null = null;
class AudioTemplateModal extends Modal {
constructor() { super('audio-template-modal'); }
snapshotValues() {
const vals: any = {
name: (document.getElementById('audio-template-name') as HTMLInputElement).value,
description: (document.getElementById('audio-template-description') as HTMLInputElement).value,
engine: (document.getElementById('audio-template-engine') as HTMLSelectElement).value,
tags: JSON.stringify(_audioTemplateTagsInput ? _audioTemplateTagsInput.getValue() : []),
};
document.querySelectorAll('#audio-engine-config-fields [data-config-key]').forEach((field: any) => {
vals['cfg_' + field.dataset.configKey] = field.value;
});
return vals;
}
onForceClose() {
if (_audioTemplateTagsInput) { _audioTemplateTagsInput.destroy(); _audioTemplateTagsInput = null; }
setCurrentEditingAudioTemplateId(null);
set_audioTemplateNameManuallyEdited(false);
}
}
const audioTemplateModal = new AudioTemplateModal();
// ===== Audio Templates =====
async function loadAvailableAudioEngines() {
try {
const response = await fetchWithAuth('/audio-engines');
if (!response.ok) throw new Error(`Failed to load audio engines: ${response.status}`);
const data = await response.json();
setAvailableAudioEngines(data.engines || []);
const select = document.getElementById('audio-template-engine') as HTMLSelectElement;
select.innerHTML = '';
availableAudioEngines.forEach((engine: any) => {
const option = document.createElement('option');
option.value = engine.type;
option.textContent = `${engine.type.toUpperCase()}`;
if (!engine.available) {
option.disabled = true;
option.textContent += ` (${t('audio_template.engine.unavailable')})`;
}
select.appendChild(option);
});
if (!select.value) {
const firstAvailable = availableAudioEngines.find(e => e.available);
if (firstAvailable) select.value = firstAvailable.type;
}
// Update icon-grid selector with dynamic engine list
const items = availableAudioEngines
.filter(e => e.available)
.map(e => ({ value: e.type, icon: getAudioEngineIcon(e.type), label: e.type.toUpperCase(), desc: '' }));
if (_audioEngineIconSelect) { _audioEngineIconSelect.updateItems(items); }
else { _audioEngineIconSelect = new IconSelect({ target: select, items, columns: 2 }); }
_audioEngineIconSelect.setValue(select.value);
} catch (error) {
console.error('Error loading audio engines:', error);
showToast(t('audio_template.error.engines') + ': ' + error.message, 'error');
}
}
let _audioEngineIconSelect: IconSelect | null = null;
export async function onAudioEngineChange() {
const engineType = (document.getElementById('audio-template-engine') as HTMLSelectElement).value;
if (_audioEngineIconSelect) _audioEngineIconSelect.setValue(engineType);
const configSection = document.getElementById('audio-engine-config-section')!;
const configFields = document.getElementById('audio-engine-config-fields')!;
if (!engineType) { configSection.style.display = 'none'; return; }
const engine = availableAudioEngines.find((e: any) => e.type === engineType);
if (!engine) { configSection.style.display = 'none'; return; }
if (!_audioTemplateNameManuallyEdited && !(document.getElementById('audio-template-id') as HTMLInputElement).value) {
(document.getElementById('audio-template-name') as HTMLInputElement).value = engine.type.toUpperCase();
}
const hint = document.getElementById('audio-engine-availability-hint')!;
if (!engine.available) {
hint.textContent = t('audio_template.engine.unavailable.hint');
hint.style.display = 'block';
hint.style.color = 'var(--error-color)';
} else {
hint.style.display = 'none';
}
configFields.innerHTML = '';
const defaultConfig = engine.default_config || {};
if (Object.keys(defaultConfig).length === 0) {
configSection.style.display = 'none';
return;
} else {
let gridHtml = '<div class="config-grid">';
Object.entries(defaultConfig).forEach(([key, value]) => {
const fieldType = typeof value === 'number' ? 'number' : 'text';
const fieldValue = typeof value === 'boolean' ? (value ? 'true' : 'false') : value;
gridHtml += `
<label class="config-grid-label" for="audio-config-${key}">${key}</label>
<div class="config-grid-value">
${typeof value === 'boolean' ? `
<select id="audio-config-${key}" data-config-key="${key}">
<option value="true" ${value ? 'selected' : ''}>true</option>
<option value="false" ${!value ? 'selected' : ''}>false</option>
</select>
` : `
<input type="${fieldType}" id="audio-config-${key}" data-config-key="${key}" value="${fieldValue}">
`}
</div>
`;
});
gridHtml += '</div>';
configFields.innerHTML = gridHtml;
}
configSection.style.display = 'block';
}
function populateAudioEngineConfig(config: any) {
Object.entries(config).forEach(([key, value]: [string, any]) => {
const field = document.getElementById(`audio-config-${key}`) as HTMLInputElement | HTMLSelectElement | null;
if (field) {
if (field.tagName === 'SELECT') {
field.value = value.toString();
} else {
field.value = value;
}
}
});
}
function collectAudioEngineConfig() {
const config: any = {};
document.querySelectorAll('#audio-engine-config-fields [data-config-key]').forEach((field: any) => {
const key = field.dataset.configKey;
let value: any = field.value;
if (field.type === 'number') {
value = parseFloat(value);
} else if (field.tagName === 'SELECT' && (value === 'true' || value === 'false')) {
value = value === 'true';
}
config[key] = value;
});
return config;
}
async function loadAudioTemplates() {
try {
await audioTemplatesCache.fetch();
await loadPictureSources();
} catch (error) {
if (error.isAuth) return;
console.error('Error loading audio templates:', error);
showToast(t('audio_template.error.load'), 'error');
}
}
export async function showAddAudioTemplateModal(cloneData: any = null) {
setCurrentEditingAudioTemplateId(null);
document.getElementById('audio-template-modal-title')!.innerHTML = `${ICON_AUDIO_TEMPLATE} ${t('audio_template.add')}`;
(document.getElementById('audio-template-form') as HTMLFormElement).reset();
(document.getElementById('audio-template-id') as HTMLInputElement).value = '';
document.getElementById('audio-engine-config-section')!.style.display = 'none';
document.getElementById('audio-template-error')!.style.display = 'none';
set_audioTemplateNameManuallyEdited(!!cloneData);
(document.getElementById('audio-template-name') as HTMLInputElement).oninput = () => { set_audioTemplateNameManuallyEdited(true); };
await loadAvailableAudioEngines();
if (cloneData) {
(document.getElementById('audio-template-name') as HTMLInputElement).value = (cloneData.name || '') + ' (Copy)';
(document.getElementById('audio-template-description') as HTMLInputElement).value = cloneData.description || '';
(document.getElementById('audio-template-engine') as HTMLSelectElement).value = cloneData.engine_type;
await onAudioEngineChange();
populateAudioEngineConfig(cloneData.engine_config);
}
// Tags
if (_audioTemplateTagsInput) { _audioTemplateTagsInput.destroy(); _audioTemplateTagsInput = null; }
_audioTemplateTagsInput = new TagInput(document.getElementById('audio-template-tags-container'), { placeholder: t('tags.placeholder') });
_audioTemplateTagsInput.setValue(cloneData ? (cloneData.tags || []) : []);
audioTemplateModal.open();
audioTemplateModal.snapshot();
}
export async function editAudioTemplate(templateId: any) {
try {
const response = await fetchWithAuth(`/audio-templates/${templateId}`);
if (!response.ok) throw new Error(`Failed to load audio template: ${response.status}`);
const template = await response.json();
setCurrentEditingAudioTemplateId(templateId);
document.getElementById('audio-template-modal-title')!.innerHTML = `${ICON_AUDIO_TEMPLATE} ${t('audio_template.edit')}`;
(document.getElementById('audio-template-id') as HTMLInputElement).value = templateId;
(document.getElementById('audio-template-name') as HTMLInputElement).value = template.name;
(document.getElementById('audio-template-description') as HTMLInputElement).value = template.description || '';
await loadAvailableAudioEngines();
(document.getElementById('audio-template-engine') as HTMLSelectElement).value = template.engine_type;
await onAudioEngineChange();
populateAudioEngineConfig(template.engine_config);
document.getElementById('audio-template-error')!.style.display = 'none';
// Tags
if (_audioTemplateTagsInput) { _audioTemplateTagsInput.destroy(); _audioTemplateTagsInput = null; }
_audioTemplateTagsInput = new TagInput(document.getElementById('audio-template-tags-container'), { placeholder: t('tags.placeholder') });
_audioTemplateTagsInput.setValue(template.tags || []);
audioTemplateModal.open();
audioTemplateModal.snapshot();
} catch (error: any) {
console.error('Error loading audio template:', error);
showToast(t('audio_template.error.load') + ': ' + error.message, 'error');
}
}
export async function closeAudioTemplateModal() {
await audioTemplateModal.close();
}
export async function saveAudioTemplate() {
const templateId = currentEditingAudioTemplateId;
const name = (document.getElementById('audio-template-name') as HTMLInputElement).value.trim();
const engineType = (document.getElementById('audio-template-engine') as HTMLSelectElement).value;
if (!name || !engineType) {
showToast(t('audio_template.error.required'), 'error');
return;
}
const description = (document.getElementById('audio-template-description') as HTMLInputElement).value.trim();
const engineConfig = collectAudioEngineConfig();
const payload = { name, engine_type: engineType, engine_config: engineConfig, description: description || null, tags: _audioTemplateTagsInput ? _audioTemplateTagsInput.getValue() : [] };
try {
let response;
if (templateId) {
response = await fetchWithAuth(`/audio-templates/${templateId}`, { method: 'PUT', body: JSON.stringify(payload) });
} else {
response = await fetchWithAuth('/audio-templates', { method: 'POST', body: JSON.stringify(payload) });
}
if (!response.ok) {
const error = await response.json();
throw new Error(error.detail || error.message || 'Failed to save audio template');
}
showToast(templateId ? t('audio_template.updated') : t('audio_template.created'), 'success');
audioTemplateModal.forceClose();
audioTemplatesCache.invalidate();
await loadAudioTemplates();
} catch (error) {
console.error('Error saving audio template:', error);
document.getElementById('audio-template-error')!.textContent = (error as any).message;
document.getElementById('audio-template-error')!.style.display = 'block';
}
}
export async function deleteAudioTemplate(templateId: any) {
const confirmed = await showConfirm(t('audio_template.delete.confirm'));
if (!confirmed) return;
try {
const response = await fetchWithAuth(`/audio-templates/${templateId}`, { method: 'DELETE' });
if (!response.ok) {
const error = await response.json();
throw new Error(error.detail || error.message || 'Failed to delete audio template');
}
showToast(t('audio_template.deleted'), 'success');
audioTemplatesCache.invalidate();
await loadAudioTemplates();
} catch (error) {
console.error('Error deleting audio template:', error);
showToast(t('audio_template.error.delete') + ': ' + error.message, 'error');
}
}
export async function cloneAudioTemplate(templateId: any) {
try {
const resp = await fetchWithAuth(`/audio-templates/${templateId}`);
if (!resp.ok) throw new Error('Failed to load audio template');
const tmpl = await resp.json();
showAddAudioTemplateModal(tmpl);
} catch (error) {
if (error.isAuth) return;
console.error('Failed to clone audio template:', error);
showToast(t('audio_template.error.clone_failed'), 'error');
}
}
// ===== Audio Template Test =====
const NUM_BANDS_TPL = 64;
const TPL_PEAK_DECAY = 0.02;
const TPL_BEAT_FLASH_DECAY = 0.06;
let _tplTestWs: WebSocket | null = null;
let _tplTestAnimFrame: number | null = null;
let _tplTestLatest: any = null;
let _tplTestPeaks = new Float32Array(NUM_BANDS_TPL);
let _tplTestBeatFlash = 0;
let _currentTestAudioTemplateId: string | null = null;
const testAudioTemplateModal = new Modal('test-audio-template-modal', { backdrop: true, lock: true });
export async function showTestAudioTemplateModal(templateId: any) {
_currentTestAudioTemplateId = templateId;
// Find template's engine type so we show the correct device list
const template = _cachedAudioTemplates.find((t: any) => t.id === templateId);
const engineType = template ? template.engine_type : null;
// Load audio devices for picker — filter by engine type
const deviceSelect = document.getElementById('test-audio-template-device') as HTMLSelectElement;
try {
const resp = await fetchWithAuth('/audio-devices');
if (resp.ok) {
const data = await resp.json();
// Use engine-specific device list if available, fall back to flat list
const devices = (engineType && data.by_engine && data.by_engine[engineType])
? data.by_engine[engineType]
: (data.devices || []);
deviceSelect.innerHTML = devices.map(d => {
const label = d.name;
const val = `${d.index}:${d.is_loopback ? '1' : '0'}`;
return `<option value="${val}">${escapeHtml(label)}</option>`;
}).join('');
if (devices.length === 0) {
deviceSelect.innerHTML = '<option value="-1:1">Default</option>';
}
}
} catch {
deviceSelect.innerHTML = '<option value="-1:1">Default</option>';
}
// Restore last used device
const lastDevice = localStorage.getItem('lastAudioTestDevice');
if (lastDevice) {
const opt = Array.from(deviceSelect.options).find((o: any) => o.value === lastDevice);
if (opt) deviceSelect.value = lastDevice;
}
// Reset visual state
document.getElementById('audio-template-test-canvas')!.style.display = 'none';
document.getElementById('audio-template-test-stats')!.style.display = 'none';
document.getElementById('audio-template-test-status')!.style.display = 'none';
document.getElementById('test-audio-template-start-btn')!.style.display = '';
_tplCleanupTest();
testAudioTemplateModal.open();
}
export function closeTestAudioTemplateModal() {
_tplCleanupTest();
testAudioTemplateModal.forceClose();
_currentTestAudioTemplateId = null;
}
export function startAudioTemplateTest() {
if (!_currentTestAudioTemplateId) return;
const deviceVal = (document.getElementById('test-audio-template-device') as HTMLSelectElement).value || '-1:1';
const [devIdx, devLoop] = deviceVal.split(':');
localStorage.setItem('lastAudioTestDevice', deviceVal);
// Show canvas + stats, hide run button, disable device picker
document.getElementById('audio-template-test-canvas')!.style.display = '';
document.getElementById('audio-template-test-stats')!.style.display = '';
document.getElementById('test-audio-template-start-btn')!.style.display = 'none';
(document.getElementById('test-audio-template-device') as HTMLSelectElement).disabled = true;
const statusEl = document.getElementById('audio-template-test-status')!;
statusEl.textContent = t('audio_source.test.connecting');
statusEl.style.display = '';
// Reset state
_tplTestLatest = null;
_tplTestPeaks.fill(0);
_tplTestBeatFlash = 0;
document.getElementById('audio-template-test-rms')!.textContent = '---';
document.getElementById('audio-template-test-peak')!.textContent = '---';
document.getElementById('audio-template-test-beat-dot')!.classList.remove('active');
// Size canvas
const canvas = document.getElementById('audio-template-test-canvas') as HTMLCanvasElement;
_tplSizeCanvas(canvas);
// Connect WebSocket
const protocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:';
const wsUrl = `${protocol}//${window.location.host}${API_BASE}/audio-templates/${_currentTestAudioTemplateId}/test/ws?token=${encodeURIComponent(apiKey)}&device_index=${devIdx}&is_loopback=${devLoop === '1' ? '1' : '0'}`;
try {
_tplTestWs = new WebSocket(wsUrl);
_tplTestWs.onopen = () => {
statusEl.style.display = 'none';
};
_tplTestWs.onmessage = (event) => {
try { _tplTestLatest = JSON.parse(event.data); } catch {}
};
_tplTestWs.onclose = () => { _tplTestWs = null; };
_tplTestWs.onerror = () => {
showToast(t('audio_source.test.error'), 'error');
_tplCleanupTest();
};
} catch {
showToast(t('audio_source.test.error'), 'error');
_tplCleanupTest();
return;
}
_tplTestAnimFrame = requestAnimationFrame(_tplRenderLoop);
}
function _tplCleanupTest() {
if (_tplTestAnimFrame) {
cancelAnimationFrame(_tplTestAnimFrame);
_tplTestAnimFrame = null;
}
if (_tplTestWs) {
_tplTestWs.onclose = null;
_tplTestWs.close();
_tplTestWs = null;
}
_tplTestLatest = null;
// Re-enable device picker
const devSel = document.getElementById('test-audio-template-device') as HTMLSelectElement | null;
if (devSel) devSel.disabled = false;
}
function _tplSizeCanvas(canvas: HTMLCanvasElement) {
const rect = canvas.parentElement!.getBoundingClientRect();
const dpr = window.devicePixelRatio || 1;
canvas.width = rect.width * dpr;
canvas.height = 200 * dpr;
canvas.style.height = '200px';
canvas.getContext('2d')!.scale(dpr, dpr);
}
function _tplRenderLoop() {
_tplRenderSpectrum();
if (testAudioTemplateModal.isOpen && _tplTestWs) {
_tplTestAnimFrame = requestAnimationFrame(_tplRenderLoop);
}
}
function _tplRenderSpectrum() {
const canvas = document.getElementById('audio-template-test-canvas') as HTMLCanvasElement | null;
if (!canvas) return;
const ctx = canvas.getContext('2d')!;
const dpr = window.devicePixelRatio || 1;
const w = canvas.width / dpr;
const h = canvas.height / dpr;
ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
ctx.clearRect(0, 0, w, h);
const data = _tplTestLatest;
if (!data || !data.spectrum) return;
const spectrum = data.spectrum;
const gap = 1;
const barWidth = (w - gap * (NUM_BANDS_TPL - 1)) / NUM_BANDS_TPL;
// Beat flash
if (data.beat) _tplTestBeatFlash = Math.min(1.0, data.beat_intensity + 0.3);
if (_tplTestBeatFlash > 0) {
ctx.fillStyle = `rgba(255, 255, 255, ${_tplTestBeatFlash * 0.08})`;
ctx.fillRect(0, 0, w, h);
_tplTestBeatFlash = Math.max(0, _tplTestBeatFlash - TPL_BEAT_FLASH_DECAY);
}
for (let i = 0; i < NUM_BANDS_TPL; i++) {
const val = Math.min(1, spectrum[i]);
const barHeight = val * h;
const x = i * (barWidth + gap);
const y = h - barHeight;
const hue = (1 - val) * 120;
ctx.fillStyle = `hsl(${hue}, 85%, 50%)`;
ctx.fillRect(x, y, barWidth, barHeight);
if (val > _tplTestPeaks[i]) {
_tplTestPeaks[i] = val;
} else {
_tplTestPeaks[i] = Math.max(0, _tplTestPeaks[i] - TPL_PEAK_DECAY);
}
const peakY = h - _tplTestPeaks[i] * h;
const peakHue = (1 - _tplTestPeaks[i]) * 120;
ctx.fillStyle = `hsl(${peakHue}, 90%, 70%)`;
ctx.fillRect(x, peakY, barWidth, 2);
}
document.getElementById('audio-template-test-rms')!.textContent = (data.rms * 100).toFixed(1) + '%';
document.getElementById('audio-template-test-peak')!.textContent = (data.peak * 100).toFixed(1) + '%';
const beatDot = document.getElementById('audio-template-test-beat-dot')!;
if (data.beat) {
beatDot.classList.add('active');
} else {
beatDot.classList.remove('active');
}
}

View File

@@ -0,0 +1,585 @@
/**
* Streams — Capture template CRUD, engine config, test modal.
* Extracted from streams.ts to reduce file size.
*/
import {
availableEngines, setAvailableEngines,
currentEditingTemplateId, setCurrentEditingTemplateId,
_templateNameManuallyEdited, set_templateNameManuallyEdited,
currentTestingTemplate, setCurrentTestingTemplate,
_cachedStreams, _cachedDisplays,
captureTemplatesCache, displaysCache,
apiKey,
} from '../core/state.ts';
import { API_BASE, getHeaders, fetchWithAuth, escapeHtml } from '../core/api.ts';
import { t } from '../core/i18n.ts';
import { Modal } from '../core/modal.ts';
import { showToast, showConfirm, showOverlaySpinner, hideOverlaySpinner, updateOverlayPreview, setupBackdropClose } from '../core/ui.ts';
import { openDisplayPicker, formatDisplayLabel } from './displays.ts';
import {
getEngineIcon,
ICON_CAPTURE_TEMPLATE,
} from '../core/icons.ts';
import * as P from '../core/icon-paths.ts';
import { TagInput } from '../core/tag-input.ts';
import { IconSelect } from '../core/icon-select.ts';
import { loadPictureSources } from './streams.ts';
const _icon = (d: string) => `<svg class="icon" viewBox="0 0 24 24">${d}</svg>`;
// ── TagInput instance for capture template modal ──
let _captureTemplateTagsInput: TagInput | null = null;
class CaptureTemplateModal extends Modal {
constructor() { super('template-modal'); }
snapshotValues() {
const vals: any = {
name: (document.getElementById('template-name') as HTMLInputElement).value,
description: (document.getElementById('template-description') as HTMLInputElement).value,
engine: (document.getElementById('template-engine') as HTMLSelectElement).value,
tags: JSON.stringify(_captureTemplateTagsInput ? _captureTemplateTagsInput.getValue() : []),
};
document.querySelectorAll('[data-config-key]').forEach((field: any) => {
vals['cfg_' + field.dataset.configKey] = field.value;
});
return vals;
}
onForceClose() {
if (_captureTemplateTagsInput) { _captureTemplateTagsInput.destroy(); _captureTemplateTagsInput = null; }
setCurrentEditingTemplateId(null);
set_templateNameManuallyEdited(false);
}
}
const templateModal = new CaptureTemplateModal();
const testTemplateModal = new Modal('test-template-modal');
// ===== Capture Templates =====
async function loadCaptureTemplates() {
try {
await captureTemplatesCache.fetch();
await loadPictureSources();
} catch (error) {
if (error.isAuth) return;
console.error('Error loading capture templates:', error);
showToast(t('streams.error.load'), 'error');
}
}
export async function showAddTemplateModal(cloneData: any = null) {
setCurrentEditingTemplateId(null);
document.getElementById('template-modal-title')!.innerHTML = `${ICON_CAPTURE_TEMPLATE} ${t('templates.add')}`;
(document.getElementById('template-form') as HTMLFormElement).reset();
(document.getElementById('template-id') as HTMLInputElement).value = '';
document.getElementById('engine-config-section')!.style.display = 'none';
document.getElementById('template-error')!.style.display = 'none';
set_templateNameManuallyEdited(!!cloneData);
(document.getElementById('template-name') as HTMLInputElement).oninput = () => { set_templateNameManuallyEdited(true); };
await loadAvailableEngines();
// Pre-fill from clone data after engines are loaded
if (cloneData) {
(document.getElementById('template-name') as HTMLInputElement).value = (cloneData.name || '') + ' (Copy)';
(document.getElementById('template-description') as HTMLInputElement).value = cloneData.description || '';
(document.getElementById('template-engine') as HTMLSelectElement).value = cloneData.engine_type;
await onEngineChange();
populateEngineConfig(cloneData.engine_config);
}
// Tags
if (_captureTemplateTagsInput) { _captureTemplateTagsInput.destroy(); _captureTemplateTagsInput = null; }
_captureTemplateTagsInput = new TagInput(document.getElementById('capture-template-tags-container'), { placeholder: t('tags.placeholder') });
_captureTemplateTagsInput.setValue(cloneData ? (cloneData.tags || []) : []);
templateModal.open();
templateModal.snapshot();
}
export async function editTemplate(templateId: any) {
try {
const response = await fetchWithAuth(`/capture-templates/${templateId}`);
if (!response.ok) throw new Error(`Failed to load template: ${response.status}`);
const template = await response.json();
setCurrentEditingTemplateId(templateId);
document.getElementById('template-modal-title')!.innerHTML = `${ICON_CAPTURE_TEMPLATE} ${t('templates.edit')}`;
(document.getElementById('template-id') as HTMLInputElement).value = templateId;
(document.getElementById('template-name') as HTMLInputElement).value = template.name;
(document.getElementById('template-description') as HTMLInputElement).value = template.description || '';
await loadAvailableEngines();
(document.getElementById('template-engine') as HTMLSelectElement).value = template.engine_type;
await onEngineChange();
populateEngineConfig(template.engine_config);
await loadDisplaysForTest();
const testResults = document.getElementById('template-test-results');
if (testResults) testResults.style.display = 'none';
document.getElementById('template-error')!.style.display = 'none';
// Tags
if (_captureTemplateTagsInput) { _captureTemplateTagsInput.destroy(); _captureTemplateTagsInput = null; }
_captureTemplateTagsInput = new TagInput(document.getElementById('capture-template-tags-container'), { placeholder: t('tags.placeholder') });
_captureTemplateTagsInput.setValue(template.tags || []);
templateModal.open();
templateModal.snapshot();
} catch (error) {
console.error('Error loading template:', error);
showToast(t('templates.error.load') + ': ' + error.message, 'error');
}
}
export async function closeTemplateModal() {
await templateModal.close();
}
export function updateCaptureDuration(value: any) {
document.getElementById('test-template-duration-value')!.textContent = value;
localStorage.setItem('capture_duration', value);
}
function restoreCaptureDuration() {
const savedDuration = localStorage.getItem('capture_duration');
if (savedDuration) {
const durationInput = document.getElementById('test-template-duration') as HTMLInputElement;
const durationValue = document.getElementById('test-template-duration-value')!;
durationInput.value = savedDuration;
durationValue.textContent = savedDuration;
}
}
export async function showTestTemplateModal(templateId: any) {
try {
const templates = await captureTemplatesCache.fetch();
const template = templates.find(tp => tp.id === templateId);
if (!template) {
showToast(t('templates.error.load'), 'error');
return;
}
setCurrentTestingTemplate(template);
await loadDisplaysForTest();
restoreCaptureDuration();
testTemplateModal.open();
setupBackdropClose((testTemplateModal as any).el, () => closeTestTemplateModal());
} catch (error) {
if (error.isAuth) return;
showToast(t('templates.error.load'), 'error');
}
}
export function closeTestTemplateModal() {
testTemplateModal.forceClose();
setCurrentTestingTemplate(null);
}
async function loadAvailableEngines() {
try {
const response = await fetchWithAuth('/capture-engines');
if (!response.ok) throw new Error(`Failed to load engines: ${response.status}`);
const data = await response.json();
setAvailableEngines(data.engines || []);
const select = document.getElementById('template-engine') as HTMLSelectElement;
select.innerHTML = '';
availableEngines.forEach((engine: any) => {
const option = document.createElement('option');
option.value = engine.type;
option.textContent = engine.name;
if (!engine.available) {
option.disabled = true;
option.textContent += ` (${t('templates.engine.unavailable')})`;
}
select.appendChild(option);
});
if (!select.value) {
const firstAvailable = availableEngines.find(e => e.available);
if (firstAvailable) select.value = firstAvailable.type;
}
// Update icon-grid selector with dynamic engine list
const items = availableEngines
.filter(e => e.available)
.map(e => ({ value: e.type, icon: getEngineIcon(e.type), label: e.name, desc: t(`templates.engine.${e.type}.desc`) }));
if (_engineIconSelect) { _engineIconSelect.updateItems(items); }
else { _engineIconSelect = new IconSelect({ target: select, items, columns: 2 }); }
_engineIconSelect.setValue(select.value);
} catch (error) {
console.error('Error loading engines:', error);
showToast(t('templates.error.engines') + ': ' + error.message, 'error');
}
}
let _engineIconSelect: IconSelect | null = null;
export async function onEngineChange() {
const engineType = (document.getElementById('template-engine') as HTMLSelectElement).value;
if (_engineIconSelect) _engineIconSelect.setValue(engineType);
const configSection = document.getElementById('engine-config-section')!;
const configFields = document.getElementById('engine-config-fields')!;
if (!engineType) { configSection.style.display = 'none'; return; }
const engine = availableEngines.find((e: any) => e.type === engineType);
if (!engine) { configSection.style.display = 'none'; return; }
if (!_templateNameManuallyEdited && !(document.getElementById('template-id') as HTMLInputElement).value) {
(document.getElementById('template-name') as HTMLInputElement).value = engine.name || engineType;
}
const hint = document.getElementById('engine-availability-hint')!;
if (!engine.available) {
hint.textContent = t('templates.engine.unavailable.hint');
hint.style.display = 'block';
hint.style.color = 'var(--error-color)';
} else {
hint.style.display = 'none';
}
configFields.innerHTML = '';
const defaultConfig = engine.default_config || {};
// Known select options for specific config keys
const CONFIG_SELECT_OPTIONS = {
camera_backend: ['auto', 'dshow', 'msmf', 'v4l2'],
};
// IconSelect definitions for specific config keys
const CONFIG_ICON_SELECT = {
camera_backend: {
columns: 2,
items: [
{ value: 'auto', icon: _icon(P.refreshCw), label: 'Auto', desc: t('templates.config.camera_backend.auto') },
{ value: 'dshow', icon: _icon(P.camera), label: 'DShow', desc: t('templates.config.camera_backend.dshow') },
{ value: 'msmf', icon: _icon(P.film), label: 'MSMF', desc: t('templates.config.camera_backend.msmf') },
{ value: 'v4l2', icon: _icon(P.monitor), label: 'V4L2', desc: t('templates.config.camera_backend.v4l2') },
],
},
};
if (Object.keys(defaultConfig).length === 0) {
configSection.style.display = 'none';
return;
} else {
let gridHtml = '<div class="config-grid">';
Object.entries(defaultConfig).forEach(([key, value]) => {
const fieldType = typeof value === 'number' ? 'number' : 'text';
const fieldValue = typeof value === 'boolean' ? (value ? 'true' : 'false') : value;
const selectOptions = CONFIG_SELECT_OPTIONS[key];
gridHtml += `
<label class="config-grid-label" for="config-${key}">${key}</label>
<div class="config-grid-value">
${typeof value === 'boolean' ? `
<select id="config-${key}" data-config-key="${key}">
<option value="true" ${value ? 'selected' : ''}>true</option>
<option value="false" ${!value ? 'selected' : ''}>false</option>
</select>
` : selectOptions ? `
<select id="config-${key}" data-config-key="${key}">
${selectOptions.map(opt => `<option value="${opt}" ${opt === String(value) ? 'selected' : ''}>${opt}</option>`).join('')}
</select>
` : `
<input type="${fieldType}" id="config-${key}" data-config-key="${key}" value="${fieldValue}">
`}
</div>
`;
});
gridHtml += '</div>';
configFields.innerHTML = gridHtml;
// Apply IconSelect to known config selects
for (const [key, cfg] of Object.entries(CONFIG_ICON_SELECT)) {
const sel = document.getElementById(`config-${key}`);
if (sel) new IconSelect({ target: sel as HTMLSelectElement, items: cfg.items, columns: cfg.columns });
}
}
configSection.style.display = 'block';
}
function populateEngineConfig(config: any) {
Object.entries(config).forEach(([key, value]: [string, any]) => {
const field = document.getElementById(`config-${key}`) as HTMLInputElement | HTMLSelectElement | null;
if (field) {
if (field.tagName === 'SELECT') {
field.value = value.toString();
} else {
field.value = value;
}
}
});
}
function collectEngineConfig() {
const config: any = {};
const fields = document.querySelectorAll('[data-config-key]');
fields.forEach((field: any) => {
const key = field.dataset.configKey;
let value: any = field.value;
if (field.type === 'number') {
value = parseFloat(value);
} else if (field.tagName === 'SELECT' && (value === 'true' || value === 'false')) {
value = value === 'true';
}
config[key] = value;
});
return config;
}
async function loadDisplaysForTest() {
try {
// Use engine-specific display list for engines with own devices (camera, scrcpy)
const engineType = currentTestingTemplate?.engine_type;
const engineHasOwnDisplays = availableEngines.find(e => e.type === engineType)?.has_own_displays || false;
const url = engineHasOwnDisplays
? `/config/displays?engine_type=${engineType}`
: '/config/displays';
// Always refetch for engines with own displays (devices may change); use cache for desktop
if (!_cachedDisplays || engineHasOwnDisplays) {
const response = await fetchWithAuth(url);
if (!response.ok) throw new Error(`Failed to load displays: ${response.status}`);
const displaysData = await response.json();
displaysCache.update(displaysData.displays || []);
}
let selectedIndex: number | null = null;
const lastDisplay = localStorage.getItem('lastTestDisplayIndex');
if (lastDisplay !== null && _cachedDisplays) {
const found = _cachedDisplays.find(d => d.index === parseInt(lastDisplay));
if (found) selectedIndex = found.index;
}
if (selectedIndex === null && _cachedDisplays) {
const primary = _cachedDisplays.find(d => d.is_primary);
if (primary) selectedIndex = primary.index;
else if (_cachedDisplays.length > 0) selectedIndex = _cachedDisplays[0].index;
}
if (selectedIndex !== null && _cachedDisplays) {
const display = _cachedDisplays.find(d => d.index === selectedIndex);
(window as any).onTestDisplaySelected(selectedIndex, display);
}
} catch (error) {
console.error('Error loading displays:', error);
}
}
export function runTemplateTest() {
if (!currentTestingTemplate) {
showToast(t('templates.test.error.no_engine'), 'error');
return;
}
const displayIndex = (document.getElementById('test-template-display') as HTMLSelectElement).value;
const captureDuration = parseFloat((document.getElementById('test-template-duration') as HTMLInputElement).value);
if (displayIndex === '') {
showToast(t('templates.test.error.no_display'), 'error');
return;
}
const template = currentTestingTemplate;
localStorage.setItem('lastTestDisplayIndex', displayIndex);
const previewWidth = Math.round(Math.min(window.innerWidth * 0.8, 1920) * Math.min(window.devicePixelRatio || 1, 2));
_runTestViaWS(
'/capture-templates/test/ws',
{},
{
engine_type: template.engine_type,
engine_config: template.engine_config,
display_index: parseInt(displayIndex),
capture_duration: captureDuration,
preview_width: previewWidth,
},
captureDuration,
);
}
function buildTestStatsHtml(result: any) {
// Support both REST format (nested) and WS format (flat)
const p = result.performance || result;
const duration = p.capture_duration_s ?? p.elapsed_s ?? 0;
const frameCount = p.frame_count ?? 0;
const fps = p.actual_fps ?? p.fps ?? 0;
const avgMs = p.avg_capture_time_ms ?? p.avg_capture_ms ?? 0;
const w = result.full_capture?.width ?? result.width ?? 0;
const h = result.full_capture?.height ?? result.height ?? 0;
const res = `${w}x${h}`;
let html = `
<div class="stat-item"><span>${t('templates.test.results.duration')}:</span> <strong>${Number(duration).toFixed(2)}s</strong></div>
<div class="stat-item"><span>${t('templates.test.results.frame_count')}:</span> <strong>${frameCount}</strong></div>`;
if (frameCount > 1) {
html += `
<div class="stat-item"><span>${t('templates.test.results.actual_fps')}:</span> <strong>${Number(fps).toFixed(1)}</strong></div>
<div class="stat-item"><span>${t('templates.test.results.avg_capture_time')}:</span> <strong>${Number(avgMs).toFixed(1)}ms</strong></div>`;
}
html += `
<div class="stat-item"><span>${t('templates.test.results.resolution')}</span> <strong>${res}</strong></div>`;
return html;
}
// ===== Shared WebSocket test helper =====
/**
* Run a capture test via WebSocket, streaming intermediate previews into
* the overlay spinner and opening the lightbox with the final result.
*
* @param {string} wsPath Relative WS path (e.g. '/picture-sources/{id}/test/ws')
* @param {Object} queryParams Extra query params (duration, source_stream_id, etc.)
* @param {Object|null} firstMessage If non-null, sent as JSON after WS opens (for template test)
* @param {number} duration Test duration for overlay progress ring
*/
export function _runTestViaWS(wsPath: string, queryParams: any = {}, firstMessage: any = null, duration = 5) {
const protocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:';
// Dynamic preview resolution: 80% of viewport width, scaled by DPR, capped at 1920px
const previewWidth = Math.round(Math.min(window.innerWidth * 0.8, 1920) * Math.min(window.devicePixelRatio || 1, 2));
const params = new URLSearchParams({ token: apiKey, preview_width: String(previewWidth), ...queryParams });
const wsUrl = `${protocol}//${window.location.host}${API_BASE}${wsPath}?${params}`;
showOverlaySpinner(t('streams.test.running'), duration);
let gotResult = false;
let ws;
try {
ws = new WebSocket(wsUrl);
} catch (e) {
hideOverlaySpinner();
showToast(t('streams.test.error.failed') + ': ' + e.message, 'error');
return;
}
// Close WS when user cancels overlay
const patchCloseBtn = () => {
const closeBtn = document.querySelector('.overlay-spinner-close') as HTMLElement | null;
if (closeBtn) {
const origHandler = closeBtn.onclick;
closeBtn.onclick = () => {
if (ws.readyState <= WebSocket.OPEN) ws.close();
if (origHandler) (origHandler as any)();
};
}
};
patchCloseBtn();
// Also close on ESC (overlay ESC handler calls hideOverlaySpinner which aborts)
const origAbort = window._overlayAbortController;
if (origAbort) {
origAbort.signal.addEventListener('abort', () => {
if (ws.readyState <= WebSocket.OPEN) ws.close();
}, { once: true });
}
ws.onopen = () => {
if (firstMessage) {
ws.send(JSON.stringify(firstMessage));
}
};
ws.onmessage = (event) => {
try {
const msg = JSON.parse(event.data);
if (msg.type === 'frame') {
updateOverlayPreview(msg.thumbnail, msg);
} else if (msg.type === 'result') {
gotResult = true;
hideOverlaySpinner();
(window as any).openLightbox(msg.full_image, buildTestStatsHtml(msg));
ws.close();
} else if (msg.type === 'error') {
hideOverlaySpinner();
showToast(msg.detail || 'Test failed', 'error');
ws.close();
}
} catch (e) {
console.error('Error parsing test WS message:', e);
}
};
ws.onerror = () => {
if (!gotResult) {
hideOverlaySpinner();
showToast(t('streams.test.error.failed'), 'error');
}
};
ws.onclose = () => {
if (!gotResult) {
hideOverlaySpinner();
}
};
}
export async function saveTemplate() {
const templateId = (document.getElementById('template-id') as HTMLInputElement).value;
const name = (document.getElementById('template-name') as HTMLInputElement).value.trim();
const engineType = (document.getElementById('template-engine') as HTMLSelectElement).value;
if (!name || !engineType) {
showToast(t('templates.error.required'), 'error');
return;
}
const description = (document.getElementById('template-description') as HTMLInputElement).value.trim();
const engineConfig = collectEngineConfig();
const payload = { name, engine_type: engineType, engine_config: engineConfig, description: description || null, tags: _captureTemplateTagsInput ? _captureTemplateTagsInput.getValue() : [] };
try {
let response;
if (templateId) {
response = await fetchWithAuth(`/capture-templates/${templateId}`, { method: 'PUT', body: JSON.stringify(payload) });
} else {
response = await fetchWithAuth('/capture-templates', { method: 'POST', body: JSON.stringify(payload) });
}
if (!response.ok) {
const error = await response.json();
throw new Error(error.detail || error.message || 'Failed to save template');
}
showToast(templateId ? t('templates.updated') : t('templates.created'), 'success');
templateModal.forceClose();
captureTemplatesCache.invalidate();
await loadCaptureTemplates();
} catch (error) {
console.error('Error saving template:', error);
document.getElementById('template-error')!.textContent = (error as any).message;
document.getElementById('template-error')!.style.display = 'block';
}
}
export async function deleteTemplate(templateId: any) {
const confirmed = await showConfirm(t('templates.delete.confirm'));
if (!confirmed) return;
try {
const response = await fetchWithAuth(`/capture-templates/${templateId}`, { method: 'DELETE' });
if (!response.ok) {
const error = await response.json();
throw new Error(error.detail || error.message || 'Failed to delete template');
}
showToast(t('templates.deleted'), 'success');
captureTemplatesCache.invalidate();
await loadCaptureTemplates();
} catch (error) {
console.error('Error deleting template:', error);
showToast(t('templates.error.delete') + ': ' + error.message, 'error');
}
}

File diff suppressed because it is too large Load Diff

View File

@@ -208,9 +208,7 @@ function _formatElapsed(seconds: number): string {
export function createSyncClockCard(clock: SyncClock) {
const statusIcon = clock.is_running ? ICON_START : ICON_PAUSE;
const statusLabel = clock.is_running ? t('sync_clock.status.running') : t('sync_clock.status.paused');
const toggleAction = clock.is_running
? `pauseSyncClock('${clock.id}')`
: `resumeSyncClock('${clock.id}')`;
const toggleAction = clock.is_running ? 'pause' : 'resume';
const toggleTitle = clock.is_running ? t('sync_clock.action.pause') : t('sync_clock.action.resume');
const elapsedLabel = clock.elapsed_time != null ? _formatElapsed(clock.elapsed_time) : null;
@@ -232,14 +230,46 @@ export function createSyncClockCard(clock: SyncClock) {
${renderTagChips(clock.tags)}
${clock.description ? `<div class="template-config" style="opacity:0.7;">${escapeHtml(clock.description)}</div>` : ''}`,
actions: `
<button class="btn btn-icon btn-secondary" onclick="event.stopPropagation(); ${toggleAction}" title="${toggleTitle}">${clock.is_running ? ICON_PAUSE : ICON_START}</button>
<button class="btn btn-icon btn-secondary" onclick="event.stopPropagation(); resetSyncClock('${clock.id}')" title="${t('sync_clock.action.reset')}">${ICON_CLOCK}</button>
<button class="btn btn-icon btn-secondary" onclick="cloneSyncClock('${clock.id}')" title="${t('common.clone')}">${ICON_CLONE}</button>
<button class="btn btn-icon btn-secondary" onclick="editSyncClock('${clock.id}')" title="${t('common.edit')}">${ICON_EDIT}</button>`,
<button class="btn btn-icon btn-secondary" data-action="${toggleAction}" data-id="${clock.id}" title="${toggleTitle}">${clock.is_running ? ICON_PAUSE : ICON_START}</button>
<button class="btn btn-icon btn-secondary" data-action="reset" data-id="${clock.id}" title="${t('sync_clock.action.reset')}">${ICON_CLOCK}</button>
<button class="btn btn-icon btn-secondary" data-action="clone" data-id="${clock.id}" title="${t('common.clone')}">${ICON_CLONE}</button>
<button class="btn btn-icon btn-secondary" data-action="edit" data-id="${clock.id}" title="${t('common.edit')}">${ICON_EDIT}</button>`,
});
}
// ── Expose to global scope for inline onclick handlers ──
// ── Event delegation for sync-clock card actions ──
const _syncClockActions: Record<string, (id: string) => void> = {
pause: pauseSyncClock,
resume: resumeSyncClock,
reset: resetSyncClock,
clone: cloneSyncClock,
edit: editSyncClock,
};
export function initSyncClockDelegation(container: HTMLElement): void {
container.addEventListener('click', (e: MouseEvent) => {
const btn = (e.target as HTMLElement).closest<HTMLElement>('[data-action]');
if (!btn) return;
// Only handle actions within a sync-clock card (data-id on card root)
const card = btn.closest<HTMLElement>('[data-id]');
const section = btn.closest<HTMLElement>('[data-card-section="sync-clocks"]');
if (!card || !section) return;
const action = btn.dataset.action;
const id = btn.dataset.id;
if (!action || !id) return;
const handler = _syncClockActions[action];
if (handler) {
e.stopPropagation();
handler(id);
}
});
}
// ── Expose to global scope for HTML template onclick handlers & graph-editor ──
window.showSyncClockModal = showSyncClockModal;
window.closeSyncClockModal = closeSyncClockModal;

View File

@@ -115,9 +115,9 @@ document.addEventListener('languageChanged', () => {
// --- FPS sparkline history and chart instances for target cards ---
const _TARGET_MAX_FPS_SAMPLES = 30;
const _targetFpsHistory = {}; // fps_actual (rolling avg)
const _targetFpsCurrentHistory = {}; // fps_current (sends/sec)
const _targetFpsCharts = {};
const _targetFpsHistory: Record<string, number[]> = {}; // fps_actual (rolling avg)
const _targetFpsCurrentHistory: Record<string, number[]> = {}; // fps_current (sends/sec)
const _targetFpsCharts: Record<string, any> = {};
function _pushTargetFps(targetId: any, actual: any, current: any) {
if (!_targetFpsHistory[targetId]) _targetFpsHistory[targetId] = [];
@@ -154,7 +154,7 @@ function _updateTargetFpsChart(targetId: any, fpsTarget: any) {
}
// --- Editor state ---
let _editorCssSources = []; // populated when editor opens
let _editorCssSources: any[] = []; // populated when editor opens
let _targetTagsInput: TagInput | null = null;
class TargetEditorModal extends Modal {
@@ -343,12 +343,12 @@ function _ensureProtocolIconSelect() {
_protocolIconSelect = new IconSelect({ target: sel as HTMLSelectElement, items, columns: 2 });
}
export async function showTargetEditor(targetId = null, cloneData = null) {
export async function showTargetEditor(targetId: string | null = null, cloneData: any = null) {
try {
// Load devices, CSS sources, and value sources for dropdowns
const [devices, cssSources] = await Promise.all([
devicesCache.fetch().catch(() => []),
colorStripSourcesCache.fetch().catch(() => []),
devicesCache.fetch().catch((): any[] => []),
colorStripSourcesCache.fetch().catch((): any[] => []),
valueSourcesCache.fetch(),
]);
@@ -368,7 +368,7 @@ export async function showTargetEditor(targetId = null, cloneData = null) {
deviceSelect.appendChild(opt);
});
let _editorTags = [];
let _editorTags: string[] = [];
if (targetId) {
// Editing existing target
const resp = await fetch(`${API_BASE}/output-targets/${targetId}`, { headers: getHeaders() });
@@ -598,14 +598,14 @@ export async function loadTargetsTab() {
try {
// Fetch all entities via DataCache
const [devices, targets, cssArr, patternTemplates, psArr, valueSrcArr, asSrcArr] = await Promise.all([
devicesCache.fetch().catch(() => []),
outputTargetsCache.fetch().catch(() => []),
colorStripSourcesCache.fetch().catch(() => []),
patternTemplatesCache.fetch().catch(() => []),
streamsCache.fetch().catch(() => []),
valueSourcesCache.fetch().catch(() => []),
audioSourcesCache.fetch().catch(() => []),
syncClocksCache.fetch().catch(() => []),
devicesCache.fetch().catch((): any[] => []),
outputTargetsCache.fetch().catch((): any[] => []),
colorStripSourcesCache.fetch().catch((): any[] => []),
patternTemplatesCache.fetch().catch((): any[] => []),
streamsCache.fetch().catch((): any[] => []),
valueSourcesCache.fetch().catch((): any[] => []),
audioSourcesCache.fetch().catch((): any[] => []),
syncClocksCache.fetch().catch((): any[] => []),
]);
const colorStripSourceMap = {};
@@ -698,7 +698,7 @@ export async function loadTargetsTab() {
const patternItems = csPatternTemplates.applySortOrder(patternTemplates.map(pt => ({ key: pt.id, html: createPatternTemplateCard(pt) })));
// Track which target cards were replaced/added (need chart re-init)
let changedTargetIds = null;
let changedTargetIds: Set<string> | null = null;
if (csDevices.isMounted()) {
// ── Incremental update: reconcile cards in-place ──
@@ -760,13 +760,13 @@ export async function loadTargetsTab() {
if ((device.capabilities || []).includes('brightness_control')) {
if (device.id in _deviceBrightnessCache) {
const bri = _deviceBrightnessCache[device.id];
const slider = document.querySelector(`[data-device-brightness="${device.id}"]`) as HTMLInputElement | null;
const slider = document.querySelector(`[data-device-brightness="${CSS.escape(device.id)}"]`) as HTMLInputElement | null;
if (slider) {
slider.value = String(bri);
slider.title = Math.round(bri / 255 * 100) + '%';
slider.disabled = false;
}
const wrap = document.querySelector(`[data-brightness-wrap="${device.id}"]`);
const wrap = document.querySelector(`[data-brightness-wrap="${CSS.escape(device.id)}"]`);
if (wrap) wrap.classList.remove('brightness-loading');
} else {
fetchDeviceBrightness(device.id);
@@ -780,7 +780,7 @@ export async function loadTargetsTab() {
// Patch "Last seen" labels in-place (avoids full card re-render on relative time changes)
for (const device of devicesWithState) {
const el = container.querySelector(`[data-last-seen="${device.id}"]`) as HTMLElement | null;
const el = container.querySelector(`[data-last-seen="${CSS.escape(device.id)}"]`) as HTMLElement | null;
if (el) {
const ts = device.state?.device_last_checked;
const label = ts ? formatRelativeTime(ts) : null;
@@ -918,7 +918,7 @@ function _buildLedTimingHTML(state: any) {
function _patchTargetMetrics(target: any) {
const container = document.getElementById('targets-panel-content');
if (!container) return;
const card = container.querySelector(`[data-target-id="${target.id}"]`);
const card = container.querySelector(`[data-target-id="${CSS.escape(target.id)}"]`);
if (!card) return;
const state = target.state || {};
const metrics = target.metrics || {};
@@ -1141,7 +1141,7 @@ export async function stopAllKCTargets() {
async function _stopAllByType(targetType: any) {
try {
const [allTargets, statesResp] = await Promise.all([
outputTargetsCache.fetch().catch(() => []),
outputTargetsCache.fetch().catch((): any[] => []),
fetchWithAuth('/output-targets/batch/states'),
]);
const statesData = statesResp.ok ? await statesResp.json() : { states: {} };
@@ -1339,7 +1339,7 @@ function _renderLedStrip(canvas: any, rgbBytes: any) {
canvas.width = ledCount;
canvas.height = 1;
const ctx = canvas.getContext('2d');
const ctx = canvas.getContext('2d')!;
const imageData = ctx.createImageData(ledCount, 1);
const data = imageData.data;
@@ -1447,7 +1447,7 @@ function connectLedPreviewWS(targetId: any) {
}
function _setPreviewButtonState(targetId: any, active: boolean) {
const btn = document.querySelector(`[data-led-preview-btn="${targetId}"]`);
const btn = document.querySelector(`[data-led-preview-btn="${CSS.escape(targetId)}"]`);
if (btn) {
btn.classList.toggle('btn-warning', active);
btn.classList.toggle('btn-secondary', !active);

View File

@@ -353,7 +353,7 @@ function showTutorialStep(index: number, direction: number = 1): void {
if (needsScroll) {
// Hide tooltip while scrolling to prevent stale position flash
const tt = overlay.querySelector('.tutorial-tooltip');
const tt = overlay.querySelector('.tutorial-tooltip') as HTMLElement | null;
if (tt) tt.style.visibility = 'hidden';
target.scrollIntoView({ behavior: 'smooth', block: 'center' });
_waitForScrollEnd().then(() => {

View File

@@ -152,7 +152,7 @@ function _drawWaveformPreview(waveformType: any) {
canvas.height = cssH * dpr;
canvas.style.height = cssH + 'px';
const ctx = canvas.getContext('2d');
const ctx = canvas.getContext('2d')!;
ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
ctx.clearRect(0, 0, cssW, cssH);
@@ -552,7 +552,7 @@ const VS_HISTORY_SIZE = 200;
let _testVsWs: WebSocket | null = null;
let _testVsAnimFrame: number | null = null;
let _testVsLatest: any = null;
let _testVsHistory = [];
let _testVsHistory: number[] = [];
let _testVsMinObserved = Infinity;
let _testVsMaxObserved = -Infinity;
@@ -602,7 +602,10 @@ export function testValueSource(sourceId: any) {
}
if (data.value < _testVsMinObserved) _testVsMinObserved = data.value;
if (data.value > _testVsMaxObserved) _testVsMaxObserved = data.value;
} catch {}
} catch (e) {
console.error('Value source test WS parse error:', e);
return;
}
};
_testVsWs.onclose = () => {
@@ -647,7 +650,7 @@ function _sizeVsCanvas(canvas: HTMLCanvasElement) {
canvas.width = rect.width * dpr;
canvas.height = 200 * dpr;
canvas.style.height = '200px';
canvas.getContext('2d').scale(dpr, dpr);
canvas.getContext('2d')!.scale(dpr, dpr);
}
function _renderVsTestLoop() {
@@ -661,7 +664,7 @@ function _renderVsChart() {
const canvas = document.getElementById('vs-test-canvas') as HTMLCanvasElement;
if (!canvas) return;
const ctx = canvas.getContext('2d');
const ctx = canvas.getContext('2d')!;
const dpr = window.devicePixelRatio || 1;
const w = canvas.width / dpr;
const h = canvas.height / dpr;
@@ -885,7 +888,7 @@ export function addSchedulePoint(time: string = '', value: number = 1.0) {
function _getScheduleFromUI() {
const rows = document.querySelectorAll('#value-source-schedule-list .schedule-row');
const schedule = [];
const schedule: { time: string; value: number }[] = [];
rows.forEach(row => {
const time = (row.querySelector('.schedule-time') as HTMLInputElement).value;
const value = parseFloat((row.querySelector('.schedule-value') as HTMLInputElement).value);