const API_BASE = '/api/v1'; let refreshInterval = null; let apiKey = null; // Toggle hint description visibility next to a label function toggleHint(btn) { const hint = btn.closest('.label-row').nextElementSibling; if (hint && hint.classList.contains('input-hint')) { const visible = hint.style.display !== 'none'; hint.style.display = visible ? 'none' : 'block'; btn.classList.toggle('active', !visible); } } // Backdrop click helper: only closes modal if both mousedown and mouseup were on the backdrop itself. // Prevents accidental close when user drags text selection outside the dialog. function setupBackdropClose(modal, closeFn) { // Guard against duplicate listeners when called on every modal open if (modal._backdropCloseSetup) { modal._backdropCloseFn = closeFn; return; } modal._backdropCloseFn = closeFn; let mouseDownTarget = null; modal.addEventListener('mousedown', (e) => { mouseDownTarget = e.target; }); modal.addEventListener('mouseup', (e) => { if (mouseDownTarget === modal && e.target === modal && modal._backdropCloseFn) modal._backdropCloseFn(); mouseDownTarget = null; }); modal.onclick = null; modal._backdropCloseSetup = true; } // Track logged errors to avoid console spam const loggedErrors = new Map(); // deviceId -> { errorCount, lastError } // Calibration test mode state const calibrationTestState = {}; // deviceId -> Set of active edge names // Modal dirty tracking - stores initial values when modals open let settingsInitialValues = {}; let calibrationInitialValues = {}; const EDGE_TEST_COLORS = { top: [255, 0, 0], right: [0, 255, 0], bottom: [0, 100, 255], left: [255, 255, 0] }; // Modal body lock helpers — uses position:fixed to freeze scroll without removing scrollbar function lockBody() { const scrollY = window.scrollY; document.body.style.top = `-${scrollY}px`; document.body.classList.add('modal-open'); } function unlockBody() { const scrollY = parseInt(document.body.style.top || '0', 10) * -1; document.body.classList.remove('modal-open'); document.body.style.top = ''; window.scrollTo(0, scrollY); } // Image lightbox function openLightbox(imageSrc, statsHtml) { const lightbox = document.getElementById('image-lightbox'); const img = document.getElementById('lightbox-image'); const statsEl = document.getElementById('lightbox-stats'); img.src = imageSrc; if (statsHtml) { statsEl.innerHTML = statsHtml; statsEl.style.display = ''; } else { statsEl.style.display = 'none'; } lightbox.classList.add('active'); lockBody(); } function closeLightbox(event) { if (event && event.target && event.target.closest('.lightbox-content')) return; const lightbox = document.getElementById('image-lightbox'); lightbox.classList.remove('active'); const img = document.getElementById('lightbox-image'); // Revoke blob URL if one was used if (img.src.startsWith('blob:')) URL.revokeObjectURL(img.src); img.src = ''; document.getElementById('lightbox-stats').style.display = 'none'; unlockBody(); } async function openFullImageLightbox(imageSource) { try { const resp = await fetch(`${API_BASE}/picture-streams/full-image?source=${encodeURIComponent(imageSource)}`, { headers: getHeaders() }); if (!resp.ok) return; const blob = await resp.blob(); const blobUrl = URL.createObjectURL(blob); openLightbox(blobUrl); } catch (err) { console.error('Failed to load full image:', err); } } document.addEventListener('keydown', (e) => { if (e.key === 'Escape') { // Close in order: overlay lightboxes first, then modals if (document.getElementById('display-picker-lightbox').classList.contains('active')) { closeDisplayPicker(); } else if (document.getElementById('image-lightbox').classList.contains('active')) { closeLightbox(); } else { // Close topmost visible modal const modals = [ { id: 'test-pp-template-modal', close: closeTestPPTemplateModal }, { id: 'test-stream-modal', close: closeTestStreamModal }, { id: 'test-template-modal', close: closeTestTemplateModal }, { id: 'stream-modal', close: closeStreamModal }, { id: 'pp-template-modal', close: closePPTemplateModal }, { id: 'template-modal', close: closeTemplateModal }, { id: 'device-settings-modal', close: forceCloseDeviceSettingsModal }, { id: 'capture-settings-modal', close: forceCloseCaptureSettingsModal }, { id: 'calibration-modal', close: forceCloseCalibrationModal }, { id: 'stream-selector-modal', close: forceCloseStreamSelectorModal }, { id: 'add-device-modal', close: closeAddDeviceModal }, ]; for (const m of modals) { const el = document.getElementById(m.id); if (el && el.style.display === 'flex') { m.close(); break; } } } } }); // Display picker lightbox let _displayPickerCallback = null; let _displayPickerSelectedIndex = null; function openDisplayPicker(callback, selectedIndex) { _displayPickerCallback = callback; _displayPickerSelectedIndex = (selectedIndex !== undefined && selectedIndex !== null && selectedIndex !== '') ? Number(selectedIndex) : null; const lightbox = document.getElementById('display-picker-lightbox'); const canvas = document.getElementById('display-picker-canvas'); lightbox.classList.add('active'); // Defer render to next frame so the lightbox has been laid out and canvas has dimensions requestAnimationFrame(() => { if (_cachedDisplays && _cachedDisplays.length > 0) { renderDisplayPickerLayout(_cachedDisplays); } else { canvas.innerHTML = '
'; loadDisplays().then(() => { if (_cachedDisplays && _cachedDisplays.length > 0) { renderDisplayPickerLayout(_cachedDisplays); } else { canvas.innerHTML = `
${t('displays.none')}
`; } }); } }); } function closeDisplayPicker(event) { if (event && event.target && event.target.closest('.display-picker-content')) return; const lightbox = document.getElementById('display-picker-lightbox'); lightbox.classList.remove('active'); _displayPickerCallback = null; } function selectDisplay(displayIndex) { if (_displayPickerCallback) { const display = _cachedDisplays ? _cachedDisplays.find(d => d.index === displayIndex) : null; _displayPickerCallback(displayIndex, display); } closeDisplayPicker(); } function renderDisplayPickerLayout(displays) { const canvas = document.getElementById('display-picker-canvas'); if (!displays || displays.length === 0) { canvas.innerHTML = `
${t('displays.none')}
`; return; } let minX = Infinity, minY = Infinity, maxX = -Infinity, maxY = -Infinity; displays.forEach(display => { minX = Math.min(minX, display.x); minY = Math.min(minY, display.y); maxX = Math.max(maxX, display.x + display.width); maxY = Math.max(maxY, display.y + display.height); }); const totalWidth = maxX - minX; const totalHeight = maxY - minY; const aspect = totalHeight / totalWidth; // Use percentage-based positioning so layout always fits its container const displayElements = displays.map(display => { const leftPct = ((display.x - minX) / totalWidth) * 100; const topPct = ((display.y - minY) / totalHeight) * 100; const widthPct = (display.width / totalWidth) * 100; const heightPct = (display.height / totalHeight) * 100; const isSelected = _displayPickerSelectedIndex !== null && display.index === _displayPickerSelectedIndex; return `
(${display.x}, ${display.y})
#${display.index}
${display.name} ${display.width}×${display.height} ${display.refresh_rate}Hz
`; }).join(''); canvas.innerHTML = `
${displayElements}
`; } function formatDisplayLabel(displayIndex, display) { if (display) { return `#${display.index}: ${display.width}×${display.height}${display.is_primary ? ' (' + t('displays.badge.primary') + ')' : ''}`; } return `Display ${displayIndex}`; } let _streamNameManuallyEdited = false; function onStreamDisplaySelected(displayIndex, display) { document.getElementById('stream-display-index').value = displayIndex; document.getElementById('stream-display-picker-label').textContent = formatDisplayLabel(displayIndex, display); _autoGenerateStreamName(); } let _streamModalPPTemplates = []; function _autoGenerateStreamName() { if (_streamNameManuallyEdited) return; if (document.getElementById('stream-id').value) return; // editing, not creating const streamType = document.getElementById('stream-type').value; const nameInput = document.getElementById('stream-name'); if (streamType === 'raw') { const displayIndex = document.getElementById('stream-display-index').value; const templateSelect = document.getElementById('stream-capture-template'); const templateName = templateSelect.selectedOptions[0]?.dataset?.name || ''; if (displayIndex === '' || !templateName) return; nameInput.value = `D${displayIndex}_${templateName}`; } else if (streamType === 'processed') { const sourceSelect = document.getElementById('stream-source'); const sourceName = sourceSelect.selectedOptions[0]?.dataset?.name || ''; const ppTemplateId = document.getElementById('stream-pp-template').value; const ppTemplate = _streamModalPPTemplates.find(t => t.id === ppTemplateId); if (!sourceName) return; if (ppTemplate && ppTemplate.name) { nameInput.value = `${sourceName} (${ppTemplate.name})`; } else { nameInput.value = sourceName; } } } function onTestDisplaySelected(displayIndex, display) { document.getElementById('test-template-display').value = displayIndex; document.getElementById('test-display-picker-label').textContent = formatDisplayLabel(displayIndex, display); } // Locale management let currentLocale = 'en'; let translations = {}; const supportedLocales = { 'en': 'English', 'ru': 'Русский' }; // Minimal inline fallback for critical UI elements const fallbackTranslations = { 'app.title': 'WLED Grab', 'auth.placeholder': 'Enter your API key...', 'auth.button.login': 'Login' }; // Translation function function t(key, params = {}) { let text = translations[key] || fallbackTranslations[key] || key; // Replace parameters like {name}, {value}, etc. Object.keys(params).forEach(param => { text = text.replace(new RegExp(`\\{${param}\\}`, 'g'), params[param]); }); return text; } // Load translation file async function loadTranslations(locale) { try { const response = await fetch(`/static/locales/${locale}.json`); if (!response.ok) { throw new Error(`Failed to load ${locale}.json`); } return await response.json(); } catch (error) { console.error(`Error loading translations for ${locale}:`, error); // Fallback to English if loading fails if (locale !== 'en') { return await loadTranslations('en'); } return {}; } } // Detect browser locale function detectBrowserLocale() { const browserLang = navigator.language || navigator.languages?.[0] || 'en'; const langCode = browserLang.split('-')[0]; // 'en-US' -> 'en', 'ru-RU' -> 'ru' // Only return if we support it return supportedLocales[langCode] ? langCode : 'en'; } // Initialize locale async function initLocale() { const savedLocale = localStorage.getItem('locale') || detectBrowserLocale(); await setLocale(savedLocale); } // Set locale async function setLocale(locale) { if (!supportedLocales[locale]) { locale = 'en'; } // Load translations for the locale translations = await loadTranslations(locale); currentLocale = locale; document.documentElement.setAttribute('data-locale', locale); document.documentElement.setAttribute('lang', locale); localStorage.setItem('locale', locale); // Update all text updateAllText(); // Update locale select dropdown (if visible) updateLocaleSelect(); } // Change locale from dropdown function changeLocale() { const select = document.getElementById('locale-select'); const newLocale = select.value; if (newLocale && newLocale !== currentLocale) { localStorage.setItem('locale', newLocale); setLocale(newLocale); } } // Update locale select dropdown function updateLocaleSelect() { const select = document.getElementById('locale-select'); if (select) { select.value = currentLocale; } } // Update all text on page function updateAllText() { // Update all elements with data-i18n attribute document.querySelectorAll('[data-i18n]').forEach(el => { const key = el.getAttribute('data-i18n'); el.textContent = t(key); }); // Update all elements with data-i18n-placeholder attribute document.querySelectorAll('[data-i18n-placeholder]').forEach(el => { const key = el.getAttribute('data-i18n-placeholder'); el.placeholder = t(key); }); // Update all elements with data-i18n-title attribute document.querySelectorAll('[data-i18n-title]').forEach(el => { const key = el.getAttribute('data-i18n-title'); el.title = t(key); }); // Re-render dynamic content with new translations if (apiKey) { loadDisplays(); loadDevices(); loadPictureStreams(); } } // 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(); if (data.displays && data.displays.length > 0) { _cachedDisplays = data.displays; } } catch (error) { console.error('Failed to load displays:', error); } } let _cachedDisplays = null; function switchTab(name) { // Migrate legacy tab values from localStorage if (name === 'templates' || name === 'pp-templates') { name = 'streams'; } document.querySelectorAll('.tab-btn').forEach(btn => btn.classList.toggle('active', btn.dataset.tab === name)); document.querySelectorAll('.tab-panel').forEach(panel => panel.classList.toggle('active', panel.id === `tab-${name}`)); localStorage.setItem('activeTab', name); if (name === 'streams') { loadPictureStreams(); } } function initTabs() { const saved = localStorage.getItem('activeTab'); if (saved && document.getElementById(`tab-${saved}`)) { switchTab(saved); } } // 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 and fetch real WLED brightness devicesWithState.forEach(device => { attachDeviceListeners(device.id); fetchDeviceBrightness(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 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')}` : ''}
${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 ? ` ` : ` `}
`; } 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'; } } // Brightness cache: stores last known WLED brightness per device (0-255) const _deviceBrightnessCache = {}; // Card brightness controls — talks directly to WLED device function updateBrightnessLabel(deviceId, value) { const slider = document.querySelector(`[data-device-brightness="${deviceId}"]`); if (slider) slider.title = Math.round(parseInt(value) / 255 * 100) + '%'; } async function saveCardBrightness(deviceId, value) { const bri = parseInt(value); _deviceBrightnessCache[deviceId] = bri; try { await fetch(`${API_BASE}/devices/${deviceId}/brightness`, { method: 'PUT', headers: getHeaders(), body: JSON.stringify({ brightness: bri }) }); } catch (err) { console.error('Failed to update brightness:', err); showToast('Failed to update brightness', 'error'); } } async function fetchDeviceBrightness(deviceId) { try { const resp = await fetch(`${API_BASE}/devices/${deviceId}/brightness`, { headers: getHeaders() }); if (!resp.ok) return; const data = await resp.json(); _deviceBrightnessCache[deviceId] = data.brightness; const slider = document.querySelector(`[data-device-brightness="${deviceId}"]`); if (slider) { slider.value = data.brightness; slider.title = Math.round(data.brightness / 255 * 100) + '%'; slider.disabled = false; } const wrap = document.querySelector(`[data-brightness-wrap="${deviceId}"]`); if (wrap) wrap.classList.remove('brightness-loading'); } catch (err) { // Silently fail — device may be offline } } // Add device modal 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: 'top' } ]; const deviceTutorialSteps = [ { selector: '.card-subtitle', textKey: 'device.tip.metadata', position: 'bottom' }, { selector: '.brightness-control', textKey: 'device.tip.brightness', position: 'bottom' }, { selector: '.card-actions .btn:nth-child(1)', textKey: 'device.tip.start', position: 'top' }, { selector: '.card-actions .btn:nth-child(2)', textKey: 'device.tip.settings', position: 'top' }, { selector: '.card-actions .btn:nth-child(3)', textKey: 'device.tip.capture_settings', position: 'top' }, { selector: '.card-actions .btn:nth-child(4)', textKey: 'device.tip.calibrate', position: 'top' }, { selector: '.card-actions .btn:nth-child(5)', textKey: 'device.tip.webui', position: 'top' } ]; function startTutorial(config) { closeTutorial(); const overlay = document.getElementById(config.overlayId); if (!overlay) return; activeTutorial = { steps: config.steps, overlay: overlay, mode: config.mode, step: 0, resolveTarget: config.resolveTarget, container: config.container }; overlay.classList.add('active'); document.addEventListener('keydown', handleTutorialKey); showTutorialStep(0); } function startCalibrationTutorial() { const container = document.querySelector('#calibration-modal .modal-body'); if (!container) return; startTutorial({ steps: calibrationTutorialSteps, overlayId: 'tutorial-overlay', mode: 'absolute', container: container, resolveTarget: (step) => document.querySelector(step.selector) }); } function startDeviceTutorial(deviceId) { // Resolve the device ID to target (don't capture card reference — it goes stale when loadDevices rebuilds DOM) const selector = deviceId ? `.card[data-device-id="${deviceId}"]` : '.card[data-device-id]'; if (!document.querySelector(selector)) return; startTutorial({ steps: deviceTutorialSteps, overlayId: 'device-tutorial-overlay', mode: 'fixed', container: null, resolveTarget: (step) => { const card = document.querySelector(selector); if (!card) return null; return step.global ? document.querySelector(step.selector) : card.querySelector(step.selector); } }); } function closeTutorial() { if (!activeTutorial) return; activeTutorial.overlay.classList.remove('active'); document.querySelectorAll('.tutorial-target').forEach(el => { el.classList.remove('tutorial-target'); el.style.zIndex = ''; }); document.removeEventListener('keydown', handleTutorialKey); activeTutorial = null; } function tutorialNext() { if (!activeTutorial) return; if (activeTutorial.step < activeTutorial.steps.length - 1) { showTutorialStep(activeTutorial.step + 1); } else { closeTutorial(); } } function tutorialPrev() { if (!activeTutorial) return; if (activeTutorial.step > 0) { showTutorialStep(activeTutorial.step - 1); } } function showTutorialStep(index) { if (!activeTutorial) return; activeTutorial.step = index; const step = activeTutorial.steps[index]; const overlay = activeTutorial.overlay; const isFixed = activeTutorial.mode === 'fixed'; // Remove previous target highlight document.querySelectorAll('.tutorial-target').forEach(el => { el.classList.remove('tutorial-target'); el.style.zIndex = ''; }); // Find and highlight target const target = activeTutorial.resolveTarget(step); if (!target) return; target.classList.add('tutorial-target'); // For fixed overlays, target must be above the z-index:10000 overlay if (isFixed) target.style.zIndex = '10001'; const targetRect = target.getBoundingClientRect(); const pad = 6; let x, y, w, h; if (isFixed) { // Fixed mode: coordinates are viewport-relative x = targetRect.left - pad; y = targetRect.top - pad; w = targetRect.width + pad * 2; h = targetRect.height + pad * 2; } else { // Absolute mode: coordinates relative to container const containerRect = activeTutorial.container.getBoundingClientRect(); x = targetRect.left - containerRect.left - pad; y = targetRect.top - containerRect.top - pad; w = targetRect.width + pad * 2; h = targetRect.height + pad * 2; } // Update backdrop clip-path (polygon with rectangular cutout) const backdrop = overlay.querySelector('.tutorial-backdrop'); if (backdrop) { backdrop.style.clipPath = `polygon( 0% 0%, 0% 100%, ${x}px 100%, ${x}px ${y}px, ${x + w}px ${y}px, ${x + w}px ${y + h}px, ${x}px ${y + h}px, ${x}px 100%, 100% 100%, 100% 0%)`; } // Position ring around target const ring = overlay.querySelector('.tutorial-ring'); if (ring) { ring.style.left = x + 'px'; ring.style.top = y + 'px'; ring.style.width = w + 'px'; ring.style.height = h + 'px'; } // Update tooltip content const tooltip = overlay.querySelector('.tutorial-tooltip'); const textEl = overlay.querySelector('.tutorial-tooltip-text'); const counterEl = overlay.querySelector('.tutorial-step-counter'); if (textEl) textEl.textContent = t(step.textKey); if (counterEl) counterEl.textContent = `${index + 1} / ${activeTutorial.steps.length}`; // Enable/disable nav buttons const prevBtn = overlay.querySelector('.tutorial-prev-btn'); const nextBtn = overlay.querySelector('.tutorial-next-btn'); if (prevBtn) prevBtn.disabled = (index === 0); if (nextBtn) nextBtn.textContent = (index === activeTutorial.steps.length - 1) ? '\u2713' : '\u2192'; // Position tooltip if (tooltip) { positionTutorialTooltip(tooltip, x, y, w, h, step.position, isFixed); } } function positionTutorialTooltip(tooltip, sx, sy, sw, sh, preferred, isFixed) { const gap = 12; const tooltipW = 260; // Place offscreen to measure real height without visual flash tooltip.setAttribute('style', 'left:-9999px;top:-9999px'); const tooltipH = tooltip.offsetHeight || 150; const positions = { top: { x: sx + sw / 2 - tooltipW / 2, y: sy - tooltipH - gap }, bottom: { x: sx + sw / 2 - tooltipW / 2, y: sy + sh + gap }, left: { x: sx - tooltipW - gap, y: sy + sh / 2 - tooltipH / 2 }, right: { x: sx + sw + gap, y: sy + sh / 2 - tooltipH / 2 } }; let pos = positions[preferred] || positions.bottom; const cW = isFixed ? window.innerWidth : activeTutorial.container.clientWidth; const cH = isFixed ? window.innerHeight : activeTutorial.container.clientHeight; // If preferred position overflows, try opposite if (pos.y + tooltipH > cH || pos.y < 0 || pos.x + tooltipW > cW || pos.x < 0) { const opposite = { top: 'bottom', bottom: 'top', left: 'right', right: 'left' }; const alt = positions[opposite[preferred]]; if (alt && alt.y >= 0 && alt.y + tooltipH <= cH && alt.x >= 0 && alt.x + tooltipW <= cW) { pos = alt; } } pos.x = Math.max(8, Math.min(cW - tooltipW - 8, pos.x)); pos.y = Math.max(8, Math.min(cH - tooltipH - 8, pos.y)); // Force-set all positioning via setAttribute to avoid any style-setting quirks tooltip.setAttribute('style', `left:${Math.round(pos.x)}px;top:${Math.round(pos.y)}px`); } function handleTutorialKey(e) { if (!activeTutorial) return; if (e.key === 'Escape') { closeTutorial(); e.stopPropagation(); } else if (e.key === 'ArrowRight' || e.key === 'ArrowDown') { e.preventDefault(); tutorialNext(); } else if (e.key === 'ArrowLeft' || e.key === 'ArrowUp') { e.preventDefault(); tutorialPrev(); } } // =========================== // Capture Templates Functions // =========================== let availableEngines = []; let currentEditingTemplateId = null; // Load and render capture templates async function loadCaptureTemplates() { try { const response = await fetchWithAuth('/capture-templates'); if (!response.ok) { throw new Error(`Failed to load templates: ${response.status}`); } const data = await response.json(); _cachedCaptureTemplates = data.templates || []; // Re-render the streams tab which now contains template sections renderPictureStreamsList(_cachedStreams); } catch (error) { console.error('Error loading capture templates:', error); } } // Get engine icon function getEngineIcon(engineType) { return '🖥️'; } // Show add template modal let _templateNameManuallyEdited = false; async function showAddTemplateModal() { currentEditingTemplateId = null; document.getElementById('template-modal-title').textContent = t('templates.add'); document.getElementById('template-form').reset(); document.getElementById('template-id').value = ''; document.getElementById('engine-config-section').style.display = 'none'; document.getElementById('template-error').style.display = 'none'; // Auto-name: reset flag and wire listener _templateNameManuallyEdited = false; document.getElementById('template-name').oninput = () => { _templateNameManuallyEdited = true; }; // Load available engines await loadAvailableEngines(); // Show modal const modal = document.getElementById('template-modal'); modal.style.display = 'flex'; setupBackdropClose(modal, closeTemplateModal); } // Edit template async function editTemplate(templateId) { try { const response = await fetchWithAuth(`/capture-templates/${templateId}`); if (!response.ok) { throw new Error(`Failed to load template: ${response.status}`); } const template = await response.json(); currentEditingTemplateId = templateId; document.getElementById('template-modal-title').textContent = t('templates.edit'); document.getElementById('template-id').value = templateId; document.getElementById('template-name').value = template.name; document.getElementById('template-description').value = template.description || ''; // Load available engines await loadAvailableEngines(); // Set engine and load config document.getElementById('template-engine').value = template.engine_type; await onEngineChange(); // Populate engine config fields populateEngineConfig(template.engine_config); // Load displays for test await loadDisplaysForTest(); const testResults = document.getElementById('template-test-results'); if (testResults) testResults.style.display = 'none'; document.getElementById('template-error').style.display = 'none'; // Show modal const modal = document.getElementById('template-modal'); modal.style.display = 'flex'; setupBackdropClose(modal, closeTemplateModal); } catch (error) { console.error('Error loading template:', error); showToast(t('templates.error.load') + ': ' + error.message, 'error'); } } // Close template modal function closeTemplateModal() { document.getElementById('template-modal').style.display = 'none'; currentEditingTemplateId = null; } // Show full-page overlay spinner with progress function showOverlaySpinner(text, duration = 0) { // Remove existing overlay if any const existing = document.getElementById('overlay-spinner'); if (existing) { // Clear any existing timer if (window.overlaySpinnerTimer) { clearInterval(window.overlaySpinnerTimer); window.overlaySpinnerTimer = null; } existing.remove(); } // Create overlay const overlay = document.createElement('div'); overlay.id = 'overlay-spinner'; overlay.className = 'overlay-spinner'; // Create progress container const progressContainer = document.createElement('div'); progressContainer.className = 'progress-container'; // Create SVG progress ring const radius = 56; const circumference = 2 * Math.PI * radius; const svg = document.createElementNS('http://www.w3.org/2000/svg', 'svg'); svg.setAttribute('width', '120'); svg.setAttribute('height', '120'); svg.setAttribute('class', 'progress-ring'); // Background circle const bgCircle = document.createElementNS('http://www.w3.org/2000/svg', 'circle'); bgCircle.setAttribute('class', 'progress-ring-bg'); bgCircle.setAttribute('cx', '60'); bgCircle.setAttribute('cy', '60'); bgCircle.setAttribute('r', radius); // Progress circle const progressCircle = document.createElementNS('http://www.w3.org/2000/svg', 'circle'); progressCircle.setAttribute('class', 'progress-ring-circle'); progressCircle.setAttribute('cx', '60'); progressCircle.setAttribute('cy', '60'); progressCircle.setAttribute('r', radius); progressCircle.style.strokeDasharray = circumference; progressCircle.style.strokeDashoffset = circumference; svg.appendChild(bgCircle); svg.appendChild(progressCircle); // Create progress content (percentage display) const progressContent = document.createElement('div'); progressContent.className = 'progress-content'; const progressPercentage = document.createElement('div'); progressPercentage.className = 'progress-percentage'; progressPercentage.textContent = '0%'; progressContent.appendChild(progressPercentage); progressContainer.appendChild(svg); progressContainer.appendChild(progressContent); // Create text const spinnerText = document.createElement('div'); spinnerText.className = 'spinner-text'; spinnerText.textContent = text; overlay.appendChild(progressContainer); overlay.appendChild(spinnerText); document.body.appendChild(overlay); // Animate progress if duration is provided if (duration > 0) { const startTime = Date.now(); window.overlaySpinnerTimer = setInterval(() => { const elapsed = (Date.now() - startTime) / 1000; const progress = Math.min(elapsed / duration, 1); const percentage = Math.round(progress * 100); // Update progress ring const offset = circumference - (progress * circumference); progressCircle.style.strokeDashoffset = offset; // Update percentage display progressPercentage.textContent = `${percentage}%`; // Stop timer if complete if (progress >= 1) { clearInterval(window.overlaySpinnerTimer); window.overlaySpinnerTimer = null; } }, 100); } } // Hide full-page overlay spinner function hideOverlaySpinner() { // Clear timer if exists if (window.overlaySpinnerTimer) { clearInterval(window.overlaySpinnerTimer); window.overlaySpinnerTimer = null; } const overlay = document.getElementById('overlay-spinner'); if (overlay) overlay.remove(); } // Update capture duration and save to localStorage function updateCaptureDuration(value) { document.getElementById('test-template-duration-value').textContent = value; localStorage.setItem('capture_duration', value); } // Restore capture duration from localStorage function restoreCaptureDuration() { const savedDuration = localStorage.getItem('capture_duration'); if (savedDuration) { const durationInput = document.getElementById('test-template-duration'); const durationValue = document.getElementById('test-template-duration-value'); durationInput.value = savedDuration; durationValue.textContent = savedDuration; } } // Show test template modal async function showTestTemplateModal(templateId) { const templates = await fetchWithAuth('/capture-templates').then(r => r.json()); const template = templates.templates.find(t => t.id === templateId); if (!template) { showToast(t('templates.error.load'), 'error'); return; } // Store current template for testing window.currentTestingTemplate = template; // Load displays await loadDisplaysForTest(); // Restore last used capture duration restoreCaptureDuration(); // Show modal const modal = document.getElementById('test-template-modal'); modal.style.display = 'flex'; setupBackdropClose(modal, closeTestTemplateModal); } // Close test template modal function closeTestTemplateModal() { document.getElementById('test-template-modal').style.display = 'none'; window.currentTestingTemplate = null; } // Load available engines async function loadAvailableEngines() { try { const response = await fetchWithAuth('/capture-engines'); if (!response.ok) { throw new Error(`Failed to load engines: ${response.status}`); } const data = await response.json(); availableEngines = data.engines || []; const select = document.getElementById('template-engine'); select.innerHTML = ``; availableEngines.forEach(engine => { const option = document.createElement('option'); option.value = engine.type; option.textContent = `${getEngineIcon(engine.type)} ${engine.name}`; if (!engine.available) { option.disabled = true; option.textContent += ` (${t('templates.engine.unavailable')})`; } select.appendChild(option); }); } catch (error) { console.error('Error loading engines:', error); showToast(t('templates.error.engines') + ': ' + error.message, 'error'); } } // Handle engine selection change async function onEngineChange() { const engineType = document.getElementById('template-engine').value; const configSection = document.getElementById('engine-config-section'); const configFields = document.getElementById('engine-config-fields'); if (!engineType) { configSection.style.display = 'none'; return; } const engine = availableEngines.find(e => e.type === engineType); if (!engine) { configSection.style.display = 'none'; return; } // Auto-name: set template name to engine name if user hasn't edited if (!_templateNameManuallyEdited && !document.getElementById('template-id').value) { document.getElementById('template-name').value = engine.name || engineType; } // Show availability hint const hint = document.getElementById('engine-availability-hint'); if (!engine.available) { hint.textContent = t('templates.engine.unavailable.hint'); hint.style.display = 'block'; hint.style.color = 'var(--error-color)'; } else { hint.style.display = 'none'; } // Render config fields based on default_config configFields.innerHTML = ''; const defaultConfig = engine.default_config || {}; if (Object.keys(defaultConfig).length === 0) { configSection.style.display = 'none'; return; } else { 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 { if (!_cachedDisplays) { const response = await fetchWithAuth('/config/displays'); if (!response.ok) throw new Error(`Failed to load displays: ${response.status}`); const displaysData = await response.json(); _cachedDisplays = displaysData.displays || []; } // Auto-select: last used display, or primary as fallback let selectedIndex = null; const lastDisplay = localStorage.getItem('lastTestDisplayIndex'); if (lastDisplay !== null) { const found = _cachedDisplays.find(d => d.index === parseInt(lastDisplay)); if (found) selectedIndex = found.index; } if (selectedIndex === null) { const primary = _cachedDisplays.find(d => d.is_primary); if (primary) selectedIndex = primary.index; else if (_cachedDisplays.length > 0) selectedIndex = _cachedDisplays[0].index; } if (selectedIndex !== null) { const display = _cachedDisplays.find(d => d.index === selectedIndex); onTestDisplaySelected(selectedIndex, display); } } catch (error) { console.error('Error loading displays:', error); } } // Run template test async function runTemplateTest() { if (!window.currentTestingTemplate) { showToast(t('templates.test.error.no_engine'), 'error'); return; } const displayIndex = document.getElementById('test-template-display').value; const captureDuration = parseFloat(document.getElementById('test-template-duration').value); if (displayIndex === '') { showToast(t('templates.test.error.no_display'), 'error'); return; } const template = window.currentTestingTemplate; // Show full-page overlay spinner with progress showOverlaySpinner(t('templates.test.running'), captureDuration); try { const response = await fetchWithAuth('/capture-templates/test', { method: 'POST', body: JSON.stringify({ engine_type: template.engine_type, engine_config: template.engine_config, display_index: parseInt(displayIndex), capture_duration: captureDuration }) }); if (!response.ok) { const error = await response.json(); throw new Error(error.detail || error.message || 'Test failed'); } const result = await response.json(); localStorage.setItem('lastTestDisplayIndex', displayIndex); displayTestResults(result); } catch (error) { console.error('Error running test:', error); // Hide overlay spinner hideOverlaySpinner(); // Show short error in snack, details are in console showToast(t('templates.test.error.failed'), 'error'); } } function buildTestStatsHtml(result) { const p = result.performance; const res = `${result.full_capture.width}x${result.full_capture.height}`; let html = `
${t('templates.test.results.duration')}: ${p.capture_duration_s.toFixed(2)}s
${t('templates.test.results.frame_count')}: ${p.frame_count}
`; if (p.frame_count > 1) { html += `
${t('templates.test.results.actual_fps')}: ${p.actual_fps.toFixed(1)}
${t('templates.test.results.avg_capture_time')}: ${p.avg_capture_time_ms.toFixed(1)}ms
`; } html += `
Resolution: ${res}
`; return html; } // Display test results — opens lightbox with stats overlay function displayTestResults(result) { hideOverlaySpinner(); const fullImageSrc = result.full_capture.full_image || result.full_capture.image; openLightbox(fullImageSrc, buildTestStatsHtml(result)); } // Save template async function saveTemplate() { const templateId = document.getElementById('template-id').value; const name = document.getElementById('template-name').value.trim(); const engineType = document.getElementById('template-engine').value; if (!name || !engineType) { showToast(t('templates.error.required'), 'error'); return; } const description = document.getElementById('template-description').value.trim(); const engineConfig = collectEngineConfig(); const payload = { name, engine_type: engineType, engine_config: engineConfig, description: description || null }; try { let response; if (templateId) { // Update existing template response = await fetchWithAuth(`/capture-templates/${templateId}`, { method: 'PUT', body: JSON.stringify(payload) }); } else { // Create new template response = await fetchWithAuth('/capture-templates', { method: 'POST', body: JSON.stringify(payload) }); } if (!response.ok) { const error = await response.json(); throw new Error(error.detail || error.message || 'Failed to save template'); } showToast(templateId ? t('templates.updated') : t('templates.created'), 'success'); closeTemplateModal(); await loadCaptureTemplates(); } catch (error) { console.error('Error saving template:', error); document.getElementById('template-error').textContent = error.message; document.getElementById('template-error').style.display = 'block'; } } // Delete template async function deleteTemplate(templateId) { const confirmed = await showConfirm(t('templates.delete.confirm')); if (!confirmed) return; try { const response = await fetchWithAuth(`/capture-templates/${templateId}`, { method: 'DELETE' }); if (!response.ok) { const error = await response.json(); throw new Error(error.detail || error.message || 'Failed to delete template'); } showToast(t('templates.deleted'), 'success'); await loadCaptureTemplates(); } catch (error) { console.error('Error deleting template:', error); showToast(t('templates.error.delete') + ': ' + error.message, 'error'); } } // ===== Picture Streams ===== let _cachedStreams = []; let _cachedPPTemplates = []; let _cachedCaptureTemplates = []; let _availableFilters = []; // Loaded from GET /filters async function loadPictureStreams() { try { // Always fetch templates, filters, and streams in parallel // since templates are now rendered inside stream sub-tabs const [filtersResp, ppResp, captResp, streamsResp] = await Promise.all([ _availableFilters.length === 0 ? fetchWithAuth('/filters') : Promise.resolve(null), fetchWithAuth('/postprocessing-templates'), fetchWithAuth('/capture-templates'), fetchWithAuth('/picture-streams') ]); if (filtersResp && filtersResp.ok) { const fd = await filtersResp.json(); _availableFilters = fd.filters || []; } if (ppResp.ok) { const pd = await ppResp.json(); _cachedPPTemplates = pd.templates || []; } if (captResp.ok) { const cd = await captResp.json(); _cachedCaptureTemplates = cd.templates || []; } if (!streamsResp.ok) { throw new Error(`Failed to load streams: ${streamsResp.status}`); } const data = await streamsResp.json(); _cachedStreams = data.streams || []; renderPictureStreamsList(_cachedStreams); } catch (error) { console.error('Error loading picture streams:', error); document.getElementById('streams-list').innerHTML = `
${t('streams.error.load')}: ${error.message}
`; } } function switchStreamTab(tabKey) { document.querySelectorAll('.stream-tab-btn').forEach(btn => btn.classList.toggle('active', btn.dataset.streamTab === tabKey) ); document.querySelectorAll('.stream-tab-panel').forEach(panel => panel.classList.toggle('active', panel.id === `stream-tab-${tabKey}`) ); localStorage.setItem('activeStreamTab', tabKey); } function renderPictureStreamsList(streams) { const container = document.getElementById('streams-list'); const activeTab = localStorage.getItem('activeStreamTab') || 'raw'; const renderStreamCard = (stream) => { const typeIcons = { raw: '🖥️', processed: '🎨', static_image: '🖼️' }; const typeIcon = typeIcons[stream.stream_type] || '📺'; let detailsHtml = ''; if (stream.stream_type === 'raw') { let capTmplName = ''; if (stream.capture_template_id) { const capTmpl = _cachedCaptureTemplates.find(t => t.id === stream.capture_template_id); if (capTmpl) capTmplName = escapeHtml(capTmpl.name); } detailsHtml = `
🖥️ ${stream.display_index ?? 0} ⚡ ${stream.target_fps ?? 30} ${capTmplName ? `📷 ${capTmplName}` : ''}
`; } else if (stream.stream_type === 'processed') { const sourceStream = _cachedStreams.find(s => s.id === stream.source_stream_id); const sourceName = sourceStream ? escapeHtml(sourceStream.name) : (stream.source_stream_id || '-'); let ppTmplName = ''; if (stream.postprocessing_template_id) { const ppTmpl = _cachedPPTemplates.find(p => p.id === stream.postprocessing_template_id); if (ppTmpl) ppTmplName = escapeHtml(ppTmpl.name); } detailsHtml = `
📺 ${sourceName} ${ppTmplName ? `🎨 ${ppTmplName}` : ''}
`; } else if (stream.stream_type === 'static_image') { const src = stream.image_source || ''; detailsHtml = `
🌐 ${escapeHtml(src)}
`; } return `
${typeIcon} ${escapeHtml(stream.name)}
${detailsHtml} ${stream.description ? `
${escapeHtml(stream.description)}
` : ''}
`; }; const renderCaptureTemplateCard = (template) => { const engineIcon = getEngineIcon(template.engine_type); const configEntries = Object.entries(template.engine_config); return `
${engineIcon} ${escapeHtml(template.name)}
${template.description ? `
${escapeHtml(template.description)}
` : ''}
⚙️ ${template.engine_type.toUpperCase()} ${configEntries.length > 0 ? `🔧 ${configEntries.length}` : ''}
${configEntries.length > 0 ? `
${t('templates.config.show')} ${configEntries.map(([key, val]) => ` `).join('')}
${escapeHtml(key)} ${escapeHtml(String(val))}
` : ''}
`; }; const renderPPTemplateCard = (tmpl) => { let filterChainHtml = ''; if (tmpl.filters && tmpl.filters.length > 0) { const filterNames = tmpl.filters.map(fi => `${escapeHtml(_getFilterName(fi.filter_id))}`); filterChainHtml = `
${filterNames.join('')}
`; } return `
🎨 ${escapeHtml(tmpl.name)}
${tmpl.description ? `
${escapeHtml(tmpl.description)}
` : ''} ${filterChainHtml}
`; }; const rawStreams = streams.filter(s => s.stream_type === 'raw'); const processedStreams = streams.filter(s => s.stream_type === 'processed'); const staticImageStreams = streams.filter(s => s.stream_type === 'static_image'); const addStreamCard = (type) => `
+
`; const tabs = [ { key: 'raw', icon: '🖥️', titleKey: 'streams.group.raw', streams: rawStreams }, { key: 'static_image', icon: '🖼️', titleKey: 'streams.group.static_image', streams: staticImageStreams }, { key: 'processed', icon: '🎨', titleKey: 'streams.group.processed', streams: processedStreams }, ]; const tabBar = `
${tabs.map(tab => `` ).join('')}
`; const panels = tabs.map(tab => { let panelContent = ''; if (tab.key === 'raw') { // Screen Capture: streams section + capture templates section panelContent = `

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

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

${t('templates.title')}

${_cachedCaptureTemplates.map(renderCaptureTemplateCard).join('')}
+
`; } else if (tab.key === 'processed') { // Processed: streams section + PP templates section panelContent = `

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

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

${t('postprocessing.title')}

${_cachedPPTemplates.map(renderPPTemplateCard).join('')}
+
`; } else { // Static Image: just the stream cards, no section headers panelContent = `
${tab.streams.map(renderStreamCard).join('')} ${addStreamCard(tab.key)}
`; } return `
${panelContent}
`; }).join(''); container.innerHTML = tabBar + panels; } function onStreamTypeChange() { const streamType = document.getElementById('stream-type').value; document.getElementById('stream-raw-fields').style.display = streamType === 'raw' ? '' : 'none'; document.getElementById('stream-processed-fields').style.display = streamType === 'processed' ? '' : 'none'; document.getElementById('stream-static-image-fields').style.display = streamType === 'static_image' ? '' : 'none'; } async function showAddStreamModal(presetType) { const streamType = presetType || 'raw'; const titleKeys = { raw: 'streams.add.raw', processed: 'streams.add.processed', static_image: 'streams.add.static_image' }; document.getElementById('stream-modal-title').textContent = t(titleKeys[streamType] || 'streams.add'); document.getElementById('stream-form').reset(); document.getElementById('stream-id').value = ''; document.getElementById('stream-display-index').value = ''; document.getElementById('stream-display-picker-label').textContent = t('displays.picker.select'); document.getElementById('stream-error').style.display = 'none'; document.getElementById('stream-type').value = streamType; // Clear static image preview and wire up auto-validation _lastValidatedImageSource = ''; const imgSrcInput = document.getElementById('stream-image-source'); imgSrcInput.value = ''; document.getElementById('stream-image-preview-container').style.display = 'none'; document.getElementById('stream-image-validation-status').style.display = 'none'; imgSrcInput.onblur = () => validateStaticImage(); imgSrcInput.onkeydown = (e) => { if (e.key === 'Enter') { e.preventDefault(); validateStaticImage(); } }; imgSrcInput.onpaste = () => setTimeout(() => validateStaticImage(), 0); onStreamTypeChange(); // Auto-name: reset flag and wire listeners _streamNameManuallyEdited = false; document.getElementById('stream-name').oninput = () => { _streamNameManuallyEdited = true; }; document.getElementById('stream-capture-template').onchange = () => _autoGenerateStreamName(); document.getElementById('stream-source').onchange = () => _autoGenerateStreamName(); document.getElementById('stream-pp-template').onchange = () => _autoGenerateStreamName(); // Populate dropdowns await populateStreamModalDropdowns(); const modal = document.getElementById('stream-modal'); modal.style.display = 'flex'; lockBody(); setupBackdropClose(modal, closeStreamModal); } async function editStream(streamId) { try { const response = await fetchWithAuth(`/picture-streams/${streamId}`); if (!response.ok) throw new Error(`Failed to load stream: ${response.status}`); const stream = await response.json(); const editTitleKeys = { raw: 'streams.edit.raw', processed: 'streams.edit.processed', static_image: 'streams.edit.static_image' }; document.getElementById('stream-modal-title').textContent = t(editTitleKeys[stream.stream_type] || 'streams.edit'); document.getElementById('stream-id').value = streamId; document.getElementById('stream-name').value = stream.name; document.getElementById('stream-description').value = stream.description || ''; document.getElementById('stream-error').style.display = 'none'; // Set type (hidden input) document.getElementById('stream-type').value = stream.stream_type; // Clear static image preview and wire up auto-validation _lastValidatedImageSource = ''; const imgSrcInput = document.getElementById('stream-image-source'); document.getElementById('stream-image-preview-container').style.display = 'none'; document.getElementById('stream-image-validation-status').style.display = 'none'; imgSrcInput.onblur = () => validateStaticImage(); imgSrcInput.onkeydown = (e) => { if (e.key === 'Enter') { e.preventDefault(); validateStaticImage(); } }; imgSrcInput.onpaste = () => setTimeout(() => validateStaticImage(), 0); onStreamTypeChange(); // Populate dropdowns before setting values await populateStreamModalDropdowns(); if (stream.stream_type === 'raw') { const displayIdx = stream.display_index ?? 0; const display = _cachedDisplays ? _cachedDisplays.find(d => d.index === displayIdx) : null; onStreamDisplaySelected(displayIdx, display); document.getElementById('stream-capture-template').value = stream.capture_template_id || ''; const fps = stream.target_fps ?? 30; document.getElementById('stream-target-fps').value = fps; document.getElementById('stream-target-fps-value').textContent = fps; } else if (stream.stream_type === 'processed') { document.getElementById('stream-source').value = stream.source_stream_id || ''; document.getElementById('stream-pp-template').value = stream.postprocessing_template_id || ''; } else if (stream.stream_type === 'static_image') { document.getElementById('stream-image-source').value = stream.image_source || ''; // Auto-validate to show preview if (stream.image_source) { validateStaticImage(); } } const modal = document.getElementById('stream-modal'); modal.style.display = 'flex'; lockBody(); setupBackdropClose(modal, closeStreamModal); } catch (error) { console.error('Error loading stream:', error); showToast(t('streams.error.load') + ': ' + error.message, 'error'); } } async function populateStreamModalDropdowns() { // Load displays, capture templates, streams, and PP templates in parallel const [displaysRes, captureTemplatesRes, streamsRes, ppTemplatesRes] = await Promise.all([ fetch(`${API_BASE}/config/displays`, { headers: getHeaders() }), fetchWithAuth('/capture-templates'), fetchWithAuth('/picture-streams'), fetchWithAuth('/postprocessing-templates'), ]); // Displays - warm cache for display picker if (displaysRes.ok) { const displaysData = await displaysRes.json(); _cachedDisplays = displaysData.displays || []; } // Auto-select primary display if none selected yet if (!document.getElementById('stream-display-index').value && _cachedDisplays && _cachedDisplays.length > 0) { const primary = _cachedDisplays.find(d => d.is_primary) || _cachedDisplays[0]; onStreamDisplaySelected(primary.index, primary); } // Capture templates const templateSelect = document.getElementById('stream-capture-template'); templateSelect.innerHTML = ''; if (captureTemplatesRes.ok) { const data = await captureTemplatesRes.json(); (data.templates || []).forEach(tmpl => { const opt = document.createElement('option'); opt.value = tmpl.id; opt.dataset.name = tmpl.name; opt.textContent = `${getEngineIcon(tmpl.engine_type)} ${tmpl.name}`; templateSelect.appendChild(opt); }); } // Source streams (all existing streams) const sourceSelect = document.getElementById('stream-source'); sourceSelect.innerHTML = ''; if (streamsRes.ok) { const data = await streamsRes.json(); const editingId = document.getElementById('stream-id').value; (data.streams || []).forEach(s => { // Don't show the current stream as a possible source if (s.id === editingId) return; const opt = document.createElement('option'); opt.value = s.id; opt.dataset.name = s.name; const typeLabels = { raw: '🖥️', processed: '🎨', static_image: '🖼️' }; const typeLabel = typeLabels[s.stream_type] || '📺'; opt.textContent = `${typeLabel} ${s.name}`; sourceSelect.appendChild(opt); }); } // PP templates _streamModalPPTemplates = []; const ppSelect = document.getElementById('stream-pp-template'); ppSelect.innerHTML = ''; if (ppTemplatesRes.ok) { const data = await ppTemplatesRes.json(); _streamModalPPTemplates = data.templates || []; _streamModalPPTemplates.forEach(tmpl => { const opt = document.createElement('option'); opt.value = tmpl.id; opt.textContent = tmpl.name; ppSelect.appendChild(opt); }); } // Trigger auto-name after dropdowns are populated _autoGenerateStreamName(); } async function saveStream() { const streamId = document.getElementById('stream-id').value; const name = document.getElementById('stream-name').value.trim(); const streamType = document.getElementById('stream-type').value; const description = document.getElementById('stream-description').value.trim(); const errorEl = document.getElementById('stream-error'); if (!name) { showToast(t('streams.error.required'), 'error'); return; } const payload = { name, description: description || null }; if (!streamId) { // Creating - include stream_type payload.stream_type = streamType; } if (streamType === 'raw') { payload.display_index = parseInt(document.getElementById('stream-display-index').value) || 0; payload.capture_template_id = document.getElementById('stream-capture-template').value; payload.target_fps = parseInt(document.getElementById('stream-target-fps').value) || 30; } else if (streamType === 'processed') { payload.source_stream_id = document.getElementById('stream-source').value; payload.postprocessing_template_id = document.getElementById('stream-pp-template').value; } else if (streamType === 'static_image') { const imageSource = document.getElementById('stream-image-source').value.trim(); if (!imageSource) { showToast(t('streams.error.required'), 'error'); return; } payload.image_source = imageSource; } try { let response; if (streamId) { response = await fetchWithAuth(`/picture-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(); } let _lastValidatedImageSource = ''; async function validateStaticImage() { const source = document.getElementById('stream-image-source').value.trim(); const previewContainer = document.getElementById('stream-image-preview-container'); const previewImg = document.getElementById('stream-image-preview'); const infoEl = document.getElementById('stream-image-info'); const statusEl = document.getElementById('stream-image-validation-status'); if (!source) { _lastValidatedImageSource = ''; previewContainer.style.display = 'none'; statusEl.style.display = 'none'; return; } if (source === _lastValidatedImageSource) return; // Show loading state statusEl.textContent = t('streams.validate_image.validating'); statusEl.className = 'validation-status loading'; statusEl.style.display = 'block'; previewContainer.style.display = 'none'; try { const response = await fetchWithAuth('/picture-streams/validate-image', { method: 'POST', body: JSON.stringify({ image_source: source }), }); const data = await response.json(); _lastValidatedImageSource = source; if (data.valid) { previewImg.src = data.preview; previewImg.style.cursor = 'pointer'; previewImg.onclick = () => openFullImageLightbox(source); infoEl.textContent = `${data.width} × ${data.height} px`; previewContainer.style.display = ''; statusEl.textContent = t('streams.validate_image.valid'); statusEl.className = 'validation-status success'; } else { previewContainer.style.display = 'none'; statusEl.textContent = `${t('streams.validate_image.invalid')}: ${data.error}`; statusEl.className = 'validation-status error'; } } catch (err) { previewContainer.style.display = 'none'; statusEl.textContent = `${t('streams.validate_image.invalid')}: ${err.message}`; statusEl.className = 'validation-status error'; } } // ===== Picture Stream Test ===== let _currentTestStreamId = null; async function showTestStreamModal(streamId) { _currentTestStreamId = streamId; restoreStreamTestDuration(); const modal = document.getElementById('test-stream-modal'); modal.style.display = 'flex'; lockBody(); setupBackdropClose(modal, closeTestStreamModal); } function closeTestStreamModal() { document.getElementById('test-stream-modal').style.display = 'none'; unlockBody(); _currentTestStreamId = null; } function updateStreamTestDuration(value) { document.getElementById('test-stream-duration-value').textContent = value; localStorage.setItem('lastStreamTestDuration', value); } function restoreStreamTestDuration() { const saved = localStorage.getItem('lastStreamTestDuration') || '5'; document.getElementById('test-stream-duration').value = saved; document.getElementById('test-stream-duration-value').textContent = saved; } async function runStreamTest() { if (!_currentTestStreamId) return; const captureDuration = parseFloat(document.getElementById('test-stream-duration').value); showOverlaySpinner(t('streams.test.running'), captureDuration); try { const response = await fetchWithAuth(`/picture-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)); } // ===== PP Template Test ===== let _currentTestPPTemplateId = null; async function showTestPPTemplateModal(templateId) { _currentTestPPTemplateId = templateId; restorePPTestDuration(); // Populate source stream selector const select = document.getElementById('test-pp-source-stream'); select.innerHTML = ''; // Ensure streams are cached if (_cachedStreams.length === 0) { try { const resp = await fetchWithAuth('/picture-streams'); if (resp.ok) { const d = await resp.json(); _cachedStreams = d.streams || []; } } catch (e) { console.warn('Could not load streams for PP test:', e); } } for (const s of _cachedStreams) { const opt = document.createElement('option'); opt.value = s.id; opt.textContent = s.name; select.appendChild(opt); } // Auto-select last used stream const lastStream = localStorage.getItem('lastPPTestStreamId'); if (lastStream && _cachedStreams.find(s => s.id === lastStream)) { select.value = lastStream; } const modal = document.getElementById('test-pp-template-modal'); modal.style.display = 'flex'; lockBody(); setupBackdropClose(modal, closeTestPPTemplateModal); } function closeTestPPTemplateModal() { document.getElementById('test-pp-template-modal').style.display = 'none'; unlockBody(); _currentTestPPTemplateId = null; } function updatePPTestDuration(value) { document.getElementById('test-pp-duration-value').textContent = value; localStorage.setItem('lastPPTestDuration', value); } function restorePPTestDuration() { const saved = localStorage.getItem('lastPPTestDuration') || '5'; document.getElementById('test-pp-duration').value = saved; document.getElementById('test-pp-duration-value').textContent = saved; } async function runPPTemplateTest() { if (!_currentTestPPTemplateId) return; const sourceStreamId = document.getElementById('test-pp-source-stream').value; if (!sourceStreamId) { showToast(t('postprocessing.test.error.no_stream'), 'error'); return; } localStorage.setItem('lastPPTestStreamId', sourceStreamId); const captureDuration = parseFloat(document.getElementById('test-pp-duration').value); showOverlaySpinner(t('postprocessing.test.running'), captureDuration); try { const response = await fetchWithAuth(`/postprocessing-templates/${_currentTestPPTemplateId}/test`, { method: 'POST', body: JSON.stringify({ source_stream_id: sourceStreamId, capture_duration: captureDuration }) }); if (!response.ok) { const error = await response.json(); throw new Error(error.detail || error.message || 'Test failed'); } const result = await response.json(); hideOverlaySpinner(); const fullImageSrc = result.full_capture.full_image || result.full_capture.image; openLightbox(fullImageSrc, buildTestStatsHtml(result)); } catch (error) { console.error('Error running PP template test:', error); hideOverlaySpinner(); showToast(t('postprocessing.test.error.failed') + ': ' + error.message, 'error'); } } // ===== Processing Templates ===== async function loadAvailableFilters() { try { const response = await fetchWithAuth('/filters'); if (!response.ok) throw new Error(`Failed to load filters: ${response.status}`); const data = await response.json(); _availableFilters = data.filters || []; } catch (error) { console.error('Error loading available filters:', error); _availableFilters = []; } } async function loadPPTemplates() { try { // Ensure filters are loaded for rendering if (_availableFilters.length === 0) { await loadAvailableFilters(); } const response = await fetchWithAuth('/postprocessing-templates'); if (!response.ok) { throw new Error(`Failed to load templates: ${response.status}`); } const data = await response.json(); _cachedPPTemplates = data.templates || []; // Re-render the streams tab which now contains template sections renderPictureStreamsList(_cachedStreams); } catch (error) { console.error('Error loading PP templates:', error); } } function _getFilterName(filterId) { const key = 'filters.' + filterId; const translated = t(key); // Fallback to filter_name from registry if no localization if (translated === key) { const def = _availableFilters.find(f => f.filter_id === filterId); return def ? def.filter_name : filterId; } return translated; } // --- Filter list management in PP template modal --- let _modalFilters = []; // Current filter list being edited in modal function _populateFilterSelect() { const select = document.getElementById('pp-add-filter-select'); // Keep first option (placeholder) select.innerHTML = ``; for (const f of _availableFilters) { const name = _getFilterName(f.filter_id); select.innerHTML += ``; } } function renderModalFilterList() { const container = document.getElementById('pp-filter-list'); if (_modalFilters.length === 0) { container.innerHTML = `
${t('filters.empty')}
`; return; } let html = ''; _modalFilters.forEach((fi, index) => { const filterDef = _availableFilters.find(f => f.filter_id === fi.filter_id); const filterName = _getFilterName(fi.filter_id); const isExpanded = fi._expanded === true; // Build compact summary of current option values for collapsed state let summary = ''; if (filterDef && !isExpanded) { summary = filterDef.options_schema.map(opt => { const val = fi.options[opt.key] !== undefined ? fi.options[opt.key] : opt.default; return val; }).join(', '); } html += `
${isExpanded ? '▼' : '▶'} ${escapeHtml(filterName)} ${summary ? `${escapeHtml(summary)}` : ''}
`; }); container.innerHTML = html; } function addFilterFromSelect() { const select = document.getElementById('pp-add-filter-select'); const filterId = select.value; if (!filterId) return; const filterDef = _availableFilters.find(f => f.filter_id === filterId); if (!filterDef) return; // Build default options const options = {}; for (const opt of filterDef.options_schema) { options[opt.key] = opt.default; } _modalFilters.push({ filter_id: filterId, options, _expanded: true }); select.value = ''; renderModalFilterList(); _autoGeneratePPTemplateName(); } function toggleFilterExpand(index) { if (_modalFilters[index]) { _modalFilters[index]._expanded = !_modalFilters[index]._expanded; renderModalFilterList(); } } function removeFilter(index) { _modalFilters.splice(index, 1); renderModalFilterList(); _autoGeneratePPTemplateName(); } function moveFilter(index, direction) { const newIndex = index + direction; if (newIndex < 0 || newIndex >= _modalFilters.length) return; const tmp = _modalFilters[index]; _modalFilters[index] = _modalFilters[newIndex]; _modalFilters[newIndex] = tmp; renderModalFilterList(); _autoGeneratePPTemplateName(); } function updateFilterOption(filterIndex, optionKey, value) { if (_modalFilters[filterIndex]) { // Determine type from schema const fi = _modalFilters[filterIndex]; const filterDef = _availableFilters.find(f => f.filter_id === fi.filter_id); if (filterDef) { const optDef = filterDef.options_schema.find(o => o.key === optionKey); if (optDef && optDef.type === 'bool') { fi.options[optionKey] = !!value; } else if (optDef && optDef.type === 'int') { fi.options[optionKey] = parseInt(value); } else { fi.options[optionKey] = parseFloat(value); } } else { fi.options[optionKey] = parseFloat(value); } } } function collectFilters() { return _modalFilters.map(fi => ({ filter_id: fi.filter_id, options: { ...fi.options }, })); } let _ppTemplateNameManuallyEdited = false; function _autoGeneratePPTemplateName() { if (_ppTemplateNameManuallyEdited) return; if (document.getElementById('pp-template-id').value) return; // editing, not creating const nameInput = document.getElementById('pp-template-name'); if (_modalFilters.length > 0) { const filterNames = _modalFilters.map(f => _getFilterName(f.filter_id)).join(' + '); nameInput.value = filterNames; } else { nameInput.value = ''; } } async function showAddPPTemplateModal() { if (_availableFilters.length === 0) await loadAvailableFilters(); document.getElementById('pp-template-modal-title').textContent = t('postprocessing.add'); document.getElementById('pp-template-form').reset(); document.getElementById('pp-template-id').value = ''; document.getElementById('pp-template-error').style.display = 'none'; _modalFilters = []; // Auto-name: reset flag and wire listener _ppTemplateNameManuallyEdited = false; document.getElementById('pp-template-name').oninput = () => { _ppTemplateNameManuallyEdited = true; }; _populateFilterSelect(); renderModalFilterList(); const modal = document.getElementById('pp-template-modal'); modal.style.display = 'flex'; lockBody(); setupBackdropClose(modal, closePPTemplateModal); } async function editPPTemplate(templateId) { try { if (_availableFilters.length === 0) await loadAvailableFilters(); const response = await fetchWithAuth(`/postprocessing-templates/${templateId}`); if (!response.ok) throw new Error(`Failed to load template: ${response.status}`); const tmpl = await response.json(); document.getElementById('pp-template-modal-title').textContent = t('postprocessing.edit'); document.getElementById('pp-template-id').value = templateId; document.getElementById('pp-template-name').value = tmpl.name; document.getElementById('pp-template-description').value = tmpl.description || ''; document.getElementById('pp-template-error').style.display = 'none'; // Load filters from template _modalFilters = (tmpl.filters || []).map(fi => ({ filter_id: fi.filter_id, options: { ...fi.options }, })); _populateFilterSelect(); renderModalFilterList(); const modal = document.getElementById('pp-template-modal'); modal.style.display = 'flex'; lockBody(); setupBackdropClose(modal, closePPTemplateModal); } catch (error) { console.error('Error loading PP template:', error); showToast(t('postprocessing.error.load') + ': ' + error.message, 'error'); } } async function savePPTemplate() { const templateId = document.getElementById('pp-template-id').value; const name = document.getElementById('pp-template-name').value.trim(); const description = document.getElementById('pp-template-description').value.trim(); const errorEl = document.getElementById('pp-template-error'); if (!name) { showToast(t('postprocessing.error.required'), 'error'); return; } const payload = { name, filters: collectFilters(), description: description || null, }; try { let response; if (templateId) { response = await fetchWithAuth(`/postprocessing-templates/${templateId}`, { method: 'PUT', body: JSON.stringify(payload) }); } else { response = await fetchWithAuth('/postprocessing-templates', { method: 'POST', body: JSON.stringify(payload) }); } if (!response.ok) { const error = await response.json(); throw new Error(error.detail || error.message || 'Failed to save template'); } showToast(templateId ? t('postprocessing.updated') : t('postprocessing.created'), 'success'); closePPTemplateModal(); await loadPPTemplates(); } catch (error) { console.error('Error saving PP template:', error); errorEl.textContent = error.message; errorEl.style.display = 'block'; } } async function deletePPTemplate(templateId) { const confirmed = await showConfirm(t('postprocessing.delete.confirm')); if (!confirmed) return; try { const response = await fetchWithAuth(`/postprocessing-templates/${templateId}`, { method: 'DELETE' }); if (!response.ok) { const error = await response.json(); throw new Error(error.detail || error.message || 'Failed to delete template'); } showToast(t('postprocessing.deleted'), 'success'); await loadPPTemplates(); } catch (error) { console.error('Error deleting PP template:', error); showToast(t('postprocessing.error.delete') + ': ' + error.message, 'error'); } } function closePPTemplateModal() { document.getElementById('pp-template-modal').style.display = 'none'; _modalFilters = []; unlockBody(); } // ===== 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(); setupBackdropClose(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(); const typeIcon = stream.stream_type === 'raw' ? '🖥️' : stream.stream_type === 'static_image' ? '🖼️' : '🎨'; const typeName = stream.stream_type === 'raw' ? t('streams.type.raw') : stream.stream_type === 'static_image' ? t('streams.type.static_image') : t('streams.type.processed'); let propsHtml = ''; if (stream.stream_type === 'raw') { let capTmplName = ''; if (stream.capture_template_id) { if (!_cachedCaptureTemplates || _cachedCaptureTemplates.length === 0) { try { const ctResp = await fetchWithAuth('/capture-templates'); if (ctResp.ok) { const d = await ctResp.json(); _cachedCaptureTemplates = d.templates || []; } } catch {} } if (_cachedCaptureTemplates) { const capTmpl = _cachedCaptureTemplates.find(t => t.id === stream.capture_template_id); if (capTmpl) capTmplName = escapeHtml(capTmpl.name); } } propsHtml = ` 🖥️ ${stream.display_index ?? 0} ⚡ ${stream.target_fps ?? 30} ${capTmplName ? `📷 ${capTmplName}` : ''} `; } else if (stream.stream_type === 'processed') { if ((!_cachedStreams || _cachedStreams.length === 0) && stream.source_stream_id) { try { const streamsResp = await fetchWithAuth('/picture-streams'); if (streamsResp.ok) { const d = await streamsResp.json(); _cachedStreams = d.streams || []; } } catch {} } const sourceStream = _cachedStreams ? _cachedStreams.find(s => s.id === stream.source_stream_id) : null; const sourceName = sourceStream ? escapeHtml(sourceStream.name) : (stream.source_stream_id || '-'); let ppTmplName = ''; if (stream.postprocessing_template_id) { if (!_cachedPPTemplates || _cachedPPTemplates.length === 0) { try { const ppResp = await fetchWithAuth('/postprocessing-templates'); if (ppResp.ok) { const d = await ppResp.json(); _cachedPPTemplates = d.templates || []; } } catch {} } if (_cachedPPTemplates) { const ppTmpl = _cachedPPTemplates.find(p => p.id === stream.postprocessing_template_id); if (ppTmpl) ppTmplName = escapeHtml(ppTmpl.name); } } propsHtml = ` 📺 ${sourceName} ${ppTmplName ? `🎨 ${ppTmplName}` : ''} `; } else if (stream.stream_type === 'static_image') { const src = stream.image_source || ''; propsHtml = `🌐 ${escapeHtml(src)}`; } infoPanel.innerHTML = `
${typeIcon} ${typeName} ${propsHtml}
`; 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 = {}; }