const API_BASE = '/api/v1'; let refreshInterval = null; let apiKey = null; // 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 - prevent layout jump when scrollbar disappears function lockBody() { const scrollbarWidth = window.innerWidth - document.documentElement.clientWidth; document.body.style.paddingRight = scrollbarWidth + 'px'; document.body.classList.add('modal-open'); } function unlockBody() { document.body.classList.remove('modal-open'); document.body.style.paddingRight = ''; } // 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')) return; const lightbox = document.getElementById('image-lightbox'); lightbox.classList.remove('active'); document.getElementById('lightbox-image').src = ''; document.getElementById('lightbox-stats').style.display = 'none'; unlockBody(); } document.addEventListener('keydown', (e) => { if (e.key === 'Escape' && document.getElementById('image-lightbox').classList.contains('active')) { closeLightbox(); } }); // Locale management let currentLocale = 'en'; let translations = {}; const supportedLocales = { 'en': 'English', 'ru': 'Русский' }; // Minimal inline fallback for critical UI elements const fallbackTranslations = { 'app.title': 'WLED Screen Controller', '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(); loadDevices(); } } // Initialize app document.addEventListener('DOMContentLoaded', async () => { // Initialize locale first await initLocale(); // Show content now that translations are loaded document.body.style.visibility = 'visible'; // Load API key from localStorage apiKey = localStorage.getItem('wled_api_key'); // Restore active tab (after API key is loaded) initTabs(); // 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(); loadDevices(); // 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(); const container = document.getElementById('displays-list'); if (!data.displays || data.displays.length === 0) { container.innerHTML = `
${t('displays.none')}
`; document.getElementById('display-layout-canvas').innerHTML = `
${t('displays.none')}
`; return; } // Cache and render visual layout _cachedDisplays = data.displays; renderDisplayLayout(data.displays); } catch (error) { console.error('Failed to load displays:', error); document.getElementById('displays-list').innerHTML = `
${t('displays.failed')}
`; document.getElementById('display-layout-canvas').innerHTML = `
${t('displays.failed')}
`; } } 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 === 'displays' && _cachedDisplays) { requestAnimationFrame(() => renderDisplayLayout(_cachedDisplays)); } if (name === 'templates') { loadCaptureTemplates(); } if (name === 'streams') { loadPictureStreams(); } if (name === 'pp-templates') { loadPPTemplates(); } } function initTabs() { const saved = localStorage.getItem('activeTab'); if (saved && document.getElementById(`tab-${saved}`)) { switchTab(saved); } } function renderDisplayLayout(displays) { const canvas = document.getElementById('display-layout-canvas'); if (!displays || displays.length === 0) { canvas.innerHTML = `
${t('displays.none')}
`; return; } // Calculate bounding box for all displays 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; // Scale factor to fit in canvas (respect available width, maintain aspect ratio) const availableWidth = canvas.clientWidth - 60; // account for padding const maxCanvasHeight = 350; const scaleX = availableWidth / totalWidth; const scaleY = maxCanvasHeight / totalHeight; const scale = Math.min(scaleX, scaleY); const canvasWidth = totalWidth * scale; const canvasHeight = totalHeight * scale; // Create display elements const displayElements = displays.map(display => { const left = (display.x - minX) * scale; const top = (display.y - minY) * scale; const width = display.width * scale; const height = display.height * scale; return `
(${display.x}, ${display.y})
#${display.index}
${display.name} ${display.width}×${display.height} ${display.refresh_rate}Hz
${display.is_primary ? '
' : ''}
`; }).join(''); canvas.innerHTML = `
${displayElements}
`; } // Load devices async function loadDevices() { try { const response = await fetch(`${API_BASE}/devices`, { headers: getHeaders() }); if (response.status === 401) { handle401Error(); return; } const data = await response.json(); const devices = data.devices || []; const container = document.getElementById('devices-list'); if (!devices || devices.length === 0) { container.innerHTML = `
+
${t('devices.add')}
`; return; } // Fetch state for each device const devicesWithState = await Promise.all( devices.map(async (device) => { try { const stateResponse = await fetch(`${API_BASE}/devices/${device.id}/state`, { headers: getHeaders() }); const state = await stateResponse.json(); const metricsResponse = await fetch(`${API_BASE}/devices/${device.id}/metrics`, { headers: getHeaders() }); const metrics = await metricsResponse.json(); // Log device errors only when they change (avoid console spam) const deviceKey = device.id; const lastLogged = loggedErrors.get(deviceKey); const hasNewErrors = !lastLogged || lastLogged.errorCount !== metrics.errors_count || lastLogged.lastError !== metrics.last_error; if (metrics.errors_count > 0 && hasNewErrors) { console.warn(`[Device: ${device.name || device.id}] Has ${metrics.errors_count} error(s)`); // Log recent errors from state if (state.errors && state.errors.length > 0) { console.error('Recent errors:'); state.errors.forEach((error, index) => { console.error(` ${index + 1}. ${error}`); }); } // Log last error from metrics if (metrics.last_error) { console.error('Last error:', metrics.last_error); } // Log full state and metrics for debugging console.log('Full state:', state); console.log('Full metrics:', metrics); // Update tracking loggedErrors.set(deviceKey, { errorCount: metrics.errors_count, lastError: metrics.last_error }); } else if (metrics.errors_count === 0 && lastLogged) { // Clear tracking when errors are resolved console.log(`[Device: ${device.name || device.id}] Errors cleared`); loggedErrors.delete(deviceKey); } return { ...device, state, metrics }; } catch (error) { console.error(`Failed to load state for device ${device.id}:`, error); return device; } }) ); container.innerHTML = devicesWithState.map(device => createDeviceCard(device)).join('') + `
+
${t('devices.add')}
`; // Update footer WLED Web UI link with first device's URL const webuiLink = document.querySelector('.wled-webui-link'); if (webuiLink && devicesWithState.length > 0 && devicesWithState[0].url) { webuiLink.href = devicesWithState[0].url; webuiLink.target = '_blank'; webuiLink.rel = 'noopener'; } // Attach event listeners devicesWithState.forEach(device => { attachDeviceListeners(device.id); }); } catch (error) { console.error('Failed to load devices:', error); document.getElementById('devices-list').innerHTML = `
${t('devices.failed')}
`; } } function createDeviceCard(device) { const state = device.state || {}; const metrics = device.metrics || {}; const settings = device.settings || {}; const isProcessing = state.processing || false; const brightnessPercent = Math.round((settings.brightness !== undefined ? settings.brightness : 1.0) * 100); const statusKey = isProcessing ? 'device.status.processing' : 'device.status.idle'; const status = isProcessing ? 'processing' : 'idle'; // WLED device health indicator const wledOnline = state.wled_online || false; const wledLatency = state.wled_latency_ms; const wledName = state.wled_name; const wledVersion = state.wled_version; const wledLastChecked = state.wled_last_checked; let healthClass, healthTitle, healthLabel; if (wledLastChecked === null || wledLastChecked === undefined) { healthClass = 'health-unknown'; healthTitle = t('device.health.checking'); healthLabel = ''; } else if (wledOnline) { healthClass = 'health-online'; healthTitle = `${t('device.health.online')}`; if (wledName) healthTitle += ` - ${wledName}`; if (wledVersion) healthTitle += ` v${wledVersion}`; if (wledLatency !== null && wledLatency !== undefined) healthTitle += ` (${Math.round(wledLatency)}ms)`; healthLabel = ''; } else { healthClass = 'health-offline'; healthTitle = t('device.health.offline'); if (state.wled_error) healthTitle += `: ${state.wled_error}`; healthLabel = `${t('device.health.offline')}`; } const ledCount = state.wled_led_count || device.led_count; return `
${device.name || device.id} ${device.url ? `${escapeHtml(device.url.replace(/^https?:\/\//, ''))}` : ''} ${healthLabel} ${isProcessing ? `${t('device.status.processing')}` : ''}
${wledVersion ? `v${wledVersion}` : ''} ${ledCount ? `💡 ${ledCount}` : ''} ${state.wled_led_type ? `🔌 ${state.wled_led_type.replace(/ RGBW$/, '')}` : ''} ${state.wled_rgbw ? '' : ''}
${isProcessing ? `
${state.fps_actual?.toFixed(1) || '0.0'}
${t('device.metrics.actual_fps')}
${state.fps_target || 0}
${t('device.metrics.target_fps')}
${metrics.frames_processed || 0}
${t('device.metrics.frames')}
${metrics.errors_count || 0}
${t('device.metrics.errors')}
` : ''}
${isProcessing ? ` ` : ` `} ${device.url ? ` 🌐 ` : ''}
`; } function attachDeviceListeners(deviceId) { // Add any specific event listeners here if needed } // Device actions async function startProcessing(deviceId) { console.log(`[Device: ${deviceId}] Starting processing...`); try { const response = await fetch(`${API_BASE}/devices/${deviceId}/start`, { method: 'POST', headers: getHeaders() }); if (response.status === 401) { handle401Error(); return; } if (response.ok) { console.log(`[Device: ${deviceId}] Processing started successfully`); showToast('Processing started', 'success'); loadDevices(); } else { const error = await response.json(); console.error(`[Device: ${deviceId}] Failed to start:`, error); showToast(`Failed to start: ${error.detail}`, 'error'); } } catch (error) { console.error(`[Device: ${deviceId}] Failed to start processing:`, error); showToast('Failed to start processing', 'error'); } } async function stopProcessing(deviceId) { console.log(`[Device: ${deviceId}] Stopping processing...`); try { const response = await fetch(`${API_BASE}/devices/${deviceId}/stop`, { method: 'POST', headers: getHeaders() }); if (response.status === 401) { handle401Error(); return; } if (response.ok) { console.log(`[Device: ${deviceId}] Processing stopped successfully`); showToast('Processing stopped', 'success'); loadDevices(); } else { const error = await response.json(); console.error(`[Device: ${deviceId}] Failed to stop:`, error); showToast(`Failed to stop: ${error.detail}`, 'error'); } } catch (error) { console.error(`[Device: ${deviceId}] Failed to stop processing:`, error); showToast('Failed to stop processing', 'error'); } } 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(); // Populate fields document.getElementById('settings-device-id').value = device.id; document.getElementById('settings-device-name').value = device.name; document.getElementById('settings-device-url').value = device.url; document.getElementById('settings-health-interval').value = device.settings.state_check_interval || 30; // Snapshot initial values for dirty checking settingsInitialValues = { name: device.name, url: device.url, state_check_interval: String(device.settings.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 isSettingsDirty() { return ( document.getElementById('settings-device-name').value !== settingsInitialValues.name || document.getElementById('settings-device-url').value !== settingsInitialValues.url || document.getElementById('settings-health-interval').value !== settingsInitialValues.state_check_interval ); } 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 = document.getElementById('settings-device-url').value.trim(); const state_check_interval = parseInt(document.getElementById('settings-health-interval').value) || 30; 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) const deviceResponse = await fetch(`${API_BASE}/devices/${deviceId}`, { method: 'PUT', headers: getHeaders(), body: JSON.stringify({ name, url }) }); 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; } // Update settings (health check interval) const settingsResponse = await fetch(`${API_BASE}/devices/${deviceId}/settings`, { method: 'PUT', headers: getHeaders(), body: JSON.stringify({ state_check_interval }) }); if (settingsResponse.status === 401) { handle401Error(); return; } if (settingsResponse.ok) { showToast(t('settings.saved'), 'success'); forceCloseDeviceSettingsModal(); loadDevices(); } else { const errorData = await settingsResponse.json(); error.textContent = `Failed to update settings: ${errorData.detail}`; error.style.display = 'block'; } } catch (err) { console.error('Failed to save device settings:', err); error.textContent = 'Failed to save settings'; error.style.display = 'block'; } } // ===== Capture Settings Modal ===== let captureSettingsInitialValues = {}; async function showCaptureSettings(deviceId) { try { // Fetch device data, displays, templates, and settings in parallel const [deviceResponse, displaysResponse, templatesResponse, settingsResponse] = await Promise.all([ fetch(`${API_BASE}/devices/${deviceId}`, { headers: getHeaders() }), fetch(`${API_BASE}/config/displays`, { headers: getHeaders() }), fetchWithAuth('/capture-templates'), fetch(`${API_BASE}/devices/${deviceId}/settings`, { headers: getHeaders() }), ]); if (deviceResponse.status === 401) { handle401Error(); return; } if (!deviceResponse.ok) { showToast('Failed to load capture settings', 'error'); return; } const device = await deviceResponse.json(); const currentSettings = settingsResponse.ok ? await settingsResponse.json() : {}; // Populate display index select const displaySelect = document.getElementById('capture-settings-display-index'); displaySelect.innerHTML = ''; if (displaysResponse.ok) { const displaysData = await displaysResponse.json(); (displaysData.displays || []).forEach(d => { const opt = document.createElement('option'); opt.value = d.index; opt.textContent = `${d.index}: ${d.width}x${d.height}${d.is_primary ? ` (${t('displays.badge.primary')})` : ''}`; displaySelect.appendChild(opt); }); } if (displaySelect.options.length === 0) { const opt = document.createElement('option'); opt.value = '0'; opt.textContent = '0'; displaySelect.appendChild(opt); } displaySelect.value = String(device.settings.display_index ?? 0); // Populate FPS slider const fpsValue = Math.max(10, Math.min(90, currentSettings.fps ?? 30)); document.getElementById('capture-settings-fps').value = fpsValue; document.getElementById('capture-settings-fps-value').textContent = fpsValue; // Populate capture template select const templateSelect = document.getElementById('capture-settings-template'); templateSelect.innerHTML = ''; if (templatesResponse.ok) { const templatesData = await templatesResponse.json(); (templatesData.templates || []).forEach(t => { const opt = document.createElement('option'); opt.value = t.id; const engineIcon = getEngineIcon(t.engine_type); opt.textContent = `${engineIcon} ${t.name}`; templateSelect.appendChild(opt); }); } templateSelect.value = device.capture_template_id || ''; // Store device ID, current settings snapshot, and initial values for dirty check document.getElementById('capture-settings-device-id').value = device.id; captureSettingsInitialValues = { display_index: String(device.settings.display_index ?? 0), fps: String(currentSettings.fps ?? 30), capture_template_id: device.capture_template_id || '', _currentSettings: currentSettings, }; // Show modal const modal = document.getElementById('capture-settings-modal'); modal.style.display = 'flex'; lockBody(); } catch (error) { console.error('Failed to load capture settings:', error); showToast('Failed to load capture settings', 'error'); } } function isCaptureSettingsDirty() { return ( document.getElementById('capture-settings-display-index').value !== captureSettingsInitialValues.display_index || document.getElementById('capture-settings-fps').value !== captureSettingsInitialValues.fps || document.getElementById('capture-settings-template').value !== captureSettingsInitialValues.capture_template_id ); } function forceCloseCaptureSettingsModal() { const modal = document.getElementById('capture-settings-modal'); const error = document.getElementById('capture-settings-error'); modal.style.display = 'none'; error.style.display = 'none'; unlockBody(); captureSettingsInitialValues = {}; } async function closeCaptureSettingsModal() { if (isCaptureSettingsDirty()) { const confirmed = await showConfirm(t('modal.discard_changes')); if (!confirmed) return; } forceCloseCaptureSettingsModal(); } async function saveCaptureSettings() { const deviceId = document.getElementById('capture-settings-device-id').value; const display_index = parseInt(document.getElementById('capture-settings-display-index').value) || 0; const fps = parseInt(document.getElementById('capture-settings-fps').value) || 30; const capture_template_id = document.getElementById('capture-settings-template').value; const error = document.getElementById('capture-settings-error'); try { // Update capture template on device const deviceResponse = await fetch(`${API_BASE}/devices/${deviceId}`, { method: 'PUT', headers: getHeaders(), body: JSON.stringify({ capture_template_id }) }); if (deviceResponse.status === 401) { handle401Error(); return; } if (!deviceResponse.ok) { const errorData = await deviceResponse.json(); error.textContent = `Failed to update capture template: ${errorData.detail}`; error.style.display = 'block'; return; } // Merge changed fields with current settings to avoid resetting other values const mergedSettings = { ...(captureSettingsInitialValues._currentSettings || {}), display_index, fps, }; // Update settings const settingsResponse = await fetch(`${API_BASE}/devices/${deviceId}/settings`, { method: 'PUT', headers: getHeaders(), body: JSON.stringify(mergedSettings) }); if (settingsResponse.status === 401) { handle401Error(); return; } if (settingsResponse.ok) { // Remember last used template for new device creation localStorage.setItem('lastCaptureTemplateId', capture_template_id); showToast(t('settings.capture.saved'), 'success'); forceCloseCaptureSettingsModal(); loadDevices(); } else { const errorData = await settingsResponse.json(); error.textContent = `Failed to update settings: ${errorData.detail}`; error.style.display = 'block'; } } catch (err) { console.error('Failed to save capture settings:', err); error.textContent = t('settings.capture.failed'); error.style.display = 'block'; } } // Card brightness controls function updateBrightnessLabel(deviceId, value) { const slider = document.querySelector(`[data-device-id="${deviceId}"] .brightness-slider`); if (slider) slider.title = value + '%'; } async function saveCardBrightness(deviceId, value) { const brightness = parseInt(value) / 100.0; try { await fetch(`${API_BASE}/devices/${deviceId}/settings`, { method: 'PUT', headers: getHeaders(), body: JSON.stringify({ brightness }) }); } catch (err) { console.error('Failed to update brightness:', err); showToast('Failed to update brightness', 'error'); } } // Add device modal 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'; modal.style.display = 'flex'; lockBody(); setTimeout(() => document.getElementById('device-name').focus(), 100); } function closeAddDeviceModal() { const modal = document.getElementById('add-device-modal'); modal.style.display = 'none'; unlockBody(); } async function handleAddDevice(event) { event.preventDefault(); const name = document.getElementById('device-name').value.trim(); const url = 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 }; 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) { loadDevices(); } }, 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; // 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), }; // 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 ); } 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 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 segments = buildSegments(calibration); if (segments.length === 0) return; const totalLeds = calibration.leds_top + calibration.leds_right + calibration.leds_bottom + calibration.leds_left; // 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; // 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; const idx = totalLeds > 0 ? (seg.led_start + i) % totalLeds : seg.led_start + i; if (idx % 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 ledIndex = totalLeds > 0 ? (seg.led_start + i) % totalLeds : seg.led_start + 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(ledIndex), 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(ledIndex), 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, }; 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: '.offset-control', textKey: 'calibration.tip.offset', 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: 'bottom' } ]; 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(); renderTemplatesList(data.templates || []); } catch (error) { console.error('Error loading capture templates:', error); document.getElementById('templates-list').innerHTML = `
${t('templates.error.load')}: ${error.message}
`; } } // Render templates list function renderTemplatesList(templates) { const container = document.getElementById('templates-list'); if (templates.length === 0) { container.innerHTML = `
+
${t('templates.add')}
`; return; } const renderCard = (template) => { const engineIcon = getEngineIcon(template.engine_type); return `
${engineIcon} ${escapeHtml(template.name)}
${t('templates.engine')} ${template.engine_type.toUpperCase()}
${Object.keys(template.engine_config).length > 0 ? `
${t('templates.config.show')} ${Object.entries(template.engine_config).map(([key, val]) => ` `).join('')}
${escapeHtml(key)} ${escapeHtml(String(val))}
` : `
${t('templates.config.none')}
`}
`; }; let html = templates.map(renderCard).join(''); html += `
+
${t('templates.add')}
`; container.innerHTML = html; } // Get engine icon function getEngineIcon(engineType) { return '🖥️'; } // Show add template modal 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'; // Load available engines await loadAvailableEngines(); // Show modal const modal = document.getElementById('template-modal'); modal.style.display = 'flex'; // Add backdrop click handler to close modal modal.onclick = function(event) { if (event.target === 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; // 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'; // Add backdrop click handler to close modal modal.onclick = function(event) { if (event.target === 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'; // Add backdrop click handler to close modal modal.onclick = function(event) { if (event.target === 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); }); } 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; } // 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) { configFields.innerHTML = `

${t('templates.config.none')}

`; } else { Object.entries(defaultConfig).forEach(([key, value]) => { const fieldType = typeof value === 'number' ? 'number' : 'text'; const fieldValue = typeof value === 'boolean' ? (value ? 'true' : 'false') : value; const fieldHtml = `
${typeof value === 'boolean' ? ` ` : ` `} ${t('templates.config.default')}: ${JSON.stringify(value)}
`; configFields.innerHTML += fieldHtml; }); } 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 { const response = await fetchWithAuth('/config/displays'); if (!response.ok) { throw new Error(`Failed to load displays: ${response.status}`); } const displaysData = await response.json(); const select = document.getElementById('test-template-display'); select.innerHTML = ''; let primaryIndex = null; (displaysData.displays || []).forEach(display => { const option = document.createElement('option'); option.value = display.index; option.textContent = `Display ${display.index} (${display.width}x${display.height})`; if (display.is_primary) { option.textContent += ' ★'; primaryIndex = display.index; } select.appendChild(option); }); // Auto-select: last used display, or primary as fallback const lastDisplay = localStorage.getItem('lastTestDisplayIndex'); if (lastDisplay !== null && select.querySelector(`option[value="${lastDisplay}"]`)) { select.value = lastDisplay; } else if (primaryIndex !== null) { select.value = String(primaryIndex); } } 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}`; return `
${t('templates.test.results.duration')}: ${p.capture_duration_s.toFixed(2)}s
${t('templates.test.results.frame_count')}: ${p.frame_count}
${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
Resolution: ${res}
`; } // 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 engineConfig = collectEngineConfig(); const payload = { name, engine_type: engineType, engine_config: engineConfig }; 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 Streams ===== let _cachedStreams = []; let _cachedPPTemplates = []; let _availableFilters = []; // Loaded from GET /filters async function loadPictureStreams() { try { const response = await fetchWithAuth('/picture-streams'); if (!response.ok) { throw new Error(`Failed to load streams: ${response.status}`); } const data = await response.json(); _cachedStreams = data.streams || []; renderPictureStreamsList(_cachedStreams); } catch (error) { console.error('Error loading picture streams:', error); document.getElementById('streams-list').innerHTML = `
${t('streams.error.load')}: ${error.message}
`; } } function renderPictureStreamsList(streams) { const container = document.getElementById('streams-list'); if (streams.length === 0) { container.innerHTML = `
🖥️ ${t('streams.group.raw')} 0
+
${t('streams.add.raw')}
🎨 ${t('streams.group.processed')} 0
+
${t('streams.add.processed')}
`; return; } const renderCard = (stream) => { const typeIcon = stream.stream_type === 'raw' ? '🖥️' : '🎨'; const typeBadge = stream.stream_type === 'raw' ? `${t('streams.type.raw')}` : `${t('streams.type.processed')}`; let detailsHtml = ''; if (stream.stream_type === 'raw') { detailsHtml = `
${t('streams.display')} ${stream.display_index ?? 0}
${t('streams.target_fps')} ${stream.target_fps ?? 30}
`; } else { // Find source stream name and PP template name const sourceStream = _cachedStreams.find(s => s.id === stream.source_stream_id); const sourceName = sourceStream ? escapeHtml(sourceStream.name) : (stream.source_stream_id || '-'); detailsHtml = `
${t('streams.source')} ${sourceName}
`; } return `
${typeIcon} ${escapeHtml(stream.name)}
${typeBadge}
${detailsHtml} ${stream.description ? `
${escapeHtml(stream.description)}
` : ''}
`; }; const rawStreams = streams.filter(s => s.stream_type === 'raw'); const processedStreams = streams.filter(s => s.stream_type === 'processed'); let html = ''; // Screen Capture streams section html += `
🖥️ ${t('streams.group.raw')} ${rawStreams.length}
${rawStreams.map(renderCard).join('')}
+
${t('streams.add.raw')}
`; // Processed streams section html += `
🎨 ${t('streams.group.processed')} ${processedStreams.length}
${processedStreams.map(renderCard).join('')}
+
${t('streams.add.processed')}
`; container.innerHTML = html; } 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'; } async function showAddStreamModal(presetType) { const streamType = presetType || 'raw'; const titleKey = streamType === 'raw' ? 'streams.add.raw' : 'streams.add.processed'; document.getElementById('stream-modal-title').textContent = t(titleKey); document.getElementById('stream-form').reset(); document.getElementById('stream-id').value = ''; document.getElementById('stream-error').style.display = 'none'; document.getElementById('stream-type').value = streamType; onStreamTypeChange(); // Populate dropdowns await populateStreamModalDropdowns(); const modal = document.getElementById('stream-modal'); modal.style.display = 'flex'; lockBody(); modal.onclick = (e) => { if (e.target === modal) closeStreamModal(); }; } async function editStream(streamId) { try { const response = await fetchWithAuth(`/picture-streams/${streamId}`); if (!response.ok) throw new Error(`Failed to load stream: ${response.status}`); const stream = await response.json(); const editTitleKey = stream.stream_type === 'raw' ? 'streams.edit.raw' : 'streams.edit.processed'; document.getElementById('stream-modal-title').textContent = t(editTitleKey); 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; onStreamTypeChange(); // Populate dropdowns before setting values await populateStreamModalDropdowns(); if (stream.stream_type === 'raw') { document.getElementById('stream-display-index').value = String(stream.display_index ?? 0); 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 { document.getElementById('stream-source').value = stream.source_stream_id || ''; document.getElementById('stream-pp-template').value = stream.postprocessing_template_id || ''; } const modal = document.getElementById('stream-modal'); modal.style.display = 'flex'; lockBody(); modal.onclick = (e) => { if (e.target === 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-streams'), fetchWithAuth('/postprocessing-templates'), ]); // Displays const displaySelect = document.getElementById('stream-display-index'); displaySelect.innerHTML = ''; if (displaysRes.ok) { const displaysData = await displaysRes.json(); (displaysData.displays || []).forEach(d => { const opt = document.createElement('option'); opt.value = d.index; opt.textContent = `${d.index}: ${d.width}x${d.height}${d.is_primary ? ` (${t('displays.badge.primary')})` : ''}`; displaySelect.appendChild(opt); }); } if (displaySelect.options.length === 0) { const opt = document.createElement('option'); opt.value = '0'; opt.textContent = '0'; displaySelect.appendChild(opt); } // 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.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; const typeLabel = s.stream_type === 'raw' ? '🖥️' : '🎨'; opt.textContent = `${typeLabel} ${s.name}`; sourceSelect.appendChild(opt); }); } // PP templates const ppSelect = document.getElementById('stream-pp-template'); ppSelect.innerHTML = ''; if (ppTemplatesRes.ok) { const data = await ppTemplatesRes.json(); (data.templates || []).forEach(tmpl => { const opt = document.createElement('option'); opt.value = tmpl.id; opt.textContent = tmpl.name; ppSelect.appendChild(opt); }); } } 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 { payload.source_stream_id = document.getElementById('stream-source').value; payload.postprocessing_template_id = document.getElementById('stream-pp-template').value; } try { let response; if (streamId) { response = await fetchWithAuth(`/picture-streams/${streamId}`, { method: 'PUT', body: JSON.stringify(payload) }); } else { response = await fetchWithAuth('/picture-streams', { 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 loadPictureStreams(); } 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-streams/${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 loadPictureStreams(); } 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(); } // ===== Picture Stream Test ===== let _currentTestStreamId = null; async function showTestStreamModal(streamId) { _currentTestStreamId = streamId; restoreStreamTestDuration(); const modal = document.getElementById('test-stream-modal'); modal.style.display = 'flex'; lockBody(); modal.onclick = (e) => { if (e.target === 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-streams/${_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)); } // ===== 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 || []; renderPPTemplatesList(_cachedPPTemplates); } catch (error) { console.error('Error loading PP templates:', error); document.getElementById('pp-templates-list').innerHTML = `
${t('postprocessing.error.load')}: ${error.message}
`; } } 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; } function renderPPTemplatesList(templates) { const container = document.getElementById('pp-templates-list'); if (templates.length === 0) { container.innerHTML = `
+
${t('postprocessing.add')}
`; return; } const renderCard = (tmpl) => { // Build config entries from filter list const filterRows = (tmpl.filters || []).map(fi => { const filterName = _getFilterName(fi.filter_id); const optStr = Object.entries(fi.options || {}).map(([k, v]) => `${v}`).join(', '); return `${escapeHtml(filterName)}${escapeHtml(optStr)}`; }).join(''); return `
🎨 ${escapeHtml(tmpl.name)}
${tmpl.description ? `
${escapeHtml(tmpl.description)}
` : ''}
${t('postprocessing.config.show')} ${filterRows}
`; }; let html = templates.map(renderCard).join(''); html += `
+
${t('postprocessing.add')}
`; container.innerHTML = html; } // --- 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(); } function toggleFilterExpand(index) { if (_modalFilters[index]) { _modalFilters[index]._expanded = !_modalFilters[index]._expanded; renderModalFilterList(); } } function removeFilter(index) { _modalFilters.splice(index, 1); renderModalFilterList(); } 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(); } 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 === '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 }, })); } 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 = []; _populateFilterSelect(); renderModalFilterList(); const modal = document.getElementById('pp-template-modal'); modal.style.display = 'flex'; lockBody(); modal.onclick = (e) => { if (e.target === 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(); modal.onclick = (e) => { if (e.target === 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(); } // ===== Device Stream Selector ===== let streamSelectorInitialValues = {}; async function showStreamSelector(deviceId) { try { const [deviceResponse, streamsResponse, settingsResponse] = await Promise.all([ fetch(`${API_BASE}/devices/${deviceId}`, { headers: getHeaders() }), fetchWithAuth('/picture-streams'), fetch(`${API_BASE}/devices/${deviceId}/settings`, { headers: getHeaders() }), ]); if (deviceResponse.status === 401) { handle401Error(); return; } if (!deviceResponse.ok) { showToast('Failed to load device', 'error'); return; } const device = await deviceResponse.json(); const settings = settingsResponse.ok ? await settingsResponse.json() : {}; // Populate stream select const streamSelect = document.getElementById('stream-selector-stream'); streamSelect.innerHTML = ''; if (streamsResponse.ok) { const data = await streamsResponse.json(); (data.streams || []).forEach(s => { const opt = document.createElement('option'); opt.value = s.id; const typeIcon = s.stream_type === 'raw' ? '🖥️' : '🎨'; opt.textContent = `${typeIcon} ${s.name}`; streamSelect.appendChild(opt); }); } const currentStreamId = device.picture_stream_id || ''; streamSelect.value = currentStreamId; // Populate LED projection fields const borderWidth = settings.border_width ?? device.settings?.border_width ?? 10; const smoothing = settings.smoothing ?? device.settings?.smoothing ?? 0.3; document.getElementById('stream-selector-border-width').value = borderWidth; document.getElementById('stream-selector-interpolation').value = device.settings?.interpolation_mode || 'average'; document.getElementById('stream-selector-smoothing').value = smoothing; document.getElementById('stream-selector-smoothing-value').textContent = smoothing; streamSelectorInitialValues = { stream: currentStreamId, border_width: String(borderWidth), interpolation: device.settings?.interpolation_mode || 'average', smoothing: String(smoothing), }; document.getElementById('stream-selector-device-id').value = deviceId; document.getElementById('stream-selector-error').style.display = 'none'; // Show info about selected stream updateStreamSelectorInfo(streamSelect.value); streamSelect.onchange = () => updateStreamSelectorInfo(streamSelect.value); const modal = document.getElementById('stream-selector-modal'); modal.style.display = 'flex'; lockBody(); modal.onclick = (e) => { if (e.target === modal) closeStreamSelectorModal(); }; } catch (error) { console.error('Failed to load stream settings:', error); showToast('Failed to load stream settings', 'error'); } } async function updateStreamSelectorInfo(streamId) { const infoPanel = document.getElementById('stream-selector-info'); if (!streamId) { infoPanel.style.display = 'none'; return; } try { const response = await fetchWithAuth(`/picture-streams/${streamId}`); if (!response.ok) { infoPanel.style.display = 'none'; return; } const stream = await response.json(); let infoHtml = `
${t('streams.type')} ${stream.stream_type === 'raw' ? t('streams.type.raw') : t('streams.type.processed')}
`; if (stream.stream_type === 'raw') { infoHtml += `
${t('streams.display')} ${stream.display_index ?? 0}
`; infoHtml += `
${t('streams.target_fps')} ${stream.target_fps ?? 30}
`; } else { const sourceStream = _cachedStreams.find(s => s.id === stream.source_stream_id); infoHtml += `
${t('streams.source')} ${sourceStream ? escapeHtml(sourceStream.name) : stream.source_stream_id}
`; } infoPanel.innerHTML = infoHtml; infoPanel.style.display = ''; } catch { infoPanel.style.display = 'none'; } } async function saveStreamSelector() { const deviceId = document.getElementById('stream-selector-device-id').value; const pictureStreamId = document.getElementById('stream-selector-stream').value; const borderWidth = parseInt(document.getElementById('stream-selector-border-width').value) || 10; const interpolation = document.getElementById('stream-selector-interpolation').value; const smoothing = parseFloat(document.getElementById('stream-selector-smoothing').value); const errorEl = document.getElementById('stream-selector-error'); try { // Save picture stream assignment const response = await fetch(`${API_BASE}/devices/${deviceId}`, { method: 'PUT', headers: getHeaders(), body: JSON.stringify({ picture_stream_id: pictureStreamId }) }); if (response.status === 401) { handle401Error(); return; } if (!response.ok) { const error = await response.json(); throw new Error(error.detail || error.message || 'Failed to save'); } // Save LED projection settings — merge with existing to avoid overwriting other fields const currentSettingsRes = await fetch(`${API_BASE}/devices/${deviceId}/settings`, { headers: getHeaders() }); const currentSettings = currentSettingsRes.ok ? await currentSettingsRes.json() : {}; const settingsResponse = await fetch(`${API_BASE}/devices/${deviceId}/settings`, { method: 'PUT', headers: getHeaders(), body: JSON.stringify({ ...currentSettings, border_width: borderWidth, interpolation_mode: interpolation, smoothing: smoothing }) }); if (!settingsResponse.ok) { const error = await settingsResponse.json(); throw new Error(error.detail || error.message || 'Failed to save settings'); } showToast(t('device.stream_selector.saved'), 'success'); forceCloseStreamSelectorModal(); await loadDevices(); } catch (error) { console.error('Error saving stream settings:', error); errorEl.textContent = error.message; errorEl.style.display = 'block'; } } function isStreamSettingsDirty() { return ( document.getElementById('stream-selector-stream').value !== streamSelectorInitialValues.stream || document.getElementById('stream-selector-border-width').value !== streamSelectorInitialValues.border_width || document.getElementById('stream-selector-interpolation').value !== streamSelectorInitialValues.interpolation || document.getElementById('stream-selector-smoothing').value !== streamSelectorInitialValues.smoothing ); } async function closeStreamSelectorModal() { if (isStreamSettingsDirty()) { const confirmed = await showConfirm(t('modal.discard_changes')); if (!confirmed) return; } forceCloseStreamSelectorModal(); } function forceCloseStreamSelectorModal() { document.getElementById('stream-selector-modal').style.display = 'none'; document.getElementById('stream-selector-error').style.display = 'none'; unlockBody(); streamSelectorInitialValues = {}; }