From fb1086b309dd2c83431389436fc81a38d81ca1d5 Mon Sep 17 00:00:00 2001 From: "alexei.dolgolyov" Date: Wed, 18 Feb 2026 17:15:00 +0300 Subject: [PATCH] Split monolithic app.js into native ES modules MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Replace the single 7034-line app.js with 17 ES module files organized into core/ (state, api, i18n, ui) and features/ (calibration, dashboard, device-discovery, devices, displays, kc-targets, pattern-templates, profiles, streams, tabs, targets, tutorials) with an app.js entry point that registers ~90 onclick globals on window. No bundler needed — FastAPI serves modules directly via + diff --git a/server/src/wled_controller/static/js/app.js b/server/src/wled_controller/static/js/app.js new file mode 100644 index 0000000..02b6c0b --- /dev/null +++ b/server/src/wled_controller/static/js/app.js @@ -0,0 +1,347 @@ +/** + * Entry point — imports all modules, registers globals, initializes app. + */ + +// Layer 0: state +import { apiKey, setApiKey, refreshInterval } from './core/state.js'; + +// Layer 1: api, i18n +import { loadServerInfo, loadDisplays, configureApiKey } from './core/api.js'; +import { t, initLocale, changeLocale } from './core/i18n.js'; + +// Layer 2: ui +import { + toggleHint, lockBody, unlockBody, closeLightbox, + showToast, showConfirm, closeConfirmModal, + openFullImageLightbox, showOverlaySpinner, hideOverlaySpinner, +} from './core/ui.js'; + +// Layer 3: displays, tutorials +import { + openDisplayPicker, closeDisplayPicker, selectDisplay, formatDisplayLabel, +} from './features/displays.js'; +import { + startCalibrationTutorial, startDeviceTutorial, + closeTutorial, tutorialNext, tutorialPrev, +} from './features/tutorials.js'; + +// Layer 4: devices, dashboard, streams, kc-targets, pattern-templates, profiles +import { + showSettings, closeDeviceSettingsModal, forceCloseDeviceSettingsModal, + saveDeviceSettings, updateBrightnessLabel, saveCardBrightness, + saveDeviceStaticColor, clearDeviceStaticColor, + toggleDevicePower, removeDevice, loadDevices, + updateSettingsBaudFpsHint, +} from './features/devices.js'; +import { + loadDashboard, startDashboardWS, stopDashboardWS, + dashboardToggleProfile, dashboardStartTarget, dashboardStopTarget, dashboardStopAll, +} from './features/dashboard.js'; +import { + loadPictureSources, switchStreamTab, + showAddTemplateModal, editTemplate, closeTemplateModal, saveTemplate, deleteTemplate, + showTestTemplateModal, closeTestTemplateModal, onEngineChange, runTemplateTest, + showAddStreamModal, editStream, closeStreamModal, saveStream, deleteStream, + onStreamTypeChange, onStreamDisplaySelected, onTestDisplaySelected, + showTestStreamModal, closeTestStreamModal, updateStreamTestDuration, runStreamTest, + showTestPPTemplateModal, closeTestPPTemplateModal, updatePPTestDuration, runPPTemplateTest, + showAddPPTemplateModal, editPPTemplate, closePPTemplateModal, savePPTemplate, deletePPTemplate, + addFilterFromSelect, toggleFilterExpand, removeFilter, moveFilter, updateFilterOption, + renderModalFilterList, updateCaptureDuration, +} from './features/streams.js'; +import { + createKCTargetCard, testKCTarget, toggleKCTestAutoRefresh, + showKCEditor, closeKCEditorModal, forceCloseKCEditorModal, saveKCEditor, + deleteKCTarget, disconnectAllKCWebSockets, +} from './features/kc-targets.js'; +import { + createPatternTemplateCard, + showPatternTemplateEditor, closePatternTemplateModal, forceClosePatternTemplateModal, + savePatternTemplate, deletePatternTemplate, + renderPatternRectList, selectPatternRect, updatePatternRect, + addPatternRect, deleteSelectedPatternRect, removePatternRect, + capturePatternBackground, +} from './features/pattern-templates.js'; +import { + loadProfiles, openProfileEditor, closeProfileEditorModal, + saveProfileEditor, addProfileCondition, + toggleProfileEnabled, deleteProfile, +} from './features/profiles.js'; + +// Layer 5: device-discovery, targets +import { + onDeviceTypeChanged, updateBaudFpsHint, onSerialPortFocus, + showAddDevice, closeAddDeviceModal, scanForDevices, handleAddDevice, +} from './features/device-discovery.js'; +import { + loadTargetsTab, loadTargets, switchTargetSubTab, + showTargetEditor, closeTargetEditorModal, forceCloseTargetEditorModal, saveTargetEditor, + startTargetProcessing, stopTargetProcessing, + startTargetOverlay, stopTargetOverlay, deleteTarget, +} from './features/targets.js'; + +// Layer 5: calibration +import { + showCalibration, closeCalibrationModal, forceCloseCalibrationModal, saveCalibration, + updateOffsetSkipLock, updateCalibrationPreview, + setStartPosition, toggleEdgeInputs, toggleDirection, toggleTestEdge, +} from './features/calibration.js'; + +// Layer 6: tabs +import { switchTab, initTabs, startAutoRefresh } from './features/tabs.js'; + +// ─── Register all HTML onclick / onchange / onfocus globals ─── + +Object.assign(window, { + // core / state (for inline script) + setApiKey, + + // core / ui + toggleHint, + lockBody, + unlockBody, + closeLightbox, + showToast, + showConfirm, + closeConfirmModal, + openFullImageLightbox, + showOverlaySpinner, + hideOverlaySpinner, + + // core / api + i18n + t, + configureApiKey, + loadServerInfo, + loadDisplays, + changeLocale, + + // displays + openDisplayPicker, + closeDisplayPicker, + selectDisplay, + formatDisplayLabel, + + // tutorials + startCalibrationTutorial, + startDeviceTutorial, + closeTutorial, + tutorialNext, + tutorialPrev, + + // devices + showSettings, + closeDeviceSettingsModal, + forceCloseDeviceSettingsModal, + saveDeviceSettings, + updateBrightnessLabel, + saveCardBrightness, + saveDeviceStaticColor, + clearDeviceStaticColor, + toggleDevicePower, + removeDevice, + loadDevices, + updateSettingsBaudFpsHint, + + // dashboard + loadDashboard, + startDashboardWS, + stopDashboardWS, + dashboardToggleProfile, + dashboardStartTarget, + dashboardStopTarget, + dashboardStopAll, + + // streams / capture templates / PP templates + loadPictureSources, + switchStreamTab, + showAddTemplateModal, + editTemplate, + closeTemplateModal, + saveTemplate, + deleteTemplate, + showTestTemplateModal, + closeTestTemplateModal, + onEngineChange, + runTemplateTest, + updateCaptureDuration, + showAddStreamModal, + editStream, + closeStreamModal, + saveStream, + deleteStream, + onStreamTypeChange, + onStreamDisplaySelected, + onTestDisplaySelected, + showTestStreamModal, + closeTestStreamModal, + updateStreamTestDuration, + runStreamTest, + showTestPPTemplateModal, + closeTestPPTemplateModal, + updatePPTestDuration, + runPPTemplateTest, + showAddPPTemplateModal, + editPPTemplate, + closePPTemplateModal, + savePPTemplate, + deletePPTemplate, + addFilterFromSelect, + toggleFilterExpand, + removeFilter, + moveFilter, + updateFilterOption, + renderModalFilterList, + + // kc-targets + createKCTargetCard, + testKCTarget, + toggleKCTestAutoRefresh, + showKCEditor, + closeKCEditorModal, + forceCloseKCEditorModal, + saveKCEditor, + deleteKCTarget, + disconnectAllKCWebSockets, + + // pattern-templates + createPatternTemplateCard, + showPatternTemplateEditor, + closePatternTemplateModal, + forceClosePatternTemplateModal, + savePatternTemplate, + deletePatternTemplate, + renderPatternRectList, + selectPatternRect, + updatePatternRect, + addPatternRect, + deleteSelectedPatternRect, + removePatternRect, + capturePatternBackground, + + // profiles + loadProfiles, + openProfileEditor, + closeProfileEditorModal, + saveProfileEditor, + addProfileCondition, + toggleProfileEnabled, + deleteProfile, + + // device-discovery + onDeviceTypeChanged, + updateBaudFpsHint, + onSerialPortFocus, + showAddDevice, + closeAddDeviceModal, + scanForDevices, + handleAddDevice, + + // targets + loadTargetsTab, + loadTargets, + switchTargetSubTab, + showTargetEditor, + closeTargetEditorModal, + forceCloseTargetEditorModal, + saveTargetEditor, + startTargetProcessing, + stopTargetProcessing, + startTargetOverlay, + stopTargetOverlay, + deleteTarget, + + // calibration + showCalibration, + closeCalibrationModal, + forceCloseCalibrationModal, + saveCalibration, + updateOffsetSkipLock, + updateCalibrationPreview, + setStartPosition, + toggleEdgeInputs, + toggleDirection, + toggleTestEdge, + + // tabs + switchTab, + startAutoRefresh, +}); + +// ─── Global Escape key handler ─── + +document.addEventListener('keydown', (e) => { + if (e.key === 'Escape') { + // Close in order: overlay lightboxes first, then modals + if (document.getElementById('display-picker-lightbox').classList.contains('active')) { + closeDisplayPicker(); + } else if (document.getElementById('image-lightbox').classList.contains('active')) { + closeLightbox(); + } else { + const modals = [ + { id: 'test-pp-template-modal', close: closeTestPPTemplateModal }, + { id: 'test-stream-modal', close: closeTestStreamModal }, + { id: 'test-template-modal', close: closeTestTemplateModal }, + { id: 'stream-modal', close: closeStreamModal }, + { id: 'pp-template-modal', close: closePPTemplateModal }, + { id: 'template-modal', close: closeTemplateModal }, + { id: 'device-settings-modal', close: forceCloseDeviceSettingsModal }, + { id: 'calibration-modal', close: forceCloseCalibrationModal }, + { id: 'target-editor-modal', close: forceCloseTargetEditorModal }, + { id: 'add-device-modal', close: closeAddDeviceModal }, + ]; + for (const m of modals) { + const el = document.getElementById(m.id); + if (el && el.style.display === 'flex') { + m.close(); + break; + } + } + } + } +}); + +// ─── Cleanup on page unload ─── + +window.addEventListener('beforeunload', () => { + if (refreshInterval) { + clearInterval(refreshInterval); + } + disconnectAllKCWebSockets(); +}); + +// ─── Initialization ─── + +document.addEventListener('DOMContentLoaded', async () => { + // Initialize locale first + await initLocale(); + + // Load API key from localStorage + setApiKey(localStorage.getItem('wled_api_key')); + + // Restore active tab before showing content to avoid visible jump + initTabs(); + + // Show content now that translations are loaded and tabs are set + document.body.style.visibility = 'visible'; + + // Setup form handler + document.getElementById('add-device-form').addEventListener('submit', handleAddDevice); + + // Show modal if no API key is stored + if (!apiKey) { + setTimeout(() => { + if (typeof window.showApiKeyModal === 'function') { + window.showApiKeyModal('Welcome! Please login with your API key to get started.', true); + } + }, 100); + return; + } + + // User is logged in, load data + loadServerInfo(); + loadDisplays(); + loadTargetsTab(); + + // Start auto-refresh + startAutoRefresh(); +}); diff --git a/server/src/wled_controller/static/js/core/api.js b/server/src/wled_controller/static/js/core/api.js new file mode 100644 index 0000000..b755c4c --- /dev/null +++ b/server/src/wled_controller/static/js/core/api.js @@ -0,0 +1,128 @@ +/** + * API utilities — base URL, auth headers, fetch wrapper, helpers. + */ + +import { apiKey, setApiKey, refreshInterval, setRefreshInterval } from './state.js'; + +export const API_BASE = '/api/v1'; + +export function getHeaders() { + const headers = { + 'Content-Type': 'application/json' + }; + if (apiKey) { + headers['Authorization'] = `Bearer ${apiKey}`; + } + return headers; +} + +export async function fetchWithAuth(url, options = {}) { + const fullUrl = url.startsWith('http') ? url : `${API_BASE}${url}`; + const headers = options.headers + ? { ...getHeaders(), ...options.headers } + : getHeaders(); + return fetch(fullUrl, { ...options, headers }); +} + +export function escapeHtml(text) { + if (!text) return ''; + const div = document.createElement('div'); + div.textContent = text; + return div.innerHTML; +} + +export function isSerialDevice(type) { + return type === 'adalight' || type === 'ambiled'; +} + +export function handle401Error() { + localStorage.removeItem('wled_api_key'); + setApiKey(null); + + if (refreshInterval) { + clearInterval(refreshInterval); + setRefreshInterval(null); + } + + if (typeof window.updateAuthUI === 'function') { + window.updateAuthUI(); + } + + if (typeof window.showApiKeyModal === 'function') { + window.showApiKeyModal('Your session has expired or the API key is invalid. Please login again.', true); + } else { + // showToast imported at call sites to avoid circular dep + import('./state.js').then(() => { + const toast = document.getElementById('toast'); + if (toast) { + toast.textContent = 'Authentication failed. Please reload the page and login.'; + toast.className = 'toast error show'; + setTimeout(() => { toast.className = 'toast'; }, 3000); + } + }); + } +} + +export async function loadServerInfo() { + try { + const response = await fetch('/health'); + const data = await response.json(); + + document.getElementById('version-number').textContent = `v${data.version}`; + document.getElementById('server-status').textContent = '●'; + document.getElementById('server-status').className = 'status-badge online'; + } catch (error) { + console.error('Failed to load server info:', error); + document.getElementById('server-status').className = 'status-badge offline'; + } +} + +export async function loadDisplays() { + try { + const response = await fetch(`${API_BASE}/config/displays`, { + headers: getHeaders() + }); + + if (response.status === 401) { + handle401Error(); + return; + } + + const data = await response.json(); + + if (data.displays && data.displays.length > 0) { + // Import setter to update shared state + const { set_cachedDisplays } = await import('./state.js'); + set_cachedDisplays(data.displays); + } + } catch (error) { + console.error('Failed to load displays:', error); + } +} + +export function configureApiKey() { + const currentKey = localStorage.getItem('wled_api_key'); + const message = currentKey + ? 'Current API key is set. Enter new key to update or leave blank to remove:' + : 'Enter your API key:'; + + const key = prompt(message); + + if (key === null) { + return; + } + + if (key === '') { + localStorage.removeItem('wled_api_key'); + setApiKey(null); + document.getElementById('api-key-btn').style.display = 'none'; + } else { + localStorage.setItem('wled_api_key', key); + setApiKey(key); + document.getElementById('api-key-btn').style.display = 'inline-block'; + } + + loadServerInfo(); + loadDisplays(); + window.loadDevices(); +} diff --git a/server/src/wled_controller/static/js/core/i18n.js b/server/src/wled_controller/static/js/core/i18n.js new file mode 100644 index 0000000..aa37a66 --- /dev/null +++ b/server/src/wled_controller/static/js/core/i18n.js @@ -0,0 +1,109 @@ +/** + * Internationalization — translations, locale detection, text updates. + */ + +import { apiKey } from './state.js'; + +let currentLocale = 'en'; +let translations = {}; + +const supportedLocales = { + 'en': 'English', + 'ru': 'Русский' +}; + +const fallbackTranslations = { + 'app.title': 'LED Grab', + 'auth.placeholder': 'Enter your API key...', + 'auth.button.login': 'Login' +}; + +export function t(key, params = {}) { + let text = translations[key] || fallbackTranslations[key] || key; + Object.keys(params).forEach(param => { + text = text.replace(new RegExp(`\\{${param}\\}`, 'g'), params[param]); + }); + return text; +} + +async function loadTranslations(locale) { + try { + const response = await fetch(`/static/locales/${locale}.json`); + if (!response.ok) { + throw new Error(`Failed to load ${locale}.json`); + } + return await response.json(); + } catch (error) { + console.error(`Error loading translations for ${locale}:`, error); + if (locale !== 'en') { + return await loadTranslations('en'); + } + return {}; + } +} + +function detectBrowserLocale() { + const browserLang = navigator.language || navigator.languages?.[0] || 'en'; + const langCode = browserLang.split('-')[0]; + return supportedLocales[langCode] ? langCode : 'en'; +} + +export async function initLocale() { + const savedLocale = localStorage.getItem('locale') || detectBrowserLocale(); + await setLocale(savedLocale); +} + +export async function setLocale(locale) { + if (!supportedLocales[locale]) { + locale = 'en'; + } + + translations = await loadTranslations(locale); + currentLocale = locale; + document.documentElement.setAttribute('data-locale', locale); + document.documentElement.setAttribute('lang', locale); + localStorage.setItem('locale', locale); + + updateAllText(); + updateLocaleSelect(); +} + +export function changeLocale() { + const select = document.getElementById('locale-select'); + const newLocale = select.value; + if (newLocale && newLocale !== currentLocale) { + localStorage.setItem('locale', newLocale); + setLocale(newLocale); + } +} + +function updateLocaleSelect() { + const select = document.getElementById('locale-select'); + if (select) { + select.value = currentLocale; + } +} + +export function updateAllText() { + document.querySelectorAll('[data-i18n]').forEach(el => { + const key = el.getAttribute('data-i18n'); + el.textContent = t(key); + }); + + document.querySelectorAll('[data-i18n-placeholder]').forEach(el => { + const key = el.getAttribute('data-i18n-placeholder'); + el.placeholder = t(key); + }); + + document.querySelectorAll('[data-i18n-title]').forEach(el => { + const key = el.getAttribute('data-i18n-title'); + el.title = t(key); + }); + + // Re-render dynamic content with new translations + if (apiKey) { + import('../core/api.js').then(({ loadDisplays }) => loadDisplays()); + if (typeof window.loadTargetsTab === 'function') window.loadTargetsTab(); + if (typeof window.loadPictureSources === 'function') window.loadPictureSources(); + } +} diff --git a/server/src/wled_controller/static/js/core/state.js b/server/src/wled_controller/static/js/core/state.js new file mode 100644 index 0000000..d07b084 --- /dev/null +++ b/server/src/wled_controller/static/js/core/state.js @@ -0,0 +1,175 @@ +/** + * Shared mutable state — all global variables live here. + * + * ES module `export let` creates live bindings: importers always see + * the latest value. But importers cannot reassign, so every variable + * gets a setter function. + */ + +export let apiKey = null; +export function setApiKey(v) { apiKey = v; } + +export let refreshInterval = null; +export function setRefreshInterval(v) { refreshInterval = v; } + +export let kcTestAutoRefresh = null; +export function setKcTestAutoRefresh(v) { kcTestAutoRefresh = v; } + +export let kcTestTargetId = null; +export function setKcTestTargetId(v) { kcTestTargetId = v; } + +export let _dashboardWS = null; +export function set_dashboardWS(v) { _dashboardWS = v; } + +export let _cachedDisplays = null; +export function set_cachedDisplays(v) { _cachedDisplays = v; } + +export let _displayPickerCallback = null; +export function set_displayPickerCallback(v) { _displayPickerCallback = v; } + +export let _displayPickerSelectedIndex = null; +export function set_displayPickerSelectedIndex(v) { _displayPickerSelectedIndex = v; } + +// Calibration +export let settingsInitialValues = {}; +export function setSettingsInitialValues(v) { settingsInitialValues = v; } + +export let calibrationInitialValues = {}; +export function setCalibrationInitialValues(v) { calibrationInitialValues = v; } + +export const calibrationTestState = {}; + +export const EDGE_TEST_COLORS = { + top: [255, 0, 0], + right: [0, 255, 0], + bottom: [0, 100, 255], + left: [255, 255, 0] +}; + +// Track logged errors to avoid console spam +export const loggedErrors = new Map(); + +// Device brightness cache +export const _deviceBrightnessCache = {}; + +// Discovery state +export let _discoveryScanRunning = false; +export function set_discoveryScanRunning(v) { _discoveryScanRunning = v; } + +export let _discoveryCache = {}; +export function set_discoveryCache(v) { _discoveryCache = v; } + +// Streams / templates state +export let _cachedStreams = []; +export function set_cachedStreams(v) { _cachedStreams = v; } + +export let _cachedPPTemplates = []; +export function set_cachedPPTemplates(v) { _cachedPPTemplates = v; } + +export let _cachedCaptureTemplates = []; +export function set_cachedCaptureTemplates(v) { _cachedCaptureTemplates = v; } + +export let _availableFilters = []; +export function set_availableFilters(v) { _availableFilters = v; } + +export let availableEngines = []; +export function setAvailableEngines(v) { availableEngines = v; } + +export let currentEditingTemplateId = null; +export function setCurrentEditingTemplateId(v) { currentEditingTemplateId = v; } + +export let _streamNameManuallyEdited = false; +export function set_streamNameManuallyEdited(v) { _streamNameManuallyEdited = v; } + +export let _streamModalPPTemplates = []; +export function set_streamModalPPTemplates(v) { _streamModalPPTemplates = v; } + +export let _templateNameManuallyEdited = false; +export function set_templateNameManuallyEdited(v) { _templateNameManuallyEdited = v; } + +// PP template state +export let _modalFilters = []; +export function set_modalFilters(v) { _modalFilters = v; } + +export let _ppTemplateNameManuallyEdited = false; +export function set_ppTemplateNameManuallyEdited(v) { _ppTemplateNameManuallyEdited = v; } + +// Stream test state +export let _currentTestStreamId = null; +export function set_currentTestStreamId(v) { _currentTestStreamId = v; } + +export let _currentTestPPTemplateId = null; +export function set_currentTestPPTemplateId(v) { _currentTestPPTemplateId = v; } + +export let _lastValidatedImageSource = ''; +export function set_lastValidatedImageSource(v) { _lastValidatedImageSource = v; } + +// Target editor state +export let targetEditorInitialValues = {}; +export function setTargetEditorInitialValues(v) { targetEditorInitialValues = v; } + +export let _targetEditorDevices = []; +export function set_targetEditorDevices(v) { _targetEditorDevices = v; } + +// KC editor state +export let kcEditorInitialValues = {}; +export function setKcEditorInitialValues(v) { kcEditorInitialValues = v; } + +export let _kcNameManuallyEdited = false; +export function set_kcNameManuallyEdited(v) { _kcNameManuallyEdited = v; } + +// KC WebSockets +export const kcWebSockets = {}; + +// Tutorial state +export let activeTutorial = null; +export function setActiveTutorial(v) { activeTutorial = v; } + +// Confirm modal +export let confirmResolve = null; +export function setConfirmResolve(v) { confirmResolve = v; } + +// Dashboard loading guard +export let _dashboardLoading = false; +export function set_dashboardLoading(v) { _dashboardLoading = v; } + +// Pattern template editor state +export let patternEditorRects = []; +export function setPatternEditorRects(v) { patternEditorRects = v; } + +export let patternEditorSelectedIdx = -1; +export function setPatternEditorSelectedIdx(v) { patternEditorSelectedIdx = v; } + +export let patternEditorBgImage = null; +export function setPatternEditorBgImage(v) { patternEditorBgImage = v; } + +export let patternEditorInitialValues = {}; +export function setPatternEditorInitialValues(v) { patternEditorInitialValues = v; } + +export let patternCanvasDragMode = null; +export function setPatternCanvasDragMode(v) { patternCanvasDragMode = v; } + +export let patternCanvasDragStart = null; +export function setPatternCanvasDragStart(v) { patternCanvasDragStart = v; } + +export let patternCanvasDragOrigRect = null; +export function setPatternCanvasDragOrigRect(v) { patternCanvasDragOrigRect = v; } + +export let patternEditorHoveredIdx = -1; +export function setPatternEditorHoveredIdx(v) { patternEditorHoveredIdx = v; } + +export let patternEditorHoverHit = null; +export function setPatternEditorHoverHit(v) { patternEditorHoverHit = v; } + +export const PATTERN_RECT_COLORS = [ + 'rgba(76,175,80,0.35)', 'rgba(33,150,243,0.35)', 'rgba(255,152,0,0.35)', + 'rgba(156,39,176,0.35)', 'rgba(0,188,212,0.35)', 'rgba(244,67,54,0.35)', + 'rgba(255,235,59,0.35)', 'rgba(121,85,72,0.35)', +]; +export const PATTERN_RECT_BORDERS = [ + '#4CAF50', '#2196F3', '#FF9800', '#9C27B0', '#00BCD4', '#F44336', '#FFEB3B', '#795548', +]; + +// Profiles +export let _profilesCache = null; +export function set_profilesCache(v) { _profilesCache = v; } diff --git a/server/src/wled_controller/static/js/core/ui.js b/server/src/wled_controller/static/js/core/ui.js new file mode 100644 index 0000000..9ff5bf2 --- /dev/null +++ b/server/src/wled_controller/static/js/core/ui.js @@ -0,0 +1,240 @@ +/** + * UI utilities — modal helpers, lightbox, toast, confirm. + */ + +import { kcTestAutoRefresh, setKcTestAutoRefresh, setKcTestTargetId, confirmResolve, setConfirmResolve } from './state.js'; +import { t } from './i18n.js'; + +export function toggleHint(btn) { + const hint = btn.closest('.label-row').nextElementSibling; + if (hint && hint.classList.contains('input-hint')) { + const visible = hint.style.display !== 'none'; + hint.style.display = visible ? 'none' : 'block'; + btn.classList.toggle('active', !visible); + } +} + +export function setupBackdropClose(modal, closeFn) { + if (modal._backdropCloseSetup) { + modal._backdropCloseFn = closeFn; + return; + } + modal._backdropCloseFn = closeFn; + let mouseDownTarget = null; + modal.addEventListener('mousedown', (e) => { mouseDownTarget = e.target; }); + modal.addEventListener('mouseup', (e) => { + if (mouseDownTarget === modal && e.target === modal && modal._backdropCloseFn) modal._backdropCloseFn(); + mouseDownTarget = null; + }); + modal.onclick = null; + modal._backdropCloseSetup = true; +} + +export function lockBody() { + const scrollY = window.scrollY; + document.body.style.top = `-${scrollY}px`; + document.body.classList.add('modal-open'); +} + +export function unlockBody() { + const scrollY = parseInt(document.body.style.top || '0', 10) * -1; + document.body.classList.remove('modal-open'); + document.body.style.top = ''; + window.scrollTo(0, scrollY); +} + +export function openLightbox(imageSrc, statsHtml) { + const lightbox = document.getElementById('image-lightbox'); + const img = document.getElementById('lightbox-image'); + const statsEl = document.getElementById('lightbox-stats'); + img.src = imageSrc; + if (statsHtml) { + statsEl.innerHTML = statsHtml; + statsEl.style.display = ''; + } else { + statsEl.style.display = 'none'; + } + lightbox.classList.add('active'); + lockBody(); +} + +export function closeLightbox(event) { + if (event && event.target && (event.target.closest('.lightbox-content') || event.target.closest('.lightbox-refresh-btn'))) return; + // Stop KC test auto-refresh if running + stopKCTestAutoRefresh(); + const lightbox = document.getElementById('image-lightbox'); + lightbox.classList.remove('active'); + const img = document.getElementById('lightbox-image'); + if (img.src.startsWith('blob:')) URL.revokeObjectURL(img.src); + img.src = ''; + img.style.display = ''; + document.getElementById('lightbox-stats').style.display = 'none'; + const spinner = lightbox.querySelector('.lightbox-spinner'); + if (spinner) spinner.style.display = 'none'; + const refreshBtn = document.getElementById('lightbox-auto-refresh'); + if (refreshBtn) { refreshBtn.style.display = 'none'; refreshBtn.classList.remove('active'); } + unlockBody(); +} + +export function stopKCTestAutoRefresh() { + if (kcTestAutoRefresh) { + clearInterval(kcTestAutoRefresh); + setKcTestAutoRefresh(null); + } + setKcTestTargetId(null); + updateAutoRefreshButton(false); +} + +export function updateAutoRefreshButton(active) { + const btn = document.getElementById('lightbox-auto-refresh'); + if (!btn) return; + if (active) { + btn.classList.add('active'); + btn.innerHTML = '⏸'; + } else { + btn.classList.remove('active'); + btn.innerHTML = '▶'; + } +} + +export function showToast(message, type = 'info') { + const toast = document.getElementById('toast'); + toast.textContent = message; + toast.className = `toast ${type} show`; + setTimeout(() => { + toast.className = 'toast'; + }, 3000); +} + +export function showConfirm(message, title = null) { + return new Promise((resolve) => { + setConfirmResolve(resolve); + + const modal = document.getElementById('confirm-modal'); + const titleEl = document.getElementById('confirm-title'); + const messageEl = document.getElementById('confirm-message'); + const yesBtn = document.getElementById('confirm-yes-btn'); + const noBtn = document.getElementById('confirm-no-btn'); + + titleEl.textContent = title || t('confirm.title'); + messageEl.textContent = message; + yesBtn.textContent = t('confirm.yes'); + noBtn.textContent = t('confirm.no'); + + modal.style.display = 'flex'; + lockBody(); + }); +} + +export function closeConfirmModal(result) { + const modal = document.getElementById('confirm-modal'); + modal.style.display = 'none'; + unlockBody(); + + if (confirmResolve) { + confirmResolve(result); + setConfirmResolve(null); + } +} + +export async function openFullImageLightbox(imageSource) { + try { + const { API_BASE, getHeaders } = await import('./api.js'); + const resp = await fetch(`${API_BASE}/picture-sources/full-image?source=${encodeURIComponent(imageSource)}`, { + headers: getHeaders() + }); + if (!resp.ok) return; + const blob = await resp.blob(); + const blobUrl = URL.createObjectURL(blob); + openLightbox(blobUrl); + } catch (err) { + console.error('Failed to load full image:', err); + } +} + +// Overlay spinner (used by capture/stream tests) +export function showOverlaySpinner(text, duration = 0) { + const existing = document.getElementById('overlay-spinner'); + if (existing) { + if (window.overlaySpinnerTimer) { + clearInterval(window.overlaySpinnerTimer); + window.overlaySpinnerTimer = null; + } + existing.remove(); + } + + const overlay = document.createElement('div'); + overlay.id = 'overlay-spinner'; + overlay.className = 'overlay-spinner'; + + const progressContainer = document.createElement('div'); + progressContainer.className = 'progress-container'; + + const radius = 56; + const circumference = 2 * Math.PI * radius; + + const svg = document.createElementNS('http://www.w3.org/2000/svg', 'svg'); + svg.setAttribute('width', '120'); + svg.setAttribute('height', '120'); + svg.setAttribute('class', 'progress-ring'); + + const bgCircle = document.createElementNS('http://www.w3.org/2000/svg', 'circle'); + bgCircle.setAttribute('class', 'progress-ring-bg'); + bgCircle.setAttribute('cx', '60'); + bgCircle.setAttribute('cy', '60'); + bgCircle.setAttribute('r', radius); + + const progressCircle = document.createElementNS('http://www.w3.org/2000/svg', 'circle'); + progressCircle.setAttribute('class', 'progress-ring-circle'); + progressCircle.setAttribute('cx', '60'); + progressCircle.setAttribute('cy', '60'); + progressCircle.setAttribute('r', radius); + progressCircle.style.strokeDasharray = circumference; + progressCircle.style.strokeDashoffset = circumference; + + svg.appendChild(bgCircle); + svg.appendChild(progressCircle); + + const progressContent = document.createElement('div'); + progressContent.className = 'progress-content'; + const progressPercentage = document.createElement('div'); + progressPercentage.className = 'progress-percentage'; + progressPercentage.textContent = '0%'; + progressContent.appendChild(progressPercentage); + + progressContainer.appendChild(svg); + progressContainer.appendChild(progressContent); + + const spinnerText = document.createElement('div'); + spinnerText.className = 'spinner-text'; + spinnerText.textContent = text; + + overlay.appendChild(progressContainer); + overlay.appendChild(spinnerText); + document.body.appendChild(overlay); + + if (duration > 0) { + const startTime = Date.now(); + window.overlaySpinnerTimer = setInterval(() => { + const elapsed = (Date.now() - startTime) / 1000; + const progress = Math.min(elapsed / duration, 1); + const percentage = Math.round(progress * 100); + const offset = circumference - (progress * circumference); + progressCircle.style.strokeDashoffset = offset; + progressPercentage.textContent = `${percentage}%`; + if (progress >= 1) { + clearInterval(window.overlaySpinnerTimer); + window.overlaySpinnerTimer = null; + } + }, 100); + } +} + +export function hideOverlaySpinner() { + if (window.overlaySpinnerTimer) { + clearInterval(window.overlaySpinnerTimer); + window.overlaySpinnerTimer = null; + } + const overlay = document.getElementById('overlay-spinner'); + if (overlay) overlay.remove(); +} diff --git a/server/src/wled_controller/static/js/features/calibration.js b/server/src/wled_controller/static/js/features/calibration.js new file mode 100644 index 0000000..2ea41f2 --- /dev/null +++ b/server/src/wled_controller/static/js/features/calibration.js @@ -0,0 +1,753 @@ +/** + * Calibration — calibration modal, canvas, drag handlers, edge test. + */ + +import { + calibrationInitialValues, setCalibrationInitialValues, + calibrationTestState, EDGE_TEST_COLORS, +} from '../core/state.js'; +import { API_BASE, getHeaders, handle401Error } from '../core/api.js'; +import { t } from '../core/i18n.js'; +import { lockBody, unlockBody, showToast, showConfirm } from '../core/ui.js'; +import { closeTutorial, startCalibrationTutorial } from './tutorials.js'; + +export async function showCalibration(deviceId) { + try { + const [response, displaysResponse] = await Promise.all([ + fetch(`${API_BASE}/devices/${deviceId}`, { headers: getHeaders() }), + fetch(`${API_BASE}/config/displays`, { headers: getHeaders() }), + ]); + + if (response.status === 401) { handle401Error(); return; } + if (!response.ok) { showToast('Failed to load calibration', 'error'); return; } + + const device = await response.json(); + const calibration = device.calibration; + + const preview = document.querySelector('.calibration-preview'); + if (displaysResponse.ok) { + const displaysData = await displaysResponse.json(); + const displayIndex = device.settings?.display_index ?? 0; + const display = (displaysData.displays || []).find(d => d.index === displayIndex); + if (display && display.width && display.height) { + preview.style.aspectRatio = `${display.width} / ${display.height}`; + } else { + preview.style.aspectRatio = ''; + } + } else { + preview.style.aspectRatio = ''; + } + + document.getElementById('calibration-device-id').value = device.id; + document.getElementById('cal-device-led-count-inline').textContent = device.led_count; + + document.getElementById('cal-start-position').value = calibration.start_position; + document.getElementById('cal-layout').value = calibration.layout; + document.getElementById('cal-offset').value = calibration.offset || 0; + + document.getElementById('cal-top-leds').value = calibration.leds_top || 0; + document.getElementById('cal-right-leds').value = calibration.leds_right || 0; + document.getElementById('cal-bottom-leds').value = calibration.leds_bottom || 0; + document.getElementById('cal-left-leds').value = calibration.leds_left || 0; + + document.getElementById('cal-skip-start').value = calibration.skip_leds_start || 0; + document.getElementById('cal-skip-end').value = calibration.skip_leds_end || 0; + updateOffsetSkipLock(); + + document.getElementById('cal-border-width').value = calibration.border_width || 10; + + window.edgeSpans = { + top: { start: calibration.span_top_start ?? 0, end: calibration.span_top_end ?? 1 }, + right: { start: calibration.span_right_start ?? 0, end: calibration.span_right_end ?? 1 }, + bottom: { start: calibration.span_bottom_start ?? 0, end: calibration.span_bottom_end ?? 1 }, + left: { start: calibration.span_left_start ?? 0, end: calibration.span_left_end ?? 1 }, + }; + + setCalibrationInitialValues({ + start_position: calibration.start_position, + layout: calibration.layout, + offset: String(calibration.offset || 0), + top: String(calibration.leds_top || 0), + right: String(calibration.leds_right || 0), + bottom: String(calibration.leds_bottom || 0), + left: String(calibration.leds_left || 0), + spans: JSON.stringify(window.edgeSpans), + skip_start: String(calibration.skip_leds_start || 0), + skip_end: String(calibration.skip_leds_end || 0), + border_width: String(calibration.border_width || 10), + }); + + calibrationTestState[device.id] = new Set(); + + updateCalibrationPreview(); + + const modal = document.getElementById('calibration-modal'); + modal.style.display = 'flex'; + lockBody(); + + initSpanDrag(); + requestAnimationFrame(() => { + renderCalibrationCanvas(); + if (!localStorage.getItem('calibrationTutorialSeen')) { + localStorage.setItem('calibrationTutorialSeen', '1'); + startCalibrationTutorial(); + } + }); + + if (!window._calibrationResizeObserver) { + window._calibrationResizeObserver = new ResizeObserver(() => { + updateSpanBars(); + renderCalibrationCanvas(); + }); + } + window._calibrationResizeObserver.observe(preview); + + } catch (error) { + console.error('Failed to load calibration:', error); + showToast('Failed to load calibration', 'error'); + } +} + +function isCalibrationDirty() { + return ( + document.getElementById('cal-start-position').value !== calibrationInitialValues.start_position || + document.getElementById('cal-layout').value !== calibrationInitialValues.layout || + document.getElementById('cal-offset').value !== calibrationInitialValues.offset || + document.getElementById('cal-top-leds').value !== calibrationInitialValues.top || + document.getElementById('cal-right-leds').value !== calibrationInitialValues.right || + document.getElementById('cal-bottom-leds').value !== calibrationInitialValues.bottom || + document.getElementById('cal-left-leds').value !== calibrationInitialValues.left || + JSON.stringify(window.edgeSpans) !== calibrationInitialValues.spans || + document.getElementById('cal-skip-start').value !== calibrationInitialValues.skip_start || + document.getElementById('cal-skip-end').value !== calibrationInitialValues.skip_end || + document.getElementById('cal-border-width').value !== calibrationInitialValues.border_width + ); +} + +export function forceCloseCalibrationModal() { + closeTutorial(); + const deviceId = document.getElementById('calibration-device-id').value; + if (deviceId) clearTestMode(deviceId); + if (window._calibrationResizeObserver) window._calibrationResizeObserver.disconnect(); + const modal = document.getElementById('calibration-modal'); + const error = document.getElementById('calibration-error'); + modal.style.display = 'none'; + error.style.display = 'none'; + unlockBody(); + setCalibrationInitialValues({}); +} + +export async function closeCalibrationModal() { + if (isCalibrationDirty()) { + const confirmed = await showConfirm(t('modal.discard_changes')); + if (!confirmed) return; + } + forceCloseCalibrationModal(); +} + +export function updateOffsetSkipLock() { + const offsetEl = document.getElementById('cal-offset'); + const skipStartEl = document.getElementById('cal-skip-start'); + const skipEndEl = document.getElementById('cal-skip-end'); + const hasOffset = parseInt(offsetEl.value || 0) > 0; + const hasSkip = parseInt(skipStartEl.value || 0) > 0 || parseInt(skipEndEl.value || 0) > 0; + skipStartEl.disabled = hasOffset; + skipEndEl.disabled = hasOffset; + offsetEl.disabled = hasSkip; +} + +export function updateCalibrationPreview() { + const total = parseInt(document.getElementById('cal-top-leds').value || 0) + + parseInt(document.getElementById('cal-right-leds').value || 0) + + parseInt(document.getElementById('cal-bottom-leds').value || 0) + + parseInt(document.getElementById('cal-left-leds').value || 0); + const totalEl = document.querySelector('.preview-screen-total'); + const deviceCount = parseInt(document.getElementById('cal-device-led-count-inline').textContent || 0); + const mismatch = total !== deviceCount; + document.getElementById('cal-total-leds-inline').textContent = (mismatch ? '\u26A0 ' : '') + total; + if (totalEl) totalEl.classList.toggle('mismatch', mismatch); + + const startPos = document.getElementById('cal-start-position').value; + ['top_left', 'top_right', 'bottom_left', 'bottom_right'].forEach(corner => { + const cornerEl = document.querySelector(`.preview-corner.corner-${corner.replace('_', '-')}`); + if (cornerEl) { + if (corner === startPos) cornerEl.classList.add('active'); + else cornerEl.classList.remove('active'); + } + }); + + const direction = document.getElementById('cal-layout').value; + const dirIcon = document.getElementById('direction-icon'); + const dirLabel = document.getElementById('direction-label'); + if (dirIcon) dirIcon.textContent = direction === 'clockwise' ? '↻' : '↺'; + if (dirLabel) dirLabel.textContent = direction === 'clockwise' ? 'CW' : 'CCW'; + + const deviceId = document.getElementById('calibration-device-id').value; + const activeEdges = calibrationTestState[deviceId] || new Set(); + + ['top', 'right', 'bottom', 'left'].forEach(edge => { + const toggleEl = document.querySelector(`.edge-toggle.toggle-${edge}`); + if (!toggleEl) return; + if (activeEdges.has(edge)) { + const [r, g, b] = EDGE_TEST_COLORS[edge]; + toggleEl.style.background = `rgba(${r}, ${g}, ${b}, 0.35)`; + toggleEl.style.boxShadow = `inset 0 0 6px rgba(${r}, ${g}, ${b}, 0.5)`; + } else { + toggleEl.style.background = ''; + toggleEl.style.boxShadow = ''; + } + }); + + ['top', 'right', 'bottom', 'left'].forEach(edge => { + const count = parseInt(document.getElementById(`cal-${edge}-leds`).value) || 0; + const edgeEl = document.querySelector(`.preview-edge.edge-${edge}`); + const toggleEl = document.querySelector(`.edge-toggle.toggle-${edge}`); + if (edgeEl) edgeEl.classList.toggle('edge-disabled', count === 0); + if (toggleEl) toggleEl.classList.toggle('edge-disabled', count === 0); + }); + + updateSpanBars(); + renderCalibrationCanvas(); +} + +export function renderCalibrationCanvas() { + const canvas = document.getElementById('calibration-preview-canvas'); + if (!canvas) return; + + const container = canvas.parentElement; + const containerRect = container.getBoundingClientRect(); + if (containerRect.width === 0 || containerRect.height === 0) return; + + const padX = 40; + const padY = 40; + + const dpr = window.devicePixelRatio || 1; + const canvasW = containerRect.width + padX * 2; + const canvasH = containerRect.height + padY * 2; + canvas.width = canvasW * dpr; + canvas.height = canvasH * dpr; + const ctx = canvas.getContext('2d'); + ctx.scale(dpr, dpr); + ctx.clearRect(0, 0, canvasW, canvasH); + + const ox = padX; + const oy = padY; + const cW = containerRect.width; + const cH = containerRect.height; + + const startPos = document.getElementById('cal-start-position').value; + const layout = document.getElementById('cal-layout').value; + const offset = parseInt(document.getElementById('cal-offset').value || 0); + const calibration = { + start_position: startPos, + layout: layout, + offset: offset, + leds_top: parseInt(document.getElementById('cal-top-leds').value || 0), + leds_right: parseInt(document.getElementById('cal-right-leds').value || 0), + leds_bottom: parseInt(document.getElementById('cal-bottom-leds').value || 0), + leds_left: parseInt(document.getElementById('cal-left-leds').value || 0), + }; + const skipStart = parseInt(document.getElementById('cal-skip-start').value || 0); + const skipEnd = parseInt(document.getElementById('cal-skip-end').value || 0); + + const segments = buildSegments(calibration); + if (segments.length === 0) return; + + const totalLeds = calibration.leds_top + calibration.leds_right + calibration.leds_bottom + calibration.leds_left; + const hasSkip = (skipStart > 0 || skipEnd > 0) && totalLeds > 1; + + const isDark = document.documentElement.getAttribute('data-theme') !== 'light'; + const tickStroke = isDark ? 'rgba(255, 255, 255, 0.4)' : 'rgba(0, 0, 0, 0.3)'; + const tickFill = isDark ? 'rgba(255, 255, 255, 0.65)' : 'rgba(0, 0, 0, 0.6)'; + const chevronStroke = isDark ? 'rgba(255, 255, 255, 0.6)' : 'rgba(0, 0, 0, 0.4)'; + + const cw = 56; + const ch = 36; + + const spans = window.edgeSpans || {}; + const edgeLenH = cW - 2 * cw; + const edgeLenV = cH - 2 * ch; + + const edgeGeometry = { + top: { x1: ox + cw + (spans.top?.start || 0) * edgeLenH, x2: ox + cw + (spans.top?.end || 1) * edgeLenH, midY: oy + ch / 2, horizontal: true }, + bottom: { x1: ox + cw + (spans.bottom?.start || 0) * edgeLenH, x2: ox + cw + (spans.bottom?.end || 1) * edgeLenH, midY: oy + cH - ch / 2, horizontal: true }, + left: { y1: oy + ch + (spans.left?.start || 0) * edgeLenV, y2: oy + ch + (spans.left?.end || 1) * edgeLenV, midX: ox + cw / 2, horizontal: false }, + right: { y1: oy + ch + (spans.right?.start || 0) * edgeLenV, y2: oy + ch + (spans.right?.end || 1) * edgeLenV, midX: ox + cW - cw / 2, horizontal: false }, + }; + + const toggleSize = 16; + const axisPos = { + top: oy - toggleSize - 3, + bottom: oy + cH + toggleSize + 3, + left: ox - toggleSize - 3, + right: ox + cW + toggleSize + 3, + }; + + const arrowInset = 12; + const arrowPos = { + top: oy + ch + arrowInset, + bottom: oy + cH - ch - arrowInset, + left: ox + cw + arrowInset, + right: ox + cW - cw - arrowInset, + }; + + segments.forEach(seg => { + const geo = edgeGeometry[seg.edge]; + if (!geo) return; + + const count = seg.led_count; + if (count === 0) return; + + const edgeDisplayStart = hasSkip ? Math.max(seg.led_start, skipStart) : seg.led_start; + const edgeDisplayEnd = hasSkip ? Math.min(seg.led_start + count, totalLeds - skipEnd) : seg.led_start + count - 1; + const edgeDisplayRange = edgeDisplayEnd - edgeDisplayStart; + const toEdgeLabel = (i) => { + if (!hasSkip) return totalLeds > 0 ? (seg.led_start + i) % totalLeds : seg.led_start + i; + if (count <= 1) return edgeDisplayStart; + return Math.round(edgeDisplayStart + i / (count - 1) * edgeDisplayRange); + }; + + const edgeBounds = new Set(); + edgeBounds.add(0); + if (count > 1) edgeBounds.add(count - 1); + + const specialTicks = new Set(); + if (offset > 0 && totalLeds > 0) { + const zeroPos = (totalLeds - seg.led_start % totalLeds) % totalLeds; + if (zeroPos < count) specialTicks.add(zeroPos); + } + + const labelsToShow = new Set([...specialTicks]); + const tickLinesOnly = new Set(); + + if (count > 2) { + const edgeLen = geo.horizontal ? (geo.x2 - geo.x1) : (geo.y2 - geo.y1); + const maxDigits = String(totalLeds > 0 ? totalLeds - 1 : count - 1).length; + const minSpacing = geo.horizontal ? maxDigits * 7 + 8 : 22; + + const allMandatory = new Set([...edgeBounds, ...specialTicks]); + const maxIntermediate = Math.max(0, 5 - allMandatory.size); + 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) <= maxIntermediate) { step = s; break; } + } + + const tickPx = i => { + const f = i / (count - 1); + return (seg.reverse ? (1 - f) : f) * edgeLen; + }; + + const placed = []; + specialTicks.forEach(i => placed.push(tickPx(i))); + + for (let i = 1; i < count - 1; i++) { + if (specialTicks.has(i)) continue; + if (toEdgeLabel(i) % step === 0) { + const px = tickPx(i); + if (!placed.some(p => Math.abs(px - p) < minSpacing)) { + labelsToShow.add(i); + placed.push(px); + } + } + } + + edgeBounds.forEach(bi => { + if (labelsToShow.has(bi) || specialTicks.has(bi)) return; + const px = tickPx(bi); + if (placed.some(p => Math.abs(px - p) < minSpacing)) { + tickLinesOnly.add(bi); + } else { + labelsToShow.add(bi); + placed.push(px); + } + }); + } else { + edgeBounds.forEach(i => labelsToShow.add(i)); + } + + const tickLenLong = toggleSize + 3; + const tickLenShort = 4; + ctx.strokeStyle = tickStroke; + ctx.lineWidth = 1; + ctx.fillStyle = tickFill; + ctx.font = '12px -apple-system, BlinkMacSystemFont, sans-serif'; + + labelsToShow.forEach(i => { + const fraction = count > 1 ? i / (count - 1) : 0.5; + const displayFraction = seg.reverse ? (1 - fraction) : fraction; + const displayLabel = toEdgeLabel(i); + const tickLen = edgeBounds.has(i) ? tickLenLong : tickLenShort; + + if (geo.horizontal) { + const tx = geo.x1 + displayFraction * (geo.x2 - geo.x1); + const axisY = axisPos[seg.edge]; + const tickDir = seg.edge === 'top' ? 1 : -1; + ctx.beginPath(); ctx.moveTo(tx, axisY); ctx.lineTo(tx, axisY + tickDir * tickLen); ctx.stroke(); + ctx.textAlign = 'center'; + ctx.textBaseline = seg.edge === 'top' ? 'bottom' : 'top'; + ctx.fillText(String(displayLabel), tx, axisY - tickDir * 1); + } else { + const ty = geo.y1 + displayFraction * (geo.y2 - geo.y1); + const axisX = axisPos[seg.edge]; + const tickDir = seg.edge === 'left' ? 1 : -1; + ctx.beginPath(); ctx.moveTo(axisX, ty); ctx.lineTo(axisX + tickDir * tickLen, ty); ctx.stroke(); + ctx.textBaseline = 'middle'; + ctx.textAlign = seg.edge === 'left' ? 'right' : 'left'; + ctx.fillText(String(displayLabel), axisX - tickDir * 1, ty); + } + }); + + tickLinesOnly.forEach(i => { + const fraction = count > 1 ? i / (count - 1) : 0.5; + const displayFraction = seg.reverse ? (1 - fraction) : fraction; + if (geo.horizontal) { + const tx = geo.x1 + displayFraction * (geo.x2 - geo.x1); + const axisY = axisPos[seg.edge]; + const tickDir = seg.edge === 'top' ? 1 : -1; + ctx.beginPath(); ctx.moveTo(tx, axisY); ctx.lineTo(tx, axisY + tickDir * tickLenLong); ctx.stroke(); + } else { + const ty = geo.y1 + displayFraction * (geo.y2 - geo.y1); + const axisX = axisPos[seg.edge]; + const tickDir = seg.edge === 'left' ? 1 : -1; + ctx.beginPath(); ctx.moveTo(axisX, ty); ctx.lineTo(axisX + tickDir * tickLenLong, ty); ctx.stroke(); + } + }); + + const s = 7; + let mx, my, angle; + if (geo.horizontal) { + mx = ox + cw + edgeLenH / 2; + my = arrowPos[seg.edge]; + angle = seg.reverse ? Math.PI : 0; + } else { + mx = arrowPos[seg.edge]; + my = oy + ch + edgeLenV / 2; + angle = seg.reverse ? -Math.PI / 2 : Math.PI / 2; + } + + ctx.save(); + ctx.translate(mx, my); + ctx.rotate(angle); + ctx.fillStyle = 'rgba(76, 175, 80, 0.85)'; + ctx.strokeStyle = chevronStroke; + ctx.lineWidth = 1; + ctx.lineCap = 'round'; + ctx.lineJoin = 'round'; + ctx.beginPath(); + ctx.moveTo(-s * 0.5, -s * 0.6); + ctx.lineTo(s * 0.5, 0); + ctx.lineTo(-s * 0.5, s * 0.6); + ctx.closePath(); + ctx.fill(); + ctx.stroke(); + ctx.restore(); + }); +} + +function updateSpanBars() { + const spans = window.edgeSpans || {}; + const container = document.querySelector('.calibration-preview'); + ['top', 'right', 'bottom', 'left'].forEach(edge => { + const bar = document.querySelector(`.edge-span-bar[data-edge="${edge}"]`); + if (!bar) return; + const span = spans[edge] || { start: 0, end: 1 }; + const edgeEl = bar.parentElement; + const isHorizontal = (edge === 'top' || edge === 'bottom'); + + if (isHorizontal) { + const totalWidth = edgeEl.clientWidth; + bar.style.left = (span.start * totalWidth) + 'px'; + bar.style.width = ((span.end - span.start) * totalWidth) + 'px'; + } else { + const totalHeight = edgeEl.clientHeight; + bar.style.top = (span.start * totalHeight) + 'px'; + bar.style.height = ((span.end - span.start) * totalHeight) + 'px'; + } + + if (!container) return; + const toggle = container.querySelector(`.toggle-${edge}`); + if (!toggle) return; + if (isHorizontal) { + const cornerW = 56; + const edgeW = container.clientWidth - 2 * cornerW; + toggle.style.left = (cornerW + span.start * edgeW) + 'px'; + toggle.style.right = 'auto'; + toggle.style.width = ((span.end - span.start) * edgeW) + 'px'; + } else { + const cornerH = 36; + const edgeH = container.clientHeight - 2 * cornerH; + toggle.style.top = (cornerH + span.start * edgeH) + 'px'; + toggle.style.bottom = 'auto'; + toggle.style.height = ((span.end - span.start) * edgeH) + 'px'; + } + }); +} + +function initSpanDrag() { + const MIN_SPAN = 0.05; + + document.querySelectorAll('.edge-span-bar').forEach(bar => { + const edge = bar.dataset.edge; + const isHorizontal = (edge === 'top' || edge === 'bottom'); + + bar.addEventListener('click', e => e.stopPropagation()); + + bar.querySelectorAll('.edge-span-handle').forEach(handle => { + handle.addEventListener('mousedown', e => { + const edgeLeds = parseInt(document.getElementById(`cal-${edge}-leds`).value) || 0; + if (edgeLeds === 0) return; + e.preventDefault(); + e.stopPropagation(); + const handleType = handle.dataset.handle; + const edgeEl = bar.parentElement; + const rect = edgeEl.getBoundingClientRect(); + + function onMouseMove(ev) { + const span = window.edgeSpans[edge]; + let fraction; + if (isHorizontal) fraction = (ev.clientX - rect.left) / rect.width; + else fraction = (ev.clientY - rect.top) / rect.height; + fraction = Math.max(0, Math.min(1, fraction)); + + if (handleType === 'start') span.start = Math.min(fraction, span.end - MIN_SPAN); + else span.end = Math.max(fraction, span.start + MIN_SPAN); + + updateSpanBars(); + renderCalibrationCanvas(); + } + + function onMouseUp() { + document.removeEventListener('mousemove', onMouseMove); + document.removeEventListener('mouseup', onMouseUp); + } + + document.addEventListener('mousemove', onMouseMove); + document.addEventListener('mouseup', onMouseUp); + }); + }); + + bar.addEventListener('mousedown', e => { + if (e.target.classList.contains('edge-span-handle')) return; + e.preventDefault(); + e.stopPropagation(); + + const edgeEl = bar.parentElement; + const rect = edgeEl.getBoundingClientRect(); + const span = window.edgeSpans[edge]; + const spanWidth = span.end - span.start; + + let startFraction; + if (isHorizontal) startFraction = (e.clientX - rect.left) / rect.width; + else startFraction = (e.clientY - rect.top) / rect.height; + const offsetInSpan = startFraction - span.start; + + function onMouseMove(ev) { + let fraction; + if (isHorizontal) fraction = (ev.clientX - rect.left) / rect.width; + else fraction = (ev.clientY - rect.top) / rect.height; + + let newStart = fraction - offsetInSpan; + newStart = Math.max(0, Math.min(1 - spanWidth, newStart)); + span.start = newStart; + span.end = newStart + spanWidth; + + updateSpanBars(); + renderCalibrationCanvas(); + } + + function onMouseUp() { + document.removeEventListener('mousemove', onMouseMove); + document.removeEventListener('mouseup', onMouseUp); + } + + document.addEventListener('mousemove', onMouseMove); + document.addEventListener('mouseup', onMouseUp); + }); + }); + + updateSpanBars(); +} + +export function setStartPosition(position) { + document.getElementById('cal-start-position').value = position; + updateCalibrationPreview(); +} + +export function toggleEdgeInputs() { + const preview = document.querySelector('.calibration-preview'); + if (preview) preview.classList.toggle('inputs-dimmed'); +} + +export function toggleDirection() { + const select = document.getElementById('cal-layout'); + select.value = select.value === 'clockwise' ? 'counterclockwise' : 'clockwise'; + updateCalibrationPreview(); +} + +export async function toggleTestEdge(edge) { + const edgeLeds = parseInt(document.getElementById(`cal-${edge}-leds`).value) || 0; + if (edgeLeds === 0) return; + + const deviceId = document.getElementById('calibration-device-id').value; + const error = document.getElementById('calibration-error'); + + if (!calibrationTestState[deviceId]) calibrationTestState[deviceId] = new Set(); + + if (calibrationTestState[deviceId].has(edge)) calibrationTestState[deviceId].delete(edge); + else calibrationTestState[deviceId].add(edge); + + const edges = {}; + calibrationTestState[deviceId].forEach(e => { edges[e] = EDGE_TEST_COLORS[e]; }); + + updateCalibrationPreview(); + + try { + const response = await fetch(`${API_BASE}/devices/${deviceId}/calibration/test`, { + method: 'PUT', + headers: getHeaders(), + body: JSON.stringify({ edges }) + }); + if (response.status === 401) { handle401Error(); return; } + if (!response.ok) { + const errorData = await response.json(); + error.textContent = `Test failed: ${errorData.detail}`; + error.style.display = 'block'; + } + } catch (err) { + console.error('Failed to toggle test edge:', err); + error.textContent = 'Failed to toggle test edge'; + error.style.display = 'block'; + } +} + +async function clearTestMode(deviceId) { + if (!calibrationTestState[deviceId] || calibrationTestState[deviceId].size === 0) return; + calibrationTestState[deviceId] = new Set(); + try { + await fetch(`${API_BASE}/devices/${deviceId}/calibration/test`, { + method: 'PUT', + headers: getHeaders(), + body: JSON.stringify({ edges: {} }) + }); + } catch (err) { + console.error('Failed to clear test mode:', err); + } +} + +export async function saveCalibration() { + const deviceId = document.getElementById('calibration-device-id').value; + const deviceLedCount = parseInt(document.getElementById('cal-device-led-count-inline').textContent); + const error = document.getElementById('calibration-error'); + + await clearTestMode(deviceId); + updateCalibrationPreview(); + + const topLeds = parseInt(document.getElementById('cal-top-leds').value || 0); + const rightLeds = parseInt(document.getElementById('cal-right-leds').value || 0); + const bottomLeds = parseInt(document.getElementById('cal-bottom-leds').value || 0); + const leftLeds = parseInt(document.getElementById('cal-left-leds').value || 0); + const total = topLeds + rightLeds + bottomLeds + leftLeds; + + if (total !== deviceLedCount) { + error.textContent = `Total LEDs (${total}) must equal device LED count (${deviceLedCount})`; + error.style.display = 'block'; + return; + } + + const startPosition = document.getElementById('cal-start-position').value; + const layout = document.getElementById('cal-layout').value; + const offset = parseInt(document.getElementById('cal-offset').value || 0); + + const spans = window.edgeSpans || {}; + const calibration = { + layout, start_position: startPosition, offset, + leds_top: topLeds, leds_right: rightLeds, leds_bottom: bottomLeds, leds_left: leftLeds, + span_top_start: spans.top?.start ?? 0, span_top_end: spans.top?.end ?? 1, + span_right_start: spans.right?.start ?? 0, span_right_end: spans.right?.end ?? 1, + span_bottom_start: spans.bottom?.start ?? 0, span_bottom_end: spans.bottom?.end ?? 1, + span_left_start: spans.left?.start ?? 0, span_left_end: spans.left?.end ?? 1, + skip_leds_start: parseInt(document.getElementById('cal-skip-start').value || 0), + skip_leds_end: parseInt(document.getElementById('cal-skip-end').value || 0), + border_width: parseInt(document.getElementById('cal-border-width').value) || 10, + }; + + try { + const response = await fetch(`${API_BASE}/devices/${deviceId}/calibration`, { + method: 'PUT', + headers: getHeaders(), + body: JSON.stringify(calibration) + }); + if (response.status === 401) { handle401Error(); return; } + if (response.ok) { + showToast('Calibration saved', 'success'); + forceCloseCalibrationModal(); + window.loadDevices(); + } else { + const errorData = await response.json(); + error.textContent = `Failed to save: ${errorData.detail}`; + error.style.display = 'block'; + } + } catch (err) { + console.error('Failed to save calibration:', err); + error.textContent = 'Failed to save calibration'; + error.style.display = 'block'; + } +} + +function getEdgeOrder(startPosition, layout) { + const orders = { + 'bottom_left_clockwise': ['left', 'top', 'right', 'bottom'], + 'bottom_left_counterclockwise': ['bottom', 'right', 'top', 'left'], + 'bottom_right_clockwise': ['bottom', 'left', 'top', 'right'], + 'bottom_right_counterclockwise': ['right', 'top', 'left', 'bottom'], + 'top_left_clockwise': ['top', 'right', 'bottom', 'left'], + 'top_left_counterclockwise': ['left', 'bottom', 'right', 'top'], + 'top_right_clockwise': ['right', 'bottom', 'left', 'top'], + 'top_right_counterclockwise': ['top', 'left', 'bottom', 'right'] + }; + return orders[`${startPosition}_${layout}`] || ['left', 'top', 'right', 'bottom']; +} + +function shouldReverse(edge, startPosition, layout) { + const reverseRules = { + 'bottom_left_clockwise': { left: true, top: false, right: false, bottom: true }, + 'bottom_left_counterclockwise': { bottom: false, right: true, top: true, left: false }, + 'bottom_right_clockwise': { bottom: true, left: true, top: false, right: false }, + 'bottom_right_counterclockwise': { right: true, top: true, left: false, bottom: false }, + 'top_left_clockwise': { top: false, right: false, bottom: true, left: true }, + 'top_left_counterclockwise': { left: false, bottom: false, right: true, top: true }, + 'top_right_clockwise': { right: false, bottom: true, left: true, top: false }, + 'top_right_counterclockwise': { top: true, left: false, bottom: false, right: true } + }; + const rules = reverseRules[`${startPosition}_${layout}`]; + return rules ? rules[edge] : false; +} + +function buildSegments(calibration) { + const edgeOrder = getEdgeOrder(calibration.start_position, calibration.layout); + const edgeCounts = { + top: calibration.leds_top || 0, + right: calibration.leds_right || 0, + bottom: calibration.leds_bottom || 0, + left: calibration.leds_left || 0 + }; + + const segments = []; + let ledStart = calibration.offset || 0; + + edgeOrder.forEach(edge => { + const count = edgeCounts[edge]; + if (count > 0) { + segments.push({ + edge, + led_start: ledStart, + led_count: count, + reverse: shouldReverse(edge, calibration.start_position, calibration.layout) + }); + ledStart += count; + } + }); + + return segments; +} diff --git a/server/src/wled_controller/static/js/features/dashboard.js b/server/src/wled_controller/static/js/features/dashboard.js new file mode 100644 index 0000000..e8d797d --- /dev/null +++ b/server/src/wled_controller/static/js/features/dashboard.js @@ -0,0 +1,322 @@ +/** + * Dashboard — real-time target status overview. + */ + +import { apiKey, _dashboardWS, set_dashboardWS, _dashboardLoading, set_dashboardLoading } from '../core/state.js'; +import { API_BASE, getHeaders, fetchWithAuth } from '../core/api.js'; +import { t } from '../core/i18n.js'; +import { escapeHtml, handle401Error } from '../core/api.js'; +import { showToast } from '../core/ui.js'; + +function formatUptime(seconds) { + if (!seconds || seconds <= 0) return '-'; + const h = Math.floor(seconds / 3600); + const m = Math.floor((seconds % 3600) / 60); + const s = Math.floor(seconds % 60); + if (h > 0) return `${h}h ${m}m`; + if (m > 0) return `${m}m ${s}s`; + return `${s}s`; +} + +export async function loadDashboard() { + if (_dashboardLoading) return; + set_dashboardLoading(true); + const container = document.getElementById('dashboard-content'); + if (!container) { set_dashboardLoading(false); return; } + + try { + const [targetsResp, profilesResp] = await Promise.all([ + fetch(`${API_BASE}/picture-targets`, { headers: getHeaders() }), + fetch(`${API_BASE}/profiles`, { headers: getHeaders() }).catch(() => null), + ]); + if (targetsResp.status === 401) { handle401Error(); return; } + + const targetsData = await targetsResp.json(); + const targets = targetsData.targets || []; + const profilesData = profilesResp && profilesResp.ok ? await profilesResp.json() : { profiles: [] }; + const profiles = profilesData.profiles || []; + + if (targets.length === 0 && profiles.length === 0) { + container.innerHTML = `
${t('dashboard.no_targets')}
`; + return; + } + + const enriched = await Promise.all(targets.map(async (target) => { + try { + const [stateResp, metricsResp] = await Promise.all([ + fetch(`${API_BASE}/picture-targets/${target.id}/state`, { headers: getHeaders() }), + fetch(`${API_BASE}/picture-targets/${target.id}/metrics`, { headers: getHeaders() }), + ]); + const state = stateResp.ok ? await stateResp.json() : {}; + const metrics = metricsResp.ok ? await metricsResp.json() : {}; + return { ...target, state, metrics }; + } catch { + return target; + } + })); + + const running = enriched.filter(t => t.state && t.state.processing); + const stopped = enriched.filter(t => !t.state || !t.state.processing); + + let html = ''; + + if (profiles.length > 0) { + const activeProfiles = profiles.filter(p => p.is_active); + const inactiveProfiles = profiles.filter(p => !p.is_active); + + html += `
+
+ ${t('dashboard.section.profiles')} + ${profiles.length} +
+ ${activeProfiles.map(p => renderDashboardProfile(p)).join('')} + ${inactiveProfiles.map(p => renderDashboardProfile(p)).join('')} +
`; + } + + if (running.length > 0) { + html += `
+
+ ${t('dashboard.section.running')} + ${running.length} + +
+ ${running.map(target => renderDashboardTarget(target, true)).join('')} +
`; + } + + if (stopped.length > 0) { + html += `
+
+ ${t('dashboard.section.stopped')} + ${stopped.length} +
+ ${stopped.map(target => renderDashboardTarget(target, false)).join('')} +
`; + } + + container.innerHTML = html; + + } catch (error) { + console.error('Failed to load dashboard:', error); + container.innerHTML = `
${t('dashboard.failed')}
`; + } finally { + set_dashboardLoading(false); + } +} + +function renderDashboardTarget(target, isRunning) { + const state = target.state || {}; + const metrics = target.metrics || {}; + const isLed = target.target_type === 'led' || target.target_type === 'wled'; + const icon = '⚡'; + const typeLabel = isLed ? 'LED' : 'Key Colors'; + + let subtitleParts = [typeLabel]; + if (isLed && state.device_name) { + subtitleParts.push(state.device_name); + } + + if (isRunning) { + const fpsActual = state.fps_actual != null ? state.fps_actual.toFixed(1) : '-'; + const fpsTarget = state.fps_target || (target.settings || target.key_colors_settings || {}).fps || '-'; + const uptime = formatUptime(metrics.uptime_seconds); + const errors = metrics.errors_count || 0; + + let healthDot = ''; + if (isLed && state.device_last_checked != null) { + const cls = state.device_online ? 'health-online' : 'health-offline'; + healthDot = ``; + } + + return `
+
+ ${icon} +
+
${escapeHtml(target.name)}${healthDot}
+ ${subtitleParts.length ? `
${escapeHtml(subtitleParts.join(' · '))}
` : ''} +
+
+
+
+
${fpsActual}/${fpsTarget}
+
${t('dashboard.fps')}
+
+
+
${uptime}
+
${t('dashboard.uptime')}
+
+
+
${errors}
+
${t('dashboard.errors')}
+
+
+
+ +
+
`; + } else { + return `
+
+ ${icon} +
+
${escapeHtml(target.name)}
+ ${subtitleParts.length ? `
${escapeHtml(subtitleParts.join(' · '))}
` : ''} +
+
+
+
+ +
+
`; + } +} + +function renderDashboardProfile(profile) { + const isActive = profile.is_active; + const isDisabled = !profile.enabled; + + let condSummary = ''; + if (profile.conditions.length > 0) { + const parts = profile.conditions.map(c => { + if (c.condition_type === 'application') { + const apps = (c.apps || []).join(', '); + const matchLabel = c.match_type === 'topmost' ? t('profiles.condition.application.match_type.topmost') : t('profiles.condition.application.match_type.running'); + return `${apps} (${matchLabel})`; + } + return c.condition_type; + }); + const logic = profile.condition_logic === 'and' ? ' & ' : ' | '; + condSummary = parts.join(logic); + } + + const statusBadge = isDisabled + ? `${t('profiles.status.disabled')}` + : isActive + ? `${t('profiles.status.active')}` + : `${t('profiles.status.inactive')}`; + + const targetCount = profile.target_ids.length; + const activeCount = (profile.active_target_ids || []).length; + const targetsInfo = isActive ? `${activeCount}/${targetCount}` : `${targetCount}`; + + return `
+
+ 📋 +
+
${escapeHtml(profile.name)}
+ ${condSummary ? `
${escapeHtml(condSummary)}
` : ''} +
+ ${statusBadge} +
+
+
+
${targetsInfo}
+
${t('dashboard.targets')}
+
+
+
+ +
+
`; +} + +export async function dashboardToggleProfile(profileId, enable) { + try { + const endpoint = enable ? 'enable' : 'disable'; + const response = await fetch(`${API_BASE}/profiles/${profileId}/${endpoint}`, { + method: 'POST', + headers: getHeaders() + }); + if (response.status === 401) { handle401Error(); return; } + if (response.ok) { + loadDashboard(); + } + } catch (error) { + showToast('Failed to toggle profile', 'error'); + } +} + +export async function dashboardStartTarget(targetId) { + try { + const response = await fetch(`${API_BASE}/picture-targets/${targetId}/start`, { + method: 'POST', + headers: getHeaders() + }); + if (response.status === 401) { handle401Error(); return; } + if (response.ok) { + showToast(t('device.started'), 'success'); + loadDashboard(); + } else { + const error = await response.json(); + showToast(`Failed to start: ${error.detail}`, 'error'); + } + } catch (error) { + showToast('Failed to start processing', 'error'); + } +} + +export async function dashboardStopTarget(targetId) { + try { + const response = await fetch(`${API_BASE}/picture-targets/${targetId}/stop`, { + method: 'POST', + headers: getHeaders() + }); + if (response.status === 401) { handle401Error(); return; } + if (response.ok) { + showToast(t('device.stopped'), 'success'); + loadDashboard(); + } else { + const error = await response.json(); + showToast(`Failed to stop: ${error.detail}`, 'error'); + } + } catch (error) { + showToast('Failed to stop processing', 'error'); + } +} + +export async function dashboardStopAll() { + try { + const targetsResp = await fetch(`${API_BASE}/picture-targets`, { headers: getHeaders() }); + if (targetsResp.status === 401) { handle401Error(); return; } + const data = await targetsResp.json(); + const running = (data.targets || []).filter(t => t.id); + await Promise.all(running.map(t => + fetch(`${API_BASE}/picture-targets/${t.id}/stop`, { method: 'POST', headers: getHeaders() }).catch(() => {}) + )); + loadDashboard(); + } catch (error) { + showToast('Failed to stop all targets', 'error'); + } +} + +export function startDashboardWS() { + stopDashboardWS(); + if (!apiKey) return; + const wsProto = location.protocol === 'https:' ? 'wss:' : 'ws:'; + const url = `${wsProto}//${location.host}/api/v1/events/ws?token=${apiKey}`; + try { + set_dashboardWS(new WebSocket(url)); + _dashboardWS.onmessage = (event) => { + try { + const data = JSON.parse(event.data); + if (data.type === 'state_change' || data.type === 'profile_state_changed') { + loadDashboard(); + } + } catch {} + }; + _dashboardWS.onclose = () => { set_dashboardWS(null); }; + _dashboardWS.onerror = () => { set_dashboardWS(null); }; + } catch { + set_dashboardWS(null); + } +} + +export function stopDashboardWS() { + if (_dashboardWS) { + _dashboardWS.close(); + set_dashboardWS(null); + } +} diff --git a/server/src/wled_controller/static/js/features/device-discovery.js b/server/src/wled_controller/static/js/features/device-discovery.js new file mode 100644 index 0000000..2c9456d --- /dev/null +++ b/server/src/wled_controller/static/js/features/device-discovery.js @@ -0,0 +1,338 @@ +/** + * Device discovery — add device modal, network/serial scanning, device type switching. + */ + +import { + _discoveryScanRunning, set_discoveryScanRunning, + _discoveryCache, set_discoveryCache, + settingsInitialValues, +} from '../core/state.js'; +import { API_BASE, getHeaders, isSerialDevice, escapeHtml, handle401Error } from '../core/api.js'; +import { t } from '../core/i18n.js'; +import { lockBody, unlockBody, showToast } from '../core/ui.js'; +import { _computeMaxFps, _renderFpsHint } from './devices.js'; + +export function onDeviceTypeChanged() { + const deviceType = document.getElementById('device-type').value; + const urlGroup = document.getElementById('device-url-group'); + const urlInput = document.getElementById('device-url'); + const serialGroup = document.getElementById('device-serial-port-group'); + const serialSelect = document.getElementById('device-serial-port'); + const ledCountGroup = document.getElementById('device-led-count-group'); + const discoverySection = document.getElementById('discovery-section'); + + const baudRateGroup = document.getElementById('device-baud-rate-group'); + + if (isSerialDevice(deviceType)) { + urlGroup.style.display = 'none'; + urlInput.removeAttribute('required'); + serialGroup.style.display = ''; + serialSelect.setAttribute('required', ''); + ledCountGroup.style.display = ''; + baudRateGroup.style.display = ''; + // Hide discovery list — serial port dropdown replaces it + if (discoverySection) discoverySection.style.display = 'none'; + // Populate from cache or show placeholder (lazy-load on focus) + if (deviceType in _discoveryCache) { + _populateSerialPortDropdown(_discoveryCache[deviceType]); + } else { + serialSelect.innerHTML = ''; + const opt = document.createElement('option'); + opt.value = ''; + opt.textContent = t('device.serial_port.hint') || 'Click to discover ports...'; + opt.disabled = true; + serialSelect.appendChild(opt); + } + updateBaudFpsHint(); + } else { + urlGroup.style.display = ''; + urlInput.setAttribute('required', ''); + serialGroup.style.display = 'none'; + serialSelect.removeAttribute('required'); + ledCountGroup.style.display = 'none'; + baudRateGroup.style.display = 'none'; + // Show cached results or trigger scan for WLED + if (deviceType in _discoveryCache) { + _renderDiscoveryList(); + } else { + scanForDevices(); + } + } +} + +export function updateBaudFpsHint() { + const hintEl = document.getElementById('baud-fps-hint'); + const baudRate = parseInt(document.getElementById('device-baud-rate').value, 10); + const ledCount = parseInt(document.getElementById('device-led-count').value, 10); + const deviceType = document.getElementById('device-type')?.value || 'adalight'; + _renderFpsHint(hintEl, baudRate, ledCount, deviceType); +} + +function _renderDiscoveryList() { + const selectedType = document.getElementById('device-type').value; + const devices = _discoveryCache[selectedType]; + + // Serial devices: populate serial port dropdown instead of discovery list + if (isSerialDevice(selectedType)) { + _populateSerialPortDropdown(devices || []); + return; + } + + // WLED and others: render discovery list cards + const list = document.getElementById('discovery-list'); + const empty = document.getElementById('discovery-empty'); + const section = document.getElementById('discovery-section'); + if (!list || !section) return; + + list.innerHTML = ''; + + if (!devices) { + section.style.display = 'none'; + return; + } + + section.style.display = 'block'; + + if (devices.length === 0) { + empty.style.display = 'block'; + return; + } + + empty.style.display = 'none'; + devices.forEach(device => { + const card = document.createElement('div'); + card.className = 'discovery-item' + (device.already_added ? ' discovery-item--added' : ''); + const meta = [device.ip]; + if (device.led_count) meta.push(device.led_count + ' LEDs'); + if (device.version) meta.push('v' + device.version); + card.innerHTML = ` +
+ ${escapeHtml(device.name)} + ${escapeHtml(meta.join(' \u00b7 '))} +
+ ${device.already_added + ? '' + t('device.scan.already_added') + '' + : ''} + `; + if (!device.already_added) { + card.onclick = () => selectDiscoveredDevice(device); + } + list.appendChild(card); + }); +} + +function _populateSerialPortDropdown(devices) { + const select = document.getElementById('device-serial-port'); + select.innerHTML = ''; + + if (devices.length === 0) { + const opt = document.createElement('option'); + opt.value = ''; + opt.textContent = t('device.serial_port.none') || 'No serial ports found'; + opt.disabled = true; + select.appendChild(opt); + return; + } + + devices.forEach(device => { + const opt = document.createElement('option'); + opt.value = device.url; + opt.textContent = device.name; + if (device.already_added) { + opt.textContent += ' (' + t('device.scan.already_added') + ')'; + } + select.appendChild(opt); + }); +} + +export function onSerialPortFocus() { + // Lazy-load: trigger discovery when user opens the serial port dropdown + const deviceType = document.getElementById('device-type')?.value || 'adalight'; + if (!(deviceType in _discoveryCache)) { + scanForDevices(deviceType); + } +} + +export function showAddDevice() { + const modal = document.getElementById('add-device-modal'); + const form = document.getElementById('add-device-form'); + const error = document.getElementById('add-device-error'); + form.reset(); + error.style.display = 'none'; + set_discoveryCache({}); + // Reset discovery section + const section = document.getElementById('discovery-section'); + if (section) { + section.style.display = 'none'; + document.getElementById('discovery-list').innerHTML = ''; + document.getElementById('discovery-empty').style.display = 'none'; + document.getElementById('discovery-loading').style.display = 'none'; + } + // Reset serial port dropdown + document.getElementById('device-serial-port').innerHTML = ''; + const scanBtn = document.getElementById('scan-network-btn'); + if (scanBtn) scanBtn.disabled = false; + modal.style.display = 'flex'; + lockBody(); + onDeviceTypeChanged(); + setTimeout(() => document.getElementById('device-name').focus(), 100); +} + +export function closeAddDeviceModal() { + const modal = document.getElementById('add-device-modal'); + modal.style.display = 'none'; + unlockBody(); +} + +export async function scanForDevices(forceType) { + const scanType = forceType || document.getElementById('device-type')?.value || 'wled'; + + // Per-type guard: prevent duplicate scans for the same type + if (_discoveryScanRunning === scanType) return; + set_discoveryScanRunning(scanType); + + const loading = document.getElementById('discovery-loading'); + const list = document.getElementById('discovery-list'); + const empty = document.getElementById('discovery-empty'); + const section = document.getElementById('discovery-section'); + const scanBtn = document.getElementById('scan-network-btn'); + + if (isSerialDevice(scanType)) { + // Show loading in the serial port dropdown + const select = document.getElementById('device-serial-port'); + select.innerHTML = ''; + const opt = document.createElement('option'); + opt.value = ''; + opt.textContent = '\u23F3'; + opt.disabled = true; + select.appendChild(opt); + } else { + // Show the discovery section with loading spinner + section.style.display = 'block'; + loading.style.display = 'flex'; + list.innerHTML = ''; + empty.style.display = 'none'; + } + if (scanBtn) scanBtn.disabled = true; + + try { + const response = await fetch(`${API_BASE}/devices/discover?timeout=3&device_type=${encodeURIComponent(scanType)}`, { + headers: getHeaders() + }); + + if (response.status === 401) { handle401Error(); return; } + + loading.style.display = 'none'; + if (scanBtn) scanBtn.disabled = false; + + if (!response.ok) { + if (!isSerialDevice(scanType)) { + empty.style.display = 'block'; + empty.querySelector('small').textContent = t('device.scan.error'); + } + return; + } + + const data = await response.json(); + _discoveryCache[scanType] = data.devices || []; + + // Only render if the user is still on this type + const currentType = document.getElementById('device-type')?.value; + if (currentType === scanType) { + _renderDiscoveryList(); + } + } catch (err) { + loading.style.display = 'none'; + if (scanBtn) scanBtn.disabled = false; + if (!isSerialDevice(scanType)) { + empty.style.display = 'block'; + empty.querySelector('small').textContent = t('device.scan.error'); + } + console.error('Device scan failed:', err); + } finally { + if (_discoveryScanRunning === scanType) { + set_discoveryScanRunning(false); + } + } +} + +export function selectDiscoveredDevice(device) { + document.getElementById('device-name').value = device.name; + const typeSelect = document.getElementById('device-type'); + if (typeSelect) typeSelect.value = device.device_type; + onDeviceTypeChanged(); + if (isSerialDevice(device.device_type)) { + document.getElementById('device-serial-port').value = device.url; + } else { + document.getElementById('device-url').value = device.url; + } + showToast(t('device.scan.selected'), 'info'); +} + +export async function handleAddDevice(event) { + event.preventDefault(); + + const name = document.getElementById('device-name').value.trim(); + const deviceType = document.getElementById('device-type')?.value || 'wled'; + const url = isSerialDevice(deviceType) + ? document.getElementById('device-serial-port').value + : document.getElementById('device-url').value.trim(); + const error = document.getElementById('add-device-error'); + + if (!name || !url) { + error.textContent = 'Please fill in all fields'; + error.style.display = 'block'; + return; + } + + try { + const body = { name, url, device_type: deviceType }; + const ledCountInput = document.getElementById('device-led-count'); + if (ledCountInput && ledCountInput.value) { + body.led_count = parseInt(ledCountInput.value, 10); + } + const baudRateSelect = document.getElementById('device-baud-rate'); + if (isSerialDevice(deviceType) && baudRateSelect && baudRateSelect.value) { + body.baud_rate = parseInt(baudRateSelect.value, 10); + } + const lastTemplateId = localStorage.getItem('lastCaptureTemplateId'); + if (lastTemplateId) { + body.capture_template_id = lastTemplateId; + } + + const response = await fetch(`${API_BASE}/devices`, { + method: 'POST', + headers: getHeaders(), + body: JSON.stringify(body) + }); + + if (response.status === 401) { + handle401Error(); + return; + } + + if (response.ok) { + const result = await response.json(); + console.log('Device added successfully:', result); + showToast('Device added successfully', 'success'); + closeAddDeviceModal(); + // Use window.* to avoid circular imports + if (typeof window.loadDevices === 'function') await window.loadDevices(); + // Auto-start device tutorial on first device add + if (!localStorage.getItem('deviceTutorialSeen')) { + localStorage.setItem('deviceTutorialSeen', '1'); + setTimeout(() => { + if (typeof window.startDeviceTutorial === 'function') window.startDeviceTutorial(); + }, 300); + } + } else { + const errorData = await response.json(); + console.error('Failed to add device:', errorData); + error.textContent = `Failed to add device: ${errorData.detail}`; + error.style.display = 'block'; + } + } catch (err) { + console.error('Failed to add device:', err); + showToast('Failed to add device', 'error'); + } +} diff --git a/server/src/wled_controller/static/js/features/devices.js b/server/src/wled_controller/static/js/features/devices.js new file mode 100644 index 0000000..2f40fad --- /dev/null +++ b/server/src/wled_controller/static/js/features/devices.js @@ -0,0 +1,453 @@ +/** + * Device cards — settings modal, brightness, power, color. + */ + +import { + settingsInitialValues, setSettingsInitialValues, + _deviceBrightnessCache, +} from '../core/state.js'; +import { API_BASE, getHeaders, escapeHtml, isSerialDevice, handle401Error } from '../core/api.js'; +import { t } from '../core/i18n.js'; +import { lockBody, unlockBody, showToast, showConfirm } from '../core/ui.js'; + +export function createDeviceCard(device) { + const state = device.state || {}; + + const devOnline = state.device_online || false; + const devLatency = state.device_latency_ms; + const devName = state.device_name; + const devVersion = state.device_version; + const devLastChecked = state.device_last_checked; + + let healthClass, healthTitle, healthLabel; + if (devLastChecked === null || devLastChecked === undefined) { + healthClass = 'health-unknown'; + healthTitle = t('device.health.checking'); + healthLabel = ''; + } else if (devOnline) { + healthClass = 'health-online'; + healthTitle = `${t('device.health.online')}`; + if (devName) healthTitle += ` - ${devName}`; + if (devVersion) healthTitle += ` v${devVersion}`; + if (devLatency !== null && devLatency !== undefined) healthTitle += ` (${Math.round(devLatency)}ms)`; + healthLabel = ''; + } else { + healthClass = 'health-offline'; + healthTitle = t('device.health.offline'); + if (state.device_error) healthTitle += `: ${state.device_error}`; + healthLabel = `${t('device.health.offline')}`; + } + + const ledCount = state.device_led_count || device.led_count; + + return ` +
+
+ ${(device.capabilities || []).includes('power_control') ? `` : ''} + +
+
+
+ + ${device.name || device.id} + ${device.url && device.url.startsWith('http') ? `${escapeHtml(device.url.replace(/^https?:\/\//, ''))}🌐` : (device.url && !device.url.startsWith('http') ? `${escapeHtml(device.url)}` : '')} + ${healthLabel} +
+
+
+ ${(device.device_type || 'wled').toUpperCase()} + ${ledCount ? `💡 ${ledCount}` : ''} + ${state.device_led_type ? `🔌 ${state.device_led_type.replace(/ RGBW$/, '')}` : ''} + ${state.device_rgbw ? '' : ''} + ${(device.capabilities || []).includes('static_color') ? `` : ''} +
+ ${(device.capabilities || []).includes('brightness_control') ? ` +
+ +
` : ''} +
+ + +
+
+ `; +} + +export async function toggleDevicePower(deviceId) { + try { + const getResp = await fetch(`${API_BASE}/devices/${deviceId}/power`, { headers: getHeaders() }); + if (getResp.status === 401) { handle401Error(); return; } + if (!getResp.ok) { showToast('Failed to get power state', 'error'); return; } + const current = await getResp.json(); + const newState = !current.on; + + const setResp = await fetch(`${API_BASE}/devices/${deviceId}/power`, { + method: 'PUT', + headers: { ...getHeaders(), 'Content-Type': 'application/json' }, + body: JSON.stringify({ on: newState }) + }); + if (setResp.status === 401) { handle401Error(); return; } + if (setResp.ok) { + showToast(t(newState ? 'device.power.on_success' : 'device.power.off_success'), 'success'); + } else { + const error = await setResp.json(); + showToast(error.detail || 'Failed', 'error'); + } + } catch (error) { + showToast('Failed to toggle power', 'error'); + } +} + +export function attachDeviceListeners(deviceId) { + // Add any specific event listeners here if needed +} + +export async function removeDevice(deviceId) { + const confirmed = await showConfirm(t('device.remove.confirm')); + if (!confirmed) return; + + try { + const response = await fetch(`${API_BASE}/devices/${deviceId}`, { + method: 'DELETE', + headers: getHeaders() + }); + if (response.status === 401) { handle401Error(); return; } + if (response.ok) { + showToast('Device removed', 'success'); + window.loadDevices(); + } else { + const error = await response.json(); + showToast(`Failed to remove: ${error.detail}`, 'error'); + } + } catch (error) { + console.error('Failed to remove device:', error); + showToast('Failed to remove device', 'error'); + } +} + +export async function showSettings(deviceId) { + try { + const deviceResponse = await fetch(`${API_BASE}/devices/${deviceId}`, { headers: getHeaders() }); + if (deviceResponse.status === 401) { handle401Error(); return; } + if (!deviceResponse.ok) { showToast('Failed to load device settings', 'error'); return; } + + const device = await deviceResponse.json(); + const isAdalight = isSerialDevice(device.device_type); + + document.getElementById('settings-device-id').value = device.id; + document.getElementById('settings-device-name').value = device.name; + document.getElementById('settings-health-interval').value = 30; + + const urlGroup = document.getElementById('settings-url-group'); + const serialGroup = document.getElementById('settings-serial-port-group'); + if (isAdalight) { + urlGroup.style.display = 'none'; + document.getElementById('settings-device-url').removeAttribute('required'); + serialGroup.style.display = ''; + _populateSettingsSerialPorts(device.url); + } else { + urlGroup.style.display = ''; + document.getElementById('settings-device-url').setAttribute('required', ''); + document.getElementById('settings-device-url').value = device.url; + serialGroup.style.display = 'none'; + } + + const caps = device.capabilities || []; + const ledCountGroup = document.getElementById('settings-led-count-group'); + if (caps.includes('manual_led_count')) { + ledCountGroup.style.display = ''; + document.getElementById('settings-led-count').value = device.led_count || ''; + } else { + ledCountGroup.style.display = 'none'; + } + + const baudRateGroup = document.getElementById('settings-baud-rate-group'); + if (isAdalight) { + baudRateGroup.style.display = ''; + const baudSelect = document.getElementById('settings-baud-rate'); + if (device.baud_rate) { + baudSelect.value = String(device.baud_rate); + } else { + baudSelect.value = '115200'; + } + updateSettingsBaudFpsHint(); + } else { + baudRateGroup.style.display = 'none'; + } + + document.getElementById('settings-auto-shutdown').checked = !!device.auto_shutdown; + + setSettingsInitialValues({ + name: device.name, + url: device.url, + led_count: String(device.led_count || ''), + baud_rate: String(device.baud_rate || '115200'), + device_type: device.device_type, + capabilities: caps, + state_check_interval: '30', + auto_shutdown: !!device.auto_shutdown, + }); + + const modal = document.getElementById('device-settings-modal'); + modal.style.display = 'flex'; + lockBody(); + + setTimeout(() => { + document.getElementById('settings-device-name').focus(); + }, 100); + + } catch (error) { + console.error('Failed to load device settings:', error); + showToast('Failed to load device settings', 'error'); + } +} + +function _getSettingsUrl() { + if (isSerialDevice(settingsInitialValues.device_type)) { + return document.getElementById('settings-serial-port').value; + } + return document.getElementById('settings-device-url').value.trim(); +} + +export function isSettingsDirty() { + const ledCountDirty = (settingsInitialValues.capabilities || []).includes('manual_led_count') + && document.getElementById('settings-led-count').value !== settingsInitialValues.led_count; + return ( + document.getElementById('settings-device-name').value !== settingsInitialValues.name || + _getSettingsUrl() !== settingsInitialValues.url || + document.getElementById('settings-health-interval').value !== settingsInitialValues.state_check_interval || + document.getElementById('settings-auto-shutdown').checked !== settingsInitialValues.auto_shutdown || + ledCountDirty + ); +} + +export function forceCloseDeviceSettingsModal() { + const modal = document.getElementById('device-settings-modal'); + const error = document.getElementById('settings-error'); + modal.style.display = 'none'; + error.style.display = 'none'; + unlockBody(); + setSettingsInitialValues({}); +} + +export async function closeDeviceSettingsModal() { + if (isSettingsDirty()) { + const confirmed = await showConfirm(t('modal.discard_changes')); + if (!confirmed) return; + } + forceCloseDeviceSettingsModal(); +} + +export async function saveDeviceSettings() { + const deviceId = document.getElementById('settings-device-id').value; + const name = document.getElementById('settings-device-name').value.trim(); + const url = _getSettingsUrl(); + const error = document.getElementById('settings-error'); + + if (!name || !url) { + error.textContent = 'Please fill in all fields correctly'; + error.style.display = 'block'; + return; + } + + try { + const body = { name, url, auto_shutdown: document.getElementById('settings-auto-shutdown').checked }; + const ledCountInput = document.getElementById('settings-led-count'); + if ((settingsInitialValues.capabilities || []).includes('manual_led_count') && ledCountInput.value) { + body.led_count = parseInt(ledCountInput.value, 10); + } + if (isSerialDevice(settingsInitialValues.device_type)) { + const baudVal = document.getElementById('settings-baud-rate').value; + if (baudVal) body.baud_rate = parseInt(baudVal, 10); + } + const deviceResponse = await fetch(`${API_BASE}/devices/${deviceId}`, { + method: 'PUT', + headers: getHeaders(), + body: JSON.stringify(body) + }); + + if (deviceResponse.status === 401) { handle401Error(); return; } + + if (!deviceResponse.ok) { + const errorData = await deviceResponse.json(); + error.textContent = `Failed to update device: ${errorData.detail}`; + error.style.display = 'block'; + return; + } + + showToast(t('settings.saved'), 'success'); + forceCloseDeviceSettingsModal(); + window.loadDevices(); + } catch (err) { + console.error('Failed to save device settings:', err); + error.textContent = 'Failed to save settings'; + error.style.display = 'block'; + } +} + +// Brightness +export function updateBrightnessLabel(deviceId, value) { + const slider = document.querySelector(`[data-device-brightness="${deviceId}"]`); + if (slider) slider.title = Math.round(parseInt(value) / 255 * 100) + '%'; +} + +export async function saveCardBrightness(deviceId, value) { + const bri = parseInt(value); + _deviceBrightnessCache[deviceId] = bri; + try { + await fetch(`${API_BASE}/devices/${deviceId}/brightness`, { + method: 'PUT', + headers: getHeaders(), + body: JSON.stringify({ brightness: bri }) + }); + } catch (err) { + console.error('Failed to update brightness:', err); + showToast('Failed to update brightness', 'error'); + } +} + +export async function fetchDeviceBrightness(deviceId) { + try { + const resp = await fetch(`${API_BASE}/devices/${deviceId}/brightness`, { + headers: getHeaders() + }); + if (!resp.ok) return; + const data = await resp.json(); + _deviceBrightnessCache[deviceId] = data.brightness; + const slider = document.querySelector(`[data-device-brightness="${deviceId}"]`); + 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}"]`); + if (wrap) wrap.classList.remove('brightness-loading'); + } catch (err) { + // Silently fail — device may be offline + } +} + +// Static color helpers +export function rgbToHex(r, g, b) { + return '#' + [r, g, b].map(c => c.toString(16).padStart(2, '0')).join(''); +} + +export function hexToRgb(hex) { + const result = /^#?([a-f\d]{2})([a-f\d]{2})([a-f\d]{2})$/i.exec(hex); + return result ? [parseInt(result[1], 16), parseInt(result[2], 16), parseInt(result[3], 16)] : null; +} + +export async function saveDeviceStaticColor(deviceId, hexValue) { + const rgb = hexToRgb(hexValue); + try { + await fetch(`${API_BASE}/devices/${deviceId}/color`, { + method: 'PUT', + headers: { ...getHeaders(), 'Content-Type': 'application/json' }, + body: JSON.stringify({ color: rgb }) + }); + const wrap = document.querySelector(`[data-color-wrap="${deviceId}"]`); + if (wrap) { + const clearBtn = wrap.querySelector('.btn-clear-color'); + if (clearBtn) clearBtn.style.display = ''; + } + } catch (err) { + console.error('Failed to set static color:', err); + showToast('Failed to set static color', 'error'); + } +} + +export async function clearDeviceStaticColor(deviceId) { + try { + await fetch(`${API_BASE}/devices/${deviceId}/color`, { + method: 'PUT', + headers: { ...getHeaders(), 'Content-Type': 'application/json' }, + body: JSON.stringify({ color: null }) + }); + const picker = document.querySelector(`[data-device-color="${deviceId}"]`); + if (picker) picker.value = '#000000'; + const wrap = document.querySelector(`[data-color-wrap="${deviceId}"]`); + if (wrap) { + const clearBtn = wrap.querySelector('.btn-clear-color'); + if (clearBtn) clearBtn.style.display = 'none'; + } + } catch (err) { + console.error('Failed to clear static color:', err); + } +} + +// FPS hint helpers (shared with device-discovery) +export function _computeMaxFps(baudRate, ledCount, deviceType) { + if (!baudRate || !ledCount || ledCount < 1) return null; + const overhead = deviceType === 'ambiled' ? 1 : 6; + const bitsPerFrame = (ledCount * 3 + overhead) * 10; + return Math.floor(baudRate / bitsPerFrame); +} + +export function _renderFpsHint(hintEl, baudRate, ledCount, deviceType) { + const fps = _computeMaxFps(baudRate, ledCount, deviceType); + if (fps !== null) { + hintEl.textContent = `Max FPS ≈ ${fps}`; + hintEl.style.display = ''; + } else { + hintEl.style.display = 'none'; + } +} + +export function updateSettingsBaudFpsHint() { + const hintEl = document.getElementById('settings-baud-fps-hint'); + const baudRate = parseInt(document.getElementById('settings-baud-rate').value, 10); + const ledCount = parseInt(document.getElementById('settings-led-count').value, 10); + _renderFpsHint(hintEl, baudRate, ledCount, settingsInitialValues.device_type); +} + +// Settings serial port population (used from showSettings) +async function _populateSettingsSerialPorts(currentUrl) { + const select = document.getElementById('settings-serial-port'); + select.innerHTML = ''; + const loadingOpt = document.createElement('option'); + loadingOpt.value = currentUrl; + loadingOpt.textContent = currentUrl + ' ⏳'; + select.appendChild(loadingOpt); + + try { + const discoverType = settingsInitialValues.device_type || 'adalight'; + const resp = await fetch(`${API_BASE}/devices/discover?timeout=2&device_type=${encodeURIComponent(discoverType)}`, { + headers: getHeaders() + }); + if (!resp.ok) return; + const data = await resp.json(); + const devices = data.devices || []; + + select.innerHTML = ''; + let currentFound = false; + devices.forEach(device => { + const opt = document.createElement('option'); + opt.value = device.url; + opt.textContent = device.name; + if (device.url === currentUrl) currentFound = true; + select.appendChild(opt); + }); + if (!currentFound) { + const opt = document.createElement('option'); + opt.value = currentUrl; + opt.textContent = currentUrl; + select.insertBefore(opt, select.firstChild); + } + select.value = currentUrl; + } catch (err) { + console.error('Failed to discover serial ports:', err); + } +} + +export async function loadDevices() { + await window.loadTargetsTab(); +} diff --git a/server/src/wled_controller/static/js/features/displays.js b/server/src/wled_controller/static/js/features/displays.js new file mode 100644 index 0000000..660a3e8 --- /dev/null +++ b/server/src/wled_controller/static/js/features/displays.js @@ -0,0 +1,112 @@ +/** + * Display picker lightbox — display selection for streams and tests. + */ + +import { + _cachedDisplays, _displayPickerCallback, _displayPickerSelectedIndex, + set_displayPickerCallback, set_displayPickerSelectedIndex, +} from '../core/state.js'; +import { t } from '../core/i18n.js'; +import { loadDisplays } from '../core/api.js'; + +export function openDisplayPicker(callback, selectedIndex) { + set_displayPickerCallback(callback); + set_displayPickerSelectedIndex((selectedIndex !== undefined && selectedIndex !== null && selectedIndex !== '') ? Number(selectedIndex) : null); + const lightbox = document.getElementById('display-picker-lightbox'); + + lightbox.classList.add('active'); + + requestAnimationFrame(() => { + if (_cachedDisplays && _cachedDisplays.length > 0) { + renderDisplayPickerLayout(_cachedDisplays); + } else { + const canvas = document.getElementById('display-picker-canvas'); + canvas.innerHTML = '
'; + loadDisplays().then(() => { + // Re-import to get updated value + import('../core/state.js').then(({ _cachedDisplays: displays }) => { + if (displays && displays.length > 0) { + renderDisplayPickerLayout(displays); + } else { + canvas.innerHTML = `
${t('displays.none')}
`; + } + }); + }); + } + }); +} + +export function closeDisplayPicker(event) { + if (event && event.target && event.target.closest('.display-picker-content')) return; + const lightbox = document.getElementById('display-picker-lightbox'); + lightbox.classList.remove('active'); + set_displayPickerCallback(null); +} + +export function selectDisplay(displayIndex) { + // Re-read live bindings + import('../core/state.js').then(({ _displayPickerCallback: cb, _cachedDisplays: displays }) => { + if (cb) { + const display = displays ? displays.find(d => d.index === displayIndex) : null; + cb(displayIndex, display); + } + closeDisplayPicker(); + }); +} + +export function renderDisplayPickerLayout(displays) { + const canvas = document.getElementById('display-picker-canvas'); + + if (!displays || displays.length === 0) { + canvas.innerHTML = `
${t('displays.none')}
`; + return; + } + + let minX = Infinity, minY = Infinity, maxX = -Infinity, maxY = -Infinity; + displays.forEach(display => { + minX = Math.min(minX, display.x); + minY = Math.min(minY, display.y); + maxX = Math.max(maxX, display.x + display.width); + maxY = Math.max(maxY, display.y + display.height); + }); + + const totalWidth = maxX - minX; + const totalHeight = maxY - minY; + const aspect = totalHeight / totalWidth; + + const displayElements = displays.map(display => { + const leftPct = ((display.x - minX) / totalWidth) * 100; + const topPct = ((display.y - minY) / totalHeight) * 100; + const widthPct = (display.width / totalWidth) * 100; + const heightPct = (display.height / totalHeight) * 100; + + const isSelected = _displayPickerSelectedIndex !== null && display.index === _displayPickerSelectedIndex; + return ` +
+
(${display.x}, ${display.y})
+
#${display.index}
+
+ ${display.name} + ${display.width}×${display.height} + ${display.refresh_rate}Hz +
+
+ `; + }).join(''); + + canvas.innerHTML = ` +
+ ${displayElements} +
+ `; +} + +export function formatDisplayLabel(displayIndex, display) { + if (display) { + return `#${display.index}: ${display.width}×${display.height}${display.is_primary ? ' (' + t('displays.badge.primary') + ')' : ''}`; + } + return `Display ${displayIndex}`; +} diff --git a/server/src/wled_controller/static/js/features/kc-targets.js b/server/src/wled_controller/static/js/features/kc-targets.js new file mode 100644 index 0000000..03ab549 --- /dev/null +++ b/server/src/wled_controller/static/js/features/kc-targets.js @@ -0,0 +1,603 @@ +/** + * Key Colors targets — cards, test lightbox, editor, WebSocket live colors. + */ + +import { + kcTestAutoRefresh, setKcTestAutoRefresh, + kcTestTargetId, setKcTestTargetId, + kcEditorInitialValues, setKcEditorInitialValues, + _kcNameManuallyEdited, set_kcNameManuallyEdited, + kcWebSockets, + PATTERN_RECT_BORDERS, +} from '../core/state.js'; +import { API_BASE, getHeaders, fetchWithAuth, escapeHtml, handle401Error } from '../core/api.js'; +import { t } from '../core/i18n.js'; +import { lockBody, unlockBody, showToast, showConfirm, setupBackdropClose } from '../core/ui.js'; + +export function createKCTargetCard(target, sourceMap, patternTemplateMap) { + const state = target.state || {}; + const metrics = target.metrics || {}; + const kcSettings = target.key_colors_settings || {}; + + const isProcessing = state.processing || false; + + const source = sourceMap[target.picture_source_id]; + const sourceName = source ? source.name : (target.picture_source_id || 'No source'); + const patTmpl = patternTemplateMap[kcSettings.pattern_template_id]; + const patternName = patTmpl ? patTmpl.name : 'No pattern'; + const rectCount = patTmpl ? (patTmpl.rectangles || []).length : 0; + + // Render initial color swatches from pre-fetched REST data + let swatchesHtml = ''; + const latestColors = target.latestColors && target.latestColors.colors; + if (isProcessing && latestColors && Object.keys(latestColors).length > 0) { + swatchesHtml = Object.entries(latestColors).map(([name, color]) => ` +
+
+ ${escapeHtml(name)} +
+ `).join(''); + } else if (isProcessing) { + swatchesHtml = `${t('kc.colors.none')}`; + } + + return ` +
+ +
+
+ ${escapeHtml(target.name)} + ${isProcessing ? `${t('targets.status.processing')}` : ''} +
+
+
+ 📺 ${escapeHtml(sourceName)} + 📄 ${escapeHtml(patternName)} + ▭ ${rectCount} rect${rectCount !== 1 ? 's' : ''} +
+
+
+ ${swatchesHtml} +
+ ${isProcessing ? ` +
+
+
${t('device.metrics.actual_fps')}
+
${state.fps_actual?.toFixed(1) || '0.0'}
+
+
+
${t('device.metrics.current_fps')}
+
${state.fps_current ?? '-'}
+
+
+
${t('device.metrics.target_fps')}
+
${state.fps_target || 0}
+
+
+
${t('device.metrics.potential_fps')}
+
${state.fps_potential?.toFixed(0) || '-'}
+
+
+
${t('device.metrics.frames')}
+
${metrics.frames_processed || 0}
+
+
+
${t('device.metrics.keepalive')}
+
${state.frames_keepalive ?? '-'}
+
+
+
${t('device.metrics.errors')}
+
${metrics.errors_count || 0}
+
+
+ ${state.timing_total_ms != null ? ` +
+
+
${t('device.metrics.timing')}
+
${state.timing_total_ms}ms
+
+
+ + + +
+
+ calc ${state.timing_calc_colors_ms}ms + smooth ${state.timing_smooth_ms}ms + broadcast ${state.timing_broadcast_ms}ms +
+
+ ` : ''} + ` : ''} +
+
+ ${isProcessing ? ` + + ` : ` + + `} + + +
+
+ `; +} + +// ===== KEY COLORS TEST ===== + +export async function fetchKCTest(targetId) { + const response = await fetch(`${API_BASE}/picture-targets/${targetId}/test`, { + method: 'POST', + headers: getHeaders(), + }); + if (!response.ok) { + const err = await response.json().catch(() => ({})); + throw new Error(err.detail || response.statusText); + } + return response.json(); +} + +export async function testKCTarget(targetId) { + setKcTestTargetId(targetId); + + // Show lightbox immediately with a spinner + const lightbox = document.getElementById('image-lightbox'); + const lbImg = document.getElementById('lightbox-image'); + const statsEl = document.getElementById('lightbox-stats'); + lbImg.style.display = 'none'; + lbImg.src = ''; + statsEl.style.display = 'none'; + + // Insert spinner if not already present + let spinner = lightbox.querySelector('.lightbox-spinner'); + if (!spinner) { + spinner = document.createElement('div'); + spinner.className = 'lightbox-spinner loading-spinner'; + lightbox.querySelector('.lightbox-content').prepend(spinner); + } + spinner.style.display = ''; + + // Show auto-refresh button + const refreshBtn = document.getElementById('lightbox-auto-refresh'); + if (refreshBtn) refreshBtn.style.display = ''; + + lightbox.classList.add('active'); + lockBody(); + + try { + const result = await fetchKCTest(targetId); + displayKCTestResults(result); + } catch (e) { + // Use window.closeLightbox to avoid importing from ui.js circular + if (typeof window.closeLightbox === 'function') window.closeLightbox(); + showToast(t('kc.test.error') + ': ' + e.message, 'error'); + } +} + +export function toggleKCTestAutoRefresh() { + if (kcTestAutoRefresh) { + stopKCTestAutoRefresh(); + } else { + setKcTestAutoRefresh(setInterval(async () => { + if (!kcTestTargetId) return; + try { + const result = await fetchKCTest(kcTestTargetId); + displayKCTestResults(result); + } catch (e) { + stopKCTestAutoRefresh(); + } + }, 1000)); + updateAutoRefreshButton(true); + } +} + +export function stopKCTestAutoRefresh() { + if (kcTestAutoRefresh) { + clearInterval(kcTestAutoRefresh); + setKcTestAutoRefresh(null); + } + setKcTestTargetId(null); + updateAutoRefreshButton(false); +} + +export function updateAutoRefreshButton(active) { + const btn = document.getElementById('lightbox-auto-refresh'); + if (!btn) return; + if (active) { + btn.classList.add('active'); + btn.innerHTML = '⏸'; // pause symbol + } else { + btn.classList.remove('active'); + btn.innerHTML = '▶'; // play symbol + } +} + +export function displayKCTestResults(result) { + const srcImg = new window.Image(); + srcImg.onload = () => { + const canvas = document.createElement('canvas'); + canvas.width = srcImg.width; + canvas.height = srcImg.height; + const ctx = canvas.getContext('2d'); + + // Draw captured frame + ctx.drawImage(srcImg, 0, 0); + + const w = srcImg.width; + const h = srcImg.height; + + // Draw each rectangle with extracted color overlay + result.rectangles.forEach((rect, i) => { + const px = rect.x * w; + const py = rect.y * h; + const pw = rect.width * w; + const ph = rect.height * h; + + const color = rect.color; + const borderColor = PATTERN_RECT_BORDERS[i % PATTERN_RECT_BORDERS.length]; + + // Semi-transparent fill with the extracted color + ctx.fillStyle = `rgba(${color.r}, ${color.g}, ${color.b}, 0.3)`; + ctx.fillRect(px, py, pw, ph); + + // Border using pattern colors for distinction + ctx.strokeStyle = borderColor; + ctx.lineWidth = 3; + ctx.strokeRect(px, py, pw, ph); + + // Color swatch in top-left corner of rect + const swatchSize = Math.max(16, Math.min(32, pw * 0.15)); + ctx.fillStyle = color.hex; + ctx.fillRect(px + 4, py + 4, swatchSize, swatchSize); + ctx.strokeStyle = '#fff'; + ctx.lineWidth = 1; + ctx.strokeRect(px + 4, py + 4, swatchSize, swatchSize); + + // Name label with shadow for readability + const fontSize = Math.max(12, Math.min(18, pw * 0.06)); + ctx.font = `bold ${fontSize}px sans-serif`; + const labelX = px + swatchSize + 10; + const labelY = py + 4 + swatchSize / 2 + fontSize / 3; + ctx.shadowColor = 'rgba(0,0,0,0.8)'; + ctx.shadowBlur = 4; + ctx.fillStyle = '#fff'; + ctx.fillText(rect.name, labelX, labelY); + + // Hex label below name + ctx.font = `${fontSize - 2}px monospace`; + ctx.fillText(color.hex, labelX, labelY + fontSize + 2); + ctx.shadowBlur = 0; + }); + + const dataUrl = canvas.toDataURL('image/jpeg', 0.92); + + // Build stats HTML + let statsHtml = `
`; + statsHtml += `${escapeHtml(result.pattern_template_name)} \u2022 ${escapeHtml(result.interpolation_mode)}`; + result.rectangles.forEach((rect) => { + const c = rect.color; + statsHtml += `
`; + statsHtml += `
`; + statsHtml += `${escapeHtml(rect.name)} ${c.hex}`; + statsHtml += `
`; + }); + statsHtml += `
`; + + // Hide spinner, show result in the already-open lightbox + const spinner = document.querySelector('.lightbox-spinner'); + if (spinner) spinner.style.display = 'none'; + + const lbImg = document.getElementById('lightbox-image'); + const statsEl = document.getElementById('lightbox-stats'); + lbImg.src = dataUrl; + lbImg.style.display = ''; + statsEl.innerHTML = statsHtml; + statsEl.style.display = ''; + }; + srcImg.src = result.image; +} + +// ===== KEY COLORS EDITOR ===== + +function _autoGenerateKCName() { + if (_kcNameManuallyEdited) return; + if (document.getElementById('kc-editor-id').value) return; + const sourceSelect = document.getElementById('kc-editor-source'); + const sourceName = sourceSelect.selectedOptions[0]?.dataset?.name || ''; + if (!sourceName) return; + const mode = document.getElementById('kc-editor-interpolation').value || 'average'; + const modeName = t(`kc.interpolation.${mode}`); + const patSelect = document.getElementById('kc-editor-pattern-template'); + const patName = patSelect.selectedOptions[0]?.dataset?.name || ''; + document.getElementById('kc-editor-name').value = `${sourceName} \u00b7 ${patName} (${modeName})`; +} + +export async function showKCEditor(targetId = null) { + try { + // Load sources and pattern templates in parallel + const [sourcesResp, patResp] = await Promise.all([ + fetchWithAuth('/picture-sources').catch(() => null), + fetchWithAuth('/pattern-templates').catch(() => null), + ]); + const sources = (sourcesResp && sourcesResp.ok) ? (await sourcesResp.json()).streams || [] : []; + const patTemplates = (patResp && patResp.ok) ? (await patResp.json()).templates || [] : []; + + // Populate source select + const sourceSelect = document.getElementById('kc-editor-source'); + sourceSelect.innerHTML = ''; + sources.forEach(s => { + const opt = document.createElement('option'); + opt.value = s.id; + opt.dataset.name = s.name; + const typeIcon = s.stream_type === 'raw' ? '\uD83D\uDDA5\uFE0F' : s.stream_type === 'static_image' ? '\uD83D\uDDBC\uFE0F' : '\uD83C\uDFA8'; + opt.textContent = `${typeIcon} ${s.name}`; + sourceSelect.appendChild(opt); + }); + + // Populate pattern template select + const patSelect = document.getElementById('kc-editor-pattern-template'); + patSelect.innerHTML = ''; + patTemplates.forEach(pt => { + const opt = document.createElement('option'); + opt.value = pt.id; + opt.dataset.name = pt.name; + const rectCount = (pt.rectangles || []).length; + opt.textContent = `${pt.name} (${rectCount} rect${rectCount !== 1 ? 's' : ''})`; + patSelect.appendChild(opt); + }); + + if (targetId) { + const resp = await fetch(`${API_BASE}/picture-targets/${targetId}`, { headers: getHeaders() }); + if (!resp.ok) throw new Error('Failed to load target'); + const target = await resp.json(); + const kcSettings = target.key_colors_settings || {}; + + document.getElementById('kc-editor-id').value = target.id; + document.getElementById('kc-editor-name').value = target.name; + sourceSelect.value = target.picture_source_id || ''; + document.getElementById('kc-editor-fps').value = kcSettings.fps ?? 10; + document.getElementById('kc-editor-fps-value').textContent = kcSettings.fps ?? 10; + document.getElementById('kc-editor-interpolation').value = kcSettings.interpolation_mode ?? 'average'; + document.getElementById('kc-editor-smoothing').value = kcSettings.smoothing ?? 0.3; + document.getElementById('kc-editor-smoothing-value').textContent = kcSettings.smoothing ?? 0.3; + patSelect.value = kcSettings.pattern_template_id || ''; + document.getElementById('kc-editor-title').textContent = t('kc.edit'); + } else { + document.getElementById('kc-editor-id').value = ''; + document.getElementById('kc-editor-name').value = ''; + if (sourceSelect.options.length > 0) sourceSelect.selectedIndex = 0; + document.getElementById('kc-editor-fps').value = 10; + document.getElementById('kc-editor-fps-value').textContent = '10'; + document.getElementById('kc-editor-interpolation').value = 'average'; + document.getElementById('kc-editor-smoothing').value = 0.3; + document.getElementById('kc-editor-smoothing-value').textContent = '0.3'; + if (patTemplates.length > 0) patSelect.value = patTemplates[0].id; + document.getElementById('kc-editor-title').textContent = t('kc.add'); + } + + // Auto-name + set_kcNameManuallyEdited(!!targetId); + document.getElementById('kc-editor-name').oninput = () => { set_kcNameManuallyEdited(true); }; + sourceSelect.onchange = () => _autoGenerateKCName(); + document.getElementById('kc-editor-interpolation').onchange = () => _autoGenerateKCName(); + patSelect.onchange = () => _autoGenerateKCName(); + if (!targetId) _autoGenerateKCName(); + + setKcEditorInitialValues({ + name: document.getElementById('kc-editor-name').value, + source: sourceSelect.value, + fps: document.getElementById('kc-editor-fps').value, + interpolation: document.getElementById('kc-editor-interpolation').value, + smoothing: document.getElementById('kc-editor-smoothing').value, + patternTemplateId: patSelect.value, + }); + + const modal = document.getElementById('kc-editor-modal'); + modal.style.display = 'flex'; + lockBody(); + setupBackdropClose(modal, closeKCEditorModal); + + document.getElementById('kc-editor-error').style.display = 'none'; + setTimeout(() => document.getElementById('kc-editor-name').focus(), 100); + } catch (error) { + console.error('Failed to open KC editor:', error); + showToast('Failed to open key colors editor', 'error'); + } +} + +export function isKCEditorDirty() { + return ( + document.getElementById('kc-editor-name').value !== kcEditorInitialValues.name || + document.getElementById('kc-editor-source').value !== kcEditorInitialValues.source || + document.getElementById('kc-editor-fps').value !== kcEditorInitialValues.fps || + document.getElementById('kc-editor-interpolation').value !== kcEditorInitialValues.interpolation || + document.getElementById('kc-editor-smoothing').value !== kcEditorInitialValues.smoothing || + document.getElementById('kc-editor-pattern-template').value !== kcEditorInitialValues.patternTemplateId + ); +} + +export async function closeKCEditorModal() { + if (isKCEditorDirty()) { + const confirmed = await showConfirm(t('modal.discard_changes')); + if (!confirmed) return; + } + forceCloseKCEditorModal(); +} + +export function forceCloseKCEditorModal() { + document.getElementById('kc-editor-modal').style.display = 'none'; + document.getElementById('kc-editor-error').style.display = 'none'; + unlockBody(); + setKcEditorInitialValues({}); +} + +export async function saveKCEditor() { + const targetId = document.getElementById('kc-editor-id').value; + const name = document.getElementById('kc-editor-name').value.trim(); + const sourceId = document.getElementById('kc-editor-source').value; + const fps = parseInt(document.getElementById('kc-editor-fps').value) || 10; + const interpolation = document.getElementById('kc-editor-interpolation').value; + const smoothing = parseFloat(document.getElementById('kc-editor-smoothing').value); + const patternTemplateId = document.getElementById('kc-editor-pattern-template').value; + const errorEl = document.getElementById('kc-editor-error'); + + if (!name) { + errorEl.textContent = t('kc.error.required'); + errorEl.style.display = 'block'; + return; + } + + if (!patternTemplateId) { + errorEl.textContent = t('kc.error.no_pattern'); + errorEl.style.display = 'block'; + return; + } + + const payload = { + name, + picture_source_id: sourceId, + key_colors_settings: { + fps, + interpolation_mode: interpolation, + smoothing, + pattern_template_id: patternTemplateId, + }, + }; + + try { + let response; + if (targetId) { + response = await fetch(`${API_BASE}/picture-targets/${targetId}`, { + method: 'PUT', + headers: getHeaders(), + body: JSON.stringify(payload), + }); + } else { + payload.target_type = 'key_colors'; + response = await fetch(`${API_BASE}/picture-targets`, { + method: 'POST', + headers: getHeaders(), + body: JSON.stringify(payload), + }); + } + + if (response.status === 401) { handle401Error(); return; } + + if (!response.ok) { + const err = await response.json(); + throw new Error(err.detail || 'Failed to save'); + } + + showToast(targetId ? t('kc.updated') : t('kc.created'), 'success'); + forceCloseKCEditorModal(); + // Use window.* to avoid circular import with targets.js + if (typeof window.loadTargetsTab === 'function') window.loadTargetsTab(); + } catch (error) { + console.error('Error saving KC target:', error); + errorEl.textContent = error.message; + errorEl.style.display = 'block'; + } +} + +export async function deleteKCTarget(targetId) { + const confirmed = await showConfirm(t('kc.delete.confirm')); + if (!confirmed) return; + + try { + disconnectKCWebSocket(targetId); + const response = await fetch(`${API_BASE}/picture-targets/${targetId}`, { + method: 'DELETE', + headers: getHeaders() + }); + if (response.status === 401) { handle401Error(); return; } + if (response.ok) { + showToast(t('kc.deleted'), 'success'); + // Use window.* to avoid circular import with targets.js + if (typeof window.loadTargetsTab === 'function') window.loadTargetsTab(); + } else { + const error = await response.json(); + showToast(`Failed to delete: ${error.detail}`, 'error'); + } + } catch (error) { + showToast('Failed to delete key colors target', 'error'); + } +} + +// ===== KEY COLORS WEBSOCKET ===== + +export function connectKCWebSocket(targetId) { + // Disconnect existing connection if any + disconnectKCWebSocket(targetId); + + const key = localStorage.getItem('wled_api_key'); + if (!key) return; + + const protocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:'; + const wsUrl = `${protocol}//${window.location.host}${API_BASE}/picture-targets/${targetId}/ws?token=${encodeURIComponent(key)}`; + + try { + const ws = new WebSocket(wsUrl); + + ws.onmessage = (event) => { + try { + const data = JSON.parse(event.data); + updateKCColorSwatches(targetId, data.colors || {}); + } catch (e) { + console.error('Failed to parse KC WebSocket message:', e); + } + }; + + ws.onclose = () => { + delete kcWebSockets[targetId]; + }; + + ws.onerror = (error) => { + console.error(`KC WebSocket error for ${targetId}:`, error); + }; + + kcWebSockets[targetId] = ws; + } catch (error) { + console.error(`Failed to connect KC WebSocket for ${targetId}:`, error); + } +} + +export function disconnectKCWebSocket(targetId) { + const ws = kcWebSockets[targetId]; + if (ws) { + ws.close(); + delete kcWebSockets[targetId]; + } +} + +export function disconnectAllKCWebSockets() { + Object.keys(kcWebSockets).forEach(targetId => disconnectKCWebSocket(targetId)); +} + +export function updateKCColorSwatches(targetId, colors) { + const container = document.getElementById(`kc-swatches-${targetId}`); + if (!container) return; + + const entries = Object.entries(colors); + if (entries.length === 0) { + container.innerHTML = `${t('kc.colors.none')}`; + return; + } + + container.innerHTML = entries.map(([name, color]) => { + const hex = color.hex || `#${(color.r || 0).toString(16).padStart(2, '0')}${(color.g || 0).toString(16).padStart(2, '0')}${(color.b || 0).toString(16).padStart(2, '0')}`; + return ` +
+
+ ${escapeHtml(name)} +
+ `; + }).join(''); +} diff --git a/server/src/wled_controller/static/js/features/pattern-templates.js b/server/src/wled_controller/static/js/features/pattern-templates.js new file mode 100644 index 0000000..af9a99f --- /dev/null +++ b/server/src/wled_controller/static/js/features/pattern-templates.js @@ -0,0 +1,792 @@ +/** + * Pattern templates — cards, visual canvas editor, rect list, drag handlers. + */ + +import { + patternEditorRects, setPatternEditorRects, + patternEditorSelectedIdx, setPatternEditorSelectedIdx, + patternEditorBgImage, setPatternEditorBgImage, + patternEditorInitialValues, setPatternEditorInitialValues, + patternCanvasDragMode, setPatternCanvasDragMode, + patternCanvasDragStart, setPatternCanvasDragStart, + patternCanvasDragOrigRect, setPatternCanvasDragOrigRect, + patternEditorHoveredIdx, setPatternEditorHoveredIdx, + patternEditorHoverHit, setPatternEditorHoverHit, + PATTERN_RECT_COLORS, + PATTERN_RECT_BORDERS, +} from '../core/state.js'; +import { API_BASE, getHeaders, fetchWithAuth, escapeHtml, handle401Error } from '../core/api.js'; +import { t } from '../core/i18n.js'; +import { lockBody, unlockBody, showToast, showConfirm, setupBackdropClose } from '../core/ui.js'; + +export function createPatternTemplateCard(pt) { + const rectCount = (pt.rectangles || []).length; + const desc = pt.description ? `
${escapeHtml(pt.description)}
` : ''; + return ` +
+ +
+ 📄 ${escapeHtml(pt.name)} +
+ ${desc} +
+ ▭ ${rectCount} rect${rectCount !== 1 ? 's' : ''} +
+
+ +
+
+ `; +} + +export async function showPatternTemplateEditor(templateId = null) { + try { + // Load sources for background capture + const sourcesResp = await fetchWithAuth('/picture-sources').catch(() => null); + const sources = (sourcesResp && sourcesResp.ok) ? (await sourcesResp.json()).streams || [] : []; + + const bgSelect = document.getElementById('pattern-bg-source'); + bgSelect.innerHTML = ''; + sources.forEach(s => { + const opt = document.createElement('option'); + opt.value = s.id; + const typeIcon = s.stream_type === 'raw' ? '\uD83D\uDDA5\uFE0F' : s.stream_type === 'static_image' ? '\uD83D\uDDBC\uFE0F' : '\uD83C\uDFA8'; + opt.textContent = `${typeIcon} ${s.name}`; + bgSelect.appendChild(opt); + }); + + setPatternEditorBgImage(null); + setPatternEditorSelectedIdx(-1); + setPatternCanvasDragMode(null); + + if (templateId) { + const resp = await fetch(`${API_BASE}/pattern-templates/${templateId}`, { headers: getHeaders() }); + if (!resp.ok) throw new Error('Failed to load pattern template'); + const tmpl = await resp.json(); + + document.getElementById('pattern-template-id').value = tmpl.id; + document.getElementById('pattern-template-name').value = tmpl.name; + document.getElementById('pattern-template-description').value = tmpl.description || ''; + document.getElementById('pattern-template-title').textContent = t('pattern.edit'); + setPatternEditorRects((tmpl.rectangles || []).map(r => ({ ...r }))); + } else { + document.getElementById('pattern-template-id').value = ''; + document.getElementById('pattern-template-name').value = ''; + document.getElementById('pattern-template-description').value = ''; + document.getElementById('pattern-template-title').textContent = t('pattern.add'); + setPatternEditorRects([]); + } + + setPatternEditorInitialValues({ + name: document.getElementById('pattern-template-name').value, + description: document.getElementById('pattern-template-description').value, + rectangles: JSON.stringify(patternEditorRects), + }); + + renderPatternRectList(); + renderPatternCanvas(); + _attachPatternCanvasEvents(); + + const modal = document.getElementById('pattern-template-modal'); + modal.style.display = 'flex'; + lockBody(); + setupBackdropClose(modal, closePatternTemplateModal); + + document.getElementById('pattern-template-error').style.display = 'none'; + setTimeout(() => document.getElementById('pattern-template-name').focus(), 100); + } catch (error) { + console.error('Failed to open pattern template editor:', error); + showToast('Failed to open pattern template editor', 'error'); + } +} + +export function isPatternEditorDirty() { + return ( + document.getElementById('pattern-template-name').value !== patternEditorInitialValues.name || + document.getElementById('pattern-template-description').value !== patternEditorInitialValues.description || + JSON.stringify(patternEditorRects) !== patternEditorInitialValues.rectangles + ); +} + +export async function closePatternTemplateModal() { + if (isPatternEditorDirty()) { + const confirmed = await showConfirm(t('modal.discard_changes')); + if (!confirmed) return; + } + forceClosePatternTemplateModal(); +} + +export function forceClosePatternTemplateModal() { + document.getElementById('pattern-template-modal').style.display = 'none'; + document.getElementById('pattern-template-error').style.display = 'none'; + unlockBody(); + setPatternEditorRects([]); + setPatternEditorSelectedIdx(-1); + setPatternEditorBgImage(null); + setPatternEditorInitialValues({}); +} + +export async function savePatternTemplate() { + const templateId = document.getElementById('pattern-template-id').value; + const name = document.getElementById('pattern-template-name').value.trim(); + const description = document.getElementById('pattern-template-description').value.trim(); + const errorEl = document.getElementById('pattern-template-error'); + + if (!name) { + errorEl.textContent = t('pattern.error.required'); + errorEl.style.display = 'block'; + return; + } + + const payload = { + name, + rectangles: patternEditorRects.map(r => ({ + name: r.name, x: r.x, y: r.y, width: r.width, height: r.height, + })), + description: description || null, + }; + + try { + let response; + if (templateId) { + response = await fetch(`${API_BASE}/pattern-templates/${templateId}`, { + method: 'PUT', headers: getHeaders(), body: JSON.stringify(payload), + }); + } else { + response = await fetch(`${API_BASE}/pattern-templates`, { + method: 'POST', headers: getHeaders(), body: JSON.stringify(payload), + }); + } + + if (response.status === 401) { handle401Error(); return; } + if (!response.ok) { + const err = await response.json(); + throw new Error(err.detail || 'Failed to save'); + } + + showToast(templateId ? t('pattern.updated') : t('pattern.created'), 'success'); + forceClosePatternTemplateModal(); + // Use window.* to avoid circular import with targets.js + if (typeof window.loadTargetsTab === 'function') window.loadTargetsTab(); + } catch (error) { + console.error('Error saving pattern template:', error); + errorEl.textContent = error.message; + errorEl.style.display = 'block'; + } +} + +export async function deletePatternTemplate(templateId) { + const confirmed = await showConfirm(t('pattern.delete.confirm')); + if (!confirmed) return; + + try { + const response = await fetch(`${API_BASE}/pattern-templates/${templateId}`, { + method: 'DELETE', headers: getHeaders(), + }); + if (response.status === 401) { handle401Error(); return; } + if (response.status === 409) { + showToast(t('pattern.delete.referenced'), 'error'); + return; + } + if (response.ok) { + showToast(t('pattern.deleted'), 'success'); + if (typeof window.loadTargetsTab === 'function') window.loadTargetsTab(); + } else { + const error = await response.json(); + showToast(`Failed to delete: ${error.detail}`, 'error'); + } + } catch (error) { + showToast('Failed to delete pattern template', 'error'); + } +} + +// ----- Pattern rect list (precise coordinate inputs) ----- + +export function renderPatternRectList() { + const container = document.getElementById('pattern-rect-list'); + if (!container) return; + + if (patternEditorRects.length === 0) { + container.innerHTML = `
${t('pattern.rect.empty')}
`; + return; + } + + container.innerHTML = patternEditorRects.map((rect, i) => ` +
+ + + + + + +
+ `).join(''); +} + +export function selectPatternRect(index) { + setPatternEditorSelectedIdx(patternEditorSelectedIdx === index ? -1 : index); + renderPatternRectList(); + renderPatternCanvas(); +} + +export function updatePatternRect(index, field, value) { + if (index < 0 || index >= patternEditorRects.length) return; + patternEditorRects[index][field] = value; + // Clamp coordinates + if (field !== 'name') { + const r = patternEditorRects[index]; + r.x = Math.max(0, Math.min(1 - r.width, r.x)); + r.y = Math.max(0, Math.min(1 - r.height, r.y)); + r.width = Math.max(0.01, Math.min(1, r.width)); + r.height = Math.max(0.01, Math.min(1, r.height)); + } + renderPatternCanvas(); +} + +export function addPatternRect() { + const name = `Zone ${patternEditorRects.length + 1}`; + // Inherit size from selected rect, or default to 30% + let w = 0.3, h = 0.3; + if (patternEditorSelectedIdx >= 0 && patternEditorSelectedIdx < patternEditorRects.length) { + const sel = patternEditorRects[patternEditorSelectedIdx]; + w = sel.width; + h = sel.height; + } + const x = Math.min(0.5 - w / 2, 1 - w); + const y = Math.min(0.5 - h / 2, 1 - h); + patternEditorRects.push({ name, x: Math.max(0, x), y: Math.max(0, y), width: w, height: h }); + setPatternEditorSelectedIdx(patternEditorRects.length - 1); + renderPatternRectList(); + renderPatternCanvas(); +} + +export function deleteSelectedPatternRect() { + if (patternEditorSelectedIdx < 0 || patternEditorSelectedIdx >= patternEditorRects.length) return; + patternEditorRects.splice(patternEditorSelectedIdx, 1); + setPatternEditorSelectedIdx(-1); + renderPatternRectList(); + renderPatternCanvas(); +} + +export function removePatternRect(index) { + patternEditorRects.splice(index, 1); + if (patternEditorSelectedIdx === index) setPatternEditorSelectedIdx(-1); + else if (patternEditorSelectedIdx > index) setPatternEditorSelectedIdx(patternEditorSelectedIdx - 1); + renderPatternRectList(); + renderPatternCanvas(); +} + +// ----- Pattern Canvas Visual Editor ----- + +export function renderPatternCanvas() { + const canvas = document.getElementById('pattern-canvas'); + if (!canvas) return; + const ctx = canvas.getContext('2d'); + const w = canvas.width; + const h = canvas.height; + + // Clear + ctx.clearRect(0, 0, w, h); + + // Draw background image or grid + if (patternEditorBgImage) { + ctx.drawImage(patternEditorBgImage, 0, 0, w, h); + } else { + // Draw subtle grid + ctx.fillStyle = 'rgba(128,128,128,0.05)'; + ctx.fillRect(0, 0, w, h); + ctx.strokeStyle = 'rgba(128,128,128,0.15)'; + ctx.lineWidth = 1; + const dpr = window.devicePixelRatio || 1; + const gridStep = 80 * dpr; + const colsCount = Math.max(2, Math.round(w / gridStep)); + const rowsCount = Math.max(2, Math.round(h / gridStep)); + for (let gx = 0; gx <= colsCount; gx++) { + const x = Math.round(gx * w / colsCount) + 0.5; + ctx.beginPath(); ctx.moveTo(x, 0); ctx.lineTo(x, h); ctx.stroke(); + } + for (let gy = 0; gy <= rowsCount; gy++) { + const y = Math.round(gy * h / rowsCount) + 0.5; + ctx.beginPath(); ctx.moveTo(0, y); ctx.lineTo(w, y); ctx.stroke(); + } + } + + // Draw rectangles + const dpr = window.devicePixelRatio || 1; + patternEditorRects.forEach((rect, i) => { + const rx = rect.x * w; + const ry = rect.y * h; + const rw = rect.width * w; + const rh = rect.height * h; + const colorIdx = i % PATTERN_RECT_COLORS.length; + const isSelected = (i === patternEditorSelectedIdx); + const isHovered = (i === patternEditorHoveredIdx) && !patternCanvasDragMode; + const isDragging = (i === patternEditorSelectedIdx) && !!patternCanvasDragMode; + + // Fill + ctx.fillStyle = PATTERN_RECT_COLORS[colorIdx]; + ctx.fillRect(rx, ry, rw, rh); + if (isHovered || isDragging) { + ctx.fillStyle = 'rgba(255,255,255,0.08)'; + ctx.fillRect(rx, ry, rw, rh); + } + + // Border + ctx.strokeStyle = PATTERN_RECT_BORDERS[colorIdx]; + ctx.lineWidth = isSelected ? 3 : isHovered ? 2.5 : 1.5; + ctx.strokeRect(rx, ry, rw, rh); + + // Edge highlight + let edgeDir = null; + if (isDragging && patternCanvasDragMode.startsWith('resize-')) { + edgeDir = patternCanvasDragMode.replace('resize-', ''); + } else if (isHovered && patternEditorHoverHit && patternEditorHoverHit !== 'move') { + edgeDir = patternEditorHoverHit; + } + + if (edgeDir) { + ctx.save(); + ctx.strokeStyle = '#fff'; + ctx.lineWidth = 3 * dpr; + ctx.shadowColor = 'rgba(76,175,80,0.7)'; + ctx.shadowBlur = 6 * dpr; + ctx.beginPath(); + if (edgeDir.includes('n')) { ctx.moveTo(rx, ry); ctx.lineTo(rx + rw, ry); } + if (edgeDir.includes('s')) { ctx.moveTo(rx, ry + rh); ctx.lineTo(rx + rw, ry + rh); } + if (edgeDir.includes('w')) { ctx.moveTo(rx, ry); ctx.lineTo(rx, ry + rh); } + if (edgeDir.includes('e')) { ctx.moveTo(rx + rw, ry); ctx.lineTo(rx + rw, ry + rh); } + ctx.stroke(); + ctx.restore(); + } + + // Name label + ctx.fillStyle = '#fff'; + ctx.font = `${12 * dpr}px sans-serif`; + ctx.shadowColor = 'rgba(0,0,0,0.7)'; + ctx.shadowBlur = 3; + ctx.fillText(rect.name, rx + 4 * dpr, ry + 14 * dpr); + ctx.shadowBlur = 0; + + // Delete button on hovered or selected rects (not during drag) + if ((isHovered || isSelected) && !patternCanvasDragMode) { + const btnR = 9 * dpr; + const btnCx = rx + rw - btnR - 2 * dpr; + const btnCy = ry + btnR + 2 * dpr; + ctx.beginPath(); + ctx.arc(btnCx, btnCy, btnR, 0, Math.PI * 2); + ctx.fillStyle = 'rgba(0,0,0,0.6)'; + ctx.fill(); + ctx.strokeStyle = 'rgba(255,255,255,0.5)'; + ctx.lineWidth = 1; + ctx.stroke(); + const cross = btnR * 0.5; + ctx.beginPath(); + ctx.moveTo(btnCx - cross, btnCy - cross); + ctx.lineTo(btnCx + cross, btnCy + cross); + ctx.moveTo(btnCx + cross, btnCy - cross); + ctx.lineTo(btnCx - cross, btnCy + cross); + ctx.strokeStyle = '#fff'; + ctx.lineWidth = 1.5 * dpr; + ctx.stroke(); + } + }); + + // Draw "add rectangle" placement buttons (4 corners + center) when not dragging + if (!patternCanvasDragMode) { + const abR = 12 * dpr; + const abMargin = 18 * dpr; + const addBtnPositions = [ + { cx: abMargin, cy: abMargin }, + { cx: w - abMargin, cy: abMargin }, + { cx: w / 2, cy: h / 2 }, + { cx: abMargin, cy: h - abMargin }, + { cx: w - abMargin, cy: h - abMargin }, + ]; + addBtnPositions.forEach(pos => { + ctx.beginPath(); + ctx.arc(pos.cx, pos.cy, abR, 0, Math.PI * 2); + ctx.fillStyle = 'rgba(255,255,255,0.10)'; + ctx.fill(); + ctx.strokeStyle = 'rgba(255,255,255,0.25)'; + ctx.lineWidth = 1; + ctx.stroke(); + const pl = abR * 0.5; + ctx.beginPath(); + ctx.moveTo(pos.cx - pl, pos.cy); + ctx.lineTo(pos.cx + pl, pos.cy); + ctx.moveTo(pos.cx, pos.cy - pl); + ctx.lineTo(pos.cx, pos.cy + pl); + ctx.strokeStyle = 'rgba(255,255,255,0.5)'; + ctx.lineWidth = 1.5 * dpr; + ctx.stroke(); + }); + } +} + +// Placement button positions (relative 0-1 coords for where the new rect is anchored) +const _ADD_BTN_ANCHORS = [ + { ax: 0, ay: 0 }, + { ax: 1, ay: 0 }, + { ax: 0.5, ay: 0.5 }, + { ax: 0, ay: 1 }, + { ax: 1, ay: 1 }, +]; + +function _hitTestAddButtons(mx, my, w, h) { + const dpr = window.devicePixelRatio || 1; + const abR = 12 * dpr; + const abMargin = 18 * dpr; + const positions = [ + { cx: abMargin, cy: abMargin }, + { cx: w - abMargin, cy: abMargin }, + { cx: w / 2, cy: h / 2 }, + { cx: abMargin, cy: h - abMargin }, + { cx: w - abMargin, cy: h - abMargin }, + ]; + for (let i = 0; i < positions.length; i++) { + const dx = mx - positions[i].cx, dy = my - positions[i].cy; + if (dx * dx + dy * dy <= (abR + 3 * dpr) * (abR + 3 * dpr)) return i; + } + return -1; +} + +function _addRectAtAnchor(anchorIdx) { + const anchor = _ADD_BTN_ANCHORS[anchorIdx]; + const name = `Zone ${patternEditorRects.length + 1}`; + let rw = 0.3, rh = 0.3; + if (patternEditorSelectedIdx >= 0 && patternEditorSelectedIdx < patternEditorRects.length) { + const sel = patternEditorRects[patternEditorSelectedIdx]; + rw = sel.width; + rh = sel.height; + } + let rx = anchor.ax - rw * anchor.ax; + let ry = anchor.ay - rh * anchor.ay; + rx = Math.max(0, Math.min(1 - rw, rx)); + ry = Math.max(0, Math.min(1 - rh, ry)); + patternEditorRects.push({ name, x: rx, y: ry, width: rw, height: rh }); + setPatternEditorSelectedIdx(patternEditorRects.length - 1); + renderPatternRectList(); + renderPatternCanvas(); +} + +// Hit-test a point against a rect's edges/corners. +const _EDGE_THRESHOLD = 8; + +function _hitTestRect(mx, my, r, w, h) { + const rx = r.x * w, ry = r.y * h, rw = r.width * w, rh = r.height * h; + const dpr = window.devicePixelRatio || 1; + const thr = _EDGE_THRESHOLD * dpr; + + const nearLeft = Math.abs(mx - rx) <= thr; + const nearRight = Math.abs(mx - (rx + rw)) <= thr; + const nearTop = Math.abs(my - ry) <= thr; + const nearBottom = Math.abs(my - (ry + rh)) <= thr; + const inHRange = mx >= rx - thr && mx <= rx + rw + thr; + const inVRange = my >= ry - thr && my <= ry + rh + thr; + + if (nearTop && nearLeft && inHRange && inVRange) return 'nw'; + if (nearTop && nearRight && inHRange && inVRange) return 'ne'; + if (nearBottom && nearLeft && inHRange && inVRange) return 'sw'; + if (nearBottom && nearRight && inHRange && inVRange) return 'se'; + + if (nearTop && inHRange) return 'n'; + if (nearBottom && inHRange) return 's'; + if (nearLeft && inVRange) return 'w'; + if (nearRight && inVRange) return 'e'; + + if (mx >= rx && mx <= rx + rw && my >= ry && my <= ry + rh) return 'move'; + return null; +} + +const _DIR_CURSORS = { + 'nw': 'nwse-resize', 'se': 'nwse-resize', + 'ne': 'nesw-resize', 'sw': 'nesw-resize', + 'n': 'ns-resize', 's': 'ns-resize', + 'e': 'ew-resize', 'w': 'ew-resize', + 'move': 'grab', +}; + +function _hitTestDeleteButton(mx, my, rect, w, h) { + const dpr = window.devicePixelRatio || 1; + const btnR = 9 * dpr; + const rx = rect.x * w, ry = rect.y * h, rw = rect.width * w; + const btnCx = rx + rw - btnR - 2 * dpr; + const btnCy = ry + btnR + 2 * dpr; + const dx = mx - btnCx, dy = my - btnCy; + return (dx * dx + dy * dy) <= (btnR + 2 * dpr) * (btnR + 2 * dpr); +} + +function _patternCanvasDragMove(e) { + if (!patternCanvasDragMode || patternEditorSelectedIdx < 0) return; + const canvas = document.getElementById('pattern-canvas'); + const w = canvas.width; + const h = canvas.height; + const canvasRect = canvas.getBoundingClientRect(); + const scaleX = w / canvasRect.width; + const scaleY = h / canvasRect.height; + 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 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-', ''); + 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; } + if (dir.includes('n')) { ny = orig.y + dy; nh = orig.height - dy; } + if (dir.includes('s')) { nh = orig.height + dy; } + if (nw < 0.02) { nw = 0.02; if (dir.includes('w')) nx = orig.x + orig.width - 0.02; } + if (nh < 0.02) { nh = 0.02; if (dir.includes('n')) ny = orig.y + orig.height - 0.02; } + nx = Math.max(0, Math.min(1 - nw, nx)); + ny = Math.max(0, Math.min(1 - nh, ny)); + nw = Math.min(1, nw); + nh = Math.min(1, nh); + r.x = nx; r.y = ny; r.width = nw; r.height = nh; + } + renderPatternCanvas(); +} + +function _patternCanvasDragEnd(e) { + window.removeEventListener('mousemove', _patternCanvasDragMove); + window.removeEventListener('mouseup', _patternCanvasDragEnd); + setPatternCanvasDragMode(null); + setPatternCanvasDragStart(null); + setPatternCanvasDragOrigRect(null); + + // Recalculate hover at current mouse position + const canvas = document.getElementById('pattern-canvas'); + if (canvas) { + const w = canvas.width; + const h = canvas.height; + const canvasRect = canvas.getBoundingClientRect(); + const scaleX = w / canvasRect.width; + const scaleY = h / canvasRect.height; + const mx = (e.clientX - canvasRect.left) * scaleX; + const my = (e.clientY - canvasRect.top) * scaleY; + let cursor = 'default'; + let newHoverIdx = -1; + let newHoverHit = 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--) { + const hit = _hitTestRect(mx, my, patternEditorRects[i], w, h); + if (hit) { + cursor = _DIR_CURSORS[hit] || 'default'; + newHoverIdx = i; + newHoverHit = hit; + break; + } + } + } + canvas.style.cursor = cursor; + setPatternEditorHoveredIdx(newHoverIdx); + setPatternEditorHoverHit(newHoverHit); + } + renderPatternRectList(); + renderPatternCanvas(); +} + +function _attachPatternCanvasEvents() { + const canvas = document.getElementById('pattern-canvas'); + if (!canvas || canvas._patternEventsAttached) return; + canvas._patternEventsAttached = true; + + canvas.addEventListener('mousedown', _patternCanvasMouseDown); + canvas.addEventListener('mousemove', _patternCanvasMouseMove); + canvas.addEventListener('mouseleave', _patternCanvasMouseLeave); + + // Touch support + canvas.addEventListener('touchstart', (e) => { + e.preventDefault(); + const touch = e.touches[0]; + _patternCanvasMouseDown(_touchToMouseEvent(canvas, touch, 'mousedown')); + }, { passive: false }); + canvas.addEventListener('touchmove', (e) => { + e.preventDefault(); + const touch = e.touches[0]; + if (patternCanvasDragMode) { + _patternCanvasDragMove({ clientX: touch.clientX, clientY: touch.clientY }); + } else { + _patternCanvasMouseMove(_touchToMouseEvent(canvas, touch, 'mousemove')); + } + }, { passive: false }); + canvas.addEventListener('touchend', () => { + if (patternCanvasDragMode) { + window.removeEventListener('mousemove', _patternCanvasDragMove); + window.removeEventListener('mouseup', _patternCanvasDragEnd); + setPatternCanvasDragMode(null); + setPatternCanvasDragStart(null); + setPatternCanvasDragOrigRect(null); + setPatternEditorHoveredIdx(-1); + setPatternEditorHoverHit(null); + renderPatternRectList(); + renderPatternCanvas(); + } + }); + + // Resize observer + const container = canvas.parentElement; + if (container && typeof ResizeObserver !== 'undefined') { + const ro = new ResizeObserver(() => { + const rect = container.getBoundingClientRect(); + const dpr = window.devicePixelRatio || 1; + canvas.width = Math.round(rect.width * dpr); + canvas.height = Math.round(rect.height * dpr); + renderPatternCanvas(); + }); + ro.observe(container); + canvas._patternResizeObserver = ro; + } +} + +function _touchToMouseEvent(canvas, touch, type) { + const rect = canvas.getBoundingClientRect(); + return { type, offsetX: touch.clientX - rect.left, offsetY: touch.clientY - rect.top, preventDefault: () => {} }; +} + +function _patternCanvasMouseDown(e) { + const canvas = document.getElementById('pattern-canvas'); + const w = canvas.width; + const h = canvas.height; + 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; + + // Check delete button on hovered or selected rects first + for (const idx of [patternEditorHoveredIdx, patternEditorSelectedIdx]) { + if (idx >= 0 && idx < patternEditorRects.length) { + if (_hitTestDeleteButton(mx, my, patternEditorRects[idx], w, h)) { + patternEditorRects.splice(idx, 1); + if (patternEditorSelectedIdx === idx) setPatternEditorSelectedIdx(-1); + else if (patternEditorSelectedIdx > idx) setPatternEditorSelectedIdx(patternEditorSelectedIdx - 1); + setPatternEditorHoveredIdx(-1); + setPatternEditorHoverHit(null); + renderPatternRectList(); + renderPatternCanvas(); + return; + } + } + } + + // Test all rects in reverse order (top-most first). + for (let i = patternEditorRects.length - 1; i >= 0; i--) { + const r = patternEditorRects[i]; + const hit = _hitTestRect(mx, my, r, w, h); + if (!hit) continue; + + setPatternEditorSelectedIdx(i); + setPatternCanvasDragStart({ mx, my }); + setPatternCanvasDragOrigRect({ ...r }); + + if (hit === 'move') { + setPatternCanvasDragMode('move'); + canvas.style.cursor = 'grabbing'; + } else { + setPatternCanvasDragMode(`resize-${hit}`); + canvas.style.cursor = _DIR_CURSORS[hit] || 'default'; + } + + // Capture mouse at window level for drag + window.addEventListener('mousemove', _patternCanvasDragMove); + window.addEventListener('mouseup', _patternCanvasDragEnd); + e.preventDefault(); + + renderPatternRectList(); + renderPatternCanvas(); + return; + } + + // Check placement "+" buttons (corners + center) + const addIdx = _hitTestAddButtons(mx, my, w, h); + if (addIdx >= 0) { + _addRectAtAnchor(addIdx); + return; + } + + // Click on empty space — deselect + setPatternEditorSelectedIdx(-1); + setPatternCanvasDragMode(null); + canvas.style.cursor = 'default'; + renderPatternRectList(); + renderPatternCanvas(); +} + +function _patternCanvasMouseMove(e) { + if (patternCanvasDragMode) return; + + const canvas = document.getElementById('pattern-canvas'); + const w = canvas.width; + const h = canvas.height; + 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; + + let cursor = 'default'; + let newHoverIdx = -1; + let newHoverHit = null; + for (let i = patternEditorRects.length - 1; i >= 0; i--) { + const hit = _hitTestRect(mx, my, patternEditorRects[i], w, h); + if (hit) { + cursor = _DIR_CURSORS[hit] || 'default'; + newHoverIdx = i; + newHoverHit = hit; + break; + } + } + canvas.style.cursor = cursor; + if (newHoverIdx !== patternEditorHoveredIdx || newHoverHit !== patternEditorHoverHit) { + setPatternEditorHoveredIdx(newHoverIdx); + setPatternEditorHoverHit(newHoverHit); + renderPatternCanvas(); + } +} + +function _patternCanvasMouseLeave() { + if (patternCanvasDragMode) return; + if (patternEditorHoveredIdx !== -1) { + setPatternEditorHoveredIdx(-1); + setPatternEditorHoverHit(null); + renderPatternCanvas(); + } +} + +export async function capturePatternBackground() { + const sourceId = document.getElementById('pattern-bg-source').value; + if (!sourceId) { + showToast(t('pattern.source_for_bg.none'), 'error'); + return; + } + + try { + const resp = await fetch(`${API_BASE}/picture-sources/${sourceId}/test`, { + method: 'POST', + headers: getHeaders(), + body: JSON.stringify({ capture_duration: 0 }), + }); + if (!resp.ok) throw new Error('Failed to capture'); + const data = await resp.json(); + + if (data.full_capture && data.full_capture.full_image) { + const img = new Image(); + img.onload = () => { + setPatternEditorBgImage(img); + renderPatternCanvas(); + }; + img.src = data.full_capture.full_image; + } + } catch (error) { + console.error('Failed to capture background:', error); + showToast('Failed to capture background', 'error'); + } +} diff --git a/server/src/wled_controller/static/js/features/profiles.js b/server/src/wled_controller/static/js/features/profiles.js new file mode 100644 index 0000000..ab58f74 --- /dev/null +++ b/server/src/wled_controller/static/js/features/profiles.js @@ -0,0 +1,378 @@ +/** + * Profiles — profile cards, editor, condition builder, process picker. + */ + +import { _profilesCache, set_profilesCache } from '../core/state.js'; +import { API_BASE, getHeaders, escapeHtml, handle401Error } from '../core/api.js'; +import { t, updateAllText } from '../core/i18n.js'; +import { lockBody, unlockBody, showToast, showConfirm } from '../core/ui.js'; + +export async function loadProfiles() { + const container = document.getElementById('profiles-content'); + if (!container) return; + + try { + const resp = await fetch(`${API_BASE}/profiles`, { headers: getHeaders() }); + if (!resp.ok) throw new Error('Failed to load profiles'); + const data = await resp.json(); + set_profilesCache(data.profiles); + renderProfiles(data.profiles); + } catch (error) { + console.error('Failed to load profiles:', error); + container.innerHTML = `

${error.message}

`; + } +} + +function renderProfiles(profiles) { + const container = document.getElementById('profiles-content'); + + let html = '
'; + for (const p of profiles) { + html += createProfileCard(p); + } + html += `
+
+
+
`; + html += '
'; + + container.innerHTML = html; + updateAllText(); +} + +function createProfileCard(profile) { + const statusClass = !profile.enabled ? 'disabled' : profile.is_active ? 'active' : 'inactive'; + const statusText = !profile.enabled ? t('profiles.status.disabled') : profile.is_active ? t('profiles.status.active') : t('profiles.status.inactive'); + + let condPills = ''; + if (profile.conditions.length === 0) { + condPills = `${t('profiles.conditions.empty')}`; + } else { + const parts = profile.conditions.map(c => { + if (c.condition_type === 'application') { + const apps = (c.apps || []).join(', '); + const matchLabel = c.match_type === 'topmost' ? t('profiles.condition.application.match_type.topmost') : t('profiles.condition.application.match_type.running'); + return `${t('profiles.condition.application')}: ${apps} (${matchLabel})`; + } + return `${c.condition_type}`; + }); + const logicLabel = profile.condition_logic === 'and' ? ' AND ' : ' OR '; + condPills = parts.join(`${logicLabel}`); + } + + const targetCountText = `${profile.target_ids.length} target(s)${profile.is_active ? ` (${profile.active_target_ids.length} active)` : ''}`; + + let lastActivityMeta = ''; + if (profile.last_activated_at) { + const ts = new Date(profile.last_activated_at); + lastActivityMeta = `🕐 ${ts.toLocaleString()}`; + } + + return ` +
+
+ +
+
+
+ ${escapeHtml(profile.name)} + ${statusText} +
+
+
+ ${profile.condition_logic === 'and' ? 'ALL' : 'ANY'} + ⚡ ${targetCountText} + ${lastActivityMeta} +
+
${condPills}
+
+ + +
+
`; +} + +export async function openProfileEditor(profileId) { + const modal = document.getElementById('profile-editor-modal'); + const titleEl = document.getElementById('profile-editor-title'); + const idInput = document.getElementById('profile-editor-id'); + const nameInput = document.getElementById('profile-editor-name'); + const enabledInput = document.getElementById('profile-editor-enabled'); + const logicSelect = document.getElementById('profile-editor-logic'); + const condList = document.getElementById('profile-conditions-list'); + const errorEl = document.getElementById('profile-editor-error'); + + errorEl.style.display = 'none'; + condList.innerHTML = ''; + + await loadProfileTargetChecklist([]); + + if (profileId) { + titleEl.textContent = t('profiles.edit'); + try { + const resp = await fetch(`${API_BASE}/profiles/${profileId}`, { headers: getHeaders() }); + if (!resp.ok) throw new Error('Failed to load profile'); + const profile = await resp.json(); + + idInput.value = profile.id; + nameInput.value = profile.name; + enabledInput.checked = profile.enabled; + logicSelect.value = profile.condition_logic; + + for (const c of profile.conditions) { + addProfileConditionRow(c); + } + + await loadProfileTargetChecklist(profile.target_ids); + } catch (e) { + showToast(e.message, 'error'); + return; + } + } else { + titleEl.textContent = t('profiles.add'); + idInput.value = ''; + nameInput.value = ''; + enabledInput.checked = true; + logicSelect.value = 'or'; + } + + modal.style.display = 'flex'; + lockBody(); + updateAllText(); +} + +export function closeProfileEditorModal() { + document.getElementById('profile-editor-modal').style.display = 'none'; + unlockBody(); +} + +async function loadProfileTargetChecklist(selectedIds) { + const container = document.getElementById('profile-targets-list'); + try { + const resp = await fetch(`${API_BASE}/picture-targets`, { headers: getHeaders() }); + if (!resp.ok) throw new Error('Failed to load targets'); + const data = await resp.json(); + const targets = data.targets || []; + + if (targets.length === 0) { + container.innerHTML = `${t('profiles.targets.empty')}`; + return; + } + + container.innerHTML = targets.map(tgt => { + const checked = selectedIds.includes(tgt.id) ? 'checked' : ''; + return ``; + }).join(''); + } catch (e) { + container.innerHTML = `${e.message}`; + } +} + +export function addProfileCondition() { + addProfileConditionRow({ condition_type: 'application', apps: [], match_type: 'running' }); +} + +function addProfileConditionRow(condition) { + const list = document.getElementById('profile-conditions-list'); + const row = document.createElement('div'); + row.className = 'profile-condition-row'; + + const appsValue = (condition.apps || []).join('\n'); + const matchType = condition.match_type || 'running'; + + row.innerHTML = ` +
+ ${t('profiles.condition.application')} + +
+
+
+ + +
+
+
+ + +
+ + +
+
+ `; + + const browseBtn = row.querySelector('.btn-browse-apps'); + const picker = row.querySelector('.process-picker'); + browseBtn.addEventListener('click', () => toggleProcessPicker(picker, row)); + + const searchInput = row.querySelector('.process-picker-search'); + searchInput.addEventListener('input', () => filterProcessPicker(picker)); + + list.appendChild(row); +} + +async function toggleProcessPicker(picker, row) { + if (picker.style.display !== 'none') { + picker.style.display = 'none'; + return; + } + + const listEl = picker.querySelector('.process-picker-list'); + const searchEl = picker.querySelector('.process-picker-search'); + searchEl.value = ''; + listEl.innerHTML = `
${t('common.loading')}
`; + picker.style.display = ''; + + try { + const resp = await fetch(`${API_BASE}/system/processes`, { headers: getHeaders() }); + if (resp.status === 401) { handle401Error(); return; } + if (!resp.ok) throw new Error('Failed to fetch processes'); + const data = await resp.json(); + + const textarea = row.querySelector('.condition-apps'); + const existing = new Set(textarea.value.split('\n').map(a => a.trim().toLowerCase()).filter(Boolean)); + + picker._processes = data.processes; + picker._existing = existing; + renderProcessPicker(picker, data.processes, existing); + searchEl.focus(); + } catch (e) { + listEl.innerHTML = `
${e.message}
`; + } +} + +function renderProcessPicker(picker, processes, existing) { + const listEl = picker.querySelector('.process-picker-list'); + if (processes.length === 0) { + listEl.innerHTML = `
${t('profiles.condition.application.no_processes')}
`; + return; + } + listEl.innerHTML = processes.map(p => { + const added = existing.has(p.toLowerCase()); + return `
${escapeHtml(p)}${added ? ' ✓' : ''}
`; + }).join(''); + + listEl.querySelectorAll('.process-picker-item:not(.added)').forEach(item => { + item.addEventListener('click', () => { + const proc = item.dataset.process; + const row = picker.closest('.profile-condition-row'); + const textarea = row.querySelector('.condition-apps'); + const current = textarea.value.trim(); + textarea.value = current ? current + '\n' + proc : proc; + item.classList.add('added'); + item.textContent = proc + ' ✓'; + picker._existing.add(proc.toLowerCase()); + }); + }); +} + +function filterProcessPicker(picker) { + const query = picker.querySelector('.process-picker-search').value.toLowerCase(); + const filtered = (picker._processes || []).filter(p => p.includes(query)); + renderProcessPicker(picker, filtered, picker._existing || new Set()); +} + +function getProfileEditorConditions() { + const rows = document.querySelectorAll('#profile-conditions-list .profile-condition-row'); + const conditions = []; + rows.forEach(row => { + const matchType = row.querySelector('.condition-match-type').value; + const appsText = row.querySelector('.condition-apps').value.trim(); + const apps = appsText ? appsText.split('\n').map(a => a.trim()).filter(Boolean) : []; + conditions.push({ condition_type: 'application', apps, match_type: matchType }); + }); + return conditions; +} + +function getProfileEditorTargetIds() { + const checkboxes = document.querySelectorAll('#profile-targets-list input[type="checkbox"]:checked'); + return Array.from(checkboxes).map(cb => cb.value); +} + +export async function saveProfileEditor() { + const idInput = document.getElementById('profile-editor-id'); + const nameInput = document.getElementById('profile-editor-name'); + const enabledInput = document.getElementById('profile-editor-enabled'); + const logicSelect = document.getElementById('profile-editor-logic'); + const errorEl = document.getElementById('profile-editor-error'); + + const name = nameInput.value.trim(); + if (!name) { + errorEl.textContent = 'Name is required'; + errorEl.style.display = 'block'; + return; + } + + const body = { + name, + enabled: enabledInput.checked, + condition_logic: logicSelect.value, + conditions: getProfileEditorConditions(), + target_ids: getProfileEditorTargetIds(), + }; + + const profileId = idInput.value; + const isEdit = !!profileId; + + try { + const url = isEdit ? `${API_BASE}/profiles/${profileId}` : `${API_BASE}/profiles`; + const resp = await fetch(url, { + method: isEdit ? 'PUT' : 'POST', + headers: getHeaders(), + body: JSON.stringify(body), + }); + if (!resp.ok) { + const err = await resp.json().catch(() => ({})); + throw new Error(err.detail || 'Failed to save profile'); + } + + closeProfileEditorModal(); + showToast(isEdit ? 'Profile updated' : 'Profile created', 'success'); + loadProfiles(); + } catch (e) { + errorEl.textContent = e.message; + errorEl.style.display = 'block'; + } +} + +export async function toggleProfileEnabled(profileId, enable) { + try { + const action = enable ? 'enable' : 'disable'; + const resp = await fetch(`${API_BASE}/profiles/${profileId}/${action}`, { + method: 'POST', + headers: getHeaders(), + }); + if (!resp.ok) throw new Error(`Failed to ${action} profile`); + loadProfiles(); + } catch (e) { + showToast(e.message, 'error'); + } +} + +export async function deleteProfile(profileId, profileName) { + const msg = t('profiles.delete.confirm').replace('{name}', profileName); + const confirmed = await showConfirm(msg); + if (!confirmed) return; + + try { + const resp = await fetch(`${API_BASE}/profiles/${profileId}`, { + method: 'DELETE', + headers: getHeaders(), + }); + if (!resp.ok) throw new Error('Failed to delete profile'); + showToast('Profile deleted', 'success'); + loadProfiles(); + } catch (e) { + showToast(e.message, 'error'); + } +} diff --git a/server/src/wled_controller/static/js/features/streams.js b/server/src/wled_controller/static/js/features/streams.js new file mode 100644 index 0000000..5ef57da --- /dev/null +++ b/server/src/wled_controller/static/js/features/streams.js @@ -0,0 +1,1386 @@ +/** + * Streams — picture sources, capture templates, PP templates, filters. + */ + +import { + _cachedDisplays, set_cachedDisplays, + _cachedStreams, set_cachedStreams, + _cachedPPTemplates, set_cachedPPTemplates, + _cachedCaptureTemplates, set_cachedCaptureTemplates, + _availableFilters, set_availableFilters, + availableEngines, setAvailableEngines, + currentEditingTemplateId, setCurrentEditingTemplateId, + _templateNameManuallyEdited, set_templateNameManuallyEdited, + _streamNameManuallyEdited, set_streamNameManuallyEdited, + _streamModalPPTemplates, set_streamModalPPTemplates, + _modalFilters, set_modalFilters, + _ppTemplateNameManuallyEdited, set_ppTemplateNameManuallyEdited, + _currentTestStreamId, set_currentTestStreamId, + _currentTestPPTemplateId, set_currentTestPPTemplateId, + _lastValidatedImageSource, set_lastValidatedImageSource, +} from '../core/state.js'; +import { API_BASE, getHeaders, fetchWithAuth, escapeHtml, handle401Error } from '../core/api.js'; +import { t } from '../core/i18n.js'; +import { setupBackdropClose, lockBody, unlockBody, showToast, showConfirm, openLightbox, openFullImageLightbox, showOverlaySpinner, hideOverlaySpinner } from '../core/ui.js'; +import { openDisplayPicker, formatDisplayLabel } from './displays.js'; + +// ===== Capture Templates ===== + +async function loadCaptureTemplates() { + try { + const response = await fetchWithAuth('/capture-templates'); + if (!response.ok) throw new Error(`Failed to load templates: ${response.status}`); + const data = await response.json(); + set_cachedCaptureTemplates(data.templates || []); + renderPictureSourcesList(_cachedStreams); + } catch (error) { + console.error('Error loading capture templates:', error); + } +} + +function getEngineIcon(engineType) { + return '🚀'; +} + +export async function showAddTemplateModal() { + setCurrentEditingTemplateId(null); + document.getElementById('template-modal-title').textContent = t('templates.add'); + document.getElementById('template-form').reset(); + document.getElementById('template-id').value = ''; + document.getElementById('engine-config-section').style.display = 'none'; + document.getElementById('template-error').style.display = 'none'; + + set_templateNameManuallyEdited(false); + document.getElementById('template-name').oninput = () => { set_templateNameManuallyEdited(true); }; + + await loadAvailableEngines(); + + const modal = document.getElementById('template-modal'); + modal.style.display = 'flex'; + setupBackdropClose(modal, closeTemplateModal); +} + +export async function editTemplate(templateId) { + 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').textContent = t('templates.edit'); + document.getElementById('template-id').value = templateId; + document.getElementById('template-name').value = template.name; + document.getElementById('template-description').value = template.description || ''; + + await loadAvailableEngines(); + document.getElementById('template-engine').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'; + + const modal = document.getElementById('template-modal'); + modal.style.display = 'flex'; + setupBackdropClose(modal, closeTemplateModal); + } catch (error) { + console.error('Error loading template:', error); + showToast(t('templates.error.load') + ': ' + error.message, 'error'); + } +} + +export function closeTemplateModal() { + document.getElementById('template-modal').style.display = 'none'; + setCurrentEditingTemplateId(null); +} + +function updateCaptureDuration(value) { + 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'); + const durationValue = document.getElementById('test-template-duration-value'); + durationInput.value = savedDuration; + durationValue.textContent = savedDuration; + } +} + +export async function showTestTemplateModal(templateId) { + const templates = await fetchWithAuth('/capture-templates').then(r => r.json()); + const template = templates.templates.find(t => t.id === templateId); + + if (!template) { + showToast(t('templates.error.load'), 'error'); + return; + } + + window.currentTestingTemplate = template; + await loadDisplaysForTest(); + restoreCaptureDuration(); + + const modal = document.getElementById('test-template-modal'); + modal.style.display = 'flex'; + setupBackdropClose(modal, closeTestTemplateModal); +} + +export function closeTestTemplateModal() { + document.getElementById('test-template-modal').style.display = 'none'; + window.currentTestingTemplate = 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'); + select.innerHTML = ''; + + availableEngines.forEach(engine => { + const option = document.createElement('option'); + option.value = engine.type; + option.textContent = `${getEngineIcon(engine.type)} ${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; + } + } catch (error) { + console.error('Error loading engines:', error); + showToast(t('templates.error.engines') + ': ' + error.message, 'error'); + } +} + +export async function onEngineChange() { + const engineType = document.getElementById('template-engine').value; + 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 => e.type === engineType); + if (!engine) { configSection.style.display = 'none'; return; } + + if (!_templateNameManuallyEdited && !document.getElementById('template-id').value) { + document.getElementById('template-name').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 || {}; + + if (Object.keys(defaultConfig).length === 0) { + configSection.style.display = 'none'; + return; + } else { + let gridHtml = '
'; + Object.entries(defaultConfig).forEach(([key, value]) => { + const fieldType = typeof value === 'number' ? 'number' : 'text'; + const fieldValue = typeof value === 'boolean' ? (value ? 'true' : 'false') : value; + gridHtml += ` + +
+ ${typeof value === 'boolean' ? ` + + ` : ` + + `} +
+ `; + }); + gridHtml += '
'; + configFields.innerHTML = gridHtml; + } + + configSection.style.display = 'block'; +} + +function populateEngineConfig(config) { + Object.entries(config).forEach(([key, value]) => { + const field = document.getElementById(`config-${key}`); + if (field) { + if (field.tagName === 'SELECT') { + field.value = value.toString(); + } else { + field.value = value; + } + } + }); +} + +function collectEngineConfig() { + const config = {}; + const fields = document.querySelectorAll('[data-config-key]'); + fields.forEach(field => { + const key = field.dataset.configKey; + let value = 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 { + if (!_cachedDisplays) { + const response = await fetchWithAuth('/config/displays'); + if (!response.ok) throw new Error(`Failed to load displays: ${response.status}`); + const displaysData = await response.json(); + set_cachedDisplays(displaysData.displays || []); + } + + let selectedIndex = null; + const lastDisplay = localStorage.getItem('lastTestDisplayIndex'); + + if (lastDisplay !== null) { + const found = _cachedDisplays.find(d => d.index === parseInt(lastDisplay)); + if (found) selectedIndex = found.index; + } + + if (selectedIndex === null) { + 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) { + const display = _cachedDisplays.find(d => d.index === selectedIndex); + onTestDisplaySelected(selectedIndex, display); + } + } catch (error) { + console.error('Error loading displays:', error); + } +} + +export async function runTemplateTest() { + if (!window.currentTestingTemplate) { + showToast(t('templates.test.error.no_engine'), 'error'); + return; + } + + const displayIndex = document.getElementById('test-template-display').value; + const captureDuration = parseFloat(document.getElementById('test-template-duration').value); + + if (displayIndex === '') { + showToast(t('templates.test.error.no_display'), 'error'); + return; + } + + const template = window.currentTestingTemplate; + showOverlaySpinner(t('templates.test.running'), captureDuration); + + try { + const response = await fetchWithAuth('/capture-templates/test', { + method: 'POST', + body: JSON.stringify({ + engine_type: template.engine_type, + engine_config: template.engine_config, + display_index: parseInt(displayIndex), + capture_duration: captureDuration + }) + }); + + if (!response.ok) { + const error = await response.json(); + throw new Error(error.detail || error.message || 'Test failed'); + } + + const result = await response.json(); + localStorage.setItem('lastTestDisplayIndex', displayIndex); + displayTestResults(result); + } catch (error) { + console.error('Error running test:', error); + hideOverlaySpinner(); + showToast(t('templates.test.error.failed'), 'error'); + } +} + +function buildTestStatsHtml(result) { + const p = result.performance; + const res = `${result.full_capture.width}x${result.full_capture.height}`; + let html = ` +
${t('templates.test.results.duration')}: ${p.capture_duration_s.toFixed(2)}s
+
${t('templates.test.results.frame_count')}: ${p.frame_count}
`; + if (p.frame_count > 1) { + html += ` +
${t('templates.test.results.actual_fps')}: ${p.actual_fps.toFixed(1)}
+
${t('templates.test.results.avg_capture_time')}: ${p.avg_capture_time_ms.toFixed(1)}ms
`; + } + html += ` +
Resolution: ${res}
`; + return html; +} + +function displayTestResults(result) { + hideOverlaySpinner(); + const fullImageSrc = result.full_capture.full_image || result.full_capture.image; + openLightbox(fullImageSrc, buildTestStatsHtml(result)); +} + +export async function saveTemplate() { + const templateId = document.getElementById('template-id').value; + const name = document.getElementById('template-name').value.trim(); + const engineType = document.getElementById('template-engine').value; + + if (!name || !engineType) { + showToast(t('templates.error.required'), 'error'); + return; + } + + const description = document.getElementById('template-description').value.trim(); + const engineConfig = collectEngineConfig(); + + const payload = { name, engine_type: engineType, engine_config: engineConfig, description: description || null }; + + 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'); + closeTemplateModal(); + await loadCaptureTemplates(); + } catch (error) { + console.error('Error saving template:', error); + document.getElementById('template-error').textContent = error.message; + document.getElementById('template-error').style.display = 'block'; + } +} + +export async function deleteTemplate(templateId) { + 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'); + await loadCaptureTemplates(); + } catch (error) { + console.error('Error deleting template:', error); + showToast(t('templates.error.delete') + ': ' + error.message, 'error'); + } +} + +// ===== Picture Sources ===== + +export async function loadPictureSources() { + try { + const [filtersResp, ppResp, captResp, streamsResp] = await Promise.all([ + _availableFilters.length === 0 ? fetchWithAuth('/filters') : Promise.resolve(null), + fetchWithAuth('/postprocessing-templates'), + fetchWithAuth('/capture-templates'), + fetchWithAuth('/picture-sources') + ]); + + if (filtersResp && filtersResp.ok) { + const fd = await filtersResp.json(); + set_availableFilters(fd.filters || []); + } + if (ppResp.ok) { + const pd = await ppResp.json(); + set_cachedPPTemplates(pd.templates || []); + } + if (captResp.ok) { + const cd = await captResp.json(); + set_cachedCaptureTemplates(cd.templates || []); + } + if (!streamsResp.ok) throw new Error(`Failed to load streams: ${streamsResp.status}`); + const data = await streamsResp.json(); + set_cachedStreams(data.streams || []); + renderPictureSourcesList(_cachedStreams); + } catch (error) { + console.error('Error loading picture sources:', error); + document.getElementById('streams-list').innerHTML = ` +
${t('streams.error.load')}: ${error.message}
+ `; + } +} + +export function switchStreamTab(tabKey) { + document.querySelectorAll('.stream-tab-btn').forEach(btn => + btn.classList.toggle('active', btn.dataset.streamTab === tabKey) + ); + document.querySelectorAll('.stream-tab-panel').forEach(panel => + panel.classList.toggle('active', panel.id === `stream-tab-${tabKey}`) + ); + localStorage.setItem('activeStreamTab', tabKey); +} + +function renderPictureSourcesList(streams) { + const container = document.getElementById('streams-list'); + const activeTab = localStorage.getItem('activeStreamTab') || 'raw'; + + const renderStreamCard = (stream) => { + const typeIcons = { raw: '🖥️', processed: '🎨', static_image: '🖼️' }; + const typeIcon = typeIcons[stream.stream_type] || '📺'; + + let detailsHtml = ''; + if (stream.stream_type === 'raw') { + let capTmplName = ''; + if (stream.capture_template_id) { + const capTmpl = _cachedCaptureTemplates.find(t => t.id === stream.capture_template_id); + if (capTmpl) capTmplName = escapeHtml(capTmpl.name); + } + detailsHtml = `
+ 🖥️ ${stream.display_index ?? 0} + ⚡ ${stream.target_fps ?? 30} + ${capTmplName ? `📋 ${capTmplName}` : ''} +
`; + } else if (stream.stream_type === 'processed') { + const sourceStream = _cachedStreams.find(s => s.id === stream.source_stream_id); + const sourceName = sourceStream ? escapeHtml(sourceStream.name) : (stream.source_stream_id || '-'); + let ppTmplName = ''; + if (stream.postprocessing_template_id) { + const ppTmpl = _cachedPPTemplates.find(p => p.id === stream.postprocessing_template_id); + if (ppTmpl) ppTmplName = escapeHtml(ppTmpl.name); + } + detailsHtml = `
+ 📺 ${sourceName} + ${ppTmplName ? `📋 ${ppTmplName}` : ''} +
`; + } else if (stream.stream_type === 'static_image') { + const src = stream.image_source || ''; + detailsHtml = `
+ 🌐 ${escapeHtml(src)} +
`; + } + + return ` +
+ +
+
${typeIcon} ${escapeHtml(stream.name)}
+
+ ${detailsHtml} + ${stream.description ? `
${escapeHtml(stream.description)}
` : ''} +
+ + +
+
+ `; + }; + + const renderCaptureTemplateCard = (template) => { + const engineIcon = getEngineIcon(template.engine_type); + const configEntries = Object.entries(template.engine_config); + return ` +
+ +
+
📋 ${escapeHtml(template.name)}
+
+ ${template.description ? `
${escapeHtml(template.description)}
` : ''} +
+ 🚀 ${template.engine_type.toUpperCase()} + ${configEntries.length > 0 ? `🔧 ${configEntries.length}` : ''} +
+ ${configEntries.length > 0 ? ` +
+ ${t('templates.config.show')} + + ${configEntries.map(([key, val]) => ` + + + + + `).join('')} +
${escapeHtml(key)}${escapeHtml(String(val))}
+
+ ` : ''} +
+ + +
+
+ `; + }; + + const renderPPTemplateCard = (tmpl) => { + let filterChainHtml = ''; + if (tmpl.filters && tmpl.filters.length > 0) { + const filterNames = tmpl.filters.map(fi => `${escapeHtml(_getFilterName(fi.filter_id))}`); + filterChainHtml = `
${filterNames.join('')}
`; + } + return ` +
+ +
+
📋 ${escapeHtml(tmpl.name)}
+
+ ${tmpl.description ? `
${escapeHtml(tmpl.description)}
` : ''} + ${filterChainHtml} +
+ + +
+
+ `; + }; + + const rawStreams = streams.filter(s => s.stream_type === 'raw'); + const processedStreams = streams.filter(s => s.stream_type === 'processed'); + const staticImageStreams = streams.filter(s => s.stream_type === 'static_image'); + + const addStreamCard = (type) => ` +
+
+
+
`; + + const tabs = [ + { key: 'raw', icon: '🖥️', titleKey: 'streams.group.raw', streams: rawStreams }, + { key: 'static_image', icon: '🖼️', titleKey: 'streams.group.static_image', streams: staticImageStreams }, + { key: 'processed', icon: '🎨', titleKey: 'streams.group.processed', streams: processedStreams }, + ]; + + const tabBar = `
${tabs.map(tab => + `` + ).join('')}
`; + + const panels = tabs.map(tab => { + let panelContent = ''; + + if (tab.key === 'raw') { + panelContent = ` +
+

${t('streams.section.streams')}

+
+ ${tab.streams.map(renderStreamCard).join('')} + ${addStreamCard(tab.key)} +
+
+
+

${t('templates.title')}

+
+ ${_cachedCaptureTemplates.map(renderCaptureTemplateCard).join('')} +
+
+
+
+
+
`; + } else if (tab.key === 'processed') { + panelContent = ` +
+

${t('streams.section.streams')}

+
+ ${tab.streams.map(renderStreamCard).join('')} + ${addStreamCard(tab.key)} +
+
+
+

${t('postprocessing.title')}

+
+ ${_cachedPPTemplates.map(renderPPTemplateCard).join('')} +
+
+
+
+
+
`; + } else { + panelContent = ` +
+ ${tab.streams.map(renderStreamCard).join('')} + ${addStreamCard(tab.key)} +
`; + } + + return `
${panelContent}
`; + }).join(''); + + container.innerHTML = tabBar + panels; +} + +export function onStreamTypeChange() { + const streamType = document.getElementById('stream-type').value; + document.getElementById('stream-raw-fields').style.display = streamType === 'raw' ? '' : 'none'; + document.getElementById('stream-processed-fields').style.display = streamType === 'processed' ? '' : 'none'; + document.getElementById('stream-static-image-fields').style.display = streamType === 'static_image' ? '' : 'none'; +} + +export function onStreamDisplaySelected(displayIndex, display) { + document.getElementById('stream-display-index').value = displayIndex; + document.getElementById('stream-display-picker-label').textContent = formatDisplayLabel(displayIndex, display); + _autoGenerateStreamName(); +} + +export function onTestDisplaySelected(displayIndex, display) { + document.getElementById('test-template-display').value = displayIndex; + document.getElementById('test-display-picker-label').textContent = formatDisplayLabel(displayIndex, display); +} + +function _autoGenerateStreamName() { + if (_streamNameManuallyEdited) return; + if (document.getElementById('stream-id').value) return; + const streamType = document.getElementById('stream-type').value; + const nameInput = document.getElementById('stream-name'); + + if (streamType === 'raw') { + const displayIndex = document.getElementById('stream-display-index').value; + const templateSelect = document.getElementById('stream-capture-template'); + const templateName = templateSelect.selectedOptions[0]?.dataset?.name || ''; + if (displayIndex === '' || !templateName) return; + nameInput.value = `D${displayIndex}_${templateName}`; + } else if (streamType === 'processed') { + const sourceSelect = document.getElementById('stream-source'); + const sourceName = sourceSelect.selectedOptions[0]?.dataset?.name || ''; + const ppTemplateId = document.getElementById('stream-pp-template').value; + const ppTemplate = _streamModalPPTemplates.find(t => t.id === ppTemplateId); + if (!sourceName) return; + if (ppTemplate && ppTemplate.name) { + nameInput.value = `${sourceName} (${ppTemplate.name})`; + } else { + nameInput.value = sourceName; + } + } +} + +export async function showAddStreamModal(presetType) { + const streamType = presetType || 'raw'; + const titleKeys = { raw: 'streams.add.raw', processed: 'streams.add.processed', static_image: 'streams.add.static_image' }; + document.getElementById('stream-modal-title').textContent = t(titleKeys[streamType] || 'streams.add'); + document.getElementById('stream-form').reset(); + document.getElementById('stream-id').value = ''; + document.getElementById('stream-display-index').value = ''; + document.getElementById('stream-display-picker-label').textContent = t('displays.picker.select'); + document.getElementById('stream-error').style.display = 'none'; + document.getElementById('stream-type').value = streamType; + set_lastValidatedImageSource(''); + const imgSrcInput = document.getElementById('stream-image-source'); + imgSrcInput.value = ''; + document.getElementById('stream-image-preview-container').style.display = 'none'; + document.getElementById('stream-image-validation-status').style.display = 'none'; + imgSrcInput.onblur = () => validateStaticImage(); + imgSrcInput.onkeydown = (e) => { if (e.key === 'Enter') { e.preventDefault(); validateStaticImage(); } }; + imgSrcInput.onpaste = () => setTimeout(() => validateStaticImage(), 0); + onStreamTypeChange(); + + set_streamNameManuallyEdited(false); + document.getElementById('stream-name').oninput = () => { set_streamNameManuallyEdited(true); }; + document.getElementById('stream-capture-template').onchange = () => _autoGenerateStreamName(); + document.getElementById('stream-source').onchange = () => _autoGenerateStreamName(); + document.getElementById('stream-pp-template').onchange = () => _autoGenerateStreamName(); + + await populateStreamModalDropdowns(); + + const modal = document.getElementById('stream-modal'); + modal.style.display = 'flex'; + lockBody(); + setupBackdropClose(modal, closeStreamModal); +} + +export async function editStream(streamId) { + try { + const response = await fetchWithAuth(`/picture-sources/${streamId}`); + if (!response.ok) throw new Error(`Failed to load stream: ${response.status}`); + const stream = await response.json(); + + const editTitleKeys = { raw: 'streams.edit.raw', processed: 'streams.edit.processed', static_image: 'streams.edit.static_image' }; + document.getElementById('stream-modal-title').textContent = t(editTitleKeys[stream.stream_type] || 'streams.edit'); + document.getElementById('stream-id').value = streamId; + document.getElementById('stream-name').value = stream.name; + document.getElementById('stream-description').value = stream.description || ''; + document.getElementById('stream-error').style.display = 'none'; + + document.getElementById('stream-type').value = stream.stream_type; + set_lastValidatedImageSource(''); + const imgSrcInput = document.getElementById('stream-image-source'); + document.getElementById('stream-image-preview-container').style.display = 'none'; + document.getElementById('stream-image-validation-status').style.display = 'none'; + imgSrcInput.onblur = () => validateStaticImage(); + imgSrcInput.onkeydown = (e) => { if (e.key === 'Enter') { e.preventDefault(); validateStaticImage(); } }; + imgSrcInput.onpaste = () => setTimeout(() => validateStaticImage(), 0); + onStreamTypeChange(); + + await populateStreamModalDropdowns(); + + if (stream.stream_type === 'raw') { + const displayIdx = stream.display_index ?? 0; + const display = _cachedDisplays ? _cachedDisplays.find(d => d.index === displayIdx) : null; + onStreamDisplaySelected(displayIdx, display); + document.getElementById('stream-capture-template').value = stream.capture_template_id || ''; + const fps = stream.target_fps ?? 30; + document.getElementById('stream-target-fps').value = fps; + document.getElementById('stream-target-fps-value').textContent = fps; + } else if (stream.stream_type === 'processed') { + document.getElementById('stream-source').value = stream.source_stream_id || ''; + document.getElementById('stream-pp-template').value = stream.postprocessing_template_id || ''; + } else if (stream.stream_type === 'static_image') { + document.getElementById('stream-image-source').value = stream.image_source || ''; + if (stream.image_source) validateStaticImage(); + } + + const modal = document.getElementById('stream-modal'); + modal.style.display = 'flex'; + lockBody(); + setupBackdropClose(modal, closeStreamModal); + } catch (error) { + console.error('Error loading stream:', error); + showToast(t('streams.error.load') + ': ' + error.message, 'error'); + } +} + +async function populateStreamModalDropdowns() { + const [displaysRes, captureTemplatesRes, streamsRes, ppTemplatesRes] = await Promise.all([ + fetch(`${API_BASE}/config/displays`, { headers: getHeaders() }), + fetchWithAuth('/capture-templates'), + fetchWithAuth('/picture-sources'), + fetchWithAuth('/postprocessing-templates'), + ]); + + if (displaysRes.ok) { + const displaysData = await displaysRes.json(); + set_cachedDisplays(displaysData.displays || []); + } + + if (!document.getElementById('stream-display-index').value && _cachedDisplays && _cachedDisplays.length > 0) { + const primary = _cachedDisplays.find(d => d.is_primary) || _cachedDisplays[0]; + onStreamDisplaySelected(primary.index, primary); + } + + const templateSelect = document.getElementById('stream-capture-template'); + templateSelect.innerHTML = ''; + if (captureTemplatesRes.ok) { + const data = await captureTemplatesRes.json(); + (data.templates || []).forEach(tmpl => { + const opt = document.createElement('option'); + opt.value = tmpl.id; + opt.dataset.name = tmpl.name; + opt.textContent = `${getEngineIcon(tmpl.engine_type)} ${tmpl.name}`; + templateSelect.appendChild(opt); + }); + } + + const sourceSelect = document.getElementById('stream-source'); + sourceSelect.innerHTML = ''; + if (streamsRes.ok) { + const data = await streamsRes.json(); + const editingId = document.getElementById('stream-id').value; + (data.streams || []).forEach(s => { + if (s.id === editingId) return; + const opt = document.createElement('option'); + opt.value = s.id; + opt.dataset.name = s.name; + const typeLabels = { raw: '🖥️', processed: '🎨', static_image: '🖼️' }; + const typeLabel = typeLabels[s.stream_type] || '📺'; + opt.textContent = `${typeLabel} ${s.name}`; + sourceSelect.appendChild(opt); + }); + } + + set_streamModalPPTemplates([]); + const ppSelect = document.getElementById('stream-pp-template'); + ppSelect.innerHTML = ''; + if (ppTemplatesRes.ok) { + const data = await ppTemplatesRes.json(); + set_streamModalPPTemplates(data.templates || []); + _streamModalPPTemplates.forEach(tmpl => { + const opt = document.createElement('option'); + opt.value = tmpl.id; + opt.textContent = tmpl.name; + ppSelect.appendChild(opt); + }); + } + + _autoGenerateStreamName(); +} + +export async function saveStream() { + const streamId = document.getElementById('stream-id').value; + const name = document.getElementById('stream-name').value.trim(); + const streamType = document.getElementById('stream-type').value; + const description = document.getElementById('stream-description').value.trim(); + const errorEl = document.getElementById('stream-error'); + + if (!name) { showToast(t('streams.error.required'), 'error'); return; } + + const payload = { name, description: description || null }; + if (!streamId) payload.stream_type = streamType; + + if (streamType === 'raw') { + payload.display_index = parseInt(document.getElementById('stream-display-index').value) || 0; + payload.capture_template_id = document.getElementById('stream-capture-template').value; + payload.target_fps = parseInt(document.getElementById('stream-target-fps').value) || 30; + } else if (streamType === 'processed') { + payload.source_stream_id = document.getElementById('stream-source').value; + payload.postprocessing_template_id = document.getElementById('stream-pp-template').value; + } else if (streamType === 'static_image') { + const imageSource = document.getElementById('stream-image-source').value.trim(); + if (!imageSource) { showToast(t('streams.error.required'), 'error'); return; } + payload.image_source = imageSource; + } + + try { + let response; + if (streamId) { + response = await fetchWithAuth(`/picture-sources/${streamId}`, { method: 'PUT', body: JSON.stringify(payload) }); + } else { + response = await fetchWithAuth('/picture-sources', { method: 'POST', body: JSON.stringify(payload) }); + } + + if (!response.ok) { + const error = await response.json(); + throw new Error(error.detail || error.message || 'Failed to save stream'); + } + + showToast(streamId ? t('streams.updated') : t('streams.created'), 'success'); + closeStreamModal(); + await loadPictureSources(); + } catch (error) { + console.error('Error saving stream:', error); + errorEl.textContent = error.message; + errorEl.style.display = 'block'; + } +} + +export async function deleteStream(streamId) { + const confirmed = await showConfirm(t('streams.delete.confirm')); + if (!confirmed) return; + + try { + const response = await fetchWithAuth(`/picture-sources/${streamId}`, { method: 'DELETE' }); + if (!response.ok) { + const error = await response.json(); + throw new Error(error.detail || error.message || 'Failed to delete stream'); + } + showToast(t('streams.deleted'), 'success'); + await loadPictureSources(); + } catch (error) { + console.error('Error deleting stream:', error); + showToast(t('streams.error.delete') + ': ' + error.message, 'error'); + } +} + +export function closeStreamModal() { + document.getElementById('stream-modal').style.display = 'none'; + document.getElementById('stream-type').disabled = false; + unlockBody(); +} + +async function validateStaticImage() { + const source = document.getElementById('stream-image-source').value.trim(); + const previewContainer = document.getElementById('stream-image-preview-container'); + const previewImg = document.getElementById('stream-image-preview'); + const infoEl = document.getElementById('stream-image-info'); + const statusEl = document.getElementById('stream-image-validation-status'); + + if (!source) { + set_lastValidatedImageSource(''); + previewContainer.style.display = 'none'; + statusEl.style.display = 'none'; + return; + } + + if (source === _lastValidatedImageSource) return; + + statusEl.textContent = t('streams.validate_image.validating'); + statusEl.className = 'validation-status loading'; + statusEl.style.display = 'block'; + previewContainer.style.display = 'none'; + + try { + const response = await fetchWithAuth('/picture-sources/validate-image', { + method: 'POST', + body: JSON.stringify({ image_source: source }), + }); + const data = await response.json(); + + set_lastValidatedImageSource(source); + if (data.valid) { + previewImg.src = data.preview; + previewImg.style.cursor = 'pointer'; + previewImg.onclick = () => openFullImageLightbox(source); + infoEl.textContent = `${data.width} × ${data.height} px`; + previewContainer.style.display = ''; + statusEl.textContent = t('streams.validate_image.valid'); + statusEl.className = 'validation-status success'; + } else { + previewContainer.style.display = 'none'; + statusEl.textContent = `${t('streams.validate_image.invalid')}: ${data.error}`; + statusEl.className = 'validation-status error'; + } + } catch (err) { + previewContainer.style.display = 'none'; + statusEl.textContent = `${t('streams.validate_image.invalid')}: ${err.message}`; + statusEl.className = 'validation-status error'; + } +} + +// ===== Picture Source Test ===== + +export async function showTestStreamModal(streamId) { + set_currentTestStreamId(streamId); + restoreStreamTestDuration(); + + const modal = document.getElementById('test-stream-modal'); + modal.style.display = 'flex'; + lockBody(); + setupBackdropClose(modal, closeTestStreamModal); +} + +export function closeTestStreamModal() { + document.getElementById('test-stream-modal').style.display = 'none'; + unlockBody(); + set_currentTestStreamId(null); +} + +export function updateStreamTestDuration(value) { + document.getElementById('test-stream-duration-value').textContent = value; + localStorage.setItem('lastStreamTestDuration', value); +} + +function restoreStreamTestDuration() { + const saved = localStorage.getItem('lastStreamTestDuration') || '5'; + document.getElementById('test-stream-duration').value = saved; + document.getElementById('test-stream-duration-value').textContent = saved; +} + +export async function runStreamTest() { + if (!_currentTestStreamId) return; + const captureDuration = parseFloat(document.getElementById('test-stream-duration').value); + showOverlaySpinner(t('streams.test.running'), captureDuration); + + try { + const response = await fetchWithAuth(`/picture-sources/${_currentTestStreamId}/test`, { + method: 'POST', + body: JSON.stringify({ capture_duration: captureDuration }) + }); + if (!response.ok) { + const error = await response.json(); + throw new Error(error.detail || error.message || 'Test failed'); + } + const result = await response.json(); + hideOverlaySpinner(); + const fullImageSrc = result.full_capture.full_image || result.full_capture.image; + openLightbox(fullImageSrc, buildTestStatsHtml(result)); + } catch (error) { + console.error('Error running stream test:', error); + hideOverlaySpinner(); + showToast(t('streams.test.error.failed') + ': ' + error.message, 'error'); + } +} + +// ===== PP Template Test ===== + +export async function showTestPPTemplateModal(templateId) { + set_currentTestPPTemplateId(templateId); + restorePPTestDuration(); + + const select = document.getElementById('test-pp-source-stream'); + select.innerHTML = ''; + if (_cachedStreams.length === 0) { + try { + const resp = await fetchWithAuth('/picture-sources'); + if (resp.ok) { const d = await resp.json(); set_cachedStreams(d.streams || []); } + } catch (e) { console.warn('Could not load streams for PP test:', e); } + } + for (const s of _cachedStreams) { + const opt = document.createElement('option'); + opt.value = s.id; + opt.textContent = s.name; + select.appendChild(opt); + } + const lastStream = localStorage.getItem('lastPPTestStreamId'); + if (lastStream && _cachedStreams.find(s => s.id === lastStream)) { + select.value = lastStream; + } + + const modal = document.getElementById('test-pp-template-modal'); + modal.style.display = 'flex'; + lockBody(); + setupBackdropClose(modal, closeTestPPTemplateModal); +} + +export function closeTestPPTemplateModal() { + document.getElementById('test-pp-template-modal').style.display = 'none'; + unlockBody(); + set_currentTestPPTemplateId(null); +} + +export function updatePPTestDuration(value) { + document.getElementById('test-pp-duration-value').textContent = value; + localStorage.setItem('lastPPTestDuration', value); +} + +function restorePPTestDuration() { + const saved = localStorage.getItem('lastPPTestDuration') || '5'; + document.getElementById('test-pp-duration').value = saved; + document.getElementById('test-pp-duration-value').textContent = saved; +} + +export async function runPPTemplateTest() { + if (!_currentTestPPTemplateId) return; + const sourceStreamId = document.getElementById('test-pp-source-stream').value; + if (!sourceStreamId) { showToast(t('postprocessing.test.error.no_stream'), 'error'); return; } + localStorage.setItem('lastPPTestStreamId', sourceStreamId); + + const captureDuration = parseFloat(document.getElementById('test-pp-duration').value); + showOverlaySpinner(t('postprocessing.test.running'), captureDuration); + + try { + const response = await fetchWithAuth(`/postprocessing-templates/${_currentTestPPTemplateId}/test`, { + method: 'POST', + body: JSON.stringify({ source_stream_id: sourceStreamId, capture_duration: captureDuration }) + }); + if (!response.ok) { + const error = await response.json(); + throw new Error(error.detail || error.message || 'Test failed'); + } + const result = await response.json(); + hideOverlaySpinner(); + const fullImageSrc = result.full_capture.full_image || result.full_capture.image; + openLightbox(fullImageSrc, buildTestStatsHtml(result)); + } catch (error) { + console.error('Error running PP template test:', error); + hideOverlaySpinner(); + showToast(t('postprocessing.test.error.failed') + ': ' + error.message, 'error'); + } +} + +// ===== PP Templates ===== + +async function loadAvailableFilters() { + try { + const response = await fetchWithAuth('/filters'); + if (!response.ok) throw new Error(`Failed to load filters: ${response.status}`); + const data = await response.json(); + set_availableFilters(data.filters || []); + } catch (error) { + console.error('Error loading available filters:', error); + set_availableFilters([]); + } +} + +async function loadPPTemplates() { + try { + if (_availableFilters.length === 0) await loadAvailableFilters(); + const response = await fetchWithAuth('/postprocessing-templates'); + if (!response.ok) throw new Error(`Failed to load templates: ${response.status}`); + const data = await response.json(); + set_cachedPPTemplates(data.templates || []); + renderPictureSourcesList(_cachedStreams); + } catch (error) { + console.error('Error loading PP templates:', error); + } +} + +function _getFilterName(filterId) { + const key = 'filters.' + filterId; + const translated = t(key); + if (translated === key) { + const def = _availableFilters.find(f => f.filter_id === filterId); + return def ? def.filter_name : filterId; + } + return translated; +} + +function _populateFilterSelect() { + const select = document.getElementById('pp-add-filter-select'); + select.innerHTML = ``; + for (const f of _availableFilters) { + const name = _getFilterName(f.filter_id); + select.innerHTML += ``; + } +} + +export function renderModalFilterList() { + const container = document.getElementById('pp-filter-list'); + if (_modalFilters.length === 0) { + container.innerHTML = `
${t('filters.empty')}
`; + return; + } + + let html = ''; + _modalFilters.forEach((fi, index) => { + const filterDef = _availableFilters.find(f => f.filter_id === fi.filter_id); + const filterName = _getFilterName(fi.filter_id); + const isExpanded = fi._expanded === true; + + let summary = ''; + if (filterDef && !isExpanded) { + summary = filterDef.options_schema.map(opt => { + const val = fi.options[opt.key] !== undefined ? fi.options[opt.key] : opt.default; + return val; + }).join(', '); + } + + html += `
+
+ ${isExpanded ? '▼' : '▶'} + ${escapeHtml(filterName)} + ${summary ? `${escapeHtml(summary)}` : ''} +
+ + + +
+
+
`; + }); + + container.innerHTML = html; +} + +export function addFilterFromSelect() { + const select = document.getElementById('pp-add-filter-select'); + const filterId = select.value; + if (!filterId) return; + + const filterDef = _availableFilters.find(f => f.filter_id === filterId); + if (!filterDef) return; + + const options = {}; + for (const opt of filterDef.options_schema) { + options[opt.key] = opt.default; + } + + _modalFilters.push({ filter_id: filterId, options, _expanded: true }); + select.value = ''; + renderModalFilterList(); + _autoGeneratePPTemplateName(); +} + +export function toggleFilterExpand(index) { + if (_modalFilters[index]) { + _modalFilters[index]._expanded = !_modalFilters[index]._expanded; + renderModalFilterList(); + } +} + +export function removeFilter(index) { + _modalFilters.splice(index, 1); + renderModalFilterList(); + _autoGeneratePPTemplateName(); +} + +export function moveFilter(index, direction) { + const newIndex = index + direction; + if (newIndex < 0 || newIndex >= _modalFilters.length) return; + const tmp = _modalFilters[index]; + _modalFilters[index] = _modalFilters[newIndex]; + _modalFilters[newIndex] = tmp; + renderModalFilterList(); + _autoGeneratePPTemplateName(); +} + +export function updateFilterOption(filterIndex, optionKey, value) { + if (_modalFilters[filterIndex]) { + const fi = _modalFilters[filterIndex]; + const filterDef = _availableFilters.find(f => f.filter_id === fi.filter_id); + if (filterDef) { + const optDef = filterDef.options_schema.find(o => o.key === optionKey); + if (optDef && optDef.type === 'bool') { + fi.options[optionKey] = !!value; + } else if (optDef && optDef.type === 'int') { + fi.options[optionKey] = parseInt(value); + } else { + fi.options[optionKey] = parseFloat(value); + } + } else { + fi.options[optionKey] = parseFloat(value); + } + } +} + +function collectFilters() { + return _modalFilters.map(fi => ({ + filter_id: fi.filter_id, + options: { ...fi.options }, + })); +} + +function _autoGeneratePPTemplateName() { + if (_ppTemplateNameManuallyEdited) return; + if (document.getElementById('pp-template-id').value) return; + const nameInput = document.getElementById('pp-template-name'); + if (_modalFilters.length > 0) { + const filterNames = _modalFilters.map(f => _getFilterName(f.filter_id)).join(' + '); + nameInput.value = filterNames; + } else { + nameInput.value = ''; + } +} + +export async function showAddPPTemplateModal() { + if (_availableFilters.length === 0) await loadAvailableFilters(); + + document.getElementById('pp-template-modal-title').textContent = t('postprocessing.add'); + document.getElementById('pp-template-form').reset(); + document.getElementById('pp-template-id').value = ''; + document.getElementById('pp-template-error').style.display = 'none'; + + set_modalFilters([]); + + set_ppTemplateNameManuallyEdited(false); + document.getElementById('pp-template-name').oninput = () => { set_ppTemplateNameManuallyEdited(true); }; + + _populateFilterSelect(); + renderModalFilterList(); + + const modal = document.getElementById('pp-template-modal'); + modal.style.display = 'flex'; + lockBody(); + setupBackdropClose(modal, closePPTemplateModal); +} + +export async function editPPTemplate(templateId) { + try { + if (_availableFilters.length === 0) await loadAvailableFilters(); + + const response = await fetchWithAuth(`/postprocessing-templates/${templateId}`); + if (!response.ok) throw new Error(`Failed to load template: ${response.status}`); + const tmpl = await response.json(); + + document.getElementById('pp-template-modal-title').textContent = t('postprocessing.edit'); + document.getElementById('pp-template-id').value = templateId; + document.getElementById('pp-template-name').value = tmpl.name; + document.getElementById('pp-template-description').value = tmpl.description || ''; + document.getElementById('pp-template-error').style.display = 'none'; + + set_modalFilters((tmpl.filters || []).map(fi => ({ + filter_id: fi.filter_id, + options: { ...fi.options }, + }))); + + _populateFilterSelect(); + renderModalFilterList(); + + const modal = document.getElementById('pp-template-modal'); + modal.style.display = 'flex'; + lockBody(); + setupBackdropClose(modal, closePPTemplateModal); + } catch (error) { + console.error('Error loading PP template:', error); + showToast(t('postprocessing.error.load') + ': ' + error.message, 'error'); + } +} + +export async function savePPTemplate() { + const templateId = document.getElementById('pp-template-id').value; + const name = document.getElementById('pp-template-name').value.trim(); + const description = document.getElementById('pp-template-description').value.trim(); + const errorEl = document.getElementById('pp-template-error'); + + if (!name) { showToast(t('postprocessing.error.required'), 'error'); return; } + + const payload = { name, filters: collectFilters(), description: description || null }; + + try { + let response; + if (templateId) { + response = await fetchWithAuth(`/postprocessing-templates/${templateId}`, { method: 'PUT', body: JSON.stringify(payload) }); + } else { + response = await fetchWithAuth('/postprocessing-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('postprocessing.updated') : t('postprocessing.created'), 'success'); + closePPTemplateModal(); + await loadPPTemplates(); + } catch (error) { + console.error('Error saving PP template:', error); + errorEl.textContent = error.message; + errorEl.style.display = 'block'; + } +} + +export async function deletePPTemplate(templateId) { + const confirmed = await showConfirm(t('postprocessing.delete.confirm')); + if (!confirmed) return; + + try { + const response = await fetchWithAuth(`/postprocessing-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('postprocessing.deleted'), 'success'); + await loadPPTemplates(); + } catch (error) { + console.error('Error deleting PP template:', error); + showToast(t('postprocessing.error.delete') + ': ' + error.message, 'error'); + } +} + +export function closePPTemplateModal() { + document.getElementById('pp-template-modal').style.display = 'none'; + set_modalFilters([]); + unlockBody(); +} + +// Exported helpers used by other modules +export { updateCaptureDuration, buildTestStatsHtml }; diff --git a/server/src/wled_controller/static/js/features/tabs.js b/server/src/wled_controller/static/js/features/tabs.js new file mode 100644 index 0000000..61befd2 --- /dev/null +++ b/server/src/wled_controller/static/js/features/tabs.js @@ -0,0 +1,50 @@ +/** + * Tab switching — switchTab, initTabs, startAutoRefresh. + */ + +import { apiKey, refreshInterval, setRefreshInterval } from '../core/state.js'; + +export function switchTab(name) { + document.querySelectorAll('.tab-btn').forEach(btn => btn.classList.toggle('active', btn.dataset.tab === name)); + document.querySelectorAll('.tab-panel').forEach(panel => panel.classList.toggle('active', panel.id === `tab-${name}`)); + localStorage.setItem('activeTab', name); + if (name === 'dashboard') { + // Use window.* to avoid circular imports with feature modules + if (typeof window.loadDashboard === 'function') window.loadDashboard(); + if (typeof window.startDashboardWS === 'function') window.startDashboardWS(); + } else { + if (typeof window.stopDashboardWS === 'function') window.stopDashboardWS(); + if (name === 'streams') { + if (typeof window.loadPictureSources === 'function') window.loadPictureSources(); + } else if (name === 'targets') { + if (typeof window.loadTargetsTab === 'function') window.loadTargetsTab(); + } else if (name === 'profiles') { + if (typeof window.loadProfiles === 'function') window.loadProfiles(); + } + } +} + +export function initTabs() { + let saved = localStorage.getItem('activeTab'); + // Migrate legacy 'devices' tab to 'targets' + if (saved === 'devices') saved = 'targets'; + if (!saved || !document.getElementById(`tab-${saved}`)) saved = 'dashboard'; + switchTab(saved); +} + +export function startAutoRefresh() { + if (refreshInterval) { + clearInterval(refreshInterval); + } + + setRefreshInterval(setInterval(() => { + if (apiKey) { + const activeTab = localStorage.getItem('activeTab') || 'dashboard'; + if (activeTab === 'targets') { + if (typeof window.loadTargetsTab === 'function') window.loadTargetsTab(); + } else if (activeTab === 'dashboard') { + if (typeof window.loadDashboard === 'function') window.loadDashboard(); + } + } + }, 2000)); +} diff --git a/server/src/wled_controller/static/js/features/targets.js b/server/src/wled_controller/static/js/features/targets.js new file mode 100644 index 0000000..1f40d27 --- /dev/null +++ b/server/src/wled_controller/static/js/features/targets.js @@ -0,0 +1,632 @@ +/** + * Targets tab — combined view of devices, LED targets, KC targets, pattern templates. + */ + +import { + targetEditorInitialValues, setTargetEditorInitialValues, + _targetEditorDevices, set_targetEditorDevices, + _deviceBrightnessCache, + kcWebSockets, +} from '../core/state.js'; +import { API_BASE, getHeaders, fetchWithAuth, escapeHtml, handle401Error } from '../core/api.js'; +import { t } from '../core/i18n.js'; +import { lockBody, unlockBody, showToast, showConfirm, setupBackdropClose } from '../core/ui.js'; +import { createDeviceCard, attachDeviceListeners, fetchDeviceBrightness } from './devices.js'; +import { createKCTargetCard, connectKCWebSocket, disconnectKCWebSocket } from './kc-targets.js'; + +// createPatternTemplateCard is imported via window.* to avoid circular deps +// (pattern-templates.js calls window.loadTargetsTab) + +function _updateStandbyVisibility() { + const deviceSelect = document.getElementById('target-editor-device'); + const standbyGroup = document.getElementById('target-editor-standby-group'); + const selectedDevice = _targetEditorDevices.find(d => d.id === deviceSelect.value); + const caps = selectedDevice?.capabilities || []; + standbyGroup.style.display = caps.includes('standby_required') ? '' : 'none'; +} + +export async function showTargetEditor(targetId = null) { + try { + // Load devices and sources for dropdowns + const [devicesResp, sourcesResp] = await Promise.all([ + fetch(`${API_BASE}/devices`, { headers: getHeaders() }), + fetchWithAuth('/picture-sources'), + ]); + + const devices = devicesResp.ok ? (await devicesResp.json()).devices || [] : []; + const sources = sourcesResp.ok ? (await sourcesResp.json()).streams || [] : []; + set_targetEditorDevices(devices); + + // Populate device select + const deviceSelect = document.getElementById('target-editor-device'); + deviceSelect.innerHTML = ''; + devices.forEach(d => { + const opt = document.createElement('option'); + opt.value = d.id; + const shortUrl = d.url ? d.url.replace(/^https?:\/\//, '') : ''; + const devType = (d.device_type || 'wled').toUpperCase(); + opt.textContent = `${d.name} [${devType}]${shortUrl ? ' (' + shortUrl + ')' : ''}`; + deviceSelect.appendChild(opt); + }); + deviceSelect.onchange = _updateStandbyVisibility; + + // Populate source select + const sourceSelect = document.getElementById('target-editor-source'); + sourceSelect.innerHTML = ''; + sources.forEach(s => { + const opt = document.createElement('option'); + opt.value = s.id; + const typeIcon = s.stream_type === 'raw' ? '\uD83D\uDDA5\uFE0F' : s.stream_type === 'static_image' ? '\uD83D\uDDBC\uFE0F' : '\uD83C\uDFA8'; + opt.textContent = `${typeIcon} ${s.name}`; + sourceSelect.appendChild(opt); + }); + + if (targetId) { + // Editing existing target + const resp = await fetch(`${API_BASE}/picture-targets/${targetId}`, { headers: getHeaders() }); + if (!resp.ok) throw new Error('Failed to load target'); + const target = await resp.json(); + + document.getElementById('target-editor-id').value = target.id; + document.getElementById('target-editor-name').value = target.name; + deviceSelect.value = target.device_id || ''; + sourceSelect.value = target.picture_source_id || ''; + document.getElementById('target-editor-fps').value = target.settings?.fps ?? 30; + document.getElementById('target-editor-fps-value').textContent = target.settings?.fps ?? 30; + document.getElementById('target-editor-interpolation').value = target.settings?.interpolation_mode ?? 'average'; + document.getElementById('target-editor-smoothing').value = target.settings?.smoothing ?? 0.3; + document.getElementById('target-editor-smoothing-value').textContent = target.settings?.smoothing ?? 0.3; + document.getElementById('target-editor-standby-interval').value = target.settings?.standby_interval ?? 1.0; + document.getElementById('target-editor-standby-interval-value').textContent = target.settings?.standby_interval ?? 1.0; + document.getElementById('target-editor-title').textContent = t('targets.edit'); + } else { + // Creating new target + document.getElementById('target-editor-id').value = ''; + document.getElementById('target-editor-name').value = ''; + deviceSelect.value = ''; + sourceSelect.value = ''; + document.getElementById('target-editor-fps').value = 30; + document.getElementById('target-editor-fps-value').textContent = '30'; + document.getElementById('target-editor-interpolation').value = 'average'; + document.getElementById('target-editor-smoothing').value = 0.3; + document.getElementById('target-editor-smoothing-value').textContent = '0.3'; + document.getElementById('target-editor-standby-interval').value = 1.0; + document.getElementById('target-editor-standby-interval-value').textContent = '1.0'; + document.getElementById('target-editor-title').textContent = t('targets.add'); + } + + // Show/hide standby interval based on selected device capabilities + _updateStandbyVisibility(); + + setTargetEditorInitialValues({ + name: document.getElementById('target-editor-name').value, + device: deviceSelect.value, + source: sourceSelect.value, + fps: document.getElementById('target-editor-fps').value, + interpolation: document.getElementById('target-editor-interpolation').value, + smoothing: document.getElementById('target-editor-smoothing').value, + standby_interval: document.getElementById('target-editor-standby-interval').value, + }); + + const modal = document.getElementById('target-editor-modal'); + modal.style.display = 'flex'; + lockBody(); + setupBackdropClose(modal, closeTargetEditorModal); + + document.getElementById('target-editor-error').style.display = 'none'; + setTimeout(() => document.getElementById('target-editor-name').focus(), 100); + } catch (error) { + console.error('Failed to open target editor:', error); + showToast('Failed to open target editor', 'error'); + } +} + +export function isTargetEditorDirty() { + return ( + document.getElementById('target-editor-name').value !== targetEditorInitialValues.name || + document.getElementById('target-editor-device').value !== targetEditorInitialValues.device || + document.getElementById('target-editor-source').value !== targetEditorInitialValues.source || + document.getElementById('target-editor-fps').value !== targetEditorInitialValues.fps || + document.getElementById('target-editor-interpolation').value !== targetEditorInitialValues.interpolation || + document.getElementById('target-editor-smoothing').value !== targetEditorInitialValues.smoothing || + document.getElementById('target-editor-standby-interval').value !== targetEditorInitialValues.standby_interval + ); +} + +export async function closeTargetEditorModal() { + if (isTargetEditorDirty()) { + const confirmed = await showConfirm(t('modal.discard_changes')); + if (!confirmed) return; + } + forceCloseTargetEditorModal(); +} + +export function forceCloseTargetEditorModal() { + document.getElementById('target-editor-modal').style.display = 'none'; + document.getElementById('target-editor-error').style.display = 'none'; + unlockBody(); + setTargetEditorInitialValues({}); +} + +export async function saveTargetEditor() { + const targetId = document.getElementById('target-editor-id').value; + const name = document.getElementById('target-editor-name').value.trim(); + const deviceId = document.getElementById('target-editor-device').value; + const sourceId = document.getElementById('target-editor-source').value; + const fps = parseInt(document.getElementById('target-editor-fps').value) || 30; + const interpolation = document.getElementById('target-editor-interpolation').value; + const smoothing = parseFloat(document.getElementById('target-editor-smoothing').value); + const standbyInterval = parseFloat(document.getElementById('target-editor-standby-interval').value); + const errorEl = document.getElementById('target-editor-error'); + + if (!name) { + errorEl.textContent = t('targets.error.name_required'); + errorEl.style.display = 'block'; + return; + } + + const payload = { + name, + device_id: deviceId, + picture_source_id: sourceId, + settings: { + fps: fps, + interpolation_mode: interpolation, + smoothing: smoothing, + standby_interval: standbyInterval, + }, + }; + + try { + let response; + if (targetId) { + response = await fetch(`${API_BASE}/picture-targets/${targetId}`, { + method: 'PUT', + headers: getHeaders(), + body: JSON.stringify(payload), + }); + } else { + payload.target_type = 'led'; + response = await fetch(`${API_BASE}/picture-targets`, { + method: 'POST', + headers: getHeaders(), + body: JSON.stringify(payload), + }); + } + + if (response.status === 401) { handle401Error(); return; } + + if (!response.ok) { + const err = await response.json(); + throw new Error(err.detail || 'Failed to save'); + } + + showToast(targetId ? t('targets.updated') : t('targets.created'), 'success'); + forceCloseTargetEditorModal(); + await loadTargetsTab(); + } catch (error) { + console.error('Error saving target:', error); + errorEl.textContent = error.message; + errorEl.style.display = 'block'; + } +} + +// ===== TARGETS TAB (WLED devices + targets combined) ===== + +export async function loadTargets() { + // Alias for backward compatibility + await loadTargetsTab(); +} + +export function switchTargetSubTab(tabKey) { + document.querySelectorAll('.target-sub-tab-btn').forEach(btn => + btn.classList.toggle('active', btn.dataset.targetSubTab === tabKey) + ); + document.querySelectorAll('.target-sub-tab-panel').forEach(panel => + panel.classList.toggle('active', panel.id === `target-sub-tab-${tabKey}`) + ); + localStorage.setItem('activeTargetSubTab', tabKey); +} + +export async function loadTargetsTab() { + const container = document.getElementById('targets-panel-content'); + if (!container) return; + + try { + // Fetch devices, targets, sources, and pattern templates in parallel + const [devicesResp, targetsResp, sourcesResp, patResp] = await Promise.all([ + fetch(`${API_BASE}/devices`, { headers: getHeaders() }), + fetch(`${API_BASE}/picture-targets`, { headers: getHeaders() }), + fetchWithAuth('/picture-sources').catch(() => null), + fetchWithAuth('/pattern-templates').catch(() => null), + ]); + + if (devicesResp.status === 401 || targetsResp.status === 401) { + handle401Error(); + return; + } + + const devicesData = await devicesResp.json(); + const devices = devicesData.devices || []; + + const targetsData = await targetsResp.json(); + const targets = targetsData.targets || []; + + let sourceMap = {}; + if (sourcesResp && sourcesResp.ok) { + const srcData = await sourcesResp.json(); + (srcData.streams || []).forEach(s => { sourceMap[s.id] = s; }); + } + + let patternTemplates = []; + let patternTemplateMap = {}; + if (patResp && patResp.ok) { + const patData = await patResp.json(); + patternTemplates = patData.templates || []; + patternTemplates.forEach(pt => { patternTemplateMap[pt.id] = pt; }); + } + + // Fetch state for each device + const devicesWithState = await Promise.all( + devices.map(async (device) => { + try { + const stateResp = await fetch(`${API_BASE}/devices/${device.id}/state`, { headers: getHeaders() }); + const state = stateResp.ok ? await stateResp.json() : {}; + return { ...device, state }; + } catch { + return device; + } + }) + ); + + // Fetch state + metrics for each target (+ colors for KC targets) + const targetsWithState = await Promise.all( + targets.map(async (target) => { + try { + const stateResp = await fetch(`${API_BASE}/picture-targets/${target.id}/state`, { headers: getHeaders() }); + const state = stateResp.ok ? await stateResp.json() : {}; + const metricsResp = await fetch(`${API_BASE}/picture-targets/${target.id}/metrics`, { headers: getHeaders() }); + const metrics = metricsResp.ok ? await metricsResp.json() : {}; + let latestColors = null; + if (target.target_type === 'key_colors' && state.processing) { + try { + const colorsResp = await fetch(`${API_BASE}/picture-targets/${target.id}/colors`, { headers: getHeaders() }); + if (colorsResp.ok) latestColors = await colorsResp.json(); + } catch {} + } + return { ...target, state, metrics, latestColors }; + } catch { + return target; + } + }) + ); + + // Build device map for target name resolution + const deviceMap = {}; + devicesWithState.forEach(d => { deviceMap[d.id] = d; }); + + // Group by type + const ledDevices = devicesWithState; + const ledTargets = targetsWithState.filter(t => t.target_type === 'led' || t.target_type === 'wled'); + const kcTargets = targetsWithState.filter(t => t.target_type === 'key_colors'); + + // Backward compat: map stored "wled" sub-tab to "led" + let activeSubTab = localStorage.getItem('activeTargetSubTab') || 'led'; + if (activeSubTab === 'wled') activeSubTab = 'led'; + + const subTabs = [ + { key: 'led', icon: '\uD83D\uDCA1', titleKey: 'targets.subtab.led', count: ledDevices.length + ledTargets.length }, + { key: 'key_colors', icon: '\uD83C\uDFA8', titleKey: 'targets.subtab.key_colors', count: kcTargets.length + patternTemplates.length }, + ]; + + const tabBar = `
${subTabs.map(tab => + `` + ).join('')}
`; + + // Use window.createPatternTemplateCard to avoid circular import + const createPatternTemplateCard = window.createPatternTemplateCard || (() => ''); + + // LED panel: devices section + targets section + const ledPanel = ` +
+
+

${t('targets.section.devices')}

+
+ ${ledDevices.map(device => createDeviceCard(device)).join('')} +
+
+
+
+
+
+
+

${t('targets.section.targets')}

+
+ ${ledTargets.map(target => createTargetCard(target, deviceMap, sourceMap)).join('')} +
+
+
+
+
+
+
`; + + // Key Colors panel + const kcPanel = ` +
+
+

${t('targets.section.key_colors')}

+
+ ${kcTargets.map(target => createKCTargetCard(target, sourceMap, patternTemplateMap)).join('')} +
+
+
+
+
+
+
+

${t('targets.section.pattern_templates')}

+
+ ${patternTemplates.map(pt => createPatternTemplateCard(pt)).join('')} +
+
+
+
+
+
+
`; + + container.innerHTML = tabBar + ledPanel + kcPanel; + + // Attach event listeners and fetch brightness for device cards + devicesWithState.forEach(device => { + attachDeviceListeners(device.id); + if ((device.capabilities || []).includes('brightness_control')) { + // Only fetch from device if we don't have a cached value yet + if (device.id in _deviceBrightnessCache) { + const bri = _deviceBrightnessCache[device.id]; + const slider = document.querySelector(`[data-device-brightness="${device.id}"]`); + if (slider) { + slider.value = bri; + slider.title = Math.round(bri / 255 * 100) + '%'; + slider.disabled = false; + } + const wrap = document.querySelector(`[data-brightness-wrap="${device.id}"]`); + if (wrap) wrap.classList.remove('brightness-loading'); + } else { + fetchDeviceBrightness(device.id); + } + } + }); + + // Manage KC WebSockets: connect for processing, disconnect for stopped + const processingKCIds = new Set(); + kcTargets.forEach(target => { + if (target.state && target.state.processing) { + processingKCIds.add(target.id); + if (!kcWebSockets[target.id]) { + connectKCWebSocket(target.id); + } + } + }); + // Disconnect WebSockets for targets no longer processing + Object.keys(kcWebSockets).forEach(id => { + if (!processingKCIds.has(id)) disconnectKCWebSocket(id); + }); + + } catch (error) { + console.error('Failed to load targets tab:', error); + container.innerHTML = `
${t('targets.failed')}
`; + } +} + +export function createTargetCard(target, deviceMap, sourceMap) { + const state = target.state || {}; + const metrics = target.metrics || {}; + const settings = target.settings || {}; + + const isProcessing = state.processing || false; + + const device = deviceMap[target.device_id]; + const source = sourceMap[target.picture_source_id]; + const deviceName = device ? device.name : (target.device_id || 'No device'); + const sourceName = source ? source.name : (target.picture_source_id || 'No source'); + + // Health info from target state (forwarded from device) + const devOnline = state.device_online || false; + let healthClass = 'health-unknown'; + let healthTitle = ''; + if (state.device_last_checked !== null && state.device_last_checked !== undefined) { + healthClass = devOnline ? 'health-online' : 'health-offline'; + healthTitle = devOnline ? t('device.health.online') : t('device.health.offline'); + } + + return ` +
+ +
+
+ + ${escapeHtml(target.name)} + ${isProcessing ? `${t('device.status.processing')}` : ''} +
+
+
+ 💡 ${escapeHtml(deviceName)} + ⚡ ${settings.fps || 30} + 📺 ${escapeHtml(sourceName)} +
+
+ ${isProcessing ? ` +
+
+
${t('device.metrics.actual_fps')}
+
${state.fps_actual?.toFixed(1) || '0.0'}
+
+
+
${t('device.metrics.current_fps')}
+
${state.fps_current ?? '-'}
+
+
+
${t('device.metrics.target_fps')}
+
${state.fps_target || 0}
+
+
+
${t('device.metrics.potential_fps')}
+
${state.fps_potential?.toFixed(0) || '-'}
+
+
+
${t('device.metrics.frames')}
+
${metrics.frames_processed || 0}
+
+
+
${t('device.metrics.keepalive')}
+
${state.frames_keepalive ?? '-'}
+
+
+
${t('device.metrics.errors')}
+
${metrics.errors_count || 0}
+
+
+ ${state.timing_total_ms != null ? ` +
+
+
${t('device.metrics.timing')}
+
${state.timing_total_ms}ms
+
+
+ + + + +
+
+ extract ${state.timing_extract_ms}ms + map ${state.timing_map_leds_ms}ms + smooth ${state.timing_smooth_ms}ms + send ${state.timing_send_ms}ms +
+
+ ` : ''} + ` : ''} +
+
+ ${isProcessing ? ` + + ` : ` + + `} + + ${state.overlay_active ? ` + + ` : ` + + `} +
+
+ `; +} + +export async function startTargetProcessing(targetId) { + try { + const response = await fetch(`${API_BASE}/picture-targets/${targetId}/start`, { + method: 'POST', + headers: getHeaders() + }); + if (response.status === 401) { handle401Error(); return; } + if (response.ok) { + showToast(t('device.started'), 'success'); + loadTargetsTab(); + } else { + const error = await response.json(); + showToast(`Failed to start: ${error.detail}`, 'error'); + } + } catch (error) { + showToast('Failed to start processing', 'error'); + } +} + +export async function stopTargetProcessing(targetId) { + try { + const response = await fetch(`${API_BASE}/picture-targets/${targetId}/stop`, { + method: 'POST', + headers: getHeaders() + }); + if (response.status === 401) { handle401Error(); return; } + if (response.ok) { + showToast(t('device.stopped'), 'success'); + loadTargetsTab(); + } else { + const error = await response.json(); + showToast(`Failed to stop: ${error.detail}`, 'error'); + } + } catch (error) { + showToast('Failed to stop processing', 'error'); + } +} + +export async function startTargetOverlay(targetId) { + try { + const response = await fetch(`${API_BASE}/picture-targets/${targetId}/overlay/start`, { + method: 'POST', + headers: getHeaders() + }); + if (response.status === 401) { handle401Error(); return; } + if (response.ok) { + showToast(t('overlay.started'), 'success'); + loadTargetsTab(); + } else { + const error = await response.json(); + showToast(t('overlay.error.start') + ': ' + error.detail, 'error'); + } + } catch (error) { + showToast(t('overlay.error.start'), 'error'); + } +} + +export async function stopTargetOverlay(targetId) { + try { + const response = await fetch(`${API_BASE}/picture-targets/${targetId}/overlay/stop`, { + method: 'POST', + headers: getHeaders() + }); + if (response.status === 401) { handle401Error(); return; } + if (response.ok) { + showToast(t('overlay.stopped'), 'success'); + loadTargetsTab(); + } else { + const error = await response.json(); + showToast(t('overlay.error.stop') + ': ' + error.detail, 'error'); + } + } catch (error) { + showToast(t('overlay.error.stop'), 'error'); + } +} + +export async function deleteTarget(targetId) { + const confirmed = await showConfirm(t('targets.delete.confirm')); + if (!confirmed) return; + + try { + const response = await fetch(`${API_BASE}/picture-targets/${targetId}`, { + method: 'DELETE', + headers: getHeaders() + }); + if (response.status === 401) { handle401Error(); return; } + if (response.ok) { + showToast(t('targets.deleted'), 'success'); + loadTargetsTab(); + } else { + const error = await response.json(); + showToast(`Failed to delete: ${error.detail}`, 'error'); + } + } catch (error) { + showToast('Failed to delete target', 'error'); + } +} diff --git a/server/src/wled_controller/static/js/features/tutorials.js b/server/src/wled_controller/static/js/features/tutorials.js new file mode 100644 index 0000000..3ebbb38 --- /dev/null +++ b/server/src/wled_controller/static/js/features/tutorials.js @@ -0,0 +1,214 @@ +/** + * Tutorial system — generic engine, steps, tooltip positioning. + */ + +import { activeTutorial, setActiveTutorial } from '../core/state.js'; +import { t } from '../core/i18n.js'; + +const calibrationTutorialSteps = [ + { selector: '#cal-top-leds', textKey: 'calibration.tip.led_count', position: 'bottom' }, + { selector: '.corner-bottom-left', textKey: 'calibration.tip.start_corner', position: 'right' }, + { selector: '.direction-toggle', textKey: 'calibration.tip.direction', position: 'bottom' }, + { selector: '.edge-span-bar[data-edge="top"]', textKey: 'calibration.tip.span', position: 'bottom' }, + { selector: '.toggle-top', textKey: 'calibration.tip.test', position: 'top' }, + { selector: '.preview-screen-total', textKey: 'calibration.tip.toggle_inputs', position: 'top' }, + { selector: '.preview-screen-border-width', textKey: 'calibration.tip.border_width', position: 'bottom' }, + { selector: '#cal-offset', textKey: 'calibration.tip.offset', position: 'top' }, + { selector: '#cal-skip-start', textKey: 'calibration.tip.skip_leds_start', position: 'top' }, + { selector: '#cal-skip-end', textKey: 'calibration.tip.skip_leds_end', position: 'top' } +]; + +const deviceTutorialSteps = [ + { selector: '.card-subtitle', textKey: 'device.tip.metadata', position: 'bottom' }, + { selector: '.brightness-control', textKey: 'device.tip.brightness', position: 'bottom' }, + { selector: '.card-actions .btn:nth-child(1)', textKey: 'device.tip.start', position: 'top' }, + { selector: '.card-actions .btn:nth-child(2)', textKey: 'device.tip.settings', position: 'top' }, + { selector: '.card-actions .btn:nth-child(3)', textKey: 'device.tip.capture_settings', position: 'top' }, + { selector: '.card-actions .btn:nth-child(4)', textKey: 'device.tip.calibrate', position: 'top' }, + { selector: '.card-actions .btn:nth-child(5)', textKey: 'device.tip.webui', position: 'top' } +]; + +export function startTutorial(config) { + closeTutorial(); + const overlay = document.getElementById(config.overlayId); + if (!overlay) return; + + setActiveTutorial({ + steps: config.steps, + overlay: overlay, + mode: config.mode, + step: 0, + resolveTarget: config.resolveTarget, + container: config.container + }); + + overlay.classList.add('active'); + document.addEventListener('keydown', handleTutorialKey); + showTutorialStep(0); +} + +export function startCalibrationTutorial() { + const container = document.querySelector('#calibration-modal .modal-body'); + if (!container) return; + startTutorial({ + steps: calibrationTutorialSteps, + overlayId: 'tutorial-overlay', + mode: 'absolute', + container: container, + resolveTarget: (step) => document.querySelector(step.selector) + }); +} + +export function startDeviceTutorial(deviceId) { + const selector = deviceId + ? `.card[data-device-id="${deviceId}"]` + : '.card[data-device-id]'; + if (!document.querySelector(selector)) return; + startTutorial({ + steps: deviceTutorialSteps, + overlayId: 'device-tutorial-overlay', + mode: 'fixed', + container: null, + resolveTarget: (step) => { + const card = document.querySelector(selector); + if (!card) return null; + return step.global + ? document.querySelector(step.selector) + : card.querySelector(step.selector); + } + }); +} + +export function closeTutorial() { + if (!activeTutorial) return; + activeTutorial.overlay.classList.remove('active'); + document.querySelectorAll('.tutorial-target').forEach(el => { + el.classList.remove('tutorial-target'); + el.style.zIndex = ''; + }); + document.removeEventListener('keydown', handleTutorialKey); + setActiveTutorial(null); +} + +export function tutorialNext() { + if (!activeTutorial) return; + if (activeTutorial.step < activeTutorial.steps.length - 1) { + showTutorialStep(activeTutorial.step + 1); + } else { + closeTutorial(); + } +} + +export function tutorialPrev() { + if (!activeTutorial) return; + if (activeTutorial.step > 0) { + showTutorialStep(activeTutorial.step - 1); + } +} + +function showTutorialStep(index) { + if (!activeTutorial) return; + activeTutorial.step = index; + const step = activeTutorial.steps[index]; + const overlay = activeTutorial.overlay; + const isFixed = activeTutorial.mode === 'fixed'; + + document.querySelectorAll('.tutorial-target').forEach(el => { + el.classList.remove('tutorial-target'); + el.style.zIndex = ''; + }); + + const target = activeTutorial.resolveTarget(step); + if (!target) return; + target.classList.add('tutorial-target'); + if (isFixed) target.style.zIndex = '10001'; + + const targetRect = target.getBoundingClientRect(); + const pad = 6; + let x, y, w, h; + + if (isFixed) { + x = targetRect.left - pad; + y = targetRect.top - pad; + w = targetRect.width + pad * 2; + h = targetRect.height + pad * 2; + } else { + const containerRect = activeTutorial.container.getBoundingClientRect(); + x = targetRect.left - containerRect.left - pad; + y = targetRect.top - containerRect.top - pad; + w = targetRect.width + pad * 2; + h = targetRect.height + pad * 2; + } + + const backdrop = overlay.querySelector('.tutorial-backdrop'); + if (backdrop) { + backdrop.style.clipPath = `polygon( + 0% 0%, 0% 100%, + ${x}px 100%, ${x}px ${y}px, + ${x + w}px ${y}px, ${x + w}px ${y + h}px, + ${x}px ${y + h}px, ${x}px 100%, + 100% 100%, 100% 0%)`; + } + + const ring = overlay.querySelector('.tutorial-ring'); + if (ring) { + ring.style.left = x + 'px'; + ring.style.top = y + 'px'; + ring.style.width = w + 'px'; + ring.style.height = h + 'px'; + } + + const tooltip = overlay.querySelector('.tutorial-tooltip'); + const textEl = overlay.querySelector('.tutorial-tooltip-text'); + const counterEl = overlay.querySelector('.tutorial-step-counter'); + if (textEl) textEl.textContent = t(step.textKey); + if (counterEl) counterEl.textContent = `${index + 1} / ${activeTutorial.steps.length}`; + + const prevBtn = overlay.querySelector('.tutorial-prev-btn'); + const nextBtn = overlay.querySelector('.tutorial-next-btn'); + if (prevBtn) prevBtn.disabled = (index === 0); + if (nextBtn) nextBtn.textContent = (index === activeTutorial.steps.length - 1) ? '\u2713' : '\u2192'; + + if (tooltip) { + positionTutorialTooltip(tooltip, x, y, w, h, step.position, isFixed); + } +} + +function positionTutorialTooltip(tooltip, sx, sy, sw, sh, preferred, isFixed) { + const gap = 12; + const tooltipW = 260; + tooltip.setAttribute('style', 'left:-9999px;top:-9999px'); + const tooltipH = tooltip.offsetHeight || 150; + + const positions = { + top: { x: sx + sw / 2 - tooltipW / 2, y: sy - tooltipH - gap }, + bottom: { x: sx + sw / 2 - tooltipW / 2, y: sy + sh + gap }, + left: { x: sx - tooltipW - gap, y: sy + sh / 2 - tooltipH / 2 }, + right: { x: sx + sw + gap, y: sy + sh / 2 - tooltipH / 2 } + }; + + let pos = positions[preferred] || positions.bottom; + + const cW = isFixed ? window.innerWidth : activeTutorial.container.clientWidth; + const cH = isFixed ? window.innerHeight : activeTutorial.container.clientHeight; + + if (pos.y + tooltipH > cH || pos.y < 0 || pos.x + tooltipW > cW || pos.x < 0) { + const opposite = { top: 'bottom', bottom: 'top', left: 'right', right: 'left' }; + const alt = positions[opposite[preferred]]; + if (alt && alt.y >= 0 && alt.y + tooltipH <= cH && alt.x >= 0 && alt.x + tooltipW <= cW) { + pos = alt; + } + } + + pos.x = Math.max(8, Math.min(cW - tooltipW - 8, pos.x)); + pos.y = Math.max(8, Math.min(cH - tooltipH - 8, pos.y)); + + tooltip.setAttribute('style', `left:${Math.round(pos.x)}px;top:${Math.round(pos.y)}px`); +} + +function handleTutorialKey(e) { + if (!activeTutorial) return; + if (e.key === 'Escape') { closeTutorial(); e.stopPropagation(); } + else if (e.key === 'ArrowRight' || e.key === 'ArrowDown') { e.preventDefault(); tutorialNext(); } + else if (e.key === 'ArrowLeft' || e.key === 'ArrowUp') { e.preventDefault(); tutorialPrev(); } +}