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 // 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; } // 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 === 'streams') { loadPictureSources(); } else if (name === 'targets') { loadTargetsTab(); } } 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}`)) { 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.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('brightness_control') ? `
` : ''}
`; } 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 = device.device_type === 'adalight'; // 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'; } // 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', }; // 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 (settingsInitialValues.device_type === 'adalight') { 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 || 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, optionally led_count, baud_rate) const body = { name, url }; const ledCountInput = document.getElementById('settings-led-count'); if ((settingsInitialValues.capabilities || []).includes('manual_led_count') && ledCountInput.value) { body.led_count = parseInt(ledCountInput.value, 10); } if (settingsInitialValues.device_type === 'adalight') { 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 } } // 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 (deviceType === 'adalight') { 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) { if (!baudRate || !ledCount || ledCount < 1) return null; const bitsPerFrame = (ledCount * 3 + 6) * 10; return Math.floor(baudRate / bitsPerFrame); } function _renderFpsHint(hintEl, baudRate, ledCount) { const fps = _computeMaxFps(baudRate, ledCount); 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); _renderFpsHint(hintEl, baudRate, ledCount); } 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); } function _renderDiscoveryList() { const selectedType = document.getElementById('device-type').value; const devices = _discoveryCache[selectedType]; // Adalight: populate serial port dropdown instead of discovery list if (selectedType === 'adalight') { _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 if (!('adalight' in _discoveryCache)) { scanForDevices('adalight'); } } 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 resp = await fetch(`${API_BASE}/devices/discover?timeout=2&device_type=adalight`, { 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 (scanType === 'adalight') { // 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 (scanType !== 'adalight') { 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 (scanType !== 'adalight') { 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 (device.device_type === 'adalight') { 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 = deviceType === 'adalight' ? 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 (deviceType === 'adalight' && 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') || 'targets'; if (activeTab === 'targets') { loadTargetsTab(); } } }, 2000); // Refresh every 2 seconds } // 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; } }); // 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}
` : ''}
${isProcessing ? ` ` : ` `}
`; } 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 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}
` : ''}
${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'); } }