diff --git a/server/src/wled_controller/static/app.js b/server/src/wled_controller/static/app.js deleted file mode 100644 index 2a76bac..0000000 --- a/server/src/wled_controller/static/app.js +++ /dev/null @@ -1,7034 +0,0 @@ -const API_BASE = '/api/v1'; -let refreshInterval = null; -let apiKey = null; -let kcTestAutoRefresh = null; // interval ID for KC test auto-refresh -let kcTestTargetId = null; // currently testing KC target -let _dashboardWS = null; // WebSocket for dashboard live updates - -// Toggle hint description visibility next to a label -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); - } -} - -// Backdrop click helper: only closes modal if both mousedown and mouseup were on the backdrop itself. -// Prevents accidental close when user drags text selection outside the dialog. -function setupBackdropClose(modal, closeFn) { - // Guard against duplicate listeners when called on every modal open - 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; -} - -// Device type helpers -function isSerialDevice(type) { return type === 'adalight' || type === 'ambiled'; } - -// Track logged errors to avoid console spam -const loggedErrors = new Map(); // deviceId -> { errorCount, lastError } - -// Calibration test mode state -const calibrationTestState = {}; // deviceId -> Set of active edge names - -// Modal dirty tracking - stores initial values when modals open -let settingsInitialValues = {}; -let calibrationInitialValues = {}; -const EDGE_TEST_COLORS = { - top: [255, 0, 0], - right: [0, 255, 0], - bottom: [0, 100, 255], - left: [255, 255, 0] -}; - -// Modal body lock helpers — uses position:fixed to freeze scroll without removing scrollbar -function lockBody() { - const scrollY = window.scrollY; - document.body.style.top = `-${scrollY}px`; - document.body.classList.add('modal-open'); -} - -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); -} - -// Image lightbox -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(); -} - -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'); - // Revoke blob URL if one was used - 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'; - // Hide auto-refresh button - const refreshBtn = document.getElementById('lightbox-auto-refresh'); - if (refreshBtn) { refreshBtn.style.display = 'none'; refreshBtn.classList.remove('active'); } - unlockBody(); -} - -async function openFullImageLightbox(imageSource) { - try { - 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); - } -} - -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 { - // Close topmost visible modal - 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; - } - } - } - } -}); - -// Display picker lightbox -let _displayPickerCallback = null; -let _displayPickerSelectedIndex = null; - -function openDisplayPicker(callback, selectedIndex) { - _displayPickerCallback = callback; - _displayPickerSelectedIndex = (selectedIndex !== undefined && selectedIndex !== null && selectedIndex !== '') ? Number(selectedIndex) : null; - const lightbox = document.getElementById('display-picker-lightbox'); - const canvas = document.getElementById('display-picker-canvas'); - - lightbox.classList.add('active'); - - // Defer render to next frame so the lightbox has been laid out and canvas has dimensions - requestAnimationFrame(() => { - if (_cachedDisplays && _cachedDisplays.length > 0) { - renderDisplayPickerLayout(_cachedDisplays); - } else { - canvas.innerHTML = '
'; - loadDisplays().then(() => { - if (_cachedDisplays && _cachedDisplays.length > 0) { - renderDisplayPickerLayout(_cachedDisplays); - } else { - canvas.innerHTML = `
${t('displays.none')}
`; - } - }); - } - }); -} - -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'); - _displayPickerCallback = null; -} - -function selectDisplay(displayIndex) { - if (_displayPickerCallback) { - const display = _cachedDisplays ? _cachedDisplays.find(d => d.index === displayIndex) : null; - _displayPickerCallback(displayIndex, display); - } - closeDisplayPicker(); -} - -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; - - // Use percentage-based positioning so layout always fits its container - 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} -
- `; -} - -function formatDisplayLabel(displayIndex, display) { - if (display) { - return `#${display.index}: ${display.width}×${display.height}${display.is_primary ? ' (' + t('displays.badge.primary') + ')' : ''}`; - } - return `Display ${displayIndex}`; -} - -let _streamNameManuallyEdited = false; - -function onStreamDisplaySelected(displayIndex, display) { - document.getElementById('stream-display-index').value = displayIndex; - document.getElementById('stream-display-picker-label').textContent = formatDisplayLabel(displayIndex, display); - _autoGenerateStreamName(); -} - -let _streamModalPPTemplates = []; - -function _autoGenerateStreamName() { - if (_streamNameManuallyEdited) return; - if (document.getElementById('stream-id').value) return; // editing, not creating - 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; - } - } -} - -function onTestDisplaySelected(displayIndex, display) { - document.getElementById('test-template-display').value = displayIndex; - document.getElementById('test-display-picker-label').textContent = formatDisplayLabel(displayIndex, display); -} - -// Locale management -let currentLocale = 'en'; -let translations = {}; -const supportedLocales = { - 'en': 'English', - 'ru': 'Русский' -}; - -// Minimal inline fallback for critical UI elements -const fallbackTranslations = { - 'app.title': 'LED Grab', - 'auth.placeholder': 'Enter your API key...', - 'auth.button.login': 'Login' -}; - -// Translation function -function t(key, params = {}) { - let text = translations[key] || fallbackTranslations[key] || key; - - // Replace parameters like {name}, {value}, etc. - Object.keys(params).forEach(param => { - text = text.replace(new RegExp(`\\{${param}\\}`, 'g'), params[param]); - }); - - return text; -} - -// Load translation file -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); - // Fallback to English if loading fails - if (locale !== 'en') { - return await loadTranslations('en'); - } - return {}; - } -} - -// Detect browser locale -function detectBrowserLocale() { - const browserLang = navigator.language || navigator.languages?.[0] || 'en'; - const langCode = browserLang.split('-')[0]; // 'en-US' -> 'en', 'ru-RU' -> 'ru' - - // Only return if we support it - return supportedLocales[langCode] ? langCode : 'en'; -} - -// Initialize locale -async function initLocale() { - const savedLocale = localStorage.getItem('locale') || detectBrowserLocale(); - await setLocale(savedLocale); -} - -// Set locale -async function setLocale(locale) { - if (!supportedLocales[locale]) { - locale = 'en'; - } - - // Load translations for the locale - translations = await loadTranslations(locale); - - currentLocale = locale; - document.documentElement.setAttribute('data-locale', locale); - document.documentElement.setAttribute('lang', locale); - localStorage.setItem('locale', locale); - - // Update all text - updateAllText(); - - // Update locale select dropdown (if visible) - updateLocaleSelect(); -} - -// Change locale from dropdown -function changeLocale() { - const select = document.getElementById('locale-select'); - const newLocale = select.value; - if (newLocale && newLocale !== currentLocale) { - localStorage.setItem('locale', newLocale); - setLocale(newLocale); - } -} - -// Update locale select dropdown -function updateLocaleSelect() { - const select = document.getElementById('locale-select'); - if (select) { - select.value = currentLocale; - } -} - -// Update all text on page -function updateAllText() { - // Update all elements with data-i18n attribute - document.querySelectorAll('[data-i18n]').forEach(el => { - const key = el.getAttribute('data-i18n'); - el.textContent = t(key); - }); - - // Update all elements with data-i18n-placeholder attribute - document.querySelectorAll('[data-i18n-placeholder]').forEach(el => { - const key = el.getAttribute('data-i18n-placeholder'); - el.placeholder = t(key); - }); - - // Update all elements with data-i18n-title attribute - 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) { - loadDisplays(); - loadTargetsTab(); - loadPictureSources(); - } -} - -// Initialize app -document.addEventListener('DOMContentLoaded', async () => { - // Initialize locale first - await initLocale(); - - // Load API key from localStorage - apiKey = 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) { - // Wait for modal functions to be defined - setTimeout(() => { - if (typeof showApiKeyModal === 'function') { - showApiKeyModal('Welcome! Please login with your API key to get started.', true); - } - }, 100); - return; // Don't load data yet - } - - // User is logged in, load data - loadServerInfo(); - loadDisplays(); - loadTargetsTab(); - - // Start auto-refresh - startAutoRefresh(); -}); - -// Helper function to add auth header if needed -function getHeaders() { - const headers = { - 'Content-Type': 'application/json' - }; - - if (apiKey) { - headers['Authorization'] = `Bearer ${apiKey}`; - } - - return headers; -} - -// Fetch wrapper that automatically includes auth headers -async function fetchWithAuth(url, options = {}) { - // Build full URL if relative path provided - const fullUrl = url.startsWith('http') ? url : `${API_BASE}${url}`; - - // Merge auth headers with any custom headers - const headers = options.headers - ? { ...getHeaders(), ...options.headers } - : getHeaders(); - - // Make request with merged options - return fetch(fullUrl, { - ...options, - headers - }); -} - -// Escape HTML to prevent XSS -function escapeHtml(text) { - if (!text) return ''; - const div = document.createElement('div'); - div.textContent = text; - return div.innerHTML; -} - -// Handle 401 errors by showing login modal -function handle401Error() { - // Clear invalid API key - localStorage.removeItem('wled_api_key'); - apiKey = null; - - // Stop auto-refresh to prevent repeated 401 errors - if (refreshInterval) { - clearInterval(refreshInterval); - refreshInterval = null; - } - - if (typeof updateAuthUI === 'function') { - updateAuthUI(); - } - - if (typeof showApiKeyModal === 'function') { - showApiKeyModal('Your session has expired or the API key is invalid. Please login again.', true); - } else { - showToast('Authentication failed. Please reload the page and login.', 'error'); - } -} - -// Configure API key -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; // Cancelled - } - - if (key === '') { - localStorage.removeItem('wled_api_key'); - apiKey = null; - document.getElementById('api-key-btn').style.display = 'none'; - showToast('API key removed', 'info'); - } else { - localStorage.setItem('wled_api_key', key); - apiKey = key; - document.getElementById('api-key-btn').style.display = 'inline-block'; - showToast('API key updated', 'success'); - } - - // Reload data with new key - loadServerInfo(); - loadDisplays(); - loadDevices(); -} - -// Server info -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'; - showToast(t('server.offline'), 'error'); - } -} - -// Load displays -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) { - _cachedDisplays = data.displays; - } - } catch (error) { - console.error('Failed to load displays:', error); - } -} - -let _cachedDisplays = null; - -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') { - loadDashboard(); - startDashboardWS(); - } else { - stopDashboardWS(); - if (name === 'streams') { - loadPictureSources(); - } else if (name === 'targets') { - loadTargetsTab(); - } else if (name === 'profiles') { - loadProfiles(); - } - } -} - -function initTabs() { - let saved = localStorage.getItem('activeTab'); - // Migrate legacy 'devices' tab to 'targets' (devices now live inside targets) - if (saved === 'devices') saved = 'targets'; - if (!saved || !document.getElementById(`tab-${saved}`)) saved = 'dashboard'; - switchTab(saved); -} - - -// Load devices -async function loadDevices() { - // Devices now render inside the combined Targets tab - await loadTargetsTab(); -} - -function createDeviceCard(device) { - const state = device.state || {}; - - // Device health indicator - 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') ? ` -
- -
` : ''} -
- - -
-
- `; -} - -async function toggleDevicePower(deviceId) { - try { - // Get current power state - 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; - - // Toggle - 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'); - } -} - -function attachDeviceListeners(deviceId) { - // Add any specific event listeners here if needed -} - -// Device actions -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'); - 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'); - } -} - -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); - - // Populate fields - document.getElementById('settings-device-id').value = device.id; - document.getElementById('settings-device-name').value = device.name; - document.getElementById('settings-health-interval').value = 30; - - // Toggle URL vs serial port field - 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 = ''; - // Populate serial port dropdown via discovery - _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'; - } - - // Show LED count field for devices with manual_led_count capability - 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'; - } - - // Show baud rate field for adalight devices - 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'; - } - - // Populate auto shutdown toggle - document.getElementById('settings-auto-shutdown').checked = !!device.auto_shutdown; - - // Snapshot initial values for dirty checking - settingsInitialValues = { - 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, - }; - - // Show modal - const modal = document.getElementById('device-settings-modal'); - modal.style.display = 'flex'; - lockBody(); - - // Focus first input - 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(); -} - -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 - ); -} - -function forceCloseDeviceSettingsModal() { - const modal = document.getElementById('device-settings-modal'); - const error = document.getElementById('settings-error'); - modal.style.display = 'none'; - error.style.display = 'none'; - unlockBody(); - settingsInitialValues = {}; -} - -async function closeDeviceSettingsModal() { - if (isSettingsDirty()) { - const confirmed = await showConfirm(t('modal.discard_changes')); - if (!confirmed) return; - } - forceCloseDeviceSettingsModal(); -} - -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'); - - // Validation - if (!name || !url) { - error.textContent = 'Please fill in all fields correctly'; - error.style.display = 'block'; - return; - } - - try { - // Update device info (name, url, auto_shutdown, optionally led_count, baud_rate) - 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(); - loadDevices(); - } catch (err) { - console.error('Failed to save device settings:', err); - error.textContent = 'Failed to save settings'; - error.style.display = 'block'; - } -} - -// Brightness cache: stores last known WLED brightness per device (0-255) -const _deviceBrightnessCache = {}; - -// Card brightness controls — talks directly to WLED device -function updateBrightnessLabel(deviceId, value) { - const slider = document.querySelector(`[data-device-brightness="${deviceId}"]`); - if (slider) slider.title = Math.round(parseInt(value) / 255 * 100) + '%'; -} - -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'); - } -} - -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 -function rgbToHex(r, g, b) { - return '#' + [r, g, b].map(c => c.toString(16).padStart(2, '0')).join(''); -} - -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; -} - -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 }) - }); - // Show clear button - 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'); - } -} - -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 }) - }); - // Reset picker to black and hide clear button - 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); - } -} - -// Add device modal -let _discoveryScanRunning = false; -let _discoveryCache = {}; // { deviceType: [...devices] } — per-type discovery cache - -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(); - } - } -} - -function _computeMaxFps(baudRate, ledCount, deviceType) { - if (!baudRate || !ledCount || ledCount < 1) return null; - // Adalight: 6-byte header + RGB data; AmbiLED: RGB data + 1-byte show command - const overhead = deviceType === 'ambiled' ? 1 : 6; - const bitsPerFrame = (ledCount * 3 + overhead) * 10; - return Math.floor(baudRate / bitsPerFrame); -} - -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'; - } -} - -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 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); -} - -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); - }); -} - -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); - } -} - -async function _populateSettingsSerialPorts(currentUrl) { - const select = document.getElementById('settings-serial-port'); - select.innerHTML = ''; - // Show loading placeholder - 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 = ''; - // Always include current port even if not discovered - 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); - // Keep the current URL as fallback - } -} - -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'; - _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); -} - -function closeAddDeviceModal() { - const modal = document.getElementById('add-device-modal'); - modal.style.display = 'none'; - unlockBody(); -} - -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; - _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 = '⏳'; - 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) { - _discoveryScanRunning = false; - } - } -} - -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'); -} - -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(); - await loadDevices(); - // Auto-start device tutorial on first device add - if (!localStorage.getItem('deviceTutorialSeen')) { - localStorage.setItem('deviceTutorialSeen', '1'); - setTimeout(() => 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'); - } -} - -// Auto-refresh -function startAutoRefresh() { - if (refreshInterval) { - clearInterval(refreshInterval); - } - - refreshInterval = setInterval(() => { - // Only refresh if user is authenticated - if (apiKey) { - const activeTab = localStorage.getItem('activeTab') || 'dashboard'; - if (activeTab === 'targets') { - loadTargetsTab(); - } else if (activeTab === 'dashboard') { - loadDashboard(); - } - } - }, 2000); // Refresh every 2 seconds -} - -// ── Dashboard ── - -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`; -} - -let _dashboardLoading = false; - -async function loadDashboard() { - if (_dashboardLoading) return; - _dashboardLoading = true; - const container = document.getElementById('dashboard-content'); - if (!container) { _dashboardLoading = false; return; } - - try { - // Fetch targets and profiles in parallel - 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; - } - - // Fetch state + metrics for each target in parallel - 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 = ''; - - // Profiles section - 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('')} -
`; - } - - // Running section - if (running.length > 0) { - html += `
-
- ${t('dashboard.section.running')} - ${running.length} - -
- ${running.map(target => renderDashboardTarget(target, true)).join('')} -
`; - } - - // Stopped section - 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 { - _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; - - // Condition summary - 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')}
-
-
-
- -
-
`; -} - -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'); - } -} - -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'); - } -} - -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'); - } -} - -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'); - } -} - -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 { - _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 = () => { _dashboardWS = null; }; - _dashboardWS.onerror = () => { _dashboardWS = null; }; - } catch { - _dashboardWS = null; - } -} - -function stopDashboardWS() { - if (_dashboardWS) { - _dashboardWS.close(); - _dashboardWS = null; - } -} - -// Toast notifications -function showToast(message, type = 'info') { - const toast = document.getElementById('toast'); - toast.textContent = message; - toast.className = `toast ${type} show`; - - setTimeout(() => { - toast.className = 'toast'; - }, 3000); -} - -// Confirmation modal -let confirmResolve = null; - -function showConfirm(message, title = null) { - return new Promise((resolve) => { - confirmResolve = 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(); - }); -} - -function closeConfirmModal(result) { - const modal = document.getElementById('confirm-modal'); - modal.style.display = 'none'; - unlockBody(); - - if (confirmResolve) { - confirmResolve(result); - confirmResolve = null; - } -} - -// Calibration functions -async function showCalibration(deviceId) { - try { - // Fetch device data and displays in parallel - 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; - - // Set aspect ratio from device's display - 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 = ''; - } - - // Store device ID and LED count - document.getElementById('calibration-device-id').value = device.id; - document.getElementById('cal-device-led-count-inline').textContent = device.led_count; - - // Set layout - document.getElementById('cal-start-position').value = calibration.start_position; - document.getElementById('cal-layout').value = calibration.layout; - document.getElementById('cal-offset').value = calibration.offset || 0; - - // Set LED counts per edge - 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; - - // Set skip LEDs - document.getElementById('cal-skip-start').value = calibration.skip_leds_start || 0; - document.getElementById('cal-skip-end').value = calibration.skip_leds_end || 0; - updateOffsetSkipLock(); - - // Set border width - document.getElementById('cal-border-width').value = calibration.border_width || 10; - - // Initialize edge spans - 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 }, - }; - - // Snapshot initial values for dirty checking - calibrationInitialValues = { - 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), - }; - - // Initialize test mode state for this device - calibrationTestState[device.id] = new Set(); - - // Update preview - updateCalibrationPreview(); - - // Show modal - const modal = document.getElementById('calibration-modal'); - modal.style.display = 'flex'; - lockBody(); - - // Initialize span drag and render canvas after layout settles - initSpanDrag(); - requestAnimationFrame(() => { - renderCalibrationCanvas(); - // Auto-start tutorial on first open - if (!localStorage.getItem('calibrationTutorialSeen')) { - localStorage.setItem('calibrationTutorialSeen', '1'); - startCalibrationTutorial(); - } - }); - - // Re-render on container resize (e.g. window resize changes aspect-ratio container) - 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 - ); -} - -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(); - calibrationInitialValues = {}; -} - -async function closeCalibrationModal() { - if (isCalibrationDirty()) { - const confirmed = await showConfirm(t('modal.discard_changes')); - if (!confirmed) return; - } - forceCloseCalibrationModal(); -} - -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; -} - -function updateCalibrationPreview() { - // Calculate total from edge inputs - 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); - // Warning if total doesn't match device LED count - 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); - } - - // Update corner dot highlights for start position - 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'); - } - } - }); - - // Update direction toggle display - 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'; - - // Update edge highlight states - 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 = ''; - } - }); - - // Disable edges with 0 LEDs - ['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); - }); - - // Position span bars and render canvas overlay - updateSpanBars(); - renderCalibrationCanvas(); -} - -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; - - // Canvas extends beyond the container (matches CSS: left:-40px, top:-40px, +80px/+80px) - 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); - - // Container origin within canvas coordinate system - const ox = padX; - const oy = padY; - const cW = containerRect.width; // container inner width - const cH = containerRect.height; // container inner height - - // Read current form values - 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; - - // Theme-aware colors - 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)'; - - // Edge bar geometry (matches CSS: corner zones 56px × 36px fixed) - const cw = 56; - const ch = 36; - - // Span-aware edge geometry: ticks/arrows render only within the span region - 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 }, - }; - - // Axis positions for labels (outside the 16px toggle zones) - const toggleSize = 16; - const axisPos = { - top: oy - toggleSize - 3, - bottom: oy + cH + toggleSize + 3, - left: ox - toggleSize - 3, - right: ox + cW + toggleSize + 3, - }; - - // Arrow positions (inside the screen area, near each edge bar) - const arrowInset = 12; - const arrowPos = { - top: oy + ch + arrowInset, - bottom: oy + cH - ch - arrowInset, - left: ox + cw + arrowInset, - right: ox + cW - cw - arrowInset, - }; - - // Draw ticks and direction arrows for each segment - segments.forEach(seg => { - const geo = edgeGeometry[seg.edge]; - if (!geo) return; - - const count = seg.led_count; - if (count === 0) return; - - // Per-edge display range: clip to active LED range when skip is set - 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); - }; - - // Edge boundary ticks (first/last LED on edge) and special ticks (LED 0 position) - 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); - } - - // Round-number ticks get priority; edge boundary labels suppressed if overlapping - 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; - }; - - // Phase 1: place round-number ticks (checked against specials + each other) - 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); - } - } - } - - // Phase 2: edge boundaries — show label unless overlapping a round-number tick - 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)); - } - - // Tick styling - const tickLenLong = toggleSize + 3; - const tickLenShort = 4; - ctx.strokeStyle = tickStroke; - ctx.lineWidth = 1; - ctx.fillStyle = tickFill; - ctx.font = '12px -apple-system, BlinkMacSystemFont, sans-serif'; - - // Draw labeled ticks - 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); - } - }); - - // Draw tick lines only (no labels) for suppressed edge boundaries - 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(); - } - }); - - // Draw direction chevron at full-edge midpoint (not affected by span) - 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'; - } - - // Also reposition toggle zone to match span region - 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'); - - // Prevent edge click-through when interacting with span bar - bar.addEventListener('click', e => e.stopPropagation()); - - // Handle resize via handles - 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); - }); - }); - - // Handle body drag (move entire span) - 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); - }); - }); - - // Initial positioning - updateSpanBars(); -} - -function setStartPosition(position) { - document.getElementById('cal-start-position').value = position; - updateCalibrationPreview(); -} - -function toggleEdgeInputs() { - const preview = document.querySelector('.calibration-preview'); - if (preview) preview.classList.toggle('inputs-dimmed'); -} - -function toggleDirection() { - const select = document.getElementById('cal-layout'); - select.value = select.value === 'clockwise' ? 'counterclockwise' : 'clockwise'; - updateCalibrationPreview(); -} - -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(); - } - - // Toggle edge - if (calibrationTestState[deviceId].has(edge)) { - calibrationTestState[deviceId].delete(edge); - } else { - calibrationTestState[deviceId].add(edge); - } - - // Build edges dict for API - const edges = {}; - calibrationTestState[deviceId].forEach(e => { - edges[e] = EDGE_TEST_COLORS[e]; - }); - - // Update visual state immediately - 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); - } -} - -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'); - - // Clear test mode before saving - 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; - - // Validation - if (total !== deviceLedCount) { - error.textContent = `Total LEDs (${total}) must equal device LED count (${deviceLedCount})`; - error.style.display = 'block'; - return; - } - - // Build calibration config - 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: layout, - start_position: startPosition, - offset: 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(); - 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) { - // Determine if this edge should be reversed based on LED strip direction - 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: edge, - led_start: ledStart, - led_count: count, - reverse: shouldReverse(edge, calibration.start_position, calibration.layout) - }); - ledStart += count; - } - }); - - return segments; -} - -// Close modals on backdrop click (only if mousedown also started on backdrop) -let backdropMouseDownTarget = null; -document.addEventListener('mousedown', (e) => { - backdropMouseDownTarget = e.target; -}); -document.addEventListener('click', (e) => { - if (!e.target.classList.contains('modal')) return; - if (backdropMouseDownTarget !== e.target) return; - if (activeTutorial) return; - - const modalId = e.target.id; - - // Confirm modal: backdrop click acts as Cancel - if (modalId === 'confirm-modal') { - closeConfirmModal(false); - return; - } - - // Login modal: close only if cancel button is visible (not required login) - if (modalId === 'api-key-modal') { - const cancelBtn = document.getElementById('modal-cancel-btn'); - if (cancelBtn && cancelBtn.style.display !== 'none') { - closeApiKeyModal(); - } - return; - } - - // General settings modal: dirty check - if (modalId === 'device-settings-modal') { - closeDeviceSettingsModal(); - return; - } - - // Capture settings modal: dirty check - if (modalId === 'capture-settings-modal') { - closeCaptureSettingsModal(); - return; - } - - // Calibration modal: dirty check - if (modalId === 'calibration-modal') { - closeCalibrationModal(); - return; - } - - // Add device modal: close on backdrop - if (modalId === 'add-device-modal') { - closeAddDeviceModal(); - return; - } - - // Profile editor modal: close on backdrop - if (modalId === 'profile-editor-modal') { - closeProfileEditorModal(); - return; - } -}); - -// Cleanup on page unload -window.addEventListener('beforeunload', () => { - if (refreshInterval) { - clearInterval(refreshInterval); - } -}); - -// ============================================================================= -// Tutorial System (generic engine) -// ============================================================================= - -let activeTutorial = null; -// Shape: { steps, overlay, mode, step, resolveTarget, container } -// mode: 'absolute' (within a container) or 'fixed' (viewport-level) - -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' } -]; - -function startTutorial(config) { - closeTutorial(); - const overlay = document.getElementById(config.overlayId); - if (!overlay) return; - - activeTutorial = { - 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); -} - -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) - }); -} - -function startDeviceTutorial(deviceId) { - // Resolve the device ID to target (don't capture card reference — it goes stale when loadDevices rebuilds DOM) - 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); - } - }); -} - -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); - activeTutorial = null; -} - -function tutorialNext() { - if (!activeTutorial) return; - if (activeTutorial.step < activeTutorial.steps.length - 1) { - showTutorialStep(activeTutorial.step + 1); - } else { - closeTutorial(); - } -} - -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'; - - // Remove previous target highlight - document.querySelectorAll('.tutorial-target').forEach(el => { - el.classList.remove('tutorial-target'); - el.style.zIndex = ''; - }); - - // Find and highlight target - const target = activeTutorial.resolveTarget(step); - if (!target) return; - target.classList.add('tutorial-target'); - // For fixed overlays, target must be above the z-index:10000 overlay - if (isFixed) target.style.zIndex = '10001'; - - const targetRect = target.getBoundingClientRect(); - const pad = 6; - let x, y, w, h; - - if (isFixed) { - // Fixed mode: coordinates are viewport-relative - x = targetRect.left - pad; - y = targetRect.top - pad; - w = targetRect.width + pad * 2; - h = targetRect.height + pad * 2; - } else { - // Absolute mode: coordinates relative to container - 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; - } - - // Update backdrop clip-path (polygon with rectangular cutout) - 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%)`; - } - - // Position ring around target - 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'; - } - - // Update tooltip content - 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}`; - - // Enable/disable nav buttons - 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'; - - // Position tooltip - 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; - // Place offscreen to measure real height without visual flash - 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 preferred position overflows, try opposite - 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)); - - // Force-set all positioning via setAttribute to avoid any style-setting quirks - 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(); } -} - - -// =========================== -// Capture Templates Functions -// =========================== - -let availableEngines = []; -let currentEditingTemplateId = null; - -// Load and render 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(); - _cachedCaptureTemplates = data.templates || []; - // Re-render the streams tab which now contains template sections - renderPictureSourcesList(_cachedStreams); - } catch (error) { - console.error('Error loading capture templates:', error); - } -} - -// Get engine icon -function getEngineIcon(engineType) { - return '🚀'; -} - -// Show add template modal -let _templateNameManuallyEdited = false; - -async function showAddTemplateModal() { - currentEditingTemplateId = 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'; - - // Auto-name: reset flag and wire listener - _templateNameManuallyEdited = false; - document.getElementById('template-name').oninput = () => { _templateNameManuallyEdited = true; }; - - // Load available engines - await loadAvailableEngines(); - - // Show modal - const modal = document.getElementById('template-modal'); - modal.style.display = 'flex'; - setupBackdropClose(modal, closeTemplateModal); -} - -// Edit template -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(); - - currentEditingTemplateId = 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 || ''; - - // Load available engines - await loadAvailableEngines(); - - // Set engine and load config - document.getElementById('template-engine').value = template.engine_type; - await onEngineChange(); - - // Populate engine config fields - populateEngineConfig(template.engine_config); - - // Load displays for test - await loadDisplaysForTest(); - - const testResults = document.getElementById('template-test-results'); - if (testResults) testResults.style.display = 'none'; - document.getElementById('template-error').style.display = 'none'; - - // Show modal - 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'); - } -} - -// Close template modal -function closeTemplateModal() { - document.getElementById('template-modal').style.display = 'none'; - currentEditingTemplateId = null; -} - -// Show full-page overlay spinner with progress -function showOverlaySpinner(text, duration = 0) { - // Remove existing overlay if any - const existing = document.getElementById('overlay-spinner'); - if (existing) { - // Clear any existing timer - if (window.overlaySpinnerTimer) { - clearInterval(window.overlaySpinnerTimer); - window.overlaySpinnerTimer = null; - } - existing.remove(); - } - - // Create overlay - const overlay = document.createElement('div'); - overlay.id = 'overlay-spinner'; - overlay.className = 'overlay-spinner'; - - // Create progress container - const progressContainer = document.createElement('div'); - progressContainer.className = 'progress-container'; - - // Create SVG progress ring - 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'); - - // Background circle - 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); - - // Progress circle - 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); - - // Create progress content (percentage display) - 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); - - // Create text - const spinnerText = document.createElement('div'); - spinnerText.className = 'spinner-text'; - spinnerText.textContent = text; - - overlay.appendChild(progressContainer); - overlay.appendChild(spinnerText); - document.body.appendChild(overlay); - - // Animate progress if duration is provided - 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); - - // Update progress ring - const offset = circumference - (progress * circumference); - progressCircle.style.strokeDashoffset = offset; - - // Update percentage display - progressPercentage.textContent = `${percentage}%`; - - // Stop timer if complete - if (progress >= 1) { - clearInterval(window.overlaySpinnerTimer); - window.overlaySpinnerTimer = null; - } - }, 100); - } -} - -// Hide full-page overlay spinner -function hideOverlaySpinner() { - // Clear timer if exists - if (window.overlaySpinnerTimer) { - clearInterval(window.overlaySpinnerTimer); - window.overlaySpinnerTimer = null; - } - - const overlay = document.getElementById('overlay-spinner'); - if (overlay) overlay.remove(); -} - -// Update capture duration and save to localStorage -function updateCaptureDuration(value) { - document.getElementById('test-template-duration-value').textContent = value; - localStorage.setItem('capture_duration', value); -} - -// Restore capture duration from localStorage -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; - } -} - -// Show test template modal -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; - } - - // Store current template for testing - window.currentTestingTemplate = template; - - // Load displays - await loadDisplaysForTest(); - - // Restore last used capture duration - restoreCaptureDuration(); - - // Show modal - const modal = document.getElementById('test-template-modal'); - modal.style.display = 'flex'; - - setupBackdropClose(modal, closeTestTemplateModal); -} - -// Close test template modal -function closeTestTemplateModal() { - document.getElementById('test-template-modal').style.display = 'none'; - window.currentTestingTemplate = null; -} - -// Load available engines -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(); - availableEngines = 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); - }); - - // Auto-select first available engine if nothing selected - 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'); - } -} - -// Handle engine selection change -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; - } - - // Auto-name: set template name to engine name if user hasn't edited - if (!_templateNameManuallyEdited && !document.getElementById('template-id').value) { - document.getElementById('template-name').value = engine.name || engineType; - } - - // Show availability hint - 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'; - } - - // Render config fields based on default_config - 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'; -} - -// Populate engine config fields with values -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; - } - } - }); -} - -// Collect engine config from form -function collectEngineConfig() { - const config = {}; - const fields = document.querySelectorAll('[data-config-key]'); - - fields.forEach(field => { - const key = field.dataset.configKey; - let value = field.value; - - // Type conversion - if (field.type === 'number') { - value = parseFloat(value); - } else if (field.tagName === 'SELECT' && (value === 'true' || value === 'false')) { - value = value === 'true'; - } - - config[key] = value; - }); - - return config; -} - -// Load displays for test selector -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(); - _cachedDisplays = displaysData.displays || []; - } - - // Auto-select: last used display, or primary as fallback - 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); - } -} - -// Run template test -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; - - // Show full-page overlay spinner with progress - 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); - // Hide overlay spinner - hideOverlaySpinner(); - // Show short error in snack, details are in console - 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; -} - -// Display test results — opens lightbox with stats overlay -function displayTestResults(result) { - hideOverlaySpinner(); - const fullImageSrc = result.full_capture.full_image || result.full_capture.image; - openLightbox(fullImageSrc, buildTestStatsHtml(result)); -} - -// Save template -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) { - // Update existing template - response = await fetchWithAuth(`/capture-templates/${templateId}`, { - method: 'PUT', - body: JSON.stringify(payload) - }); - } else { - // Create new template - 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'; - } -} - -// Delete template -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 ===== - -let _cachedStreams = []; -let _cachedPPTemplates = []; -let _cachedCaptureTemplates = []; -let _availableFilters = []; // Loaded from GET /filters - -async function loadPictureSources() { - try { - // Always fetch templates, filters, and streams in parallel - // since templates are now rendered inside stream sub-tabs - 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(); - _availableFilters = fd.filters || []; - } - if (ppResp.ok) { - const pd = await ppResp.json(); - _cachedPPTemplates = pd.templates || []; - } - if (captResp.ok) { - const cd = await captResp.json(); - _cachedCaptureTemplates = cd.templates || []; - } - if (!streamsResp.ok) { - throw new Error(`Failed to load streams: ${streamsResp.status}`); - } - const data = await streamsResp.json(); - _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}
- `; - } -} - -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') { - // Screen Capture: streams section + capture templates section - 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') { - // Processed: streams section + PP templates section - panelContent = ` -
-

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

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

${t('postprocessing.title')}

-
- ${_cachedPPTemplates.map(renderPPTemplateCard).join('')} -
-
+
-
-
-
`; - } else { - // Static Image: just the stream cards, no section headers - panelContent = ` -
- ${tab.streams.map(renderStreamCard).join('')} - ${addStreamCard(tab.key)} -
`; - } - - return `
${panelContent}
`; - }).join(''); - - container.innerHTML = tabBar + panels; -} - -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'; -} - -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; - // Clear static image preview and wire up auto-validation - _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(); - - // Auto-name: reset flag and wire listeners - _streamNameManuallyEdited = false; - document.getElementById('stream-name').oninput = () => { _streamNameManuallyEdited = true; }; - document.getElementById('stream-capture-template').onchange = () => _autoGenerateStreamName(); - document.getElementById('stream-source').onchange = () => _autoGenerateStreamName(); - document.getElementById('stream-pp-template').onchange = () => _autoGenerateStreamName(); - - // Populate dropdowns - await populateStreamModalDropdowns(); - - const modal = document.getElementById('stream-modal'); - modal.style.display = 'flex'; - lockBody(); - setupBackdropClose(modal, closeStreamModal); -} - -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'; - - // Set type (hidden input) - document.getElementById('stream-type').value = stream.stream_type; - // Clear static image preview and wire up auto-validation - _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(); - - // Populate dropdowns before setting values - 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 || ''; - // Auto-validate to show preview - 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() { - // Load displays, capture templates, streams, and PP templates in parallel - const [displaysRes, captureTemplatesRes, streamsRes, ppTemplatesRes] = await Promise.all([ - fetch(`${API_BASE}/config/displays`, { headers: getHeaders() }), - fetchWithAuth('/capture-templates'), - fetchWithAuth('/picture-sources'), - fetchWithAuth('/postprocessing-templates'), - ]); - - // Displays - warm cache for display picker - if (displaysRes.ok) { - const displaysData = await displaysRes.json(); - _cachedDisplays = displaysData.displays || []; - } - - // Auto-select primary display if none selected yet - 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); - } - - // Capture templates - 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); - }); - } - - // Source streams (all existing streams) - 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 => { - // Don't show the current stream as a possible source - 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); - }); - } - - // PP templates - _streamModalPPTemplates = []; - const ppSelect = document.getElementById('stream-pp-template'); - ppSelect.innerHTML = ''; - if (ppTemplatesRes.ok) { - const data = await ppTemplatesRes.json(); - _streamModalPPTemplates = data.templates || []; - _streamModalPPTemplates.forEach(tmpl => { - const opt = document.createElement('option'); - opt.value = tmpl.id; - opt.textContent = tmpl.name; - ppSelect.appendChild(opt); - }); - } - - // Trigger auto-name after dropdowns are populated - _autoGenerateStreamName(); -} - -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) { - // Creating - include stream_type - 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'; - } -} - -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'); - } -} - -function closeStreamModal() { - document.getElementById('stream-modal').style.display = 'none'; - document.getElementById('stream-type').disabled = false; - unlockBody(); -} - -let _lastValidatedImageSource = ''; - -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) { - _lastValidatedImageSource = ''; - previewContainer.style.display = 'none'; - statusEl.style.display = 'none'; - return; - } - - if (source === _lastValidatedImageSource) return; - - // Show loading state - 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(); - - _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 ===== - -let _currentTestStreamId = null; - -async function showTestStreamModal(streamId) { - _currentTestStreamId = streamId; - restoreStreamTestDuration(); - - const modal = document.getElementById('test-stream-modal'); - modal.style.display = 'flex'; - lockBody(); - setupBackdropClose(modal, closeTestStreamModal); -} - -function closeTestStreamModal() { - document.getElementById('test-stream-modal').style.display = 'none'; - unlockBody(); - _currentTestStreamId = null; -} - -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; -} - -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(); - displayStreamTestResults(result); - } catch (error) { - console.error('Error running stream test:', error); - hideOverlaySpinner(); - showToast(t('streams.test.error.failed') + ': ' + error.message, 'error'); - } -} - -function displayStreamTestResults(result) { - hideOverlaySpinner(); - const fullImageSrc = result.full_capture.full_image || result.full_capture.image; - openLightbox(fullImageSrc, buildTestStatsHtml(result)); -} - -// ===== PP Template Test ===== - -let _currentTestPPTemplateId = null; - -async function showTestPPTemplateModal(templateId) { - _currentTestPPTemplateId = templateId; - restorePPTestDuration(); - - // Populate source stream selector - const select = document.getElementById('test-pp-source-stream'); - select.innerHTML = ''; - // Ensure streams are cached - if (_cachedStreams.length === 0) { - try { - const resp = await fetchWithAuth('/picture-sources'); - if (resp.ok) { const d = await resp.json(); _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); - } - // Auto-select last used stream - 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); -} - -function closeTestPPTemplateModal() { - document.getElementById('test-pp-template-modal').style.display = 'none'; - unlockBody(); - _currentTestPPTemplateId = null; -} - -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; -} - -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'); - } -} - -// ===== Processing 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(); - _availableFilters = data.filters || []; - } catch (error) { - console.error('Error loading available filters:', error); - _availableFilters = []; - } -} - -async function loadPPTemplates() { - try { - // Ensure filters are loaded for rendering - 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(); - _cachedPPTemplates = data.templates || []; - // Re-render the streams tab which now contains template sections - renderPictureSourcesList(_cachedStreams); - } catch (error) { - console.error('Error loading PP templates:', error); - } -} - -function _getFilterName(filterId) { - const key = 'filters.' + filterId; - const translated = t(key); - // Fallback to filter_name from registry if no localization - if (translated === key) { - const def = _availableFilters.find(f => f.filter_id === filterId); - return def ? def.filter_name : filterId; - } - return translated; -} - -// --- Filter list management in PP template modal --- - -let _modalFilters = []; // Current filter list being edited in modal - -function _populateFilterSelect() { - const select = document.getElementById('pp-add-filter-select'); - // Keep first option (placeholder) - select.innerHTML = ``; - for (const f of _availableFilters) { - const name = _getFilterName(f.filter_id); - select.innerHTML += ``; - } -} - -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; - - // Build compact summary of current option values for collapsed state - 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; -} - -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; - - // Build default options - 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(); -} - -function toggleFilterExpand(index) { - if (_modalFilters[index]) { - _modalFilters[index]._expanded = !_modalFilters[index]._expanded; - renderModalFilterList(); - } -} - -function removeFilter(index) { - _modalFilters.splice(index, 1); - renderModalFilterList(); - _autoGeneratePPTemplateName(); -} - -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(); -} - -function updateFilterOption(filterIndex, optionKey, value) { - if (_modalFilters[filterIndex]) { - // Determine type from schema - 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 }, - })); -} - -let _ppTemplateNameManuallyEdited = false; - -function _autoGeneratePPTemplateName() { - if (_ppTemplateNameManuallyEdited) return; - if (document.getElementById('pp-template-id').value) return; // editing, not creating - 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 = ''; - } -} - -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'; - - _modalFilters = []; - - // Auto-name: reset flag and wire listener - _ppTemplateNameManuallyEdited = false; - document.getElementById('pp-template-name').oninput = () => { _ppTemplateNameManuallyEdited = true; }; - - _populateFilterSelect(); - renderModalFilterList(); - - const modal = document.getElementById('pp-template-modal'); - modal.style.display = 'flex'; - lockBody(); - setupBackdropClose(modal, closePPTemplateModal); -} - -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'; - - // Load filters from template - _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'); - } -} - -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'; - } -} - -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'); - } -} - -function closePPTemplateModal() { - document.getElementById('pp-template-modal').style.display = 'none'; - _modalFilters = []; - unlockBody(); -} - -// ===== TARGET EDITOR MODAL ===== -let targetEditorInitialValues = {}; -let _targetEditorDevices = []; // cached devices list for capability checks - -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'; -} - -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 || [] : []; - _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' ? '🖥️' : s.stream_type === 'static_image' ? '🖼️' : '🎨'; - 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(); - - targetEditorInitialValues = { - 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'); - } -} - -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 - ); -} - -async function closeTargetEditorModal() { - if (isTargetEditorDirty()) { - const confirmed = await showConfirm(t('modal.discard_changes')); - if (!confirmed) return; - } - forceCloseTargetEditorModal(); -} - -function forceCloseTargetEditorModal() { - document.getElementById('target-editor-modal').style.display = 'none'; - document.getElementById('target-editor-error').style.display = 'none'; - unlockBody(); - targetEditorInitialValues = {}; -} - -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 loadTargets(); - } catch (error) { - console.error('Error saving target:', error); - errorEl.textContent = error.message; - errorEl.style.display = 'block'; - } -} - -// ===== TARGETS TAB (WLED devices + targets combined) ===== - -async function loadTargets() { - // Alias for backward compatibility - await loadTargetsTab(); -} - -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); -} - -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: '💡', titleKey: 'targets.subtab.led', count: ledDevices.length + ledTargets.length }, - { key: 'key_colors', icon: '🎨', titleKey: 'targets.subtab.key_colors', count: kcTargets.length + patternTemplates.length }, - ]; - - const tabBar = `
${subTabs.map(tab => - `` - ).join('')}
`; - - // 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 — - // avoids HTTP requests to the ESP every 2s which cause LED stutters - 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')}
`; - } -} - -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 ? ` - - ` : ` - - `} -
-
- `; -} - -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'); - loadTargets(); - } else { - const error = await response.json(); - showToast(`Failed to start: ${error.detail}`, 'error'); - } - } catch (error) { - showToast('Failed to start processing', 'error'); - } -} - -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'); - loadTargets(); - } else { - const error = await response.json(); - showToast(`Failed to stop: ${error.detail}`, 'error'); - } - } catch (error) { - showToast('Failed to stop processing', 'error'); - } -} - -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'); - loadTargets(); - } else { - const error = await response.json(); - showToast(t('overlay.error.start') + ': ' + error.detail, 'error'); - } - } catch (error) { - showToast(t('overlay.error.start'), 'error'); - } -} - -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'); - loadTargets(); - } else { - const error = await response.json(); - showToast(t('overlay.error.stop') + ': ' + error.detail, 'error'); - } - } catch (error) { - showToast(t('overlay.error.stop'), 'error'); - } -} - -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'); - loadTargets(); - } else { - const error = await response.json(); - showToast(`Failed to delete: ${error.detail}`, 'error'); - } - } catch (error) { - showToast('Failed to delete target', 'error'); - } -} - -// ===== KEY COLORS TARGET CARD ===== - -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 ===== - -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(); -} - -async function testKCTarget(targetId) { - kcTestTargetId = 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) { - closeLightbox(); - showToast(t('kc.test.error') + ': ' + e.message, 'error'); - } -} - -function toggleKCTestAutoRefresh() { - if (kcTestAutoRefresh) { - stopKCTestAutoRefresh(); - } else { - kcTestAutoRefresh = setInterval(async () => { - if (!kcTestTargetId) return; - try { - const result = await fetchKCTest(kcTestTargetId); - displayKCTestResults(result); - } catch (e) { - stopKCTestAutoRefresh(); - } - }, 1000); - updateAutoRefreshButton(true); - } -} - -function stopKCTestAutoRefresh() { - if (kcTestAutoRefresh) { - clearInterval(kcTestAutoRefresh); - kcTestAutoRefresh = null; - } - kcTestTargetId = null; - updateAutoRefreshButton(false); -} - -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 - } -} - -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 ===== - -let kcEditorInitialValues = {}; -let _kcNameManuallyEdited = false; - -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} · ${patName} (${modeName})`; -} - -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' ? '🖥️' : s.stream_type === 'static_image' ? '🖼️' : '🎨'; - 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 - _kcNameManuallyEdited = !!targetId; - document.getElementById('kc-editor-name').oninput = () => { _kcNameManuallyEdited = true; }; - sourceSelect.onchange = () => _autoGenerateKCName(); - document.getElementById('kc-editor-interpolation').onchange = () => _autoGenerateKCName(); - patSelect.onchange = () => _autoGenerateKCName(); - if (!targetId) _autoGenerateKCName(); - - kcEditorInitialValues = { - 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'); - } -} - -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 - ); -} - -async function closeKCEditorModal() { - if (isKCEditorDirty()) { - const confirmed = await showConfirm(t('modal.discard_changes')); - if (!confirmed) return; - } - forceCloseKCEditorModal(); -} - -function forceCloseKCEditorModal() { - document.getElementById('kc-editor-modal').style.display = 'none'; - document.getElementById('kc-editor-error').style.display = 'none'; - unlockBody(); - kcEditorInitialValues = {}; -} - -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(); - await loadTargets(); - } catch (error) { - console.error('Error saving KC target:', error); - errorEl.textContent = error.message; - errorEl.style.display = 'block'; - } -} - -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'); - loadTargets(); - } 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 ===== - -const kcWebSockets = {}; - -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); - } -} - -function disconnectKCWebSocket(targetId) { - const ws = kcWebSockets[targetId]; - if (ws) { - ws.close(); - delete kcWebSockets[targetId]; - } -} - -function disconnectAllKCWebSockets() { - Object.keys(kcWebSockets).forEach(targetId => disconnectKCWebSocket(targetId)); -} - -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(''); -} - -// ===== PATTERN TEMPLATES ===== - -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' : ''} -
-
- -
-
- `; -} - -// ----- Pattern Template Editor state ----- -let patternEditorRects = []; -let patternEditorSelectedIdx = -1; -let patternEditorBgImage = null; -let patternEditorInitialValues = {}; -let patternCanvasDragMode = null; -let patternCanvasDragStart = null; -let patternCanvasDragOrigRect = null; -let patternEditorHoveredIdx = -1; -let patternEditorHoverHit = null; // 'move', 'n', 's', 'e', 'w', 'nw', etc. - -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)', -]; -const PATTERN_RECT_BORDERS = [ - '#4CAF50', '#2196F3', '#FF9800', '#9C27B0', '#00BCD4', '#F44336', '#FFEB3B', '#795548', -]; - -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' ? '🖥️' : s.stream_type === 'static_image' ? '🖼️' : '🎨'; - opt.textContent = `${typeIcon} ${s.name}`; - bgSelect.appendChild(opt); - }); - - patternEditorBgImage = null; - patternEditorSelectedIdx = -1; - patternCanvasDragMode = 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'); - patternEditorRects = (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'); - patternEditorRects = []; - } - - patternEditorInitialValues = { - 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'); - } -} - -function isPatternEditorDirty() { - return ( - document.getElementById('pattern-template-name').value !== patternEditorInitialValues.name || - document.getElementById('pattern-template-description').value !== patternEditorInitialValues.description || - JSON.stringify(patternEditorRects) !== patternEditorInitialValues.rectangles - ); -} - -async function closePatternTemplateModal() { - if (isPatternEditorDirty()) { - const confirmed = await showConfirm(t('modal.discard_changes')); - if (!confirmed) return; - } - forceClosePatternTemplateModal(); -} - -function forceClosePatternTemplateModal() { - document.getElementById('pattern-template-modal').style.display = 'none'; - document.getElementById('pattern-template-error').style.display = 'none'; - unlockBody(); - patternEditorRects = []; - patternEditorSelectedIdx = -1; - patternEditorBgImage = null; - patternEditorInitialValues = {}; -} - -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(); - await loadTargets(); - } catch (error) { - console.error('Error saving pattern template:', error); - errorEl.textContent = error.message; - errorEl.style.display = 'block'; - } -} - -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'); - loadTargets(); - } 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) ----- - -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(''); -} - -function selectPatternRect(index) { - patternEditorSelectedIdx = (patternEditorSelectedIdx === index) ? -1 : index; - renderPatternRectList(); - renderPatternCanvas(); -} - -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(); -} - -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 }); - patternEditorSelectedIdx = patternEditorRects.length - 1; - renderPatternRectList(); - renderPatternCanvas(); -} - -function deleteSelectedPatternRect() { - if (patternEditorSelectedIdx < 0 || patternEditorSelectedIdx >= patternEditorRects.length) return; - patternEditorRects.splice(patternEditorSelectedIdx, 1); - patternEditorSelectedIdx = -1; - renderPatternRectList(); - renderPatternCanvas(); -} - -function removePatternRect(index) { - patternEditorRects.splice(index, 1); - if (patternEditorSelectedIdx === index) patternEditorSelectedIdx = -1; - else if (patternEditorSelectedIdx > index) patternEditorSelectedIdx--; - renderPatternRectList(); - renderPatternCanvas(); -} - -// ----- Pattern Canvas Visual Editor ----- - -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 — spacing adapts to canvas size - 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; // ~80 CSS pixels between grid lines - 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 — brighter when hovered or being dragged - 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); - - // Determine edge direction to highlight (during drag or hover) - let edgeDir = null; - if (isDragging && patternCanvasDragMode.startsWith('resize-')) { - edgeDir = patternCanvasDragMode.replace('resize-', ''); - } else if (isHovered && patternEditorHoverHit && patternEditorHoverHit !== 'move') { - edgeDir = patternEditorHoverHit; - } - - // Draw highlighted edge/corner indicator - 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 }, // top-left - { cx: w - abMargin, cy: abMargin }, // top-right - { cx: w / 2, cy: h / 2 }, // center - { cx: abMargin, cy: h - abMargin }, // bottom-left - { cx: w - abMargin, cy: h - abMargin }, // bottom-right - ]; - 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 }, // top-left: rect starts at (0,0) - { ax: 1, ay: 0 }, // top-right: rect ends at (1,0) - { ax: 0.5, ay: 0.5 }, // center - { ax: 0, ay: 1 }, // bottom-left: rect starts at (0, 1-h) - { ax: 1, ay: 1 }, // bottom-right: rect ends at (1,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; - } - // Position based on anchor point - let rx = anchor.ax - rw * anchor.ax; // 0→0, 0.5→centered, 1→1-rw - 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 }); - patternEditorSelectedIdx = patternEditorRects.length - 1; - renderPatternRectList(); - renderPatternCanvas(); -} - -// Hit-test a point against a rect's edges/corners. Returns a resize direction -// string ('n','s','e','w','nw','ne','sw','se') or 'move' if inside, or null. -const _EDGE_THRESHOLD = 8; // pixels (in canvas coords) - -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; - - // Corners first (both edges near) - 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'; - - // Edges - if (nearTop && inHRange) return 'n'; - if (nearBottom && inHRange) return 's'; - if (nearLeft && inVRange) return 'w'; - if (nearRight && inVRange) return 'e'; - - // Interior (move) - 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); - patternCanvasDragMode = null; - patternCanvasDragStart = null; - patternCanvasDragOrigRect = 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; - patternEditorHoveredIdx = newHoverIdx; - patternEditorHoverHit = 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); - patternCanvasDragMode = null; - patternCanvasDragStart = null; - patternCanvasDragOrigRect = null; - patternEditorHoveredIdx = -1; - patternEditorHoverHit = null; - renderPatternRectList(); - renderPatternCanvas(); - } - }); - - // Resize observer — update canvas internal resolution when container is resized - 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) patternEditorSelectedIdx = -1; - else if (patternEditorSelectedIdx > idx) patternEditorSelectedIdx--; - patternEditorHoveredIdx = -1; - patternEditorHoverHit = 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; - - patternEditorSelectedIdx = i; - patternCanvasDragStart = { mx, my }; - patternCanvasDragOrigRect = { ...r }; - - if (hit === 'move') { - patternCanvasDragMode = 'move'; - canvas.style.cursor = 'grabbing'; - } else { - patternCanvasDragMode = `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 - patternEditorSelectedIdx = -1; - patternCanvasDragMode = null; - canvas.style.cursor = 'default'; - renderPatternRectList(); - renderPatternCanvas(); -} - -function _patternCanvasMouseMove(e) { - // During drag, movement is handled by window-level _patternCanvasDragMove - 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) { - patternEditorHoveredIdx = newHoverIdx; - patternEditorHoverHit = newHoverHit; - renderPatternCanvas(); - } -} - -function _patternCanvasMouseLeave() { - // During drag, window-level listeners handle everything - if (patternCanvasDragMode) return; - if (patternEditorHoveredIdx !== -1) { - patternEditorHoveredIdx = -1; - patternEditorHoverHit = null; - renderPatternCanvas(); - } -} - -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 = () => { - patternEditorBgImage = img; - renderPatternCanvas(); - }; - img.src = data.full_capture.full_image; - } - } catch (error) { - console.error('Failed to capture background:', error); - showToast('Failed to capture background', 'error'); - } -} - -// ===================================================================== -// PROFILES -// ===================================================================== - -let _profilesCache = null; - -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(); - _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'); - - // Condition summary as pills - 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}`); - } - - // Target count pill - const targetCountText = `${profile.target_ids.length} target(s)${profile.is_active ? ` (${profile.active_target_ids.length} active)` : ''}`; - - // Last activation timestamp - 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}
-
- - -
-
`; -} - -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 = ''; - - // Load available targets for the checklist - await loadProfileTargetChecklist([]); - - if (profileId) { - // Edit mode - 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; - - // Populate conditions - for (const c of profile.conditions) { - addProfileConditionRow(c); - } - - // Populate target checklist - await loadProfileTargetChecklist(profile.target_ids); - } catch (e) { - showToast(e.message, 'error'); - return; - } - } else { - // Create mode - titleEl.textContent = t('profiles.add'); - idInput.value = ''; - nameInput.value = ''; - enabledInput.checked = true; - logicSelect.value = 'or'; - } - - modal.style.display = 'flex'; - lockBody(); - updateAllText(); -} - -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}`; - } -} - -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')} - -
-
-
- - -
-
-
- - -
- - -
-
- `; - - // Wire up browse button - const browseBtn = row.querySelector('.btn-browse-apps'); - const picker = row.querySelector('.process-picker'); - browseBtn.addEventListener('click', () => toggleProcessPicker(picker, row)); - - // Wire up search filter - 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(); - - // Get already-added apps to mark them - 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(''); - - // Click handler for each item - 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 + ' ✓'; - // Update existing set - 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); -} - -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'; - } -} - -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'); - } -} - -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/index.html b/server/src/wled_controller/static/index.html index 05ab0fc..74480d6 100644 --- a/server/src/wled_controller/static/index.html +++ b/server/src/wled_controller/static/index.html @@ -1103,7 +1103,7 @@ - + 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(); } +}