diff --git a/server/src/wled_controller/static/app.js b/server/src/wled_controller/static/app.js
deleted file mode 100644
index 2a76bac..0000000
--- a/server/src/wled_controller/static/app.js
+++ /dev/null
@@ -1,7034 +0,0 @@
-const API_BASE = '/api/v1';
-let refreshInterval = null;
-let apiKey = null;
-let kcTestAutoRefresh = null; // interval ID for KC test auto-refresh
-let kcTestTargetId = null; // currently testing KC target
-let _dashboardWS = null; // WebSocket for dashboard live updates
-
-// Toggle hint description visibility next to a label
-function toggleHint(btn) {
- const hint = btn.closest('.label-row').nextElementSibling;
- if (hint && hint.classList.contains('input-hint')) {
- const visible = hint.style.display !== 'none';
- hint.style.display = visible ? 'none' : 'block';
- btn.classList.toggle('active', !visible);
- }
-}
-
-// Backdrop click helper: only closes modal if both mousedown and mouseup were on the backdrop itself.
-// Prevents accidental close when user drags text selection outside the dialog.
-function setupBackdropClose(modal, closeFn) {
- // Guard against duplicate listeners when called on every modal open
- if (modal._backdropCloseSetup) {
- modal._backdropCloseFn = closeFn;
- return;
- }
- modal._backdropCloseFn = closeFn;
- let mouseDownTarget = null;
- modal.addEventListener('mousedown', (e) => { mouseDownTarget = e.target; });
- modal.addEventListener('mouseup', (e) => {
- if (mouseDownTarget === modal && e.target === modal && modal._backdropCloseFn) modal._backdropCloseFn();
- mouseDownTarget = null;
- });
- modal.onclick = null;
- modal._backdropCloseSetup = true;
-}
-
-// Device type helpers
-function isSerialDevice(type) { return type === 'adalight' || type === 'ambiled'; }
-
-// Track logged errors to avoid console spam
-const loggedErrors = new Map(); // deviceId -> { errorCount, lastError }
-
-// Calibration test mode state
-const calibrationTestState = {}; // deviceId -> Set of active edge names
-
-// Modal dirty tracking - stores initial values when modals open
-let settingsInitialValues = {};
-let calibrationInitialValues = {};
-const EDGE_TEST_COLORS = {
- top: [255, 0, 0],
- right: [0, 255, 0],
- bottom: [0, 100, 255],
- left: [255, 255, 0]
-};
-
-// Modal body lock helpers — uses position:fixed to freeze scroll without removing scrollbar
-function lockBody() {
- const scrollY = window.scrollY;
- document.body.style.top = `-${scrollY}px`;
- document.body.classList.add('modal-open');
-}
-
-function unlockBody() {
- const scrollY = parseInt(document.body.style.top || '0', 10) * -1;
- document.body.classList.remove('modal-open');
- document.body.style.top = '';
- window.scrollTo(0, scrollY);
-}
-
-// Image lightbox
-function openLightbox(imageSrc, statsHtml) {
- const lightbox = document.getElementById('image-lightbox');
- const img = document.getElementById('lightbox-image');
- const statsEl = document.getElementById('lightbox-stats');
- img.src = imageSrc;
- if (statsHtml) {
- statsEl.innerHTML = statsHtml;
- statsEl.style.display = '';
- } else {
- statsEl.style.display = 'none';
- }
- lightbox.classList.add('active');
- lockBody();
-}
-
-function closeLightbox(event) {
- if (event && event.target && (event.target.closest('.lightbox-content') || event.target.closest('.lightbox-refresh-btn'))) return;
- // Stop KC test auto-refresh if running
- stopKCTestAutoRefresh();
- const lightbox = document.getElementById('image-lightbox');
- lightbox.classList.remove('active');
- const img = document.getElementById('lightbox-image');
- // Revoke blob URL if one was used
- if (img.src.startsWith('blob:')) URL.revokeObjectURL(img.src);
- img.src = '';
- img.style.display = '';
- document.getElementById('lightbox-stats').style.display = 'none';
- const spinner = lightbox.querySelector('.lightbox-spinner');
- if (spinner) spinner.style.display = 'none';
- // Hide auto-refresh button
- const refreshBtn = document.getElementById('lightbox-auto-refresh');
- if (refreshBtn) { refreshBtn.style.display = 'none'; refreshBtn.classList.remove('active'); }
- unlockBody();
-}
-
-async function openFullImageLightbox(imageSource) {
- try {
- const resp = await fetch(`${API_BASE}/picture-sources/full-image?source=${encodeURIComponent(imageSource)}`, {
- headers: getHeaders()
- });
- if (!resp.ok) return;
- const blob = await resp.blob();
- const blobUrl = URL.createObjectURL(blob);
- openLightbox(blobUrl);
- } catch (err) {
- console.error('Failed to load full image:', err);
- }
-}
-
-document.addEventListener('keydown', (e) => {
- if (e.key === 'Escape') {
- // Close in order: overlay lightboxes first, then modals
- if (document.getElementById('display-picker-lightbox').classList.contains('active')) {
- closeDisplayPicker();
- } else if (document.getElementById('image-lightbox').classList.contains('active')) {
- closeLightbox();
- } else {
- // Close topmost visible modal
- const modals = [
- { id: 'test-pp-template-modal', close: closeTestPPTemplateModal },
- { id: 'test-stream-modal', close: closeTestStreamModal },
- { id: 'test-template-modal', close: closeTestTemplateModal },
- { id: 'stream-modal', close: closeStreamModal },
- { id: 'pp-template-modal', close: closePPTemplateModal },
- { id: 'template-modal', close: closeTemplateModal },
- { id: 'device-settings-modal', close: forceCloseDeviceSettingsModal },
- { id: 'calibration-modal', close: forceCloseCalibrationModal },
- { id: 'target-editor-modal', close: forceCloseTargetEditorModal },
- { id: 'add-device-modal', close: closeAddDeviceModal },
- ];
- for (const m of modals) {
- const el = document.getElementById(m.id);
- if (el && el.style.display === 'flex') {
- m.close();
- break;
- }
- }
- }
- }
-});
-
-// Display picker lightbox
-let _displayPickerCallback = null;
-let _displayPickerSelectedIndex = null;
-
-function openDisplayPicker(callback, selectedIndex) {
- _displayPickerCallback = callback;
- _displayPickerSelectedIndex = (selectedIndex !== undefined && selectedIndex !== null && selectedIndex !== '') ? Number(selectedIndex) : null;
- const lightbox = document.getElementById('display-picker-lightbox');
- const canvas = document.getElementById('display-picker-canvas');
-
- lightbox.classList.add('active');
-
- // Defer render to next frame so the lightbox has been laid out and canvas has dimensions
- requestAnimationFrame(() => {
- if (_cachedDisplays && _cachedDisplays.length > 0) {
- renderDisplayPickerLayout(_cachedDisplays);
- } else {
- canvas.innerHTML = '
';
- loadDisplays().then(() => {
- if (_cachedDisplays && _cachedDisplays.length > 0) {
- renderDisplayPickerLayout(_cachedDisplays);
- } else {
- canvas.innerHTML = `${t('displays.none')}
`;
- }
- });
- }
- });
-}
-
-function closeDisplayPicker(event) {
- if (event && event.target && event.target.closest('.display-picker-content')) return;
- const lightbox = document.getElementById('display-picker-lightbox');
- lightbox.classList.remove('active');
- _displayPickerCallback = null;
-}
-
-function selectDisplay(displayIndex) {
- if (_displayPickerCallback) {
- const display = _cachedDisplays ? _cachedDisplays.find(d => d.index === displayIndex) : null;
- _displayPickerCallback(displayIndex, display);
- }
- closeDisplayPicker();
-}
-
-function renderDisplayPickerLayout(displays) {
- const canvas = document.getElementById('display-picker-canvas');
-
- if (!displays || displays.length === 0) {
- canvas.innerHTML = `${t('displays.none')}
`;
- return;
- }
-
- let minX = Infinity, minY = Infinity, maxX = -Infinity, maxY = -Infinity;
- displays.forEach(display => {
- minX = Math.min(minX, display.x);
- minY = Math.min(minY, display.y);
- maxX = Math.max(maxX, display.x + display.width);
- maxY = Math.max(maxY, display.y + display.height);
- });
-
- const totalWidth = maxX - minX;
- const totalHeight = maxY - minY;
- const aspect = totalHeight / totalWidth;
-
- // Use percentage-based positioning so layout always fits its container
- const displayElements = displays.map(display => {
- const leftPct = ((display.x - minX) / totalWidth) * 100;
- const topPct = ((display.y - minY) / totalHeight) * 100;
- const widthPct = (display.width / totalWidth) * 100;
- const heightPct = (display.height / totalHeight) * 100;
-
- const isSelected = _displayPickerSelectedIndex !== null && display.index === _displayPickerSelectedIndex;
- return `
-
-
(${display.x}, ${display.y})
-
#${display.index}
-
- ${display.name}
- ${display.width}×${display.height}
- ${display.refresh_rate}Hz
-
-
- `;
- }).join('');
-
- canvas.innerHTML = `
-
- ${displayElements}
-
- `;
-}
-
-function formatDisplayLabel(displayIndex, display) {
- if (display) {
- return `#${display.index}: ${display.width}×${display.height}${display.is_primary ? ' (' + t('displays.badge.primary') + ')' : ''}`;
- }
- return `Display ${displayIndex}`;
-}
-
-let _streamNameManuallyEdited = false;
-
-function onStreamDisplaySelected(displayIndex, display) {
- document.getElementById('stream-display-index').value = displayIndex;
- document.getElementById('stream-display-picker-label').textContent = formatDisplayLabel(displayIndex, display);
- _autoGenerateStreamName();
-}
-
-let _streamModalPPTemplates = [];
-
-function _autoGenerateStreamName() {
- if (_streamNameManuallyEdited) return;
- if (document.getElementById('stream-id').value) return; // editing, not creating
- const streamType = document.getElementById('stream-type').value;
- const nameInput = document.getElementById('stream-name');
-
- if (streamType === 'raw') {
- const displayIndex = document.getElementById('stream-display-index').value;
- const templateSelect = document.getElementById('stream-capture-template');
- const templateName = templateSelect.selectedOptions[0]?.dataset?.name || '';
- if (displayIndex === '' || !templateName) return;
- nameInput.value = `D${displayIndex}_${templateName}`;
- } else if (streamType === 'processed') {
- const sourceSelect = document.getElementById('stream-source');
- const sourceName = sourceSelect.selectedOptions[0]?.dataset?.name || '';
- const ppTemplateId = document.getElementById('stream-pp-template').value;
- const ppTemplate = _streamModalPPTemplates.find(t => t.id === ppTemplateId);
- if (!sourceName) return;
- if (ppTemplate && ppTemplate.name) {
- nameInput.value = `${sourceName} (${ppTemplate.name})`;
- } else {
- nameInput.value = sourceName;
- }
- }
-}
-
-function onTestDisplaySelected(displayIndex, display) {
- document.getElementById('test-template-display').value = displayIndex;
- document.getElementById('test-display-picker-label').textContent = formatDisplayLabel(displayIndex, display);
-}
-
-// Locale management
-let currentLocale = 'en';
-let translations = {};
-const supportedLocales = {
- 'en': 'English',
- 'ru': 'Русский'
-};
-
-// Minimal inline fallback for critical UI elements
-const fallbackTranslations = {
- 'app.title': 'LED Grab',
- 'auth.placeholder': 'Enter your API key...',
- 'auth.button.login': 'Login'
-};
-
-// Translation function
-function t(key, params = {}) {
- let text = translations[key] || fallbackTranslations[key] || key;
-
- // Replace parameters like {name}, {value}, etc.
- Object.keys(params).forEach(param => {
- text = text.replace(new RegExp(`\\{${param}\\}`, 'g'), params[param]);
- });
-
- return text;
-}
-
-// Load translation file
-async function loadTranslations(locale) {
- try {
- const response = await fetch(`/static/locales/${locale}.json`);
- if (!response.ok) {
- throw new Error(`Failed to load ${locale}.json`);
- }
- return await response.json();
- } catch (error) {
- console.error(`Error loading translations for ${locale}:`, error);
- // Fallback to English if loading fails
- if (locale !== 'en') {
- return await loadTranslations('en');
- }
- return {};
- }
-}
-
-// Detect browser locale
-function detectBrowserLocale() {
- const browserLang = navigator.language || navigator.languages?.[0] || 'en';
- const langCode = browserLang.split('-')[0]; // 'en-US' -> 'en', 'ru-RU' -> 'ru'
-
- // Only return if we support it
- return supportedLocales[langCode] ? langCode : 'en';
-}
-
-// Initialize locale
-async function initLocale() {
- const savedLocale = localStorage.getItem('locale') || detectBrowserLocale();
- await setLocale(savedLocale);
-}
-
-// Set locale
-async function setLocale(locale) {
- if (!supportedLocales[locale]) {
- locale = 'en';
- }
-
- // Load translations for the locale
- translations = await loadTranslations(locale);
-
- currentLocale = locale;
- document.documentElement.setAttribute('data-locale', locale);
- document.documentElement.setAttribute('lang', locale);
- localStorage.setItem('locale', locale);
-
- // Update all text
- updateAllText();
-
- // Update locale select dropdown (if visible)
- updateLocaleSelect();
-}
-
-// Change locale from dropdown
-function changeLocale() {
- const select = document.getElementById('locale-select');
- const newLocale = select.value;
- if (newLocale && newLocale !== currentLocale) {
- localStorage.setItem('locale', newLocale);
- setLocale(newLocale);
- }
-}
-
-// Update locale select dropdown
-function updateLocaleSelect() {
- const select = document.getElementById('locale-select');
- if (select) {
- select.value = currentLocale;
- }
-}
-
-// Update all text on page
-function updateAllText() {
- // Update all elements with data-i18n attribute
- document.querySelectorAll('[data-i18n]').forEach(el => {
- const key = el.getAttribute('data-i18n');
- el.textContent = t(key);
- });
-
- // Update all elements with data-i18n-placeholder attribute
- document.querySelectorAll('[data-i18n-placeholder]').forEach(el => {
- const key = el.getAttribute('data-i18n-placeholder');
- el.placeholder = t(key);
- });
-
- // Update all elements with data-i18n-title attribute
- document.querySelectorAll('[data-i18n-title]').forEach(el => {
- const key = el.getAttribute('data-i18n-title');
- el.title = t(key);
- });
-
- // Re-render dynamic content with new translations
- if (apiKey) {
- loadDisplays();
- loadTargetsTab();
- loadPictureSources();
- }
-}
-
-// Initialize app
-document.addEventListener('DOMContentLoaded', async () => {
- // Initialize locale first
- await initLocale();
-
- // Load API key from localStorage
- apiKey = localStorage.getItem('wled_api_key');
-
- // Restore active tab before showing content to avoid visible jump
- initTabs();
-
- // Show content now that translations are loaded and tabs are set
- document.body.style.visibility = 'visible';
-
- // Setup form handler
- document.getElementById('add-device-form').addEventListener('submit', handleAddDevice);
-
- // Show modal if no API key is stored
- if (!apiKey) {
- // Wait for modal functions to be defined
- setTimeout(() => {
- if (typeof showApiKeyModal === 'function') {
- showApiKeyModal('Welcome! Please login with your API key to get started.', true);
- }
- }, 100);
- return; // Don't load data yet
- }
-
- // User is logged in, load data
- loadServerInfo();
- loadDisplays();
- loadTargetsTab();
-
- // Start auto-refresh
- startAutoRefresh();
-});
-
-// Helper function to add auth header if needed
-function getHeaders() {
- const headers = {
- 'Content-Type': 'application/json'
- };
-
- if (apiKey) {
- headers['Authorization'] = `Bearer ${apiKey}`;
- }
-
- return headers;
-}
-
-// Fetch wrapper that automatically includes auth headers
-async function fetchWithAuth(url, options = {}) {
- // Build full URL if relative path provided
- const fullUrl = url.startsWith('http') ? url : `${API_BASE}${url}`;
-
- // Merge auth headers with any custom headers
- const headers = options.headers
- ? { ...getHeaders(), ...options.headers }
- : getHeaders();
-
- // Make request with merged options
- return fetch(fullUrl, {
- ...options,
- headers
- });
-}
-
-// Escape HTML to prevent XSS
-function escapeHtml(text) {
- if (!text) return '';
- const div = document.createElement('div');
- div.textContent = text;
- return div.innerHTML;
-}
-
-// Handle 401 errors by showing login modal
-function handle401Error() {
- // Clear invalid API key
- localStorage.removeItem('wled_api_key');
- apiKey = null;
-
- // Stop auto-refresh to prevent repeated 401 errors
- if (refreshInterval) {
- clearInterval(refreshInterval);
- refreshInterval = null;
- }
-
- if (typeof updateAuthUI === 'function') {
- updateAuthUI();
- }
-
- if (typeof showApiKeyModal === 'function') {
- showApiKeyModal('Your session has expired or the API key is invalid. Please login again.', true);
- } else {
- showToast('Authentication failed. Please reload the page and login.', 'error');
- }
-}
-
-// Configure API key
-function configureApiKey() {
- const currentKey = localStorage.getItem('wled_api_key');
- const message = currentKey
- ? 'Current API key is set. Enter new key to update or leave blank to remove:'
- : 'Enter your API key:';
-
- const key = prompt(message);
-
- if (key === null) {
- return; // Cancelled
- }
-
- if (key === '') {
- localStorage.removeItem('wled_api_key');
- apiKey = null;
- document.getElementById('api-key-btn').style.display = 'none';
- showToast('API key removed', 'info');
- } else {
- localStorage.setItem('wled_api_key', key);
- apiKey = key;
- document.getElementById('api-key-btn').style.display = 'inline-block';
- showToast('API key updated', 'success');
- }
-
- // Reload data with new key
- loadServerInfo();
- loadDisplays();
- loadDevices();
-}
-
-// Server info
-async function loadServerInfo() {
- try {
- const response = await fetch('/health');
- const data = await response.json();
-
- document.getElementById('version-number').textContent = `v${data.version}`;
- document.getElementById('server-status').textContent = '●';
- document.getElementById('server-status').className = 'status-badge online';
- } catch (error) {
- console.error('Failed to load server info:', error);
- document.getElementById('server-status').className = 'status-badge offline';
- showToast(t('server.offline'), 'error');
- }
-}
-
-// Load displays
-async function loadDisplays() {
- try {
- const response = await fetch(`${API_BASE}/config/displays`, {
- headers: getHeaders()
- });
-
- if (response.status === 401) {
- handle401Error();
- return;
- }
-
- const data = await response.json();
-
- if (data.displays && data.displays.length > 0) {
- _cachedDisplays = data.displays;
- }
- } catch (error) {
- console.error('Failed to load displays:', error);
- }
-}
-
-let _cachedDisplays = null;
-
-function switchTab(name) {
- document.querySelectorAll('.tab-btn').forEach(btn => btn.classList.toggle('active', btn.dataset.tab === name));
- document.querySelectorAll('.tab-panel').forEach(panel => panel.classList.toggle('active', panel.id === `tab-${name}`));
- localStorage.setItem('activeTab', name);
- if (name === 'dashboard') {
- loadDashboard();
- startDashboardWS();
- } else {
- stopDashboardWS();
- if (name === 'streams') {
- loadPictureSources();
- } else if (name === 'targets') {
- loadTargetsTab();
- } else if (name === 'profiles') {
- loadProfiles();
- }
- }
-}
-
-function initTabs() {
- let saved = localStorage.getItem('activeTab');
- // Migrate legacy 'devices' tab to 'targets' (devices now live inside targets)
- if (saved === 'devices') saved = 'targets';
- if (!saved || !document.getElementById(`tab-${saved}`)) saved = 'dashboard';
- switchTab(saved);
-}
-
-
-// Load devices
-async function loadDevices() {
- // Devices now render inside the combined Targets tab
- await loadTargetsTab();
-}
-
-function createDeviceCard(device) {
- const state = device.state || {};
-
- // Device health indicator
- const devOnline = state.device_online || false;
- const devLatency = state.device_latency_ms;
- const devName = state.device_name;
- const devVersion = state.device_version;
- const devLastChecked = state.device_last_checked;
-
- let healthClass, healthTitle, healthLabel;
- if (devLastChecked === null || devLastChecked === undefined) {
- healthClass = 'health-unknown';
- healthTitle = t('device.health.checking');
- healthLabel = '';
- } else if (devOnline) {
- healthClass = 'health-online';
- healthTitle = `${t('device.health.online')}`;
- if (devName) healthTitle += ` - ${devName}`;
- if (devVersion) healthTitle += ` v${devVersion}`;
- if (devLatency !== null && devLatency !== undefined) healthTitle += ` (${Math.round(devLatency)}ms)`;
- healthLabel = '';
- } else {
- healthClass = 'health-offline';
- healthTitle = t('device.health.offline');
- if (state.device_error) healthTitle += `: ${state.device_error}`;
- healthLabel = `${t('device.health.offline')}`;
- }
-
- const ledCount = state.device_led_count || device.led_count;
-
- return `
-
-
- ${(device.capabilities || []).includes('power_control') ? `` : ''}
-
-
-
-
- ${(device.device_type || 'wled').toUpperCase()}
- ${ledCount ? `💡 ${ledCount}` : ''}
- ${state.device_led_type ? `🔌 ${state.device_led_type.replace(/ RGBW$/, '')}` : ''}
- ${state.device_rgbw ? '' : ''}
- ${(device.capabilities || []).includes('static_color') ? `` : ''}
-
- ${(device.capabilities || []).includes('brightness_control') ? `
-
-
-
` : ''}
-
-
-
-
-
- `;
-}
-
-async function toggleDevicePower(deviceId) {
- try {
- // Get current power state
- const getResp = await fetch(`${API_BASE}/devices/${deviceId}/power`, { headers: getHeaders() });
- if (getResp.status === 401) { handle401Error(); return; }
- if (!getResp.ok) { showToast('Failed to get power state', 'error'); return; }
- const current = await getResp.json();
- const newState = !current.on;
-
- // Toggle
- const setResp = await fetch(`${API_BASE}/devices/${deviceId}/power`, {
- method: 'PUT',
- headers: { ...getHeaders(), 'Content-Type': 'application/json' },
- body: JSON.stringify({ on: newState })
- });
- if (setResp.status === 401) { handle401Error(); return; }
- if (setResp.ok) {
- showToast(t(newState ? 'device.power.on_success' : 'device.power.off_success'), 'success');
- } else {
- const error = await setResp.json();
- showToast(error.detail || 'Failed', 'error');
- }
- } catch (error) {
- showToast('Failed to toggle power', 'error');
- }
-}
-
-function attachDeviceListeners(deviceId) {
- // Add any specific event listeners here if needed
-}
-
-// Device actions
-async function removeDevice(deviceId) {
- const confirmed = await showConfirm(t('device.remove.confirm'));
- if (!confirmed) {
- return;
- }
-
- try {
- const response = await fetch(`${API_BASE}/devices/${deviceId}`, {
- method: 'DELETE',
- headers: getHeaders()
- });
-
- if (response.status === 401) {
- handle401Error();
- return;
- }
-
- if (response.ok) {
- showToast('Device removed', 'success');
- loadDevices();
- } else {
- const error = await response.json();
- showToast(`Failed to remove: ${error.detail}`, 'error');
- }
- } catch (error) {
- console.error('Failed to remove device:', error);
- showToast('Failed to remove device', 'error');
- }
-}
-
-async function showSettings(deviceId) {
- try {
- const deviceResponse = await fetch(`${API_BASE}/devices/${deviceId}`, { headers: getHeaders() });
-
- if (deviceResponse.status === 401) {
- handle401Error();
- return;
- }
-
- if (!deviceResponse.ok) {
- showToast('Failed to load device settings', 'error');
- return;
- }
-
- const device = await deviceResponse.json();
- const isAdalight = isSerialDevice(device.device_type);
-
- // Populate fields
- document.getElementById('settings-device-id').value = device.id;
- document.getElementById('settings-device-name').value = device.name;
- document.getElementById('settings-health-interval').value = 30;
-
- // Toggle URL vs serial port field
- const urlGroup = document.getElementById('settings-url-group');
- const serialGroup = document.getElementById('settings-serial-port-group');
- if (isAdalight) {
- urlGroup.style.display = 'none';
- document.getElementById('settings-device-url').removeAttribute('required');
- serialGroup.style.display = '';
- // Populate serial port dropdown via discovery
- _populateSettingsSerialPorts(device.url);
- } else {
- urlGroup.style.display = '';
- document.getElementById('settings-device-url').setAttribute('required', '');
- document.getElementById('settings-device-url').value = device.url;
- serialGroup.style.display = 'none';
- }
-
- // Show LED count field for devices with manual_led_count capability
- const caps = device.capabilities || [];
- const ledCountGroup = document.getElementById('settings-led-count-group');
- if (caps.includes('manual_led_count')) {
- ledCountGroup.style.display = '';
- document.getElementById('settings-led-count').value = device.led_count || '';
- } else {
- ledCountGroup.style.display = 'none';
- }
-
- // Show baud rate field for adalight devices
- const baudRateGroup = document.getElementById('settings-baud-rate-group');
- if (isAdalight) {
- baudRateGroup.style.display = '';
- const baudSelect = document.getElementById('settings-baud-rate');
- if (device.baud_rate) {
- baudSelect.value = String(device.baud_rate);
- } else {
- baudSelect.value = '115200';
- }
- updateSettingsBaudFpsHint();
- } else {
- baudRateGroup.style.display = 'none';
- }
-
- // Populate auto shutdown toggle
- document.getElementById('settings-auto-shutdown').checked = !!device.auto_shutdown;
-
- // Snapshot initial values for dirty checking
- settingsInitialValues = {
- name: device.name,
- url: device.url,
- led_count: String(device.led_count || ''),
- baud_rate: String(device.baud_rate || '115200'),
- device_type: device.device_type,
- capabilities: caps,
- state_check_interval: '30',
- auto_shutdown: !!device.auto_shutdown,
- };
-
- // Show modal
- const modal = document.getElementById('device-settings-modal');
- modal.style.display = 'flex';
- lockBody();
-
- // Focus first input
- setTimeout(() => {
- document.getElementById('settings-device-name').focus();
- }, 100);
-
- } catch (error) {
- console.error('Failed to load device settings:', error);
- showToast('Failed to load device settings', 'error');
- }
-}
-
-function _getSettingsUrl() {
- if (isSerialDevice(settingsInitialValues.device_type)) {
- return document.getElementById('settings-serial-port').value;
- }
- return document.getElementById('settings-device-url').value.trim();
-}
-
-function isSettingsDirty() {
- const ledCountDirty = (settingsInitialValues.capabilities || []).includes('manual_led_count')
- && document.getElementById('settings-led-count').value !== settingsInitialValues.led_count;
- return (
- document.getElementById('settings-device-name').value !== settingsInitialValues.name ||
- _getSettingsUrl() !== settingsInitialValues.url ||
- document.getElementById('settings-health-interval').value !== settingsInitialValues.state_check_interval ||
- document.getElementById('settings-auto-shutdown').checked !== settingsInitialValues.auto_shutdown ||
- ledCountDirty
- );
-}
-
-function forceCloseDeviceSettingsModal() {
- const modal = document.getElementById('device-settings-modal');
- const error = document.getElementById('settings-error');
- modal.style.display = 'none';
- error.style.display = 'none';
- unlockBody();
- settingsInitialValues = {};
-}
-
-async function closeDeviceSettingsModal() {
- if (isSettingsDirty()) {
- const confirmed = await showConfirm(t('modal.discard_changes'));
- if (!confirmed) return;
- }
- forceCloseDeviceSettingsModal();
-}
-
-async function saveDeviceSettings() {
- const deviceId = document.getElementById('settings-device-id').value;
- const name = document.getElementById('settings-device-name').value.trim();
- const url = _getSettingsUrl();
- const error = document.getElementById('settings-error');
-
- // Validation
- if (!name || !url) {
- error.textContent = 'Please fill in all fields correctly';
- error.style.display = 'block';
- return;
- }
-
- try {
- // Update device info (name, url, auto_shutdown, optionally led_count, baud_rate)
- const body = { name, url, auto_shutdown: document.getElementById('settings-auto-shutdown').checked };
- const ledCountInput = document.getElementById('settings-led-count');
- if ((settingsInitialValues.capabilities || []).includes('manual_led_count') && ledCountInput.value) {
- body.led_count = parseInt(ledCountInput.value, 10);
- }
- if (isSerialDevice(settingsInitialValues.device_type)) {
- const baudVal = document.getElementById('settings-baud-rate').value;
- if (baudVal) body.baud_rate = parseInt(baudVal, 10);
- }
- const deviceResponse = await fetch(`${API_BASE}/devices/${deviceId}`, {
- method: 'PUT',
- headers: getHeaders(),
- body: JSON.stringify(body)
- });
-
- if (deviceResponse.status === 401) {
- handle401Error();
- return;
- }
-
- if (!deviceResponse.ok) {
- const errorData = await deviceResponse.json();
- error.textContent = `Failed to update device: ${errorData.detail}`;
- error.style.display = 'block';
- return;
- }
-
- showToast(t('settings.saved'), 'success');
- forceCloseDeviceSettingsModal();
- loadDevices();
- } catch (err) {
- console.error('Failed to save device settings:', err);
- error.textContent = 'Failed to save settings';
- error.style.display = 'block';
- }
-}
-
-// Brightness cache: stores last known WLED brightness per device (0-255)
-const _deviceBrightnessCache = {};
-
-// Card brightness controls — talks directly to WLED device
-function updateBrightnessLabel(deviceId, value) {
- const slider = document.querySelector(`[data-device-brightness="${deviceId}"]`);
- if (slider) slider.title = Math.round(parseInt(value) / 255 * 100) + '%';
-}
-
-async function saveCardBrightness(deviceId, value) {
- const bri = parseInt(value);
- _deviceBrightnessCache[deviceId] = bri;
- try {
- await fetch(`${API_BASE}/devices/${deviceId}/brightness`, {
- method: 'PUT',
- headers: getHeaders(),
- body: JSON.stringify({ brightness: bri })
- });
- } catch (err) {
- console.error('Failed to update brightness:', err);
- showToast('Failed to update brightness', 'error');
- }
-}
-
-async function fetchDeviceBrightness(deviceId) {
- try {
- const resp = await fetch(`${API_BASE}/devices/${deviceId}/brightness`, {
- headers: getHeaders()
- });
- if (!resp.ok) return;
- const data = await resp.json();
- _deviceBrightnessCache[deviceId] = data.brightness;
- const slider = document.querySelector(`[data-device-brightness="${deviceId}"]`);
- if (slider) {
- slider.value = data.brightness;
- slider.title = Math.round(data.brightness / 255 * 100) + '%';
- slider.disabled = false;
- }
- const wrap = document.querySelector(`[data-brightness-wrap="${deviceId}"]`);
- if (wrap) wrap.classList.remove('brightness-loading');
- } catch (err) {
- // Silently fail — device may be offline
- }
-}
-
-// Static color helpers
-function rgbToHex(r, g, b) {
- return '#' + [r, g, b].map(c => c.toString(16).padStart(2, '0')).join('');
-}
-
-function hexToRgb(hex) {
- const result = /^#?([a-f\d]{2})([a-f\d]{2})([a-f\d]{2})$/i.exec(hex);
- return result ? [parseInt(result[1], 16), parseInt(result[2], 16), parseInt(result[3], 16)] : null;
-}
-
-async function saveDeviceStaticColor(deviceId, hexValue) {
- const rgb = hexToRgb(hexValue);
- try {
- await fetch(`${API_BASE}/devices/${deviceId}/color`, {
- method: 'PUT',
- headers: { ...getHeaders(), 'Content-Type': 'application/json' },
- body: JSON.stringify({ color: rgb })
- });
- // Show clear button
- const wrap = document.querySelector(`[data-color-wrap="${deviceId}"]`);
- if (wrap) {
- const clearBtn = wrap.querySelector('.btn-clear-color');
- if (clearBtn) clearBtn.style.display = '';
- }
- } catch (err) {
- console.error('Failed to set static color:', err);
- showToast('Failed to set static color', 'error');
- }
-}
-
-async function clearDeviceStaticColor(deviceId) {
- try {
- await fetch(`${API_BASE}/devices/${deviceId}/color`, {
- method: 'PUT',
- headers: { ...getHeaders(), 'Content-Type': 'application/json' },
- body: JSON.stringify({ color: null })
- });
- // Reset picker to black and hide clear button
- const picker = document.querySelector(`[data-device-color="${deviceId}"]`);
- if (picker) picker.value = '#000000';
- const wrap = document.querySelector(`[data-color-wrap="${deviceId}"]`);
- if (wrap) {
- const clearBtn = wrap.querySelector('.btn-clear-color');
- if (clearBtn) clearBtn.style.display = 'none';
- }
- } catch (err) {
- console.error('Failed to clear static color:', err);
- }
-}
-
-// Add device modal
-let _discoveryScanRunning = false;
-let _discoveryCache = {}; // { deviceType: [...devices] } — per-type discovery cache
-
-function onDeviceTypeChanged() {
- const deviceType = document.getElementById('device-type').value;
- const urlGroup = document.getElementById('device-url-group');
- const urlInput = document.getElementById('device-url');
- const serialGroup = document.getElementById('device-serial-port-group');
- const serialSelect = document.getElementById('device-serial-port');
- const ledCountGroup = document.getElementById('device-led-count-group');
- const discoverySection = document.getElementById('discovery-section');
-
- const baudRateGroup = document.getElementById('device-baud-rate-group');
-
- if (isSerialDevice(deviceType)) {
- urlGroup.style.display = 'none';
- urlInput.removeAttribute('required');
- serialGroup.style.display = '';
- serialSelect.setAttribute('required', '');
- ledCountGroup.style.display = '';
- baudRateGroup.style.display = '';
- // Hide discovery list — serial port dropdown replaces it
- if (discoverySection) discoverySection.style.display = 'none';
- // Populate from cache or show placeholder (lazy-load on focus)
- if (deviceType in _discoveryCache) {
- _populateSerialPortDropdown(_discoveryCache[deviceType]);
- } else {
- serialSelect.innerHTML = '';
- const opt = document.createElement('option');
- opt.value = '';
- opt.textContent = t('device.serial_port.hint') || 'Click to discover ports...';
- opt.disabled = true;
- serialSelect.appendChild(opt);
- }
- updateBaudFpsHint();
- } else {
- urlGroup.style.display = '';
- urlInput.setAttribute('required', '');
- serialGroup.style.display = 'none';
- serialSelect.removeAttribute('required');
- ledCountGroup.style.display = 'none';
- baudRateGroup.style.display = 'none';
- // Show cached results or trigger scan for WLED
- if (deviceType in _discoveryCache) {
- _renderDiscoveryList();
- } else {
- scanForDevices();
- }
- }
-}
-
-function _computeMaxFps(baudRate, ledCount, deviceType) {
- if (!baudRate || !ledCount || ledCount < 1) return null;
- // Adalight: 6-byte header + RGB data; AmbiLED: RGB data + 1-byte show command
- const overhead = deviceType === 'ambiled' ? 1 : 6;
- const bitsPerFrame = (ledCount * 3 + overhead) * 10;
- return Math.floor(baudRate / bitsPerFrame);
-}
-
-function _renderFpsHint(hintEl, baudRate, ledCount, deviceType) {
- const fps = _computeMaxFps(baudRate, ledCount, deviceType);
- if (fps !== null) {
- hintEl.textContent = `Max FPS ≈ ${fps}`;
- hintEl.style.display = '';
- } else {
- hintEl.style.display = 'none';
- }
-}
-
-function updateBaudFpsHint() {
- const hintEl = document.getElementById('baud-fps-hint');
- const baudRate = parseInt(document.getElementById('device-baud-rate').value, 10);
- const ledCount = parseInt(document.getElementById('device-led-count').value, 10);
- const deviceType = document.getElementById('device-type')?.value || 'adalight';
- _renderFpsHint(hintEl, baudRate, ledCount, deviceType);
-}
-
-function updateSettingsBaudFpsHint() {
- const hintEl = document.getElementById('settings-baud-fps-hint');
- const baudRate = parseInt(document.getElementById('settings-baud-rate').value, 10);
- const ledCount = parseInt(document.getElementById('settings-led-count').value, 10);
- _renderFpsHint(hintEl, baudRate, ledCount, settingsInitialValues.device_type);
-}
-
-function _renderDiscoveryList() {
- const selectedType = document.getElementById('device-type').value;
- const devices = _discoveryCache[selectedType];
-
- // Serial devices: populate serial port dropdown instead of discovery list
- if (isSerialDevice(selectedType)) {
- _populateSerialPortDropdown(devices || []);
- return;
- }
-
- // WLED and others: render discovery list cards
- const list = document.getElementById('discovery-list');
- const empty = document.getElementById('discovery-empty');
- const section = document.getElementById('discovery-section');
- if (!list || !section) return;
-
- list.innerHTML = '';
-
- if (!devices) {
- section.style.display = 'none';
- return;
- }
-
- section.style.display = 'block';
-
- if (devices.length === 0) {
- empty.style.display = 'block';
- return;
- }
-
- empty.style.display = 'none';
- devices.forEach(device => {
- const card = document.createElement('div');
- card.className = 'discovery-item' + (device.already_added ? ' discovery-item--added' : '');
- const meta = [device.ip];
- if (device.led_count) meta.push(device.led_count + ' LEDs');
- if (device.version) meta.push('v' + device.version);
- card.innerHTML = `
-
- ${escapeHtml(device.name)}
- ${escapeHtml(meta.join(' \u00b7 '))}
-
- ${device.already_added
- ? '' + t('device.scan.already_added') + ''
- : ''}
- `;
- if (!device.already_added) {
- card.onclick = () => selectDiscoveredDevice(device);
- }
- list.appendChild(card);
- });
-}
-
-function _populateSerialPortDropdown(devices) {
- const select = document.getElementById('device-serial-port');
- select.innerHTML = '';
-
- if (devices.length === 0) {
- const opt = document.createElement('option');
- opt.value = '';
- opt.textContent = t('device.serial_port.none') || 'No serial ports found';
- opt.disabled = true;
- select.appendChild(opt);
- return;
- }
-
- devices.forEach(device => {
- const opt = document.createElement('option');
- opt.value = device.url;
- opt.textContent = device.name;
- if (device.already_added) {
- opt.textContent += ' (' + t('device.scan.already_added') + ')';
- }
- select.appendChild(opt);
- });
-}
-
-function onSerialPortFocus() {
- // Lazy-load: trigger discovery when user opens the serial port dropdown
- const deviceType = document.getElementById('device-type')?.value || 'adalight';
- if (!(deviceType in _discoveryCache)) {
- scanForDevices(deviceType);
- }
-}
-
-async function _populateSettingsSerialPorts(currentUrl) {
- const select = document.getElementById('settings-serial-port');
- select.innerHTML = '';
- // Show loading placeholder
- const loadingOpt = document.createElement('option');
- loadingOpt.value = currentUrl;
- loadingOpt.textContent = currentUrl + ' ⏳';
- select.appendChild(loadingOpt);
-
- try {
- const discoverType = settingsInitialValues.device_type || 'adalight';
- const resp = await fetch(`${API_BASE}/devices/discover?timeout=2&device_type=${encodeURIComponent(discoverType)}`, {
- headers: getHeaders()
- });
- if (!resp.ok) return;
- const data = await resp.json();
- const devices = data.devices || [];
-
- select.innerHTML = '';
- // Always include current port even if not discovered
- let currentFound = false;
- devices.forEach(device => {
- const opt = document.createElement('option');
- opt.value = device.url;
- opt.textContent = device.name;
- if (device.url === currentUrl) currentFound = true;
- select.appendChild(opt);
- });
- if (!currentFound) {
- const opt = document.createElement('option');
- opt.value = currentUrl;
- opt.textContent = currentUrl;
- select.insertBefore(opt, select.firstChild);
- }
- select.value = currentUrl;
- } catch (err) {
- console.error('Failed to discover serial ports:', err);
- // Keep the current URL as fallback
- }
-}
-
-function showAddDevice() {
- const modal = document.getElementById('add-device-modal');
- const form = document.getElementById('add-device-form');
- const error = document.getElementById('add-device-error');
- form.reset();
- error.style.display = 'none';
- _discoveryCache = {};
- // Reset discovery section
- const section = document.getElementById('discovery-section');
- if (section) {
- section.style.display = 'none';
- document.getElementById('discovery-list').innerHTML = '';
- document.getElementById('discovery-empty').style.display = 'none';
- document.getElementById('discovery-loading').style.display = 'none';
- }
- // Reset serial port dropdown
- document.getElementById('device-serial-port').innerHTML = '';
- const scanBtn = document.getElementById('scan-network-btn');
- if (scanBtn) scanBtn.disabled = false;
- modal.style.display = 'flex';
- lockBody();
- onDeviceTypeChanged();
- setTimeout(() => document.getElementById('device-name').focus(), 100);
-}
-
-function closeAddDeviceModal() {
- const modal = document.getElementById('add-device-modal');
- modal.style.display = 'none';
- unlockBody();
-}
-
-async function scanForDevices(forceType) {
- const scanType = forceType || document.getElementById('device-type')?.value || 'wled';
-
- // Per-type guard: prevent duplicate scans for the same type
- if (_discoveryScanRunning === scanType) return;
- _discoveryScanRunning = scanType;
-
- const loading = document.getElementById('discovery-loading');
- const list = document.getElementById('discovery-list');
- const empty = document.getElementById('discovery-empty');
- const section = document.getElementById('discovery-section');
- const scanBtn = document.getElementById('scan-network-btn');
-
- if (isSerialDevice(scanType)) {
- // Show loading in the serial port dropdown
- const select = document.getElementById('device-serial-port');
- select.innerHTML = '';
- const opt = document.createElement('option');
- opt.value = '';
- opt.textContent = '⏳';
- opt.disabled = true;
- select.appendChild(opt);
- } else {
- // Show the discovery section with loading spinner
- section.style.display = 'block';
- loading.style.display = 'flex';
- list.innerHTML = '';
- empty.style.display = 'none';
- }
- if (scanBtn) scanBtn.disabled = true;
-
- try {
- const response = await fetch(`${API_BASE}/devices/discover?timeout=3&device_type=${encodeURIComponent(scanType)}`, {
- headers: getHeaders()
- });
-
- if (response.status === 401) { handle401Error(); return; }
-
- loading.style.display = 'none';
- if (scanBtn) scanBtn.disabled = false;
-
- if (!response.ok) {
- if (!isSerialDevice(scanType)) {
- empty.style.display = 'block';
- empty.querySelector('small').textContent = t('device.scan.error');
- }
- return;
- }
-
- const data = await response.json();
- _discoveryCache[scanType] = data.devices || [];
-
- // Only render if the user is still on this type
- const currentType = document.getElementById('device-type')?.value;
- if (currentType === scanType) {
- _renderDiscoveryList();
- }
- } catch (err) {
- loading.style.display = 'none';
- if (scanBtn) scanBtn.disabled = false;
- if (!isSerialDevice(scanType)) {
- empty.style.display = 'block';
- empty.querySelector('small').textContent = t('device.scan.error');
- }
- console.error('Device scan failed:', err);
- } finally {
- if (_discoveryScanRunning === scanType) {
- _discoveryScanRunning = false;
- }
- }
-}
-
-function selectDiscoveredDevice(device) {
- document.getElementById('device-name').value = device.name;
- const typeSelect = document.getElementById('device-type');
- if (typeSelect) typeSelect.value = device.device_type;
- onDeviceTypeChanged();
- if (isSerialDevice(device.device_type)) {
- document.getElementById('device-serial-port').value = device.url;
- } else {
- document.getElementById('device-url').value = device.url;
- }
- showToast(t('device.scan.selected'), 'info');
-}
-
-async function handleAddDevice(event) {
- event.preventDefault();
-
- const name = document.getElementById('device-name').value.trim();
- const deviceType = document.getElementById('device-type')?.value || 'wled';
- const url = isSerialDevice(deviceType)
- ? document.getElementById('device-serial-port').value
- : document.getElementById('device-url').value.trim();
- const error = document.getElementById('add-device-error');
-
- if (!name || !url) {
- error.textContent = 'Please fill in all fields';
- error.style.display = 'block';
- return;
- }
-
- try {
- const body = { name, url, device_type: deviceType };
- const ledCountInput = document.getElementById('device-led-count');
- if (ledCountInput && ledCountInput.value) {
- body.led_count = parseInt(ledCountInput.value, 10);
- }
- const baudRateSelect = document.getElementById('device-baud-rate');
- if (isSerialDevice(deviceType) && baudRateSelect && baudRateSelect.value) {
- body.baud_rate = parseInt(baudRateSelect.value, 10);
- }
- const lastTemplateId = localStorage.getItem('lastCaptureTemplateId');
- if (lastTemplateId) {
- body.capture_template_id = lastTemplateId;
- }
-
- const response = await fetch(`${API_BASE}/devices`, {
- method: 'POST',
- headers: getHeaders(),
- body: JSON.stringify(body)
- });
-
- if (response.status === 401) {
- handle401Error();
- return;
- }
-
- if (response.ok) {
- const result = await response.json();
- console.log('Device added successfully:', result);
- showToast('Device added successfully', 'success');
- closeAddDeviceModal();
- await loadDevices();
- // Auto-start device tutorial on first device add
- if (!localStorage.getItem('deviceTutorialSeen')) {
- localStorage.setItem('deviceTutorialSeen', '1');
- setTimeout(() => startDeviceTutorial(), 300);
- }
- } else {
- const errorData = await response.json();
- console.error('Failed to add device:', errorData);
- error.textContent = `Failed to add device: ${errorData.detail}`;
- error.style.display = 'block';
- }
- } catch (err) {
- console.error('Failed to add device:', err);
- showToast('Failed to add device', 'error');
- }
-}
-
-// Auto-refresh
-function startAutoRefresh() {
- if (refreshInterval) {
- clearInterval(refreshInterval);
- }
-
- refreshInterval = setInterval(() => {
- // Only refresh if user is authenticated
- if (apiKey) {
- const activeTab = localStorage.getItem('activeTab') || 'dashboard';
- if (activeTab === 'targets') {
- loadTargetsTab();
- } else if (activeTab === 'dashboard') {
- loadDashboard();
- }
- }
- }, 2000); // Refresh every 2 seconds
-}
-
-// ── Dashboard ──
-
-function formatUptime(seconds) {
- if (!seconds || seconds <= 0) return '-';
- const h = Math.floor(seconds / 3600);
- const m = Math.floor((seconds % 3600) / 60);
- const s = Math.floor(seconds % 60);
- if (h > 0) return `${h}h ${m}m`;
- if (m > 0) return `${m}m ${s}s`;
- return `${s}s`;
-}
-
-let _dashboardLoading = false;
-
-async function loadDashboard() {
- if (_dashboardLoading) return;
- _dashboardLoading = true;
- const container = document.getElementById('dashboard-content');
- if (!container) { _dashboardLoading = false; return; }
-
- try {
- // Fetch targets and profiles in parallel
- const [targetsResp, profilesResp] = await Promise.all([
- fetch(`${API_BASE}/picture-targets`, { headers: getHeaders() }),
- fetch(`${API_BASE}/profiles`, { headers: getHeaders() }).catch(() => null),
- ]);
- if (targetsResp.status === 401) { handle401Error(); return; }
-
- const targetsData = await targetsResp.json();
- const targets = targetsData.targets || [];
- const profilesData = profilesResp && profilesResp.ok ? await profilesResp.json() : { profiles: [] };
- const profiles = profilesData.profiles || [];
-
- if (targets.length === 0 && profiles.length === 0) {
- container.innerHTML = `${t('dashboard.no_targets')}
`;
- return;
- }
-
- // Fetch state + metrics for each target in parallel
- const enriched = await Promise.all(targets.map(async (target) => {
- try {
- const [stateResp, metricsResp] = await Promise.all([
- fetch(`${API_BASE}/picture-targets/${target.id}/state`, { headers: getHeaders() }),
- fetch(`${API_BASE}/picture-targets/${target.id}/metrics`, { headers: getHeaders() }),
- ]);
- const state = stateResp.ok ? await stateResp.json() : {};
- const metrics = metricsResp.ok ? await metricsResp.json() : {};
- return { ...target, state, metrics };
- } catch {
- return target;
- }
- }));
-
- const running = enriched.filter(t => t.state && t.state.processing);
- const stopped = enriched.filter(t => !t.state || !t.state.processing);
-
- let html = '';
-
- // Profiles section
- if (profiles.length > 0) {
- const activeProfiles = profiles.filter(p => p.is_active);
- const inactiveProfiles = profiles.filter(p => !p.is_active);
-
- html += `
-
- ${activeProfiles.map(p => renderDashboardProfile(p)).join('')}
- ${inactiveProfiles.map(p => renderDashboardProfile(p)).join('')}
-
`;
- }
-
- // Running section
- if (running.length > 0) {
- html += `
-
- ${running.map(target => renderDashboardTarget(target, true)).join('')}
-
`;
- }
-
- // Stopped section
- if (stopped.length > 0) {
- html += `
-
- ${stopped.map(target => renderDashboardTarget(target, false)).join('')}
-
`;
- }
-
- container.innerHTML = html;
-
- } catch (error) {
- console.error('Failed to load dashboard:', error);
- container.innerHTML = `${t('dashboard.failed')}
`;
- } finally {
- _dashboardLoading = false;
- }
-}
-
-function renderDashboardTarget(target, isRunning) {
- const state = target.state || {};
- const metrics = target.metrics || {};
- const isLed = target.target_type === 'led' || target.target_type === 'wled';
- const icon = '⚡';
- const typeLabel = isLed ? 'LED' : 'Key Colors';
-
- let subtitleParts = [typeLabel];
- if (isLed && state.device_name) {
- subtitleParts.push(state.device_name);
- }
-
- if (isRunning) {
- const fpsActual = state.fps_actual != null ? state.fps_actual.toFixed(1) : '-';
- const fpsTarget = state.fps_target || (target.settings || target.key_colors_settings || {}).fps || '-';
- const uptime = formatUptime(metrics.uptime_seconds);
- const errors = metrics.errors_count || 0;
-
- let healthDot = '';
- if (isLed && state.device_last_checked != null) {
- const cls = state.device_online ? 'health-online' : 'health-offline';
- healthDot = ``;
- }
-
- return `
-
-
${icon}
-
-
${escapeHtml(target.name)}${healthDot}
- ${subtitleParts.length ? `
${escapeHtml(subtitleParts.join(' · '))}
` : ''}
-
-
-
-
-
${fpsActual}/${fpsTarget}
-
${t('dashboard.fps')}
-
-
-
${uptime}
-
${t('dashboard.uptime')}
-
-
-
${errors}
-
${t('dashboard.errors')}
-
-
-
-
-
-
`;
- } else {
- return `
-
-
${icon}
-
-
${escapeHtml(target.name)}
- ${subtitleParts.length ? `
${escapeHtml(subtitleParts.join(' · '))}
` : ''}
-
-
-
-
-
-
-
`;
- }
-}
-
-function renderDashboardProfile(profile) {
- const isActive = profile.is_active;
- const isDisabled = !profile.enabled;
-
- // Condition summary
- let condSummary = '';
- if (profile.conditions.length > 0) {
- const parts = profile.conditions.map(c => {
- if (c.condition_type === 'application') {
- const apps = (c.apps || []).join(', ');
- const matchLabel = c.match_type === 'topmost' ? t('profiles.condition.application.match_type.topmost') : t('profiles.condition.application.match_type.running');
- return `${apps} (${matchLabel})`;
- }
- return c.condition_type;
- });
- const logic = profile.condition_logic === 'and' ? ' & ' : ' | ';
- condSummary = parts.join(logic);
- }
-
- const statusBadge = isDisabled
- ? `${t('profiles.status.disabled')}`
- : isActive
- ? `${t('profiles.status.active')}`
- : `${t('profiles.status.inactive')}`;
-
- const targetCount = profile.target_ids.length;
- const activeCount = (profile.active_target_ids || []).length;
- const targetsInfo = isActive ? `${activeCount}/${targetCount}` : `${targetCount}`;
-
- return `
-
-
📋
-
-
${escapeHtml(profile.name)}
- ${condSummary ? `
${escapeHtml(condSummary)}
` : ''}
-
- ${statusBadge}
-
-
-
-
${targetsInfo}
-
${t('dashboard.targets')}
-
-
-
-
-
-
`;
-}
-
-async function dashboardToggleProfile(profileId, enable) {
- try {
- const endpoint = enable ? 'enable' : 'disable';
- const response = await fetch(`${API_BASE}/profiles/${profileId}/${endpoint}`, {
- method: 'POST',
- headers: getHeaders()
- });
- if (response.status === 401) { handle401Error(); return; }
- if (response.ok) {
- loadDashboard();
- }
- } catch (error) {
- showToast('Failed to toggle profile', 'error');
- }
-}
-
-async function dashboardStartTarget(targetId) {
- try {
- const response = await fetch(`${API_BASE}/picture-targets/${targetId}/start`, {
- method: 'POST',
- headers: getHeaders()
- });
- if (response.status === 401) { handle401Error(); return; }
- if (response.ok) {
- showToast(t('device.started'), 'success');
- loadDashboard();
- } else {
- const error = await response.json();
- showToast(`Failed to start: ${error.detail}`, 'error');
- }
- } catch (error) {
- showToast('Failed to start processing', 'error');
- }
-}
-
-async function dashboardStopTarget(targetId) {
- try {
- const response = await fetch(`${API_BASE}/picture-targets/${targetId}/stop`, {
- method: 'POST',
- headers: getHeaders()
- });
- if (response.status === 401) { handle401Error(); return; }
- if (response.ok) {
- showToast(t('device.stopped'), 'success');
- loadDashboard();
- } else {
- const error = await response.json();
- showToast(`Failed to stop: ${error.detail}`, 'error');
- }
- } catch (error) {
- showToast('Failed to stop processing', 'error');
- }
-}
-
-async function dashboardStopAll() {
- try {
- const targetsResp = await fetch(`${API_BASE}/picture-targets`, { headers: getHeaders() });
- if (targetsResp.status === 401) { handle401Error(); return; }
- const data = await targetsResp.json();
- const running = (data.targets || []).filter(t => t.id);
- await Promise.all(running.map(t =>
- fetch(`${API_BASE}/picture-targets/${t.id}/stop`, { method: 'POST', headers: getHeaders() }).catch(() => {})
- ));
- loadDashboard();
- } catch (error) {
- showToast('Failed to stop all targets', 'error');
- }
-}
-
-function startDashboardWS() {
- stopDashboardWS();
- if (!apiKey) return;
- const wsProto = location.protocol === 'https:' ? 'wss:' : 'ws:';
- const url = `${wsProto}//${location.host}/api/v1/events/ws?token=${apiKey}`;
- try {
- _dashboardWS = new WebSocket(url);
- _dashboardWS.onmessage = (event) => {
- try {
- const data = JSON.parse(event.data);
- if (data.type === 'state_change' || data.type === 'profile_state_changed') {
- loadDashboard();
- }
- } catch {}
- };
- _dashboardWS.onclose = () => { _dashboardWS = null; };
- _dashboardWS.onerror = () => { _dashboardWS = null; };
- } catch {
- _dashboardWS = null;
- }
-}
-
-function stopDashboardWS() {
- if (_dashboardWS) {
- _dashboardWS.close();
- _dashboardWS = null;
- }
-}
-
-// Toast notifications
-function showToast(message, type = 'info') {
- const toast = document.getElementById('toast');
- toast.textContent = message;
- toast.className = `toast ${type} show`;
-
- setTimeout(() => {
- toast.className = 'toast';
- }, 3000);
-}
-
-// Confirmation modal
-let confirmResolve = null;
-
-function showConfirm(message, title = null) {
- return new Promise((resolve) => {
- confirmResolve = resolve;
-
- const modal = document.getElementById('confirm-modal');
- const titleEl = document.getElementById('confirm-title');
- const messageEl = document.getElementById('confirm-message');
- const yesBtn = document.getElementById('confirm-yes-btn');
- const noBtn = document.getElementById('confirm-no-btn');
-
- titleEl.textContent = title || t('confirm.title');
- messageEl.textContent = message;
- yesBtn.textContent = t('confirm.yes');
- noBtn.textContent = t('confirm.no');
-
- modal.style.display = 'flex';
- lockBody();
- });
-}
-
-function closeConfirmModal(result) {
- const modal = document.getElementById('confirm-modal');
- modal.style.display = 'none';
- unlockBody();
-
- if (confirmResolve) {
- confirmResolve(result);
- confirmResolve = null;
- }
-}
-
-// Calibration functions
-async function showCalibration(deviceId) {
- try {
- // Fetch device data and displays in parallel
- const [response, displaysResponse] = await Promise.all([
- fetch(`${API_BASE}/devices/${deviceId}`, { headers: getHeaders() }),
- fetch(`${API_BASE}/config/displays`, { headers: getHeaders() }),
- ]);
-
- if (response.status === 401) {
- handle401Error();
- return;
- }
-
- if (!response.ok) {
- showToast('Failed to load calibration', 'error');
- return;
- }
-
- const device = await response.json();
- const calibration = device.calibration;
-
- // Set aspect ratio from device's display
- const preview = document.querySelector('.calibration-preview');
- if (displaysResponse.ok) {
- const displaysData = await displaysResponse.json();
- const displayIndex = device.settings?.display_index ?? 0;
- const display = (displaysData.displays || []).find(d => d.index === displayIndex);
- if (display && display.width && display.height) {
- preview.style.aspectRatio = `${display.width} / ${display.height}`;
- } else {
- preview.style.aspectRatio = '';
- }
- } else {
- preview.style.aspectRatio = '';
- }
-
- // Store device ID and LED count
- document.getElementById('calibration-device-id').value = device.id;
- document.getElementById('cal-device-led-count-inline').textContent = device.led_count;
-
- // Set layout
- document.getElementById('cal-start-position').value = calibration.start_position;
- document.getElementById('cal-layout').value = calibration.layout;
- document.getElementById('cal-offset').value = calibration.offset || 0;
-
- // Set LED counts per edge
- document.getElementById('cal-top-leds').value = calibration.leds_top || 0;
- document.getElementById('cal-right-leds').value = calibration.leds_right || 0;
- document.getElementById('cal-bottom-leds').value = calibration.leds_bottom || 0;
- document.getElementById('cal-left-leds').value = calibration.leds_left || 0;
-
- // Set skip LEDs
- document.getElementById('cal-skip-start').value = calibration.skip_leds_start || 0;
- document.getElementById('cal-skip-end').value = calibration.skip_leds_end || 0;
- updateOffsetSkipLock();
-
- // Set border width
- document.getElementById('cal-border-width').value = calibration.border_width || 10;
-
- // Initialize edge spans
- window.edgeSpans = {
- top: { start: calibration.span_top_start ?? 0, end: calibration.span_top_end ?? 1 },
- right: { start: calibration.span_right_start ?? 0, end: calibration.span_right_end ?? 1 },
- bottom: { start: calibration.span_bottom_start ?? 0, end: calibration.span_bottom_end ?? 1 },
- left: { start: calibration.span_left_start ?? 0, end: calibration.span_left_end ?? 1 },
- };
-
- // Snapshot initial values for dirty checking
- calibrationInitialValues = {
- start_position: calibration.start_position,
- layout: calibration.layout,
- offset: String(calibration.offset || 0),
- top: String(calibration.leds_top || 0),
- right: String(calibration.leds_right || 0),
- bottom: String(calibration.leds_bottom || 0),
- left: String(calibration.leds_left || 0),
- spans: JSON.stringify(window.edgeSpans),
- skip_start: String(calibration.skip_leds_start || 0),
- skip_end: String(calibration.skip_leds_end || 0),
- border_width: String(calibration.border_width || 10),
- };
-
- // Initialize test mode state for this device
- calibrationTestState[device.id] = new Set();
-
- // Update preview
- updateCalibrationPreview();
-
- // Show modal
- const modal = document.getElementById('calibration-modal');
- modal.style.display = 'flex';
- lockBody();
-
- // Initialize span drag and render canvas after layout settles
- initSpanDrag();
- requestAnimationFrame(() => {
- renderCalibrationCanvas();
- // Auto-start tutorial on first open
- if (!localStorage.getItem('calibrationTutorialSeen')) {
- localStorage.setItem('calibrationTutorialSeen', '1');
- startCalibrationTutorial();
- }
- });
-
- // Re-render on container resize (e.g. window resize changes aspect-ratio container)
- if (!window._calibrationResizeObserver) {
- window._calibrationResizeObserver = new ResizeObserver(() => {
- updateSpanBars();
- renderCalibrationCanvas();
- });
- }
- window._calibrationResizeObserver.observe(preview);
-
- } catch (error) {
- console.error('Failed to load calibration:', error);
- showToast('Failed to load calibration', 'error');
- }
-}
-
-function isCalibrationDirty() {
- return (
- document.getElementById('cal-start-position').value !== calibrationInitialValues.start_position ||
- document.getElementById('cal-layout').value !== calibrationInitialValues.layout ||
- document.getElementById('cal-offset').value !== calibrationInitialValues.offset ||
- document.getElementById('cal-top-leds').value !== calibrationInitialValues.top ||
- document.getElementById('cal-right-leds').value !== calibrationInitialValues.right ||
- document.getElementById('cal-bottom-leds').value !== calibrationInitialValues.bottom ||
- document.getElementById('cal-left-leds').value !== calibrationInitialValues.left ||
- JSON.stringify(window.edgeSpans) !== calibrationInitialValues.spans ||
- document.getElementById('cal-skip-start').value !== calibrationInitialValues.skip_start ||
- document.getElementById('cal-skip-end').value !== calibrationInitialValues.skip_end ||
- document.getElementById('cal-border-width').value !== calibrationInitialValues.border_width
- );
-}
-
-function forceCloseCalibrationModal() {
- closeTutorial();
- const deviceId = document.getElementById('calibration-device-id').value;
- if (deviceId) {
- clearTestMode(deviceId);
- }
- if (window._calibrationResizeObserver) {
- window._calibrationResizeObserver.disconnect();
- }
- const modal = document.getElementById('calibration-modal');
- const error = document.getElementById('calibration-error');
- modal.style.display = 'none';
- error.style.display = 'none';
- unlockBody();
- calibrationInitialValues = {};
-}
-
-async function closeCalibrationModal() {
- if (isCalibrationDirty()) {
- const confirmed = await showConfirm(t('modal.discard_changes'));
- if (!confirmed) return;
- }
- forceCloseCalibrationModal();
-}
-
-function updateOffsetSkipLock() {
- const offsetEl = document.getElementById('cal-offset');
- const skipStartEl = document.getElementById('cal-skip-start');
- const skipEndEl = document.getElementById('cal-skip-end');
- const hasOffset = parseInt(offsetEl.value || 0) > 0;
- const hasSkip = parseInt(skipStartEl.value || 0) > 0 || parseInt(skipEndEl.value || 0) > 0;
- skipStartEl.disabled = hasOffset;
- skipEndEl.disabled = hasOffset;
- offsetEl.disabled = hasSkip;
-}
-
-function updateCalibrationPreview() {
- // Calculate total from edge inputs
- const total = parseInt(document.getElementById('cal-top-leds').value || 0) +
- parseInt(document.getElementById('cal-right-leds').value || 0) +
- parseInt(document.getElementById('cal-bottom-leds').value || 0) +
- parseInt(document.getElementById('cal-left-leds').value || 0);
- // Warning if total doesn't match device LED count
- const totalEl = document.querySelector('.preview-screen-total');
- const deviceCount = parseInt(document.getElementById('cal-device-led-count-inline').textContent || 0);
- const mismatch = total !== deviceCount;
- document.getElementById('cal-total-leds-inline').textContent = (mismatch ? '\u26A0 ' : '') + total;
- if (totalEl) {
- totalEl.classList.toggle('mismatch', mismatch);
- }
-
- // Update corner dot highlights for start position
- const startPos = document.getElementById('cal-start-position').value;
- ['top_left', 'top_right', 'bottom_left', 'bottom_right'].forEach(corner => {
- const cornerEl = document.querySelector(`.preview-corner.corner-${corner.replace('_', '-')}`);
- if (cornerEl) {
- if (corner === startPos) {
- cornerEl.classList.add('active');
- } else {
- cornerEl.classList.remove('active');
- }
- }
- });
-
- // Update direction toggle display
- const direction = document.getElementById('cal-layout').value;
- const dirIcon = document.getElementById('direction-icon');
- const dirLabel = document.getElementById('direction-label');
- if (dirIcon) dirIcon.textContent = direction === 'clockwise' ? '↻' : '↺';
- if (dirLabel) dirLabel.textContent = direction === 'clockwise' ? 'CW' : 'CCW';
-
- // Update edge highlight states
- const deviceId = document.getElementById('calibration-device-id').value;
- const activeEdges = calibrationTestState[deviceId] || new Set();
-
- ['top', 'right', 'bottom', 'left'].forEach(edge => {
- const toggleEl = document.querySelector(`.edge-toggle.toggle-${edge}`);
- if (!toggleEl) return;
-
- if (activeEdges.has(edge)) {
- const [r, g, b] = EDGE_TEST_COLORS[edge];
- toggleEl.style.background = `rgba(${r}, ${g}, ${b}, 0.35)`;
- toggleEl.style.boxShadow = `inset 0 0 6px rgba(${r}, ${g}, ${b}, 0.5)`;
- } else {
- toggleEl.style.background = '';
- toggleEl.style.boxShadow = '';
- }
- });
-
- // Disable edges with 0 LEDs
- ['top', 'right', 'bottom', 'left'].forEach(edge => {
- const count = parseInt(document.getElementById(`cal-${edge}-leds`).value) || 0;
- const edgeEl = document.querySelector(`.preview-edge.edge-${edge}`);
- const toggleEl = document.querySelector(`.edge-toggle.toggle-${edge}`);
- if (edgeEl) edgeEl.classList.toggle('edge-disabled', count === 0);
- if (toggleEl) toggleEl.classList.toggle('edge-disabled', count === 0);
- });
-
- // Position span bars and render canvas overlay
- updateSpanBars();
- renderCalibrationCanvas();
-}
-
-function renderCalibrationCanvas() {
- const canvas = document.getElementById('calibration-preview-canvas');
- if (!canvas) return;
-
- const container = canvas.parentElement;
- const containerRect = container.getBoundingClientRect();
- if (containerRect.width === 0 || containerRect.height === 0) return;
-
- // Canvas extends beyond the container (matches CSS: left:-40px, top:-40px, +80px/+80px)
- const padX = 40;
- const padY = 40;
-
- const dpr = window.devicePixelRatio || 1;
- const canvasW = containerRect.width + padX * 2;
- const canvasH = containerRect.height + padY * 2;
- canvas.width = canvasW * dpr;
- canvas.height = canvasH * dpr;
- const ctx = canvas.getContext('2d');
- ctx.scale(dpr, dpr);
-
- ctx.clearRect(0, 0, canvasW, canvasH);
-
- // Container origin within canvas coordinate system
- const ox = padX;
- const oy = padY;
- const cW = containerRect.width; // container inner width
- const cH = containerRect.height; // container inner height
-
- // Read current form values
- const startPos = document.getElementById('cal-start-position').value;
- const layout = document.getElementById('cal-layout').value;
- const offset = parseInt(document.getElementById('cal-offset').value || 0);
- const calibration = {
- start_position: startPos,
- layout: layout,
- offset: offset,
- leds_top: parseInt(document.getElementById('cal-top-leds').value || 0),
- leds_right: parseInt(document.getElementById('cal-right-leds').value || 0),
- leds_bottom: parseInt(document.getElementById('cal-bottom-leds').value || 0),
- leds_left: parseInt(document.getElementById('cal-left-leds').value || 0),
- };
- const skipStart = parseInt(document.getElementById('cal-skip-start').value || 0);
- const skipEnd = parseInt(document.getElementById('cal-skip-end').value || 0);
-
- const segments = buildSegments(calibration);
- if (segments.length === 0) return;
-
- const totalLeds = calibration.leds_top + calibration.leds_right + calibration.leds_bottom + calibration.leds_left;
- const hasSkip = (skipStart > 0 || skipEnd > 0) && totalLeds > 1;
-
- // Theme-aware colors
- const isDark = document.documentElement.getAttribute('data-theme') !== 'light';
- const tickStroke = isDark ? 'rgba(255, 255, 255, 0.4)' : 'rgba(0, 0, 0, 0.3)';
- const tickFill = isDark ? 'rgba(255, 255, 255, 0.65)' : 'rgba(0, 0, 0, 0.6)';
- const chevronStroke = isDark ? 'rgba(255, 255, 255, 0.6)' : 'rgba(0, 0, 0, 0.4)';
-
- // Edge bar geometry (matches CSS: corner zones 56px × 36px fixed)
- const cw = 56;
- const ch = 36;
-
- // Span-aware edge geometry: ticks/arrows render only within the span region
- const spans = window.edgeSpans || {};
- const edgeLenH = cW - 2 * cw;
- const edgeLenV = cH - 2 * ch;
-
- const edgeGeometry = {
- top: { x1: ox + cw + (spans.top?.start || 0) * edgeLenH, x2: ox + cw + (spans.top?.end || 1) * edgeLenH, midY: oy + ch / 2, horizontal: true },
- bottom: { x1: ox + cw + (spans.bottom?.start || 0) * edgeLenH, x2: ox + cw + (spans.bottom?.end || 1) * edgeLenH, midY: oy + cH - ch / 2, horizontal: true },
- left: { y1: oy + ch + (spans.left?.start || 0) * edgeLenV, y2: oy + ch + (spans.left?.end || 1) * edgeLenV, midX: ox + cw / 2, horizontal: false },
- right: { y1: oy + ch + (spans.right?.start || 0) * edgeLenV, y2: oy + ch + (spans.right?.end || 1) * edgeLenV, midX: ox + cW - cw / 2, horizontal: false },
- };
-
- // Axis positions for labels (outside the 16px toggle zones)
- const toggleSize = 16;
- const axisPos = {
- top: oy - toggleSize - 3,
- bottom: oy + cH + toggleSize + 3,
- left: ox - toggleSize - 3,
- right: ox + cW + toggleSize + 3,
- };
-
- // Arrow positions (inside the screen area, near each edge bar)
- const arrowInset = 12;
- const arrowPos = {
- top: oy + ch + arrowInset,
- bottom: oy + cH - ch - arrowInset,
- left: ox + cw + arrowInset,
- right: ox + cW - cw - arrowInset,
- };
-
- // Draw ticks and direction arrows for each segment
- segments.forEach(seg => {
- const geo = edgeGeometry[seg.edge];
- if (!geo) return;
-
- const count = seg.led_count;
- if (count === 0) return;
-
- // Per-edge display range: clip to active LED range when skip is set
- const edgeDisplayStart = hasSkip ? Math.max(seg.led_start, skipStart) : seg.led_start;
- const edgeDisplayEnd = hasSkip ? Math.min(seg.led_start + count, totalLeds - skipEnd) : seg.led_start + count - 1;
- const edgeDisplayRange = edgeDisplayEnd - edgeDisplayStart;
- const toEdgeLabel = (i) => {
- if (!hasSkip) return totalLeds > 0 ? (seg.led_start + i) % totalLeds : seg.led_start + i;
- if (count <= 1) return edgeDisplayStart;
- return Math.round(edgeDisplayStart + i / (count - 1) * edgeDisplayRange);
- };
-
- // Edge boundary ticks (first/last LED on edge) and special ticks (LED 0 position)
- const edgeBounds = new Set();
- edgeBounds.add(0);
- if (count > 1) edgeBounds.add(count - 1);
-
- const specialTicks = new Set();
- if (offset > 0 && totalLeds > 0) {
- const zeroPos = (totalLeds - seg.led_start % totalLeds) % totalLeds;
- if (zeroPos < count) specialTicks.add(zeroPos);
- }
-
- // Round-number ticks get priority; edge boundary labels suppressed if overlapping
- const labelsToShow = new Set([...specialTicks]);
- const tickLinesOnly = new Set();
-
- if (count > 2) {
- const edgeLen = geo.horizontal ? (geo.x2 - geo.x1) : (geo.y2 - geo.y1);
- const maxDigits = String(totalLeds > 0 ? totalLeds - 1 : count - 1).length;
- const minSpacing = geo.horizontal ? maxDigits * 7 + 8 : 22;
-
- const allMandatory = new Set([...edgeBounds, ...specialTicks]);
- const maxIntermediate = Math.max(0, 5 - allMandatory.size);
- const niceSteps = [5, 10, 25, 50, 100, 250, 500];
- let step = niceSteps[niceSteps.length - 1];
- for (const s of niceSteps) {
- if (Math.floor(count / s) <= maxIntermediate) {
- step = s;
- break;
- }
- }
-
- const tickPx = i => {
- const f = i / (count - 1);
- return (seg.reverse ? (1 - f) : f) * edgeLen;
- };
-
- // Phase 1: place round-number ticks (checked against specials + each other)
- const placed = [];
- specialTicks.forEach(i => placed.push(tickPx(i)));
-
- for (let i = 1; i < count - 1; i++) {
- if (specialTicks.has(i)) continue;
- if (toEdgeLabel(i) % step === 0) {
- const px = tickPx(i);
- if (!placed.some(p => Math.abs(px - p) < minSpacing)) {
- labelsToShow.add(i);
- placed.push(px);
- }
- }
- }
-
- // Phase 2: edge boundaries — show label unless overlapping a round-number tick
- edgeBounds.forEach(bi => {
- if (labelsToShow.has(bi) || specialTicks.has(bi)) return;
- const px = tickPx(bi);
- if (placed.some(p => Math.abs(px - p) < minSpacing)) {
- tickLinesOnly.add(bi);
- } else {
- labelsToShow.add(bi);
- placed.push(px);
- }
- });
- } else {
- edgeBounds.forEach(i => labelsToShow.add(i));
- }
-
- // Tick styling
- const tickLenLong = toggleSize + 3;
- const tickLenShort = 4;
- ctx.strokeStyle = tickStroke;
- ctx.lineWidth = 1;
- ctx.fillStyle = tickFill;
- ctx.font = '12px -apple-system, BlinkMacSystemFont, sans-serif';
-
- // Draw labeled ticks
- labelsToShow.forEach(i => {
- const fraction = count > 1 ? i / (count - 1) : 0.5;
- const displayFraction = seg.reverse ? (1 - fraction) : fraction;
- const displayLabel = toEdgeLabel(i);
- const tickLen = edgeBounds.has(i) ? tickLenLong : tickLenShort;
-
- if (geo.horizontal) {
- const tx = geo.x1 + displayFraction * (geo.x2 - geo.x1);
- const axisY = axisPos[seg.edge];
- const tickDir = seg.edge === 'top' ? 1 : -1;
-
- ctx.beginPath();
- ctx.moveTo(tx, axisY);
- ctx.lineTo(tx, axisY + tickDir * tickLen);
- ctx.stroke();
-
- ctx.textAlign = 'center';
- ctx.textBaseline = seg.edge === 'top' ? 'bottom' : 'top';
- ctx.fillText(String(displayLabel), tx, axisY - tickDir * 1);
- } else {
- const ty = geo.y1 + displayFraction * (geo.y2 - geo.y1);
- const axisX = axisPos[seg.edge];
- const tickDir = seg.edge === 'left' ? 1 : -1;
-
- ctx.beginPath();
- ctx.moveTo(axisX, ty);
- ctx.lineTo(axisX + tickDir * tickLen, ty);
- ctx.stroke();
-
- ctx.textBaseline = 'middle';
- ctx.textAlign = seg.edge === 'left' ? 'right' : 'left';
- ctx.fillText(String(displayLabel), axisX - tickDir * 1, ty);
- }
- });
-
- // Draw tick lines only (no labels) for suppressed edge boundaries
- tickLinesOnly.forEach(i => {
- const fraction = count > 1 ? i / (count - 1) : 0.5;
- const displayFraction = seg.reverse ? (1 - fraction) : fraction;
-
- if (geo.horizontal) {
- const tx = geo.x1 + displayFraction * (geo.x2 - geo.x1);
- const axisY = axisPos[seg.edge];
- const tickDir = seg.edge === 'top' ? 1 : -1;
- ctx.beginPath();
- ctx.moveTo(tx, axisY);
- ctx.lineTo(tx, axisY + tickDir * tickLenLong);
- ctx.stroke();
- } else {
- const ty = geo.y1 + displayFraction * (geo.y2 - geo.y1);
- const axisX = axisPos[seg.edge];
- const tickDir = seg.edge === 'left' ? 1 : -1;
- ctx.beginPath();
- ctx.moveTo(axisX, ty);
- ctx.lineTo(axisX + tickDir * tickLenLong, ty);
- ctx.stroke();
- }
- });
-
- // Draw direction chevron at full-edge midpoint (not affected by span)
- const s = 7;
- let mx, my, angle;
- if (geo.horizontal) {
- mx = ox + cw + edgeLenH / 2;
- my = arrowPos[seg.edge];
- angle = seg.reverse ? Math.PI : 0;
- } else {
- mx = arrowPos[seg.edge];
- my = oy + ch + edgeLenV / 2;
- angle = seg.reverse ? -Math.PI / 2 : Math.PI / 2;
- }
-
- ctx.save();
- ctx.translate(mx, my);
- ctx.rotate(angle);
- ctx.fillStyle = 'rgba(76, 175, 80, 0.85)';
- ctx.strokeStyle = chevronStroke;
- ctx.lineWidth = 1;
- ctx.lineCap = 'round';
- ctx.lineJoin = 'round';
- ctx.beginPath();
- ctx.moveTo(-s * 0.5, -s * 0.6);
- ctx.lineTo(s * 0.5, 0);
- ctx.lineTo(-s * 0.5, s * 0.6);
- ctx.closePath();
- ctx.fill();
- ctx.stroke();
- ctx.restore();
- });
-
-}
-
-function updateSpanBars() {
- const spans = window.edgeSpans || {};
- const container = document.querySelector('.calibration-preview');
- ['top', 'right', 'bottom', 'left'].forEach(edge => {
- const bar = document.querySelector(`.edge-span-bar[data-edge="${edge}"]`);
- if (!bar) return;
- const span = spans[edge] || { start: 0, end: 1 };
- const edgeEl = bar.parentElement;
- const isHorizontal = (edge === 'top' || edge === 'bottom');
-
- if (isHorizontal) {
- const totalWidth = edgeEl.clientWidth;
- bar.style.left = (span.start * totalWidth) + 'px';
- bar.style.width = ((span.end - span.start) * totalWidth) + 'px';
- } else {
- const totalHeight = edgeEl.clientHeight;
- bar.style.top = (span.start * totalHeight) + 'px';
- bar.style.height = ((span.end - span.start) * totalHeight) + 'px';
- }
-
- // Also reposition toggle zone to match span region
- if (!container) return;
- const toggle = container.querySelector(`.toggle-${edge}`);
- if (!toggle) return;
- if (isHorizontal) {
- const cornerW = 56;
- const edgeW = container.clientWidth - 2 * cornerW;
- toggle.style.left = (cornerW + span.start * edgeW) + 'px';
- toggle.style.right = 'auto';
- toggle.style.width = ((span.end - span.start) * edgeW) + 'px';
- } else {
- const cornerH = 36;
- const edgeH = container.clientHeight - 2 * cornerH;
- toggle.style.top = (cornerH + span.start * edgeH) + 'px';
- toggle.style.bottom = 'auto';
- toggle.style.height = ((span.end - span.start) * edgeH) + 'px';
- }
- });
-}
-
-function initSpanDrag() {
- const MIN_SPAN = 0.05;
-
- document.querySelectorAll('.edge-span-bar').forEach(bar => {
- const edge = bar.dataset.edge;
- const isHorizontal = (edge === 'top' || edge === 'bottom');
-
- // Prevent edge click-through when interacting with span bar
- bar.addEventListener('click', e => e.stopPropagation());
-
- // Handle resize via handles
- bar.querySelectorAll('.edge-span-handle').forEach(handle => {
- handle.addEventListener('mousedown', e => {
- const edgeLeds = parseInt(document.getElementById(`cal-${edge}-leds`).value) || 0;
- if (edgeLeds === 0) return;
- e.preventDefault();
- e.stopPropagation();
- const handleType = handle.dataset.handle;
- const edgeEl = bar.parentElement;
- const rect = edgeEl.getBoundingClientRect();
-
- function onMouseMove(ev) {
- const span = window.edgeSpans[edge];
- let fraction;
- if (isHorizontal) {
- fraction = (ev.clientX - rect.left) / rect.width;
- } else {
- fraction = (ev.clientY - rect.top) / rect.height;
- }
- fraction = Math.max(0, Math.min(1, fraction));
-
- if (handleType === 'start') {
- span.start = Math.min(fraction, span.end - MIN_SPAN);
- } else {
- span.end = Math.max(fraction, span.start + MIN_SPAN);
- }
-
- updateSpanBars();
- renderCalibrationCanvas();
- }
-
- function onMouseUp() {
- document.removeEventListener('mousemove', onMouseMove);
- document.removeEventListener('mouseup', onMouseUp);
- }
-
- document.addEventListener('mousemove', onMouseMove);
- document.addEventListener('mouseup', onMouseUp);
- });
- });
-
- // Handle body drag (move entire span)
- bar.addEventListener('mousedown', e => {
- if (e.target.classList.contains('edge-span-handle')) return;
- e.preventDefault();
- e.stopPropagation();
-
- const edgeEl = bar.parentElement;
- const rect = edgeEl.getBoundingClientRect();
- const span = window.edgeSpans[edge];
- const spanWidth = span.end - span.start;
-
- let startFraction;
- if (isHorizontal) {
- startFraction = (e.clientX - rect.left) / rect.width;
- } else {
- startFraction = (e.clientY - rect.top) / rect.height;
- }
- const offsetInSpan = startFraction - span.start;
-
- function onMouseMove(ev) {
- let fraction;
- if (isHorizontal) {
- fraction = (ev.clientX - rect.left) / rect.width;
- } else {
- fraction = (ev.clientY - rect.top) / rect.height;
- }
-
- let newStart = fraction - offsetInSpan;
- newStart = Math.max(0, Math.min(1 - spanWidth, newStart));
- span.start = newStart;
- span.end = newStart + spanWidth;
-
- updateSpanBars();
- renderCalibrationCanvas();
- }
-
- function onMouseUp() {
- document.removeEventListener('mousemove', onMouseMove);
- document.removeEventListener('mouseup', onMouseUp);
- }
-
- document.addEventListener('mousemove', onMouseMove);
- document.addEventListener('mouseup', onMouseUp);
- });
- });
-
- // Initial positioning
- updateSpanBars();
-}
-
-function setStartPosition(position) {
- document.getElementById('cal-start-position').value = position;
- updateCalibrationPreview();
-}
-
-function toggleEdgeInputs() {
- const preview = document.querySelector('.calibration-preview');
- if (preview) preview.classList.toggle('inputs-dimmed');
-}
-
-function toggleDirection() {
- const select = document.getElementById('cal-layout');
- select.value = select.value === 'clockwise' ? 'counterclockwise' : 'clockwise';
- updateCalibrationPreview();
-}
-
-async function toggleTestEdge(edge) {
- const edgeLeds = parseInt(document.getElementById(`cal-${edge}-leds`).value) || 0;
- if (edgeLeds === 0) return;
-
- const deviceId = document.getElementById('calibration-device-id').value;
- const error = document.getElementById('calibration-error');
-
- if (!calibrationTestState[deviceId]) {
- calibrationTestState[deviceId] = new Set();
- }
-
- // Toggle edge
- if (calibrationTestState[deviceId].has(edge)) {
- calibrationTestState[deviceId].delete(edge);
- } else {
- calibrationTestState[deviceId].add(edge);
- }
-
- // Build edges dict for API
- const edges = {};
- calibrationTestState[deviceId].forEach(e => {
- edges[e] = EDGE_TEST_COLORS[e];
- });
-
- // Update visual state immediately
- updateCalibrationPreview();
-
- try {
- const response = await fetch(`${API_BASE}/devices/${deviceId}/calibration/test`, {
- method: 'PUT',
- headers: getHeaders(),
- body: JSON.stringify({ edges })
- });
-
- if (response.status === 401) {
- handle401Error();
- return;
- }
-
- if (!response.ok) {
- const errorData = await response.json();
- error.textContent = `Test failed: ${errorData.detail}`;
- error.style.display = 'block';
- }
- } catch (err) {
- console.error('Failed to toggle test edge:', err);
- error.textContent = 'Failed to toggle test edge';
- error.style.display = 'block';
- }
-}
-
-async function clearTestMode(deviceId) {
- if (!calibrationTestState[deviceId] || calibrationTestState[deviceId].size === 0) {
- return;
- }
-
- calibrationTestState[deviceId] = new Set();
-
- try {
- await fetch(`${API_BASE}/devices/${deviceId}/calibration/test`, {
- method: 'PUT',
- headers: getHeaders(),
- body: JSON.stringify({ edges: {} })
- });
- } catch (err) {
- console.error('Failed to clear test mode:', err);
- }
-}
-
-async function saveCalibration() {
- const deviceId = document.getElementById('calibration-device-id').value;
- const deviceLedCount = parseInt(document.getElementById('cal-device-led-count-inline').textContent);
- const error = document.getElementById('calibration-error');
-
- // Clear test mode before saving
- await clearTestMode(deviceId);
- updateCalibrationPreview();
-
- const topLeds = parseInt(document.getElementById('cal-top-leds').value || 0);
- const rightLeds = parseInt(document.getElementById('cal-right-leds').value || 0);
- const bottomLeds = parseInt(document.getElementById('cal-bottom-leds').value || 0);
- const leftLeds = parseInt(document.getElementById('cal-left-leds').value || 0);
- const total = topLeds + rightLeds + bottomLeds + leftLeds;
-
- // Validation
- if (total !== deviceLedCount) {
- error.textContent = `Total LEDs (${total}) must equal device LED count (${deviceLedCount})`;
- error.style.display = 'block';
- return;
- }
-
- // Build calibration config
- const startPosition = document.getElementById('cal-start-position').value;
- const layout = document.getElementById('cal-layout').value;
- const offset = parseInt(document.getElementById('cal-offset').value || 0);
-
- const spans = window.edgeSpans || {};
- const calibration = {
- layout: layout,
- start_position: startPosition,
- offset: offset,
- leds_top: topLeds,
- leds_right: rightLeds,
- leds_bottom: bottomLeds,
- leds_left: leftLeds,
- span_top_start: spans.top?.start ?? 0,
- span_top_end: spans.top?.end ?? 1,
- span_right_start: spans.right?.start ?? 0,
- span_right_end: spans.right?.end ?? 1,
- span_bottom_start: spans.bottom?.start ?? 0,
- span_bottom_end: spans.bottom?.end ?? 1,
- span_left_start: spans.left?.start ?? 0,
- span_left_end: spans.left?.end ?? 1,
- skip_leds_start: parseInt(document.getElementById('cal-skip-start').value || 0),
- skip_leds_end: parseInt(document.getElementById('cal-skip-end').value || 0),
- border_width: parseInt(document.getElementById('cal-border-width').value) || 10,
- };
-
- try {
- const response = await fetch(`${API_BASE}/devices/${deviceId}/calibration`, {
- method: 'PUT',
- headers: getHeaders(),
- body: JSON.stringify(calibration)
- });
-
- if (response.status === 401) {
- handle401Error();
- return;
- }
-
- if (response.ok) {
- showToast('Calibration saved', 'success');
- forceCloseCalibrationModal();
- loadDevices();
- } else {
- const errorData = await response.json();
- error.textContent = `Failed to save: ${errorData.detail}`;
- error.style.display = 'block';
- }
- } catch (err) {
- console.error('Failed to save calibration:', err);
- error.textContent = 'Failed to save calibration';
- error.style.display = 'block';
- }
-}
-
-function getEdgeOrder(startPosition, layout) {
- const orders = {
- 'bottom_left_clockwise': ['left', 'top', 'right', 'bottom'],
- 'bottom_left_counterclockwise': ['bottom', 'right', 'top', 'left'],
- 'bottom_right_clockwise': ['bottom', 'left', 'top', 'right'],
- 'bottom_right_counterclockwise': ['right', 'top', 'left', 'bottom'],
- 'top_left_clockwise': ['top', 'right', 'bottom', 'left'],
- 'top_left_counterclockwise': ['left', 'bottom', 'right', 'top'],
- 'top_right_clockwise': ['right', 'bottom', 'left', 'top'],
- 'top_right_counterclockwise': ['top', 'left', 'bottom', 'right']
- };
-
- return orders[`${startPosition}_${layout}`] || ['left', 'top', 'right', 'bottom'];
-}
-
-function shouldReverse(edge, startPosition, layout) {
- // Determine if this edge should be reversed based on LED strip direction
- const reverseRules = {
- 'bottom_left_clockwise': { left: true, top: false, right: false, bottom: true },
- 'bottom_left_counterclockwise': { bottom: false, right: true, top: true, left: false },
- 'bottom_right_clockwise': { bottom: true, left: true, top: false, right: false },
- 'bottom_right_counterclockwise': { right: true, top: true, left: false, bottom: false },
- 'top_left_clockwise': { top: false, right: false, bottom: true, left: true },
- 'top_left_counterclockwise': { left: false, bottom: false, right: true, top: true },
- 'top_right_clockwise': { right: false, bottom: true, left: true, top: false },
- 'top_right_counterclockwise': { top: true, left: false, bottom: false, right: true }
- };
-
- const rules = reverseRules[`${startPosition}_${layout}`];
- return rules ? rules[edge] : false;
-}
-
-function buildSegments(calibration) {
- const edgeOrder = getEdgeOrder(calibration.start_position, calibration.layout);
- const edgeCounts = {
- top: calibration.leds_top || 0,
- right: calibration.leds_right || 0,
- bottom: calibration.leds_bottom || 0,
- left: calibration.leds_left || 0
- };
-
- const segments = [];
- let ledStart = calibration.offset || 0;
-
- edgeOrder.forEach(edge => {
- const count = edgeCounts[edge];
- if (count > 0) {
- segments.push({
- edge: edge,
- led_start: ledStart,
- led_count: count,
- reverse: shouldReverse(edge, calibration.start_position, calibration.layout)
- });
- ledStart += count;
- }
- });
-
- return segments;
-}
-
-// Close modals on backdrop click (only if mousedown also started on backdrop)
-let backdropMouseDownTarget = null;
-document.addEventListener('mousedown', (e) => {
- backdropMouseDownTarget = e.target;
-});
-document.addEventListener('click', (e) => {
- if (!e.target.classList.contains('modal')) return;
- if (backdropMouseDownTarget !== e.target) return;
- if (activeTutorial) return;
-
- const modalId = e.target.id;
-
- // Confirm modal: backdrop click acts as Cancel
- if (modalId === 'confirm-modal') {
- closeConfirmModal(false);
- return;
- }
-
- // Login modal: close only if cancel button is visible (not required login)
- if (modalId === 'api-key-modal') {
- const cancelBtn = document.getElementById('modal-cancel-btn');
- if (cancelBtn && cancelBtn.style.display !== 'none') {
- closeApiKeyModal();
- }
- return;
- }
-
- // General settings modal: dirty check
- if (modalId === 'device-settings-modal') {
- closeDeviceSettingsModal();
- return;
- }
-
- // Capture settings modal: dirty check
- if (modalId === 'capture-settings-modal') {
- closeCaptureSettingsModal();
- return;
- }
-
- // Calibration modal: dirty check
- if (modalId === 'calibration-modal') {
- closeCalibrationModal();
- return;
- }
-
- // Add device modal: close on backdrop
- if (modalId === 'add-device-modal') {
- closeAddDeviceModal();
- return;
- }
-
- // Profile editor modal: close on backdrop
- if (modalId === 'profile-editor-modal') {
- closeProfileEditorModal();
- return;
- }
-});
-
-// Cleanup on page unload
-window.addEventListener('beforeunload', () => {
- if (refreshInterval) {
- clearInterval(refreshInterval);
- }
-});
-
-// =============================================================================
-// Tutorial System (generic engine)
-// =============================================================================
-
-let activeTutorial = null;
-// Shape: { steps, overlay, mode, step, resolveTarget, container }
-// mode: 'absolute' (within a container) or 'fixed' (viewport-level)
-
-const calibrationTutorialSteps = [
- { selector: '#cal-top-leds', textKey: 'calibration.tip.led_count', position: 'bottom' },
- { selector: '.corner-bottom-left', textKey: 'calibration.tip.start_corner', position: 'right' },
- { selector: '.direction-toggle', textKey: 'calibration.tip.direction', position: 'bottom' },
- { selector: '.edge-span-bar[data-edge="top"]', textKey: 'calibration.tip.span', position: 'bottom' },
- { selector: '.toggle-top', textKey: 'calibration.tip.test', position: 'top' },
- { selector: '.preview-screen-total', textKey: 'calibration.tip.toggle_inputs', position: 'top' },
- { selector: '.preview-screen-border-width', textKey: 'calibration.tip.border_width', position: 'bottom' },
- { selector: '#cal-offset', textKey: 'calibration.tip.offset', position: 'top' },
- { selector: '#cal-skip-start', textKey: 'calibration.tip.skip_leds_start', position: 'top' },
- { selector: '#cal-skip-end', textKey: 'calibration.tip.skip_leds_end', position: 'top' }
-];
-
-const deviceTutorialSteps = [
- { selector: '.card-subtitle', textKey: 'device.tip.metadata', position: 'bottom' },
- { selector: '.brightness-control', textKey: 'device.tip.brightness', position: 'bottom' },
- { selector: '.card-actions .btn:nth-child(1)', textKey: 'device.tip.start', position: 'top' },
- { selector: '.card-actions .btn:nth-child(2)', textKey: 'device.tip.settings', position: 'top' },
- { selector: '.card-actions .btn:nth-child(3)', textKey: 'device.tip.capture_settings', position: 'top' },
- { selector: '.card-actions .btn:nth-child(4)', textKey: 'device.tip.calibrate', position: 'top' },
- { selector: '.card-actions .btn:nth-child(5)', textKey: 'device.tip.webui', position: 'top' }
-];
-
-function startTutorial(config) {
- closeTutorial();
- const overlay = document.getElementById(config.overlayId);
- if (!overlay) return;
-
- activeTutorial = {
- steps: config.steps,
- overlay: overlay,
- mode: config.mode,
- step: 0,
- resolveTarget: config.resolveTarget,
- container: config.container
- };
-
- overlay.classList.add('active');
- document.addEventListener('keydown', handleTutorialKey);
- showTutorialStep(0);
-}
-
-function startCalibrationTutorial() {
- const container = document.querySelector('#calibration-modal .modal-body');
- if (!container) return;
- startTutorial({
- steps: calibrationTutorialSteps,
- overlayId: 'tutorial-overlay',
- mode: 'absolute',
- container: container,
- resolveTarget: (step) => document.querySelector(step.selector)
- });
-}
-
-function startDeviceTutorial(deviceId) {
- // Resolve the device ID to target (don't capture card reference — it goes stale when loadDevices rebuilds DOM)
- const selector = deviceId
- ? `.card[data-device-id="${deviceId}"]`
- : '.card[data-device-id]';
- if (!document.querySelector(selector)) return;
- startTutorial({
- steps: deviceTutorialSteps,
- overlayId: 'device-tutorial-overlay',
- mode: 'fixed',
- container: null,
- resolveTarget: (step) => {
- const card = document.querySelector(selector);
- if (!card) return null;
- return step.global
- ? document.querySelector(step.selector)
- : card.querySelector(step.selector);
- }
- });
-}
-
-function closeTutorial() {
- if (!activeTutorial) return;
- activeTutorial.overlay.classList.remove('active');
- document.querySelectorAll('.tutorial-target').forEach(el => {
- el.classList.remove('tutorial-target');
- el.style.zIndex = '';
- });
- document.removeEventListener('keydown', handleTutorialKey);
- activeTutorial = null;
-}
-
-function tutorialNext() {
- if (!activeTutorial) return;
- if (activeTutorial.step < activeTutorial.steps.length - 1) {
- showTutorialStep(activeTutorial.step + 1);
- } else {
- closeTutorial();
- }
-}
-
-function tutorialPrev() {
- if (!activeTutorial) return;
- if (activeTutorial.step > 0) {
- showTutorialStep(activeTutorial.step - 1);
- }
-}
-
-function showTutorialStep(index) {
- if (!activeTutorial) return;
- activeTutorial.step = index;
- const step = activeTutorial.steps[index];
- const overlay = activeTutorial.overlay;
- const isFixed = activeTutorial.mode === 'fixed';
-
- // Remove previous target highlight
- document.querySelectorAll('.tutorial-target').forEach(el => {
- el.classList.remove('tutorial-target');
- el.style.zIndex = '';
- });
-
- // Find and highlight target
- const target = activeTutorial.resolveTarget(step);
- if (!target) return;
- target.classList.add('tutorial-target');
- // For fixed overlays, target must be above the z-index:10000 overlay
- if (isFixed) target.style.zIndex = '10001';
-
- const targetRect = target.getBoundingClientRect();
- const pad = 6;
- let x, y, w, h;
-
- if (isFixed) {
- // Fixed mode: coordinates are viewport-relative
- x = targetRect.left - pad;
- y = targetRect.top - pad;
- w = targetRect.width + pad * 2;
- h = targetRect.height + pad * 2;
- } else {
- // Absolute mode: coordinates relative to container
- const containerRect = activeTutorial.container.getBoundingClientRect();
- x = targetRect.left - containerRect.left - pad;
- y = targetRect.top - containerRect.top - pad;
- w = targetRect.width + pad * 2;
- h = targetRect.height + pad * 2;
- }
-
- // Update backdrop clip-path (polygon with rectangular cutout)
- const backdrop = overlay.querySelector('.tutorial-backdrop');
- if (backdrop) {
- backdrop.style.clipPath = `polygon(
- 0% 0%, 0% 100%,
- ${x}px 100%, ${x}px ${y}px,
- ${x + w}px ${y}px, ${x + w}px ${y + h}px,
- ${x}px ${y + h}px, ${x}px 100%,
- 100% 100%, 100% 0%)`;
- }
-
- // Position ring around target
- const ring = overlay.querySelector('.tutorial-ring');
- if (ring) {
- ring.style.left = x + 'px';
- ring.style.top = y + 'px';
- ring.style.width = w + 'px';
- ring.style.height = h + 'px';
- }
-
- // Update tooltip content
- const tooltip = overlay.querySelector('.tutorial-tooltip');
- const textEl = overlay.querySelector('.tutorial-tooltip-text');
- const counterEl = overlay.querySelector('.tutorial-step-counter');
- if (textEl) textEl.textContent = t(step.textKey);
- if (counterEl) counterEl.textContent = `${index + 1} / ${activeTutorial.steps.length}`;
-
- // Enable/disable nav buttons
- const prevBtn = overlay.querySelector('.tutorial-prev-btn');
- const nextBtn = overlay.querySelector('.tutorial-next-btn');
- if (prevBtn) prevBtn.disabled = (index === 0);
- if (nextBtn) nextBtn.textContent = (index === activeTutorial.steps.length - 1) ? '\u2713' : '\u2192';
-
- // Position tooltip
- if (tooltip) {
- positionTutorialTooltip(tooltip, x, y, w, h, step.position, isFixed);
- }
-}
-
-function positionTutorialTooltip(tooltip, sx, sy, sw, sh, preferred, isFixed) {
- const gap = 12;
- const tooltipW = 260;
- // Place offscreen to measure real height without visual flash
- tooltip.setAttribute('style', 'left:-9999px;top:-9999px');
- const tooltipH = tooltip.offsetHeight || 150;
-
- const positions = {
- top: { x: sx + sw / 2 - tooltipW / 2, y: sy - tooltipH - gap },
- bottom: { x: sx + sw / 2 - tooltipW / 2, y: sy + sh + gap },
- left: { x: sx - tooltipW - gap, y: sy + sh / 2 - tooltipH / 2 },
- right: { x: sx + sw + gap, y: sy + sh / 2 - tooltipH / 2 }
- };
-
- let pos = positions[preferred] || positions.bottom;
-
- const cW = isFixed ? window.innerWidth : activeTutorial.container.clientWidth;
- const cH = isFixed ? window.innerHeight : activeTutorial.container.clientHeight;
-
- // If preferred position overflows, try opposite
- if (pos.y + tooltipH > cH || pos.y < 0 || pos.x + tooltipW > cW || pos.x < 0) {
- const opposite = { top: 'bottom', bottom: 'top', left: 'right', right: 'left' };
- const alt = positions[opposite[preferred]];
- if (alt && alt.y >= 0 && alt.y + tooltipH <= cH && alt.x >= 0 && alt.x + tooltipW <= cW) {
- pos = alt;
- }
- }
-
- pos.x = Math.max(8, Math.min(cW - tooltipW - 8, pos.x));
- pos.y = Math.max(8, Math.min(cH - tooltipH - 8, pos.y));
-
- // Force-set all positioning via setAttribute to avoid any style-setting quirks
- tooltip.setAttribute('style', `left:${Math.round(pos.x)}px;top:${Math.round(pos.y)}px`);
-}
-
-function handleTutorialKey(e) {
- if (!activeTutorial) return;
- if (e.key === 'Escape') { closeTutorial(); e.stopPropagation(); }
- else if (e.key === 'ArrowRight' || e.key === 'ArrowDown') { e.preventDefault(); tutorialNext(); }
- else if (e.key === 'ArrowLeft' || e.key === 'ArrowUp') { e.preventDefault(); tutorialPrev(); }
-}
-
-
-// ===========================
-// Capture Templates Functions
-// ===========================
-
-let availableEngines = [];
-let currentEditingTemplateId = null;
-
-// Load and render capture templates
-async function loadCaptureTemplates() {
- try {
- const response = await fetchWithAuth('/capture-templates');
- if (!response.ok) {
- throw new Error(`Failed to load templates: ${response.status}`);
- }
- const data = await response.json();
- _cachedCaptureTemplates = data.templates || [];
- // Re-render the streams tab which now contains template sections
- renderPictureSourcesList(_cachedStreams);
- } catch (error) {
- console.error('Error loading capture templates:', error);
- }
-}
-
-// Get engine icon
-function getEngineIcon(engineType) {
- return '🚀';
-}
-
-// Show add template modal
-let _templateNameManuallyEdited = false;
-
-async function showAddTemplateModal() {
- currentEditingTemplateId = null;
- document.getElementById('template-modal-title').textContent = t('templates.add');
- document.getElementById('template-form').reset();
- document.getElementById('template-id').value = '';
- document.getElementById('engine-config-section').style.display = 'none';
- document.getElementById('template-error').style.display = 'none';
-
- // Auto-name: reset flag and wire listener
- _templateNameManuallyEdited = false;
- document.getElementById('template-name').oninput = () => { _templateNameManuallyEdited = true; };
-
- // Load available engines
- await loadAvailableEngines();
-
- // Show modal
- const modal = document.getElementById('template-modal');
- modal.style.display = 'flex';
- setupBackdropClose(modal, closeTemplateModal);
-}
-
-// Edit template
-async function editTemplate(templateId) {
- try {
- const response = await fetchWithAuth(`/capture-templates/${templateId}`);
- if (!response.ok) {
- throw new Error(`Failed to load template: ${response.status}`);
- }
-
- const template = await response.json();
-
- currentEditingTemplateId = templateId;
- document.getElementById('template-modal-title').textContent = t('templates.edit');
- document.getElementById('template-id').value = templateId;
- document.getElementById('template-name').value = template.name;
- document.getElementById('template-description').value = template.description || '';
-
- // Load available engines
- await loadAvailableEngines();
-
- // Set engine and load config
- document.getElementById('template-engine').value = template.engine_type;
- await onEngineChange();
-
- // Populate engine config fields
- populateEngineConfig(template.engine_config);
-
- // Load displays for test
- await loadDisplaysForTest();
-
- const testResults = document.getElementById('template-test-results');
- if (testResults) testResults.style.display = 'none';
- document.getElementById('template-error').style.display = 'none';
-
- // Show modal
- const modal = document.getElementById('template-modal');
- modal.style.display = 'flex';
- setupBackdropClose(modal, closeTemplateModal);
- } catch (error) {
- console.error('Error loading template:', error);
- showToast(t('templates.error.load') + ': ' + error.message, 'error');
- }
-}
-
-// Close template modal
-function closeTemplateModal() {
- document.getElementById('template-modal').style.display = 'none';
- currentEditingTemplateId = null;
-}
-
-// Show full-page overlay spinner with progress
-function showOverlaySpinner(text, duration = 0) {
- // Remove existing overlay if any
- const existing = document.getElementById('overlay-spinner');
- if (existing) {
- // Clear any existing timer
- if (window.overlaySpinnerTimer) {
- clearInterval(window.overlaySpinnerTimer);
- window.overlaySpinnerTimer = null;
- }
- existing.remove();
- }
-
- // Create overlay
- const overlay = document.createElement('div');
- overlay.id = 'overlay-spinner';
- overlay.className = 'overlay-spinner';
-
- // Create progress container
- const progressContainer = document.createElement('div');
- progressContainer.className = 'progress-container';
-
- // Create SVG progress ring
- const radius = 56;
- const circumference = 2 * Math.PI * radius;
-
- const svg = document.createElementNS('http://www.w3.org/2000/svg', 'svg');
- svg.setAttribute('width', '120');
- svg.setAttribute('height', '120');
- svg.setAttribute('class', 'progress-ring');
-
- // Background circle
- const bgCircle = document.createElementNS('http://www.w3.org/2000/svg', 'circle');
- bgCircle.setAttribute('class', 'progress-ring-bg');
- bgCircle.setAttribute('cx', '60');
- bgCircle.setAttribute('cy', '60');
- bgCircle.setAttribute('r', radius);
-
- // Progress circle
- const progressCircle = document.createElementNS('http://www.w3.org/2000/svg', 'circle');
- progressCircle.setAttribute('class', 'progress-ring-circle');
- progressCircle.setAttribute('cx', '60');
- progressCircle.setAttribute('cy', '60');
- progressCircle.setAttribute('r', radius);
- progressCircle.style.strokeDasharray = circumference;
- progressCircle.style.strokeDashoffset = circumference;
-
- svg.appendChild(bgCircle);
- svg.appendChild(progressCircle);
-
- // Create progress content (percentage display)
- const progressContent = document.createElement('div');
- progressContent.className = 'progress-content';
-
- const progressPercentage = document.createElement('div');
- progressPercentage.className = 'progress-percentage';
- progressPercentage.textContent = '0%';
-
- progressContent.appendChild(progressPercentage);
-
- progressContainer.appendChild(svg);
- progressContainer.appendChild(progressContent);
-
- // Create text
- const spinnerText = document.createElement('div');
- spinnerText.className = 'spinner-text';
- spinnerText.textContent = text;
-
- overlay.appendChild(progressContainer);
- overlay.appendChild(spinnerText);
- document.body.appendChild(overlay);
-
- // Animate progress if duration is provided
- if (duration > 0) {
- const startTime = Date.now();
-
- window.overlaySpinnerTimer = setInterval(() => {
- const elapsed = (Date.now() - startTime) / 1000;
- const progress = Math.min(elapsed / duration, 1);
- const percentage = Math.round(progress * 100);
-
- // Update progress ring
- const offset = circumference - (progress * circumference);
- progressCircle.style.strokeDashoffset = offset;
-
- // Update percentage display
- progressPercentage.textContent = `${percentage}%`;
-
- // Stop timer if complete
- if (progress >= 1) {
- clearInterval(window.overlaySpinnerTimer);
- window.overlaySpinnerTimer = null;
- }
- }, 100);
- }
-}
-
-// Hide full-page overlay spinner
-function hideOverlaySpinner() {
- // Clear timer if exists
- if (window.overlaySpinnerTimer) {
- clearInterval(window.overlaySpinnerTimer);
- window.overlaySpinnerTimer = null;
- }
-
- const overlay = document.getElementById('overlay-spinner');
- if (overlay) overlay.remove();
-}
-
-// Update capture duration and save to localStorage
-function updateCaptureDuration(value) {
- document.getElementById('test-template-duration-value').textContent = value;
- localStorage.setItem('capture_duration', value);
-}
-
-// Restore capture duration from localStorage
-function restoreCaptureDuration() {
- const savedDuration = localStorage.getItem('capture_duration');
- if (savedDuration) {
- const durationInput = document.getElementById('test-template-duration');
- const durationValue = document.getElementById('test-template-duration-value');
- durationInput.value = savedDuration;
- durationValue.textContent = savedDuration;
- }
-}
-
-// Show test template modal
-async function showTestTemplateModal(templateId) {
- const templates = await fetchWithAuth('/capture-templates').then(r => r.json());
- const template = templates.templates.find(t => t.id === templateId);
-
- if (!template) {
- showToast(t('templates.error.load'), 'error');
- return;
- }
-
- // Store current template for testing
- window.currentTestingTemplate = template;
-
- // Load displays
- await loadDisplaysForTest();
-
- // Restore last used capture duration
- restoreCaptureDuration();
-
- // Show modal
- const modal = document.getElementById('test-template-modal');
- modal.style.display = 'flex';
-
- setupBackdropClose(modal, closeTestTemplateModal);
-}
-
-// Close test template modal
-function closeTestTemplateModal() {
- document.getElementById('test-template-modal').style.display = 'none';
- window.currentTestingTemplate = null;
-}
-
-// Load available engines
-async function loadAvailableEngines() {
- try {
- const response = await fetchWithAuth('/capture-engines');
- if (!response.ok) {
- throw new Error(`Failed to load engines: ${response.status}`);
- }
-
- const data = await response.json();
- availableEngines = data.engines || [];
-
- const select = document.getElementById('template-engine');
- select.innerHTML = '';
-
- availableEngines.forEach(engine => {
- const option = document.createElement('option');
- option.value = engine.type;
- option.textContent = `${getEngineIcon(engine.type)} ${engine.name}`;
- if (!engine.available) {
- option.disabled = true;
- option.textContent += ` (${t('templates.engine.unavailable')})`;
- }
- select.appendChild(option);
- });
-
- // Auto-select first available engine if nothing selected
- if (!select.value) {
- const firstAvailable = availableEngines.find(e => e.available);
- if (firstAvailable) select.value = firstAvailable.type;
- }
- } catch (error) {
- console.error('Error loading engines:', error);
- showToast(t('templates.error.engines') + ': ' + error.message, 'error');
- }
-}
-
-// Handle engine selection change
-async function onEngineChange() {
- const engineType = document.getElementById('template-engine').value;
- const configSection = document.getElementById('engine-config-section');
- const configFields = document.getElementById('engine-config-fields');
-
- if (!engineType) {
- configSection.style.display = 'none';
- return;
- }
-
- const engine = availableEngines.find(e => e.type === engineType);
- if (!engine) {
- configSection.style.display = 'none';
- return;
- }
-
- // Auto-name: set template name to engine name if user hasn't edited
- if (!_templateNameManuallyEdited && !document.getElementById('template-id').value) {
- document.getElementById('template-name').value = engine.name || engineType;
- }
-
- // Show availability hint
- const hint = document.getElementById('engine-availability-hint');
- if (!engine.available) {
- hint.textContent = t('templates.engine.unavailable.hint');
- hint.style.display = 'block';
- hint.style.color = 'var(--error-color)';
- } else {
- hint.style.display = 'none';
- }
-
- // Render config fields based on default_config
- configFields.innerHTML = '';
- const defaultConfig = engine.default_config || {};
-
- if (Object.keys(defaultConfig).length === 0) {
- configSection.style.display = 'none';
- return;
- } else {
- let gridHtml = '';
- Object.entries(defaultConfig).forEach(([key, value]) => {
- const fieldType = typeof value === 'number' ? 'number' : 'text';
- const fieldValue = typeof value === 'boolean' ? (value ? 'true' : 'false') : value;
-
- gridHtml += `
-
-
- ${typeof value === 'boolean' ? `
-
- ` : `
-
- `}
-
- `;
- });
- gridHtml += '
';
- configFields.innerHTML = gridHtml;
- }
-
- configSection.style.display = 'block';
-}
-
-// Populate engine config fields with values
-function populateEngineConfig(config) {
- Object.entries(config).forEach(([key, value]) => {
- const field = document.getElementById(`config-${key}`);
- if (field) {
- if (field.tagName === 'SELECT') {
- field.value = value.toString();
- } else {
- field.value = value;
- }
- }
- });
-}
-
-// Collect engine config from form
-function collectEngineConfig() {
- const config = {};
- const fields = document.querySelectorAll('[data-config-key]');
-
- fields.forEach(field => {
- const key = field.dataset.configKey;
- let value = field.value;
-
- // Type conversion
- if (field.type === 'number') {
- value = parseFloat(value);
- } else if (field.tagName === 'SELECT' && (value === 'true' || value === 'false')) {
- value = value === 'true';
- }
-
- config[key] = value;
- });
-
- return config;
-}
-
-// Load displays for test selector
-async function loadDisplaysForTest() {
- try {
- if (!_cachedDisplays) {
- const response = await fetchWithAuth('/config/displays');
- if (!response.ok) throw new Error(`Failed to load displays: ${response.status}`);
- const displaysData = await response.json();
- _cachedDisplays = displaysData.displays || [];
- }
-
- // Auto-select: last used display, or primary as fallback
- let selectedIndex = null;
- const lastDisplay = localStorage.getItem('lastTestDisplayIndex');
-
- if (lastDisplay !== null) {
- const found = _cachedDisplays.find(d => d.index === parseInt(lastDisplay));
- if (found) selectedIndex = found.index;
- }
-
- if (selectedIndex === null) {
- const primary = _cachedDisplays.find(d => d.is_primary);
- if (primary) selectedIndex = primary.index;
- else if (_cachedDisplays.length > 0) selectedIndex = _cachedDisplays[0].index;
- }
-
- if (selectedIndex !== null) {
- const display = _cachedDisplays.find(d => d.index === selectedIndex);
- onTestDisplaySelected(selectedIndex, display);
- }
- } catch (error) {
- console.error('Error loading displays:', error);
- }
-}
-
-// Run template test
-async function runTemplateTest() {
- if (!window.currentTestingTemplate) {
- showToast(t('templates.test.error.no_engine'), 'error');
- return;
- }
-
- const displayIndex = document.getElementById('test-template-display').value;
- const captureDuration = parseFloat(document.getElementById('test-template-duration').value);
-
- if (displayIndex === '') {
- showToast(t('templates.test.error.no_display'), 'error');
- return;
- }
-
- const template = window.currentTestingTemplate;
-
- // Show full-page overlay spinner with progress
- showOverlaySpinner(t('templates.test.running'), captureDuration);
-
- try {
- const response = await fetchWithAuth('/capture-templates/test', {
- method: 'POST',
- body: JSON.stringify({
- engine_type: template.engine_type,
- engine_config: template.engine_config,
- display_index: parseInt(displayIndex),
- capture_duration: captureDuration
- })
- });
-
- if (!response.ok) {
- const error = await response.json();
- throw new Error(error.detail || error.message || 'Test failed');
- }
-
- const result = await response.json();
- localStorage.setItem('lastTestDisplayIndex', displayIndex);
- displayTestResults(result);
- } catch (error) {
- console.error('Error running test:', error);
- // Hide overlay spinner
- hideOverlaySpinner();
- // Show short error in snack, details are in console
- showToast(t('templates.test.error.failed'), 'error');
- }
-}
-
-function buildTestStatsHtml(result) {
- const p = result.performance;
- const res = `${result.full_capture.width}x${result.full_capture.height}`;
- let html = `
- ${t('templates.test.results.duration')}: ${p.capture_duration_s.toFixed(2)}s
- ${t('templates.test.results.frame_count')}: ${p.frame_count}
`;
- if (p.frame_count > 1) {
- html += `
- ${t('templates.test.results.actual_fps')}: ${p.actual_fps.toFixed(1)}
- ${t('templates.test.results.avg_capture_time')}: ${p.avg_capture_time_ms.toFixed(1)}ms
`;
- }
- html += `
- Resolution: ${res}
`;
- return html;
-}
-
-// Display test results — opens lightbox with stats overlay
-function displayTestResults(result) {
- hideOverlaySpinner();
- const fullImageSrc = result.full_capture.full_image || result.full_capture.image;
- openLightbox(fullImageSrc, buildTestStatsHtml(result));
-}
-
-// Save template
-async function saveTemplate() {
- const templateId = document.getElementById('template-id').value;
- const name = document.getElementById('template-name').value.trim();
- const engineType = document.getElementById('template-engine').value;
-
- if (!name || !engineType) {
- showToast(t('templates.error.required'), 'error');
- return;
- }
-
- const description = document.getElementById('template-description').value.trim();
- const engineConfig = collectEngineConfig();
-
- const payload = {
- name,
- engine_type: engineType,
- engine_config: engineConfig,
- description: description || null
- };
-
- try {
- let response;
- if (templateId) {
- // Update existing template
- response = await fetchWithAuth(`/capture-templates/${templateId}`, {
- method: 'PUT',
- body: JSON.stringify(payload)
- });
- } else {
- // Create new template
- response = await fetchWithAuth('/capture-templates', {
- method: 'POST',
- body: JSON.stringify(payload)
- });
- }
-
- if (!response.ok) {
- const error = await response.json();
- throw new Error(error.detail || error.message || 'Failed to save template');
- }
-
- showToast(templateId ? t('templates.updated') : t('templates.created'), 'success');
- closeTemplateModal();
- await loadCaptureTemplates();
- } catch (error) {
- console.error('Error saving template:', error);
- document.getElementById('template-error').textContent = error.message;
- document.getElementById('template-error').style.display = 'block';
- }
-}
-
-// Delete template
-async function deleteTemplate(templateId) {
- const confirmed = await showConfirm(t('templates.delete.confirm'));
- if (!confirmed) return;
-
- try {
- const response = await fetchWithAuth(`/capture-templates/${templateId}`, {
- method: 'DELETE'
- });
-
- if (!response.ok) {
- const error = await response.json();
- throw new Error(error.detail || error.message || 'Failed to delete template');
- }
-
- showToast(t('templates.deleted'), 'success');
- await loadCaptureTemplates();
- } catch (error) {
- console.error('Error deleting template:', error);
- showToast(t('templates.error.delete') + ': ' + error.message, 'error');
- }
-}
-
-// ===== Picture Sources =====
-
-let _cachedStreams = [];
-let _cachedPPTemplates = [];
-let _cachedCaptureTemplates = [];
-let _availableFilters = []; // Loaded from GET /filters
-
-async function loadPictureSources() {
- try {
- // Always fetch templates, filters, and streams in parallel
- // since templates are now rendered inside stream sub-tabs
- const [filtersResp, ppResp, captResp, streamsResp] = await Promise.all([
- _availableFilters.length === 0 ? fetchWithAuth('/filters') : Promise.resolve(null),
- fetchWithAuth('/postprocessing-templates'),
- fetchWithAuth('/capture-templates'),
- fetchWithAuth('/picture-sources')
- ]);
-
- if (filtersResp && filtersResp.ok) {
- const fd = await filtersResp.json();
- _availableFilters = fd.filters || [];
- }
- if (ppResp.ok) {
- const pd = await ppResp.json();
- _cachedPPTemplates = pd.templates || [];
- }
- if (captResp.ok) {
- const cd = await captResp.json();
- _cachedCaptureTemplates = cd.templates || [];
- }
- if (!streamsResp.ok) {
- throw new Error(`Failed to load streams: ${streamsResp.status}`);
- }
- const data = await streamsResp.json();
- _cachedStreams = data.streams || [];
- renderPictureSourcesList(_cachedStreams);
- } catch (error) {
- console.error('Error loading picture sources:', error);
- document.getElementById('streams-list').innerHTML = `
- ${t('streams.error.load')}: ${error.message}
- `;
- }
-}
-
-function switchStreamTab(tabKey) {
- document.querySelectorAll('.stream-tab-btn').forEach(btn =>
- btn.classList.toggle('active', btn.dataset.streamTab === tabKey)
- );
- document.querySelectorAll('.stream-tab-panel').forEach(panel =>
- panel.classList.toggle('active', panel.id === `stream-tab-${tabKey}`)
- );
- localStorage.setItem('activeStreamTab', tabKey);
-}
-
-function renderPictureSourcesList(streams) {
- const container = document.getElementById('streams-list');
- const activeTab = localStorage.getItem('activeStreamTab') || 'raw';
-
- const renderStreamCard = (stream) => {
- const typeIcons = { raw: '🖥️', processed: '🎨', static_image: '🖼️' };
- const typeIcon = typeIcons[stream.stream_type] || '📺';
-
- let detailsHtml = '';
- if (stream.stream_type === 'raw') {
- let capTmplName = '';
- if (stream.capture_template_id) {
- const capTmpl = _cachedCaptureTemplates.find(t => t.id === stream.capture_template_id);
- if (capTmpl) capTmplName = escapeHtml(capTmpl.name);
- }
- detailsHtml = `
- 🖥️ ${stream.display_index ?? 0}
- ⚡ ${stream.target_fps ?? 30}
- ${capTmplName ? `📋 ${capTmplName}` : ''}
-
`;
- } else if (stream.stream_type === 'processed') {
- const sourceStream = _cachedStreams.find(s => s.id === stream.source_stream_id);
- const sourceName = sourceStream ? escapeHtml(sourceStream.name) : (stream.source_stream_id || '-');
- let ppTmplName = '';
- if (stream.postprocessing_template_id) {
- const ppTmpl = _cachedPPTemplates.find(p => p.id === stream.postprocessing_template_id);
- if (ppTmpl) ppTmplName = escapeHtml(ppTmpl.name);
- }
- detailsHtml = `
- 📺 ${sourceName}
- ${ppTmplName ? `📋 ${ppTmplName}` : ''}
-
`;
- } else if (stream.stream_type === 'static_image') {
- const src = stream.image_source || '';
- detailsHtml = `
- 🌐 ${escapeHtml(src)}
-
`;
- }
-
- return `
-
-
-
- ${detailsHtml}
- ${stream.description ? `
${escapeHtml(stream.description)}
` : ''}
-
-
-
-
-
- `;
- };
-
- const renderCaptureTemplateCard = (template) => {
- const engineIcon = getEngineIcon(template.engine_type);
- const configEntries = Object.entries(template.engine_config);
- return `
-
-
-
- ${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]) => `
-
- | ${escapeHtml(key)} |
- ${escapeHtml(String(val))} |
-
- `).join('')}
-
-
- ` : ''}
-
-
-
-
-
- `;
- };
-
- 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 `
-
-
-
- ${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 = `
-
-
-
- ${tab.streams.map(renderStreamCard).join('')}
- ${addStreamCard(tab.key)}
-
-
-
-
-
- ${_cachedCaptureTemplates.map(renderCaptureTemplateCard).join('')}
-
-
-
`;
- } else if (tab.key === 'processed') {
- // Processed: streams section + PP templates section
- panelContent = `
-
-
-
- ${tab.streams.map(renderStreamCard).join('')}
- ${addStreamCard(tab.key)}
-
-
-
-
-
- ${_cachedPPTemplates.map(renderPPTemplateCard).join('')}
-
-
-
`;
- } else {
- // Static Image: just the stream cards, no section headers
- panelContent = `
-
- ${tab.streams.map(renderStreamCard).join('')}
- ${addStreamCard(tab.key)}
-
`;
- }
-
- return `${panelContent}
`;
- }).join('');
-
- container.innerHTML = tabBar + panels;
-}
-
-function onStreamTypeChange() {
- const streamType = document.getElementById('stream-type').value;
- document.getElementById('stream-raw-fields').style.display = streamType === 'raw' ? '' : 'none';
- document.getElementById('stream-processed-fields').style.display = streamType === 'processed' ? '' : 'none';
- document.getElementById('stream-static-image-fields').style.display = streamType === 'static_image' ? '' : 'none';
-}
-
-async function showAddStreamModal(presetType) {
- const streamType = presetType || 'raw';
- const titleKeys = { raw: 'streams.add.raw', processed: 'streams.add.processed', static_image: 'streams.add.static_image' };
- document.getElementById('stream-modal-title').textContent = t(titleKeys[streamType] || 'streams.add');
- document.getElementById('stream-form').reset();
- document.getElementById('stream-id').value = '';
- document.getElementById('stream-display-index').value = '';
- document.getElementById('stream-display-picker-label').textContent = t('displays.picker.select');
- document.getElementById('stream-error').style.display = 'none';
- document.getElementById('stream-type').value = streamType;
- // Clear static image preview and wire up auto-validation
- _lastValidatedImageSource = '';
- const imgSrcInput = document.getElementById('stream-image-source');
- imgSrcInput.value = '';
- document.getElementById('stream-image-preview-container').style.display = 'none';
- document.getElementById('stream-image-validation-status').style.display = 'none';
- imgSrcInput.onblur = () => validateStaticImage();
- imgSrcInput.onkeydown = (e) => { if (e.key === 'Enter') { e.preventDefault(); validateStaticImage(); } };
- imgSrcInput.onpaste = () => setTimeout(() => validateStaticImage(), 0);
- onStreamTypeChange();
-
- // Auto-name: reset flag and wire listeners
- _streamNameManuallyEdited = false;
- document.getElementById('stream-name').oninput = () => { _streamNameManuallyEdited = true; };
- document.getElementById('stream-capture-template').onchange = () => _autoGenerateStreamName();
- document.getElementById('stream-source').onchange = () => _autoGenerateStreamName();
- document.getElementById('stream-pp-template').onchange = () => _autoGenerateStreamName();
-
- // Populate dropdowns
- await populateStreamModalDropdowns();
-
- const modal = document.getElementById('stream-modal');
- modal.style.display = 'flex';
- lockBody();
- setupBackdropClose(modal, closeStreamModal);
-}
-
-async function editStream(streamId) {
- try {
- const response = await fetchWithAuth(`/picture-sources/${streamId}`);
- if (!response.ok) throw new Error(`Failed to load stream: ${response.status}`);
- const stream = await response.json();
-
- const editTitleKeys = { raw: 'streams.edit.raw', processed: 'streams.edit.processed', static_image: 'streams.edit.static_image' };
- document.getElementById('stream-modal-title').textContent = t(editTitleKeys[stream.stream_type] || 'streams.edit');
- document.getElementById('stream-id').value = streamId;
- document.getElementById('stream-name').value = stream.name;
- document.getElementById('stream-description').value = stream.description || '';
- document.getElementById('stream-error').style.display = 'none';
-
- // Set type (hidden input)
- document.getElementById('stream-type').value = stream.stream_type;
- // Clear static image preview and wire up auto-validation
- _lastValidatedImageSource = '';
- const imgSrcInput = document.getElementById('stream-image-source');
- document.getElementById('stream-image-preview-container').style.display = 'none';
- document.getElementById('stream-image-validation-status').style.display = 'none';
- imgSrcInput.onblur = () => validateStaticImage();
- imgSrcInput.onkeydown = (e) => { if (e.key === 'Enter') { e.preventDefault(); validateStaticImage(); } };
- imgSrcInput.onpaste = () => setTimeout(() => validateStaticImage(), 0);
- onStreamTypeChange();
-
- // Populate dropdowns before setting values
- await populateStreamModalDropdowns();
-
- if (stream.stream_type === 'raw') {
- const displayIdx = stream.display_index ?? 0;
- const display = _cachedDisplays ? _cachedDisplays.find(d => d.index === displayIdx) : null;
- onStreamDisplaySelected(displayIdx, display);
- document.getElementById('stream-capture-template').value = stream.capture_template_id || '';
- const fps = stream.target_fps ?? 30;
- document.getElementById('stream-target-fps').value = fps;
- document.getElementById('stream-target-fps-value').textContent = fps;
- } else if (stream.stream_type === 'processed') {
- document.getElementById('stream-source').value = stream.source_stream_id || '';
- document.getElementById('stream-pp-template').value = stream.postprocessing_template_id || '';
- } else if (stream.stream_type === 'static_image') {
- document.getElementById('stream-image-source').value = stream.image_source || '';
- // Auto-validate to show preview
- if (stream.image_source) {
- validateStaticImage();
- }
- }
-
- const modal = document.getElementById('stream-modal');
- modal.style.display = 'flex';
- lockBody();
- setupBackdropClose(modal, closeStreamModal);
- } catch (error) {
- console.error('Error loading stream:', error);
- showToast(t('streams.error.load') + ': ' + error.message, 'error');
- }
-}
-
-async function populateStreamModalDropdowns() {
- // Load displays, capture templates, streams, and PP templates in parallel
- const [displaysRes, captureTemplatesRes, streamsRes, ppTemplatesRes] = await Promise.all([
- fetch(`${API_BASE}/config/displays`, { headers: getHeaders() }),
- fetchWithAuth('/capture-templates'),
- fetchWithAuth('/picture-sources'),
- fetchWithAuth('/postprocessing-templates'),
- ]);
-
- // Displays - warm cache for display picker
- if (displaysRes.ok) {
- const displaysData = await displaysRes.json();
- _cachedDisplays = displaysData.displays || [];
- }
-
- // Auto-select primary display if none selected yet
- if (!document.getElementById('stream-display-index').value && _cachedDisplays && _cachedDisplays.length > 0) {
- const primary = _cachedDisplays.find(d => d.is_primary) || _cachedDisplays[0];
- onStreamDisplaySelected(primary.index, primary);
- }
-
- // Capture templates
- const templateSelect = document.getElementById('stream-capture-template');
- templateSelect.innerHTML = '';
- if (captureTemplatesRes.ok) {
- const data = await captureTemplatesRes.json();
- (data.templates || []).forEach(tmpl => {
- const opt = document.createElement('option');
- opt.value = tmpl.id;
- opt.dataset.name = tmpl.name;
- opt.textContent = `${getEngineIcon(tmpl.engine_type)} ${tmpl.name}`;
- templateSelect.appendChild(opt);
- });
- }
-
- // Source streams (all existing streams)
- const sourceSelect = document.getElementById('stream-source');
- sourceSelect.innerHTML = '';
- if (streamsRes.ok) {
- const data = await streamsRes.json();
- const editingId = document.getElementById('stream-id').value;
- (data.streams || []).forEach(s => {
- // Don't show the current stream as a possible source
- if (s.id === editingId) return;
- const opt = document.createElement('option');
- opt.value = s.id;
- opt.dataset.name = s.name;
- const typeLabels = { raw: '🖥️', processed: '🎨', static_image: '🖼️' };
- const typeLabel = typeLabels[s.stream_type] || '📺';
- opt.textContent = `${typeLabel} ${s.name}`;
- sourceSelect.appendChild(opt);
- });
- }
-
- // PP templates
- _streamModalPPTemplates = [];
- const ppSelect = document.getElementById('stream-pp-template');
- ppSelect.innerHTML = '';
- if (ppTemplatesRes.ok) {
- const data = await ppTemplatesRes.json();
- _streamModalPPTemplates = data.templates || [];
- _streamModalPPTemplates.forEach(tmpl => {
- const opt = document.createElement('option');
- opt.value = tmpl.id;
- opt.textContent = tmpl.name;
- ppSelect.appendChild(opt);
- });
- }
-
- // Trigger auto-name after dropdowns are populated
- _autoGenerateStreamName();
-}
-
-async function saveStream() {
- const streamId = document.getElementById('stream-id').value;
- const name = document.getElementById('stream-name').value.trim();
- const streamType = document.getElementById('stream-type').value;
- const description = document.getElementById('stream-description').value.trim();
- const errorEl = document.getElementById('stream-error');
-
- if (!name) {
- showToast(t('streams.error.required'), 'error');
- return;
- }
-
- const payload = { name, description: description || null };
-
- if (!streamId) {
- // Creating - include stream_type
- payload.stream_type = streamType;
- }
-
- if (streamType === 'raw') {
- payload.display_index = parseInt(document.getElementById('stream-display-index').value) || 0;
- payload.capture_template_id = document.getElementById('stream-capture-template').value;
- payload.target_fps = parseInt(document.getElementById('stream-target-fps').value) || 30;
- } else if (streamType === 'processed') {
- payload.source_stream_id = document.getElementById('stream-source').value;
- payload.postprocessing_template_id = document.getElementById('stream-pp-template').value;
- } else if (streamType === 'static_image') {
- const imageSource = document.getElementById('stream-image-source').value.trim();
- if (!imageSource) {
- showToast(t('streams.error.required'), 'error');
- return;
- }
- payload.image_source = imageSource;
- }
-
- try {
- let response;
- if (streamId) {
- response = await fetchWithAuth(`/picture-sources/${streamId}`, {
- method: 'PUT',
- body: JSON.stringify(payload)
- });
- } else {
- response = await fetchWithAuth('/picture-sources', {
- method: 'POST',
- body: JSON.stringify(payload)
- });
- }
-
- if (!response.ok) {
- const error = await response.json();
- throw new Error(error.detail || error.message || 'Failed to save stream');
- }
-
- showToast(streamId ? t('streams.updated') : t('streams.created'), 'success');
- closeStreamModal();
- await loadPictureSources();
- } catch (error) {
- console.error('Error saving stream:', error);
- errorEl.textContent = error.message;
- errorEl.style.display = 'block';
- }
-}
-
-async function deleteStream(streamId) {
- const confirmed = await showConfirm(t('streams.delete.confirm'));
- if (!confirmed) return;
-
- try {
- const response = await fetchWithAuth(`/picture-sources/${streamId}`, {
- method: 'DELETE'
- });
-
- if (!response.ok) {
- const error = await response.json();
- throw new Error(error.detail || error.message || 'Failed to delete stream');
- }
-
- showToast(t('streams.deleted'), 'success');
- await loadPictureSources();
- } catch (error) {
- console.error('Error deleting stream:', error);
- showToast(t('streams.error.delete') + ': ' + error.message, 'error');
- }
-}
-
-function closeStreamModal() {
- document.getElementById('stream-modal').style.display = 'none';
- document.getElementById('stream-type').disabled = false;
- unlockBody();
-}
-
-let _lastValidatedImageSource = '';
-
-async function validateStaticImage() {
- const source = document.getElementById('stream-image-source').value.trim();
- const previewContainer = document.getElementById('stream-image-preview-container');
- const previewImg = document.getElementById('stream-image-preview');
- const infoEl = document.getElementById('stream-image-info');
- const statusEl = document.getElementById('stream-image-validation-status');
-
- if (!source) {
- _lastValidatedImageSource = '';
- previewContainer.style.display = 'none';
- statusEl.style.display = 'none';
- return;
- }
-
- if (source === _lastValidatedImageSource) return;
-
- // Show loading state
- statusEl.textContent = t('streams.validate_image.validating');
- statusEl.className = 'validation-status loading';
- statusEl.style.display = 'block';
- previewContainer.style.display = 'none';
-
- try {
- const response = await fetchWithAuth('/picture-sources/validate-image', {
- method: 'POST',
- body: JSON.stringify({ image_source: source }),
- });
- const data = await response.json();
-
- _lastValidatedImageSource = source;
- if (data.valid) {
- previewImg.src = data.preview;
- previewImg.style.cursor = 'pointer';
- previewImg.onclick = () => openFullImageLightbox(source);
- infoEl.textContent = `${data.width} × ${data.height} px`;
- previewContainer.style.display = '';
- statusEl.textContent = t('streams.validate_image.valid');
- statusEl.className = 'validation-status success';
- } else {
- previewContainer.style.display = 'none';
- statusEl.textContent = `${t('streams.validate_image.invalid')}: ${data.error}`;
- statusEl.className = 'validation-status error';
- }
- } catch (err) {
- previewContainer.style.display = 'none';
- statusEl.textContent = `${t('streams.validate_image.invalid')}: ${err.message}`;
- statusEl.className = 'validation-status error';
- }
-}
-
-// ===== Picture Source Test =====
-
-let _currentTestStreamId = null;
-
-async function showTestStreamModal(streamId) {
- _currentTestStreamId = streamId;
- restoreStreamTestDuration();
-
- const modal = document.getElementById('test-stream-modal');
- modal.style.display = 'flex';
- lockBody();
- setupBackdropClose(modal, closeTestStreamModal);
-}
-
-function closeTestStreamModal() {
- document.getElementById('test-stream-modal').style.display = 'none';
- unlockBody();
- _currentTestStreamId = null;
-}
-
-function updateStreamTestDuration(value) {
- document.getElementById('test-stream-duration-value').textContent = value;
- localStorage.setItem('lastStreamTestDuration', value);
-}
-
-function restoreStreamTestDuration() {
- const saved = localStorage.getItem('lastStreamTestDuration') || '5';
- document.getElementById('test-stream-duration').value = saved;
- document.getElementById('test-stream-duration-value').textContent = saved;
-}
-
-async function runStreamTest() {
- if (!_currentTestStreamId) return;
-
- const captureDuration = parseFloat(document.getElementById('test-stream-duration').value);
-
- showOverlaySpinner(t('streams.test.running'), captureDuration);
-
- try {
- const response = await fetchWithAuth(`/picture-sources/${_currentTestStreamId}/test`, {
- method: 'POST',
- body: JSON.stringify({ capture_duration: captureDuration })
- });
-
- if (!response.ok) {
- const error = await response.json();
- throw new Error(error.detail || error.message || 'Test failed');
- }
-
- const result = await response.json();
- displayStreamTestResults(result);
- } catch (error) {
- console.error('Error running stream test:', error);
- hideOverlaySpinner();
- showToast(t('streams.test.error.failed') + ': ' + error.message, 'error');
- }
-}
-
-function displayStreamTestResults(result) {
- hideOverlaySpinner();
- const fullImageSrc = result.full_capture.full_image || result.full_capture.image;
- openLightbox(fullImageSrc, buildTestStatsHtml(result));
-}
-
-// ===== PP Template Test =====
-
-let _currentTestPPTemplateId = null;
-
-async function showTestPPTemplateModal(templateId) {
- _currentTestPPTemplateId = templateId;
- restorePPTestDuration();
-
- // Populate source stream selector
- const select = document.getElementById('test-pp-source-stream');
- select.innerHTML = '';
- // Ensure streams are cached
- if (_cachedStreams.length === 0) {
- try {
- const resp = await fetchWithAuth('/picture-sources');
- if (resp.ok) { const d = await resp.json(); _cachedStreams = d.streams || []; }
- } catch (e) { console.warn('Could not load streams for PP test:', e); }
- }
- for (const s of _cachedStreams) {
- const opt = document.createElement('option');
- opt.value = s.id;
- opt.textContent = s.name;
- select.appendChild(opt);
- }
- // Auto-select last used stream
- const lastStream = localStorage.getItem('lastPPTestStreamId');
- if (lastStream && _cachedStreams.find(s => s.id === lastStream)) {
- select.value = lastStream;
- }
-
- const modal = document.getElementById('test-pp-template-modal');
- modal.style.display = 'flex';
- lockBody();
- setupBackdropClose(modal, closeTestPPTemplateModal);
-}
-
-function closeTestPPTemplateModal() {
- document.getElementById('test-pp-template-modal').style.display = 'none';
- unlockBody();
- _currentTestPPTemplateId = null;
-}
-
-function updatePPTestDuration(value) {
- document.getElementById('test-pp-duration-value').textContent = value;
- localStorage.setItem('lastPPTestDuration', value);
-}
-
-function restorePPTestDuration() {
- const saved = localStorage.getItem('lastPPTestDuration') || '5';
- document.getElementById('test-pp-duration').value = saved;
- document.getElementById('test-pp-duration-value').textContent = saved;
-}
-
-async function runPPTemplateTest() {
- if (!_currentTestPPTemplateId) return;
-
- const sourceStreamId = document.getElementById('test-pp-source-stream').value;
- if (!sourceStreamId) {
- showToast(t('postprocessing.test.error.no_stream'), 'error');
- return;
- }
- localStorage.setItem('lastPPTestStreamId', sourceStreamId);
-
- const captureDuration = parseFloat(document.getElementById('test-pp-duration').value);
-
- showOverlaySpinner(t('postprocessing.test.running'), captureDuration);
-
- try {
- const response = await fetchWithAuth(`/postprocessing-templates/${_currentTestPPTemplateId}/test`, {
- method: 'POST',
- body: JSON.stringify({ source_stream_id: sourceStreamId, capture_duration: captureDuration })
- });
-
- if (!response.ok) {
- const error = await response.json();
- throw new Error(error.detail || error.message || 'Test failed');
- }
-
- const result = await response.json();
- hideOverlaySpinner();
- const fullImageSrc = result.full_capture.full_image || result.full_capture.image;
- openLightbox(fullImageSrc, buildTestStatsHtml(result));
- } catch (error) {
- console.error('Error running PP template test:', error);
- hideOverlaySpinner();
- showToast(t('postprocessing.test.error.failed') + ': ' + error.message, 'error');
- }
-}
-
-// ===== Processing Templates =====
-
-async function loadAvailableFilters() {
- try {
- const response = await fetchWithAuth('/filters');
- if (!response.ok) throw new Error(`Failed to load filters: ${response.status}`);
- const data = await response.json();
- _availableFilters = data.filters || [];
- } catch (error) {
- console.error('Error loading available filters:', error);
- _availableFilters = [];
- }
-}
-
-async function loadPPTemplates() {
- try {
- // Ensure filters are loaded for rendering
- if (_availableFilters.length === 0) {
- await loadAvailableFilters();
- }
- const response = await fetchWithAuth('/postprocessing-templates');
- if (!response.ok) {
- throw new Error(`Failed to load templates: ${response.status}`);
- }
- const data = await response.json();
- _cachedPPTemplates = data.templates || [];
- // Re-render the streams tab which now contains template sections
- renderPictureSourcesList(_cachedStreams);
- } catch (error) {
- console.error('Error loading PP templates:', error);
- }
-}
-
-function _getFilterName(filterId) {
- const key = 'filters.' + filterId;
- const translated = t(key);
- // Fallback to filter_name from registry if no localization
- if (translated === key) {
- const def = _availableFilters.find(f => f.filter_id === filterId);
- return def ? def.filter_name : filterId;
- }
- return translated;
-}
-
-// --- Filter list management in PP template modal ---
-
-let _modalFilters = []; // Current filter list being edited in modal
-
-function _populateFilterSelect() {
- const select = document.getElementById('pp-add-filter-select');
- // Keep first option (placeholder)
- select.innerHTML = ``;
- for (const f of _availableFilters) {
- const name = _getFilterName(f.filter_id);
- select.innerHTML += ``;
- }
-}
-
-function renderModalFilterList() {
- const container = document.getElementById('pp-filter-list');
- if (_modalFilters.length === 0) {
- container.innerHTML = `${t('filters.empty')}
`;
- return;
- }
-
- let html = '';
- _modalFilters.forEach((fi, index) => {
- const filterDef = _availableFilters.find(f => f.filter_id === fi.filter_id);
- const filterName = _getFilterName(fi.filter_id);
- const isExpanded = fi._expanded === true;
-
- // Build compact summary of current option values for collapsed state
- let summary = '';
- if (filterDef && !isExpanded) {
- summary = filterDef.options_schema.map(opt => {
- const val = fi.options[opt.key] !== undefined ? fi.options[opt.key] : opt.default;
- return val;
- }).join(', ');
- }
-
- html += ``;
- });
-
- container.innerHTML = html;
-}
-
-function addFilterFromSelect() {
- const select = document.getElementById('pp-add-filter-select');
- const filterId = select.value;
- if (!filterId) return;
-
- const filterDef = _availableFilters.find(f => f.filter_id === filterId);
- if (!filterDef) return;
-
- // Build default options
- const options = {};
- for (const opt of filterDef.options_schema) {
- options[opt.key] = opt.default;
- }
-
- _modalFilters.push({ filter_id: filterId, options, _expanded: true });
- select.value = '';
- renderModalFilterList();
- _autoGeneratePPTemplateName();
-}
-
-function toggleFilterExpand(index) {
- if (_modalFilters[index]) {
- _modalFilters[index]._expanded = !_modalFilters[index]._expanded;
- renderModalFilterList();
- }
-}
-
-function removeFilter(index) {
- _modalFilters.splice(index, 1);
- renderModalFilterList();
- _autoGeneratePPTemplateName();
-}
-
-function moveFilter(index, direction) {
- const newIndex = index + direction;
- if (newIndex < 0 || newIndex >= _modalFilters.length) return;
- const tmp = _modalFilters[index];
- _modalFilters[index] = _modalFilters[newIndex];
- _modalFilters[newIndex] = tmp;
- renderModalFilterList();
- _autoGeneratePPTemplateName();
-}
-
-function updateFilterOption(filterIndex, optionKey, value) {
- if (_modalFilters[filterIndex]) {
- // Determine type from schema
- const fi = _modalFilters[filterIndex];
- const filterDef = _availableFilters.find(f => f.filter_id === fi.filter_id);
- if (filterDef) {
- const optDef = filterDef.options_schema.find(o => o.key === optionKey);
- if (optDef && optDef.type === 'bool') {
- fi.options[optionKey] = !!value;
- } else if (optDef && optDef.type === 'int') {
- fi.options[optionKey] = parseInt(value);
- } else {
- fi.options[optionKey] = parseFloat(value);
- }
- } else {
- fi.options[optionKey] = parseFloat(value);
- }
- }
-}
-
-function collectFilters() {
- return _modalFilters.map(fi => ({
- filter_id: fi.filter_id,
- options: { ...fi.options },
- }));
-}
-
-let _ppTemplateNameManuallyEdited = false;
-
-function _autoGeneratePPTemplateName() {
- if (_ppTemplateNameManuallyEdited) return;
- if (document.getElementById('pp-template-id').value) return; // editing, not creating
- const nameInput = document.getElementById('pp-template-name');
- if (_modalFilters.length > 0) {
- const filterNames = _modalFilters.map(f => _getFilterName(f.filter_id)).join(' + ');
- nameInput.value = filterNames;
- } else {
- nameInput.value = '';
- }
-}
-
-async function showAddPPTemplateModal() {
- if (_availableFilters.length === 0) await loadAvailableFilters();
-
- document.getElementById('pp-template-modal-title').textContent = t('postprocessing.add');
- document.getElementById('pp-template-form').reset();
- document.getElementById('pp-template-id').value = '';
- document.getElementById('pp-template-error').style.display = 'none';
-
- _modalFilters = [];
-
- // Auto-name: reset flag and wire listener
- _ppTemplateNameManuallyEdited = false;
- document.getElementById('pp-template-name').oninput = () => { _ppTemplateNameManuallyEdited = true; };
-
- _populateFilterSelect();
- renderModalFilterList();
-
- const modal = document.getElementById('pp-template-modal');
- modal.style.display = 'flex';
- lockBody();
- setupBackdropClose(modal, closePPTemplateModal);
-}
-
-async function editPPTemplate(templateId) {
- try {
- if (_availableFilters.length === 0) await loadAvailableFilters();
-
- const response = await fetchWithAuth(`/postprocessing-templates/${templateId}`);
- if (!response.ok) throw new Error(`Failed to load template: ${response.status}`);
- const tmpl = await response.json();
-
- document.getElementById('pp-template-modal-title').textContent = t('postprocessing.edit');
- document.getElementById('pp-template-id').value = templateId;
- document.getElementById('pp-template-name').value = tmpl.name;
- document.getElementById('pp-template-description').value = tmpl.description || '';
- document.getElementById('pp-template-error').style.display = 'none';
-
- // Load filters from template
- _modalFilters = (tmpl.filters || []).map(fi => ({
- filter_id: fi.filter_id,
- options: { ...fi.options },
- }));
-
- _populateFilterSelect();
- renderModalFilterList();
-
- const modal = document.getElementById('pp-template-modal');
- modal.style.display = 'flex';
- lockBody();
- setupBackdropClose(modal, closePPTemplateModal);
- } catch (error) {
- console.error('Error loading PP template:', error);
- showToast(t('postprocessing.error.load') + ': ' + error.message, 'error');
- }
-}
-
-async function savePPTemplate() {
- const templateId = document.getElementById('pp-template-id').value;
- const name = document.getElementById('pp-template-name').value.trim();
- const description = document.getElementById('pp-template-description').value.trim();
- const errorEl = document.getElementById('pp-template-error');
-
- if (!name) {
- showToast(t('postprocessing.error.required'), 'error');
- return;
- }
-
- const payload = {
- name,
- filters: collectFilters(),
- description: description || null,
- };
-
- try {
- let response;
- if (templateId) {
- response = await fetchWithAuth(`/postprocessing-templates/${templateId}`, {
- method: 'PUT',
- body: JSON.stringify(payload)
- });
- } else {
- response = await fetchWithAuth('/postprocessing-templates', {
- method: 'POST',
- body: JSON.stringify(payload)
- });
- }
-
- if (!response.ok) {
- const error = await response.json();
- throw new Error(error.detail || error.message || 'Failed to save template');
- }
-
- showToast(templateId ? t('postprocessing.updated') : t('postprocessing.created'), 'success');
- closePPTemplateModal();
- await loadPPTemplates();
- } catch (error) {
- console.error('Error saving PP template:', error);
- errorEl.textContent = error.message;
- errorEl.style.display = 'block';
- }
-}
-
-async function deletePPTemplate(templateId) {
- const confirmed = await showConfirm(t('postprocessing.delete.confirm'));
- if (!confirmed) return;
-
- try {
- const response = await fetchWithAuth(`/postprocessing-templates/${templateId}`, {
- method: 'DELETE'
- });
-
- if (!response.ok) {
- const error = await response.json();
- throw new Error(error.detail || error.message || 'Failed to delete template');
- }
-
- showToast(t('postprocessing.deleted'), 'success');
- await loadPPTemplates();
- } catch (error) {
- console.error('Error deleting PP template:', error);
- showToast(t('postprocessing.error.delete') + ': ' + error.message, 'error');
- }
-}
-
-function closePPTemplateModal() {
- document.getElementById('pp-template-modal').style.display = 'none';
- _modalFilters = [];
- unlockBody();
-}
-
-// ===== TARGET EDITOR MODAL =====
-let targetEditorInitialValues = {};
-let _targetEditorDevices = []; // cached devices list for capability checks
-
-function _updateStandbyVisibility() {
- const deviceSelect = document.getElementById('target-editor-device');
- const standbyGroup = document.getElementById('target-editor-standby-group');
- const selectedDevice = _targetEditorDevices.find(d => d.id === deviceSelect.value);
- const caps = selectedDevice?.capabilities || [];
- standbyGroup.style.display = caps.includes('standby_required') ? '' : 'none';
-}
-
-async function showTargetEditor(targetId = null) {
- try {
- // Load devices and sources for dropdowns
- const [devicesResp, sourcesResp] = await Promise.all([
- fetch(`${API_BASE}/devices`, { headers: getHeaders() }),
- fetchWithAuth('/picture-sources'),
- ]);
-
- const devices = devicesResp.ok ? (await devicesResp.json()).devices || [] : [];
- const sources = sourcesResp.ok ? (await sourcesResp.json()).streams || [] : [];
- _targetEditorDevices = devices;
-
- // Populate device select
- const deviceSelect = document.getElementById('target-editor-device');
- deviceSelect.innerHTML = '';
- devices.forEach(d => {
- const opt = document.createElement('option');
- opt.value = d.id;
- const shortUrl = d.url ? d.url.replace(/^https?:\/\//, '') : '';
- const devType = (d.device_type || 'wled').toUpperCase();
- opt.textContent = `${d.name} [${devType}]${shortUrl ? ' (' + shortUrl + ')' : ''}`;
- deviceSelect.appendChild(opt);
- });
- deviceSelect.onchange = _updateStandbyVisibility;
-
- // Populate source select
- const sourceSelect = document.getElementById('target-editor-source');
- sourceSelect.innerHTML = '';
- sources.forEach(s => {
- const opt = document.createElement('option');
- opt.value = s.id;
- const typeIcon = s.stream_type === 'raw' ? '🖥️' : s.stream_type === 'static_image' ? '🖼️' : '🎨';
- opt.textContent = `${typeIcon} ${s.name}`;
- sourceSelect.appendChild(opt);
- });
-
- if (targetId) {
- // Editing existing target
- const resp = await fetch(`${API_BASE}/picture-targets/${targetId}`, { headers: getHeaders() });
- if (!resp.ok) throw new Error('Failed to load target');
- const target = await resp.json();
-
- document.getElementById('target-editor-id').value = target.id;
- document.getElementById('target-editor-name').value = target.name;
- deviceSelect.value = target.device_id || '';
- sourceSelect.value = target.picture_source_id || '';
- document.getElementById('target-editor-fps').value = target.settings?.fps ?? 30;
- document.getElementById('target-editor-fps-value').textContent = target.settings?.fps ?? 30;
- document.getElementById('target-editor-interpolation').value = target.settings?.interpolation_mode ?? 'average';
- document.getElementById('target-editor-smoothing').value = target.settings?.smoothing ?? 0.3;
- document.getElementById('target-editor-smoothing-value').textContent = target.settings?.smoothing ?? 0.3;
- document.getElementById('target-editor-standby-interval').value = target.settings?.standby_interval ?? 1.0;
- document.getElementById('target-editor-standby-interval-value').textContent = target.settings?.standby_interval ?? 1.0;
- document.getElementById('target-editor-title').textContent = t('targets.edit');
- } else {
- // Creating new target
- document.getElementById('target-editor-id').value = '';
- document.getElementById('target-editor-name').value = '';
- deviceSelect.value = '';
- sourceSelect.value = '';
- document.getElementById('target-editor-fps').value = 30;
- document.getElementById('target-editor-fps-value').textContent = '30';
- document.getElementById('target-editor-interpolation').value = 'average';
- document.getElementById('target-editor-smoothing').value = 0.3;
- document.getElementById('target-editor-smoothing-value').textContent = '0.3';
- document.getElementById('target-editor-standby-interval').value = 1.0;
- document.getElementById('target-editor-standby-interval-value').textContent = '1.0';
- document.getElementById('target-editor-title').textContent = t('targets.add');
- }
-
- // Show/hide standby interval based on selected device capabilities
- _updateStandbyVisibility();
-
- targetEditorInitialValues = {
- name: document.getElementById('target-editor-name').value,
- device: deviceSelect.value,
- source: sourceSelect.value,
- fps: document.getElementById('target-editor-fps').value,
- interpolation: document.getElementById('target-editor-interpolation').value,
- smoothing: document.getElementById('target-editor-smoothing').value,
- standby_interval: document.getElementById('target-editor-standby-interval').value,
- };
-
- const modal = document.getElementById('target-editor-modal');
- modal.style.display = 'flex';
- lockBody();
- setupBackdropClose(modal, closeTargetEditorModal);
-
- document.getElementById('target-editor-error').style.display = 'none';
- setTimeout(() => document.getElementById('target-editor-name').focus(), 100);
- } catch (error) {
- console.error('Failed to open target editor:', error);
- showToast('Failed to open target editor', 'error');
- }
-}
-
-function isTargetEditorDirty() {
- return (
- document.getElementById('target-editor-name').value !== targetEditorInitialValues.name ||
- document.getElementById('target-editor-device').value !== targetEditorInitialValues.device ||
- document.getElementById('target-editor-source').value !== targetEditorInitialValues.source ||
- document.getElementById('target-editor-fps').value !== targetEditorInitialValues.fps ||
- document.getElementById('target-editor-interpolation').value !== targetEditorInitialValues.interpolation ||
- document.getElementById('target-editor-smoothing').value !== targetEditorInitialValues.smoothing ||
- document.getElementById('target-editor-standby-interval').value !== targetEditorInitialValues.standby_interval
- );
-}
-
-async function closeTargetEditorModal() {
- if (isTargetEditorDirty()) {
- const confirmed = await showConfirm(t('modal.discard_changes'));
- if (!confirmed) return;
- }
- forceCloseTargetEditorModal();
-}
-
-function forceCloseTargetEditorModal() {
- document.getElementById('target-editor-modal').style.display = 'none';
- document.getElementById('target-editor-error').style.display = 'none';
- unlockBody();
- targetEditorInitialValues = {};
-}
-
-async function saveTargetEditor() {
- const targetId = document.getElementById('target-editor-id').value;
- const name = document.getElementById('target-editor-name').value.trim();
- const deviceId = document.getElementById('target-editor-device').value;
- const sourceId = document.getElementById('target-editor-source').value;
- const fps = parseInt(document.getElementById('target-editor-fps').value) || 30;
- const interpolation = document.getElementById('target-editor-interpolation').value;
- const smoothing = parseFloat(document.getElementById('target-editor-smoothing').value);
- const standbyInterval = parseFloat(document.getElementById('target-editor-standby-interval').value);
- const errorEl = document.getElementById('target-editor-error');
-
- if (!name) {
- errorEl.textContent = t('targets.error.name_required');
- errorEl.style.display = 'block';
- return;
- }
-
- const payload = {
- name,
- device_id: deviceId,
- picture_source_id: sourceId,
- settings: {
- fps: fps,
- interpolation_mode: interpolation,
- smoothing: smoothing,
- standby_interval: standbyInterval,
- },
- };
-
- try {
- let response;
- if (targetId) {
- response = await fetch(`${API_BASE}/picture-targets/${targetId}`, {
- method: 'PUT',
- headers: getHeaders(),
- body: JSON.stringify(payload),
- });
- } else {
- payload.target_type = 'led';
- response = await fetch(`${API_BASE}/picture-targets`, {
- method: 'POST',
- headers: getHeaders(),
- body: JSON.stringify(payload),
- });
- }
-
- if (response.status === 401) { handle401Error(); return; }
-
- if (!response.ok) {
- const err = await response.json();
- throw new Error(err.detail || 'Failed to save');
- }
-
- showToast(targetId ? t('targets.updated') : t('targets.created'), 'success');
- forceCloseTargetEditorModal();
- await loadTargets();
- } catch (error) {
- console.error('Error saving target:', error);
- errorEl.textContent = error.message;
- errorEl.style.display = 'block';
- }
-}
-
-// ===== TARGETS TAB (WLED devices + targets combined) =====
-
-async function loadTargets() {
- // Alias for backward compatibility
- await loadTargetsTab();
-}
-
-function switchTargetSubTab(tabKey) {
- document.querySelectorAll('.target-sub-tab-btn').forEach(btn =>
- btn.classList.toggle('active', btn.dataset.targetSubTab === tabKey)
- );
- document.querySelectorAll('.target-sub-tab-panel').forEach(panel =>
- panel.classList.toggle('active', panel.id === `target-sub-tab-${tabKey}`)
- );
- localStorage.setItem('activeTargetSubTab', tabKey);
-}
-
-async function loadTargetsTab() {
- const container = document.getElementById('targets-panel-content');
- if (!container) return;
-
- try {
- // Fetch devices, targets, sources, and pattern templates in parallel
- const [devicesResp, targetsResp, sourcesResp, patResp] = await Promise.all([
- fetch(`${API_BASE}/devices`, { headers: getHeaders() }),
- fetch(`${API_BASE}/picture-targets`, { headers: getHeaders() }),
- fetchWithAuth('/picture-sources').catch(() => null),
- fetchWithAuth('/pattern-templates').catch(() => null),
- ]);
-
- if (devicesResp.status === 401 || targetsResp.status === 401) {
- handle401Error();
- return;
- }
-
- const devicesData = await devicesResp.json();
- const devices = devicesData.devices || [];
-
- const targetsData = await targetsResp.json();
- const targets = targetsData.targets || [];
-
- let sourceMap = {};
- if (sourcesResp && sourcesResp.ok) {
- const srcData = await sourcesResp.json();
- (srcData.streams || []).forEach(s => { sourceMap[s.id] = s; });
- }
-
- let patternTemplates = [];
- let patternTemplateMap = {};
- if (patResp && patResp.ok) {
- const patData = await patResp.json();
- patternTemplates = patData.templates || [];
- patternTemplates.forEach(pt => { patternTemplateMap[pt.id] = pt; });
- }
-
- // Fetch state for each device
- const devicesWithState = await Promise.all(
- devices.map(async (device) => {
- try {
- const stateResp = await fetch(`${API_BASE}/devices/${device.id}/state`, { headers: getHeaders() });
- const state = stateResp.ok ? await stateResp.json() : {};
- return { ...device, state };
- } catch {
- return device;
- }
- })
- );
-
- // Fetch state + metrics for each target (+ colors for KC targets)
- const targetsWithState = await Promise.all(
- targets.map(async (target) => {
- try {
- const stateResp = await fetch(`${API_BASE}/picture-targets/${target.id}/state`, { headers: getHeaders() });
- const state = stateResp.ok ? await stateResp.json() : {};
- const metricsResp = await fetch(`${API_BASE}/picture-targets/${target.id}/metrics`, { headers: getHeaders() });
- const metrics = metricsResp.ok ? await metricsResp.json() : {};
- let latestColors = null;
- if (target.target_type === 'key_colors' && state.processing) {
- try {
- const colorsResp = await fetch(`${API_BASE}/picture-targets/${target.id}/colors`, { headers: getHeaders() });
- if (colorsResp.ok) latestColors = await colorsResp.json();
- } catch {}
- }
- return { ...target, state, metrics, latestColors };
- } catch {
- return target;
- }
- })
- );
-
- // Build device map for target name resolution
- const deviceMap = {};
- devicesWithState.forEach(d => { deviceMap[d.id] = d; });
-
- // Group by type
- const ledDevices = devicesWithState;
- const ledTargets = targetsWithState.filter(t => t.target_type === 'led' || t.target_type === 'wled');
- const kcTargets = targetsWithState.filter(t => t.target_type === 'key_colors');
-
- // Backward compat: map stored "wled" sub-tab to "led"
- let activeSubTab = localStorage.getItem('activeTargetSubTab') || 'led';
- if (activeSubTab === 'wled') activeSubTab = 'led';
-
- const subTabs = [
- { key: 'led', icon: '💡', titleKey: 'targets.subtab.led', count: ledDevices.length + ledTargets.length },
- { key: 'key_colors', icon: '🎨', titleKey: 'targets.subtab.key_colors', count: kcTargets.length + patternTemplates.length },
- ];
-
- const tabBar = `${subTabs.map(tab =>
- ``
- ).join('')}
`;
-
- // LED panel: devices section + targets section
- const ledPanel = `
-
-
-
-
- ${ledDevices.map(device => createDeviceCard(device)).join('')}
-
-
-
-
-
-
- ${ledTargets.map(target => createTargetCard(target, deviceMap, sourceMap)).join('')}
-
-
-
-
`;
-
- // Key Colors panel
- const kcPanel = `
-
-
-
-
- ${kcTargets.map(target => createKCTargetCard(target, sourceMap, patternTemplateMap)).join('')}
-
-
-
-
-
-
- ${patternTemplates.map(pt => createPatternTemplateCard(pt)).join('')}
-
-
-
-
`;
-
- container.innerHTML = tabBar + ledPanel + kcPanel;
-
- // Attach event listeners and fetch brightness for device cards
- devicesWithState.forEach(device => {
- attachDeviceListeners(device.id);
- if ((device.capabilities || []).includes('brightness_control')) {
- // Only fetch from device if we don't have a cached value yet —
- // avoids HTTP requests to the ESP every 2s which cause LED stutters
- if (device.id in _deviceBrightnessCache) {
- const bri = _deviceBrightnessCache[device.id];
- const slider = document.querySelector(`[data-device-brightness="${device.id}"]`);
- if (slider) {
- slider.value = bri;
- slider.title = Math.round(bri / 255 * 100) + '%';
- slider.disabled = false;
- }
- const wrap = document.querySelector(`[data-brightness-wrap="${device.id}"]`);
- if (wrap) wrap.classList.remove('brightness-loading');
- } else {
- fetchDeviceBrightness(device.id);
- }
- }
- });
-
- // Manage KC WebSockets: connect for processing, disconnect for stopped
- const processingKCIds = new Set();
- kcTargets.forEach(target => {
- if (target.state && target.state.processing) {
- processingKCIds.add(target.id);
- if (!kcWebSockets[target.id]) {
- connectKCWebSocket(target.id);
- }
- }
- });
- // Disconnect WebSockets for targets no longer processing
- Object.keys(kcWebSockets).forEach(id => {
- if (!processingKCIds.has(id)) disconnectKCWebSocket(id);
- });
-
- } catch (error) {
- console.error('Failed to load targets tab:', error);
- container.innerHTML = `${t('targets.failed')}
`;
- }
-}
-
-function createTargetCard(target, deviceMap, sourceMap) {
- const state = target.state || {};
- const metrics = target.metrics || {};
- const settings = target.settings || {};
-
- const isProcessing = state.processing || false;
-
- const device = deviceMap[target.device_id];
- const source = sourceMap[target.picture_source_id];
- const deviceName = device ? device.name : (target.device_id || 'No device');
- const sourceName = source ? source.name : (target.picture_source_id || 'No source');
-
- // Health info from target state (forwarded from device)
- const devOnline = state.device_online || false;
- let healthClass = 'health-unknown';
- let healthTitle = '';
- if (state.device_last_checked !== null && state.device_last_checked !== undefined) {
- healthClass = devOnline ? 'health-online' : 'health-offline';
- healthTitle = devOnline ? t('device.health.online') : t('device.health.offline');
- }
-
- return `
-
-
-
-
- 💡 ${escapeHtml(deviceName)}
- ⚡ ${settings.fps || 30}
- 📺 ${escapeHtml(sourceName)}
-
-
- ${isProcessing ? `
-
-
-
${t('device.metrics.actual_fps')}
-
${state.fps_actual?.toFixed(1) || '0.0'}
-
-
-
${t('device.metrics.current_fps')}
-
${state.fps_current ?? '-'}
-
-
-
${t('device.metrics.target_fps')}
-
${state.fps_target || 0}
-
-
-
${t('device.metrics.potential_fps')}
-
${state.fps_potential?.toFixed(0) || '-'}
-
-
-
${t('device.metrics.frames')}
-
${metrics.frames_processed || 0}
-
-
-
${t('device.metrics.keepalive')}
-
${state.frames_keepalive ?? '-'}
-
-
-
${t('device.metrics.errors')}
-
${metrics.errors_count || 0}
-
-
- ${state.timing_total_ms != null ? `
-
-
-
-
-
-
-
-
-
- extract ${state.timing_extract_ms}ms
- map ${state.timing_map_leds_ms}ms
- smooth ${state.timing_smooth_ms}ms
- send ${state.timing_send_ms}ms
-
-
- ` : ''}
- ` : ''}
-
-
- ${isProcessing ? `
-
- ` : `
-
- `}
-
- ${state.overlay_active ? `
-
- ` : `
-
- `}
-
-
- `;
-}
-
-async function startTargetProcessing(targetId) {
- try {
- const response = await fetch(`${API_BASE}/picture-targets/${targetId}/start`, {
- method: 'POST',
- headers: getHeaders()
- });
- if (response.status === 401) { handle401Error(); return; }
- if (response.ok) {
- showToast(t('device.started'), 'success');
- loadTargets();
- } else {
- const error = await response.json();
- showToast(`Failed to start: ${error.detail}`, 'error');
- }
- } catch (error) {
- showToast('Failed to start processing', 'error');
- }
-}
-
-async function stopTargetProcessing(targetId) {
- try {
- const response = await fetch(`${API_BASE}/picture-targets/${targetId}/stop`, {
- method: 'POST',
- headers: getHeaders()
- });
- if (response.status === 401) { handle401Error(); return; }
- if (response.ok) {
- showToast(t('device.stopped'), 'success');
- loadTargets();
- } else {
- const error = await response.json();
- showToast(`Failed to stop: ${error.detail}`, 'error');
- }
- } catch (error) {
- showToast('Failed to stop processing', 'error');
- }
-}
-
-async function startTargetOverlay(targetId) {
- try {
- const response = await fetch(`${API_BASE}/picture-targets/${targetId}/overlay/start`, {
- method: 'POST',
- headers: getHeaders()
- });
- if (response.status === 401) { handle401Error(); return; }
- if (response.ok) {
- showToast(t('overlay.started'), 'success');
- loadTargets();
- } else {
- const error = await response.json();
- showToast(t('overlay.error.start') + ': ' + error.detail, 'error');
- }
- } catch (error) {
- showToast(t('overlay.error.start'), 'error');
- }
-}
-
-async function stopTargetOverlay(targetId) {
- try {
- const response = await fetch(`${API_BASE}/picture-targets/${targetId}/overlay/stop`, {
- method: 'POST',
- headers: getHeaders()
- });
- if (response.status === 401) { handle401Error(); return; }
- if (response.ok) {
- showToast(t('overlay.stopped'), 'success');
- loadTargets();
- } else {
- const error = await response.json();
- showToast(t('overlay.error.stop') + ': ' + error.detail, 'error');
- }
- } catch (error) {
- showToast(t('overlay.error.stop'), 'error');
- }
-}
-
-async function deleteTarget(targetId) {
- const confirmed = await showConfirm(t('targets.delete.confirm'));
- if (!confirmed) return;
-
- try {
- const response = await fetch(`${API_BASE}/picture-targets/${targetId}`, {
- method: 'DELETE',
- headers: getHeaders()
- });
- if (response.status === 401) { handle401Error(); return; }
- if (response.ok) {
- showToast(t('targets.deleted'), 'success');
- loadTargets();
- } else {
- const error = await response.json();
- showToast(`Failed to delete: ${error.detail}`, 'error');
- }
- } catch (error) {
- showToast('Failed to delete target', 'error');
- }
-}
-
-// ===== KEY COLORS TARGET CARD =====
-
-function createKCTargetCard(target, sourceMap, patternTemplateMap) {
- const state = target.state || {};
- const metrics = target.metrics || {};
- const kcSettings = target.key_colors_settings || {};
-
- const isProcessing = state.processing || false;
-
- const source = sourceMap[target.picture_source_id];
- const sourceName = source ? source.name : (target.picture_source_id || 'No source');
- const patTmpl = patternTemplateMap[kcSettings.pattern_template_id];
- const patternName = patTmpl ? patTmpl.name : 'No pattern';
- const rectCount = patTmpl ? (patTmpl.rectangles || []).length : 0;
-
- // Render initial color swatches from pre-fetched REST data
- let swatchesHtml = '';
- const latestColors = target.latestColors && target.latestColors.colors;
- if (isProcessing && latestColors && Object.keys(latestColors).length > 0) {
- swatchesHtml = Object.entries(latestColors).map(([name, color]) => `
-
-
-
${escapeHtml(name)}
-
- `).join('');
- } else if (isProcessing) {
- swatchesHtml = `${t('kc.colors.none')}`;
- }
-
- return `
-
-
-
-
- 📺 ${escapeHtml(sourceName)}
- 📄 ${escapeHtml(patternName)}
- ▭ ${rectCount} rect${rectCount !== 1 ? 's' : ''}
-
-
-
- ${swatchesHtml}
-
- ${isProcessing ? `
-
-
-
${t('device.metrics.actual_fps')}
-
${state.fps_actual?.toFixed(1) || '0.0'}
-
-
-
${t('device.metrics.current_fps')}
-
${state.fps_current ?? '-'}
-
-
-
${t('device.metrics.target_fps')}
-
${state.fps_target || 0}
-
-
-
${t('device.metrics.potential_fps')}
-
${state.fps_potential?.toFixed(0) || '-'}
-
-
-
${t('device.metrics.frames')}
-
${metrics.frames_processed || 0}
-
-
-
${t('device.metrics.keepalive')}
-
${state.frames_keepalive ?? '-'}
-
-
-
${t('device.metrics.errors')}
-
${metrics.errors_count || 0}
-
-
- ${state.timing_total_ms != null ? `
-
-
-
-
-
-
-
-
- calc ${state.timing_calc_colors_ms}ms
- smooth ${state.timing_smooth_ms}ms
- broadcast ${state.timing_broadcast_ms}ms
-
-
- ` : ''}
- ` : ''}
-
-
- ${isProcessing ? `
-
- ` : `
-
- `}
-
-
-
-
- `;
-}
-
-// ===== KEY COLORS TEST =====
-
-async function fetchKCTest(targetId) {
- const response = await fetch(`${API_BASE}/picture-targets/${targetId}/test`, {
- method: 'POST',
- headers: getHeaders(),
- });
- if (!response.ok) {
- const err = await response.json().catch(() => ({}));
- throw new Error(err.detail || response.statusText);
- }
- return response.json();
-}
-
-async function testKCTarget(targetId) {
- kcTestTargetId = targetId;
-
- // Show lightbox immediately with a spinner
- const lightbox = document.getElementById('image-lightbox');
- const lbImg = document.getElementById('lightbox-image');
- const statsEl = document.getElementById('lightbox-stats');
- lbImg.style.display = 'none';
- lbImg.src = '';
- statsEl.style.display = 'none';
-
- // Insert spinner if not already present
- let spinner = lightbox.querySelector('.lightbox-spinner');
- if (!spinner) {
- spinner = document.createElement('div');
- spinner.className = 'lightbox-spinner loading-spinner';
- lightbox.querySelector('.lightbox-content').prepend(spinner);
- }
- spinner.style.display = '';
-
- // Show auto-refresh button
- const refreshBtn = document.getElementById('lightbox-auto-refresh');
- if (refreshBtn) refreshBtn.style.display = '';
-
- lightbox.classList.add('active');
- lockBody();
-
- try {
- const result = await fetchKCTest(targetId);
- displayKCTestResults(result);
- } catch (e) {
- closeLightbox();
- showToast(t('kc.test.error') + ': ' + e.message, 'error');
- }
-}
-
-function toggleKCTestAutoRefresh() {
- if (kcTestAutoRefresh) {
- stopKCTestAutoRefresh();
- } else {
- kcTestAutoRefresh = setInterval(async () => {
- if (!kcTestTargetId) return;
- try {
- const result = await fetchKCTest(kcTestTargetId);
- displayKCTestResults(result);
- } catch (e) {
- stopKCTestAutoRefresh();
- }
- }, 1000);
- updateAutoRefreshButton(true);
- }
-}
-
-function stopKCTestAutoRefresh() {
- if (kcTestAutoRefresh) {
- clearInterval(kcTestAutoRefresh);
- kcTestAutoRefresh = null;
- }
- kcTestTargetId = null;
- updateAutoRefreshButton(false);
-}
-
-function updateAutoRefreshButton(active) {
- const btn = document.getElementById('lightbox-auto-refresh');
- if (!btn) return;
- if (active) {
- btn.classList.add('active');
- btn.innerHTML = '⏸'; // pause symbol
- } else {
- btn.classList.remove('active');
- btn.innerHTML = '▶'; // play symbol
- }
-}
-
-function displayKCTestResults(result) {
- const srcImg = new window.Image();
- srcImg.onload = () => {
- const canvas = document.createElement('canvas');
- canvas.width = srcImg.width;
- canvas.height = srcImg.height;
- const ctx = canvas.getContext('2d');
-
- // Draw captured frame
- ctx.drawImage(srcImg, 0, 0);
-
- const w = srcImg.width;
- const h = srcImg.height;
-
- // Draw each rectangle with extracted color overlay
- result.rectangles.forEach((rect, i) => {
- const px = rect.x * w;
- const py = rect.y * h;
- const pw = rect.width * w;
- const ph = rect.height * h;
-
- const color = rect.color;
- const borderColor = PATTERN_RECT_BORDERS[i % PATTERN_RECT_BORDERS.length];
-
- // Semi-transparent fill with the extracted color
- ctx.fillStyle = `rgba(${color.r}, ${color.g}, ${color.b}, 0.3)`;
- ctx.fillRect(px, py, pw, ph);
-
- // Border using pattern colors for distinction
- ctx.strokeStyle = borderColor;
- ctx.lineWidth = 3;
- ctx.strokeRect(px, py, pw, ph);
-
- // Color swatch in top-left corner of rect
- const swatchSize = Math.max(16, Math.min(32, pw * 0.15));
- ctx.fillStyle = color.hex;
- ctx.fillRect(px + 4, py + 4, swatchSize, swatchSize);
- ctx.strokeStyle = '#fff';
- ctx.lineWidth = 1;
- ctx.strokeRect(px + 4, py + 4, swatchSize, swatchSize);
-
- // Name label with shadow for readability
- const fontSize = Math.max(12, Math.min(18, pw * 0.06));
- ctx.font = `bold ${fontSize}px sans-serif`;
- const labelX = px + swatchSize + 10;
- const labelY = py + 4 + swatchSize / 2 + fontSize / 3;
- ctx.shadowColor = 'rgba(0,0,0,0.8)';
- ctx.shadowBlur = 4;
- ctx.fillStyle = '#fff';
- ctx.fillText(rect.name, labelX, labelY);
-
- // Hex label below name
- ctx.font = `${fontSize - 2}px monospace`;
- ctx.fillText(color.hex, labelX, labelY + fontSize + 2);
- ctx.shadowBlur = 0;
- });
-
- const dataUrl = canvas.toDataURL('image/jpeg', 0.92);
-
- // Build stats HTML
- let statsHtml = ``;
- statsHtml += `
${escapeHtml(result.pattern_template_name)} \u2022 ${escapeHtml(result.interpolation_mode)}`;
- result.rectangles.forEach((rect) => {
- const c = rect.color;
- statsHtml += `
`;
- statsHtml += `
`;
- statsHtml += `
${escapeHtml(rect.name)} ${c.hex}`;
- statsHtml += `
`;
- });
- statsHtml += `
`;
-
- // Hide spinner, show result in the already-open lightbox
- const spinner = document.querySelector('.lightbox-spinner');
- if (spinner) spinner.style.display = 'none';
-
- const lbImg = document.getElementById('lightbox-image');
- const statsEl = document.getElementById('lightbox-stats');
- lbImg.src = dataUrl;
- lbImg.style.display = '';
- statsEl.innerHTML = statsHtml;
- statsEl.style.display = '';
- };
- srcImg.src = result.image;
-}
-
-// ===== KEY COLORS EDITOR =====
-
-let kcEditorInitialValues = {};
-let _kcNameManuallyEdited = false;
-
-function _autoGenerateKCName() {
- if (_kcNameManuallyEdited) return;
- if (document.getElementById('kc-editor-id').value) return;
- const sourceSelect = document.getElementById('kc-editor-source');
- const sourceName = sourceSelect.selectedOptions[0]?.dataset?.name || '';
- if (!sourceName) return;
- const mode = document.getElementById('kc-editor-interpolation').value || 'average';
- const modeName = t(`kc.interpolation.${mode}`);
- const patSelect = document.getElementById('kc-editor-pattern-template');
- const patName = patSelect.selectedOptions[0]?.dataset?.name || '';
- document.getElementById('kc-editor-name').value = `${sourceName} · ${patName} (${modeName})`;
-}
-
-async function showKCEditor(targetId = null) {
- try {
- // Load sources and pattern templates in parallel
- const [sourcesResp, patResp] = await Promise.all([
- fetchWithAuth('/picture-sources').catch(() => null),
- fetchWithAuth('/pattern-templates').catch(() => null),
- ]);
- const sources = (sourcesResp && sourcesResp.ok) ? (await sourcesResp.json()).streams || [] : [];
- const patTemplates = (patResp && patResp.ok) ? (await patResp.json()).templates || [] : [];
-
- // Populate source select
- const sourceSelect = document.getElementById('kc-editor-source');
- sourceSelect.innerHTML = '';
- sources.forEach(s => {
- const opt = document.createElement('option');
- opt.value = s.id;
- opt.dataset.name = s.name;
- const typeIcon = s.stream_type === 'raw' ? '🖥️' : s.stream_type === 'static_image' ? '🖼️' : '🎨';
- opt.textContent = `${typeIcon} ${s.name}`;
- sourceSelect.appendChild(opt);
- });
-
- // Populate pattern template select
- const patSelect = document.getElementById('kc-editor-pattern-template');
- patSelect.innerHTML = '';
- patTemplates.forEach(pt => {
- const opt = document.createElement('option');
- opt.value = pt.id;
- opt.dataset.name = pt.name;
- const rectCount = (pt.rectangles || []).length;
- opt.textContent = `${pt.name} (${rectCount} rect${rectCount !== 1 ? 's' : ''})`;
- patSelect.appendChild(opt);
- });
-
- if (targetId) {
- const resp = await fetch(`${API_BASE}/picture-targets/${targetId}`, { headers: getHeaders() });
- if (!resp.ok) throw new Error('Failed to load target');
- const target = await resp.json();
- const kcSettings = target.key_colors_settings || {};
-
- document.getElementById('kc-editor-id').value = target.id;
- document.getElementById('kc-editor-name').value = target.name;
- sourceSelect.value = target.picture_source_id || '';
- document.getElementById('kc-editor-fps').value = kcSettings.fps ?? 10;
- document.getElementById('kc-editor-fps-value').textContent = kcSettings.fps ?? 10;
- document.getElementById('kc-editor-interpolation').value = kcSettings.interpolation_mode ?? 'average';
- document.getElementById('kc-editor-smoothing').value = kcSettings.smoothing ?? 0.3;
- document.getElementById('kc-editor-smoothing-value').textContent = kcSettings.smoothing ?? 0.3;
- patSelect.value = kcSettings.pattern_template_id || '';
- document.getElementById('kc-editor-title').textContent = t('kc.edit');
- } else {
- document.getElementById('kc-editor-id').value = '';
- document.getElementById('kc-editor-name').value = '';
- if (sourceSelect.options.length > 0) sourceSelect.selectedIndex = 0;
- document.getElementById('kc-editor-fps').value = 10;
- document.getElementById('kc-editor-fps-value').textContent = '10';
- document.getElementById('kc-editor-interpolation').value = 'average';
- document.getElementById('kc-editor-smoothing').value = 0.3;
- document.getElementById('kc-editor-smoothing-value').textContent = '0.3';
- if (patTemplates.length > 0) patSelect.value = patTemplates[0].id;
- document.getElementById('kc-editor-title').textContent = t('kc.add');
- }
-
- // Auto-name
- _kcNameManuallyEdited = !!targetId;
- document.getElementById('kc-editor-name').oninput = () => { _kcNameManuallyEdited = true; };
- sourceSelect.onchange = () => _autoGenerateKCName();
- document.getElementById('kc-editor-interpolation').onchange = () => _autoGenerateKCName();
- patSelect.onchange = () => _autoGenerateKCName();
- if (!targetId) _autoGenerateKCName();
-
- kcEditorInitialValues = {
- name: document.getElementById('kc-editor-name').value,
- source: sourceSelect.value,
- fps: document.getElementById('kc-editor-fps').value,
- interpolation: document.getElementById('kc-editor-interpolation').value,
- smoothing: document.getElementById('kc-editor-smoothing').value,
- patternTemplateId: patSelect.value,
- };
-
- const modal = document.getElementById('kc-editor-modal');
- modal.style.display = 'flex';
- lockBody();
- setupBackdropClose(modal, closeKCEditorModal);
-
- document.getElementById('kc-editor-error').style.display = 'none';
- setTimeout(() => document.getElementById('kc-editor-name').focus(), 100);
- } catch (error) {
- console.error('Failed to open KC editor:', error);
- showToast('Failed to open key colors editor', 'error');
- }
-}
-
-function isKCEditorDirty() {
- return (
- document.getElementById('kc-editor-name').value !== kcEditorInitialValues.name ||
- document.getElementById('kc-editor-source').value !== kcEditorInitialValues.source ||
- document.getElementById('kc-editor-fps').value !== kcEditorInitialValues.fps ||
- document.getElementById('kc-editor-interpolation').value !== kcEditorInitialValues.interpolation ||
- document.getElementById('kc-editor-smoothing').value !== kcEditorInitialValues.smoothing ||
- document.getElementById('kc-editor-pattern-template').value !== kcEditorInitialValues.patternTemplateId
- );
-}
-
-async function closeKCEditorModal() {
- if (isKCEditorDirty()) {
- const confirmed = await showConfirm(t('modal.discard_changes'));
- if (!confirmed) return;
- }
- forceCloseKCEditorModal();
-}
-
-function forceCloseKCEditorModal() {
- document.getElementById('kc-editor-modal').style.display = 'none';
- document.getElementById('kc-editor-error').style.display = 'none';
- unlockBody();
- kcEditorInitialValues = {};
-}
-
-async function saveKCEditor() {
- const targetId = document.getElementById('kc-editor-id').value;
- const name = document.getElementById('kc-editor-name').value.trim();
- const sourceId = document.getElementById('kc-editor-source').value;
- const fps = parseInt(document.getElementById('kc-editor-fps').value) || 10;
- const interpolation = document.getElementById('kc-editor-interpolation').value;
- const smoothing = parseFloat(document.getElementById('kc-editor-smoothing').value);
- const patternTemplateId = document.getElementById('kc-editor-pattern-template').value;
- const errorEl = document.getElementById('kc-editor-error');
-
- if (!name) {
- errorEl.textContent = t('kc.error.required');
- errorEl.style.display = 'block';
- return;
- }
-
- if (!patternTemplateId) {
- errorEl.textContent = t('kc.error.no_pattern');
- errorEl.style.display = 'block';
- return;
- }
-
- const payload = {
- name,
- picture_source_id: sourceId,
- key_colors_settings: {
- fps,
- interpolation_mode: interpolation,
- smoothing,
- pattern_template_id: patternTemplateId,
- },
- };
-
- try {
- let response;
- if (targetId) {
- response = await fetch(`${API_BASE}/picture-targets/${targetId}`, {
- method: 'PUT',
- headers: getHeaders(),
- body: JSON.stringify(payload),
- });
- } else {
- payload.target_type = 'key_colors';
- response = await fetch(`${API_BASE}/picture-targets`, {
- method: 'POST',
- headers: getHeaders(),
- body: JSON.stringify(payload),
- });
- }
-
- if (response.status === 401) { handle401Error(); return; }
-
- if (!response.ok) {
- const err = await response.json();
- throw new Error(err.detail || 'Failed to save');
- }
-
- showToast(targetId ? t('kc.updated') : t('kc.created'), 'success');
- forceCloseKCEditorModal();
- await loadTargets();
- } catch (error) {
- console.error('Error saving KC target:', error);
- errorEl.textContent = error.message;
- errorEl.style.display = 'block';
- }
-}
-
-async function deleteKCTarget(targetId) {
- const confirmed = await showConfirm(t('kc.delete.confirm'));
- if (!confirmed) return;
-
- try {
- disconnectKCWebSocket(targetId);
- const response = await fetch(`${API_BASE}/picture-targets/${targetId}`, {
- method: 'DELETE',
- headers: getHeaders()
- });
- if (response.status === 401) { handle401Error(); return; }
- if (response.ok) {
- showToast(t('kc.deleted'), 'success');
- loadTargets();
- } else {
- const error = await response.json();
- showToast(`Failed to delete: ${error.detail}`, 'error');
- }
- } catch (error) {
- showToast('Failed to delete key colors target', 'error');
- }
-}
-
-// ===== KEY COLORS WEBSOCKET =====
-
-const kcWebSockets = {};
-
-function connectKCWebSocket(targetId) {
- // Disconnect existing connection if any
- disconnectKCWebSocket(targetId);
-
- const key = localStorage.getItem('wled_api_key');
- if (!key) return;
-
- const protocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:';
- const wsUrl = `${protocol}//${window.location.host}${API_BASE}/picture-targets/${targetId}/ws?token=${encodeURIComponent(key)}`;
-
- try {
- const ws = new WebSocket(wsUrl);
-
- ws.onmessage = (event) => {
- try {
- const data = JSON.parse(event.data);
- updateKCColorSwatches(targetId, data.colors || {});
- } catch (e) {
- console.error('Failed to parse KC WebSocket message:', e);
- }
- };
-
- ws.onclose = () => {
- delete kcWebSockets[targetId];
- };
-
- ws.onerror = (error) => {
- console.error(`KC WebSocket error for ${targetId}:`, error);
- };
-
- kcWebSockets[targetId] = ws;
- } catch (error) {
- console.error(`Failed to connect KC WebSocket for ${targetId}:`, error);
- }
-}
-
-function disconnectKCWebSocket(targetId) {
- const ws = kcWebSockets[targetId];
- if (ws) {
- ws.close();
- delete kcWebSockets[targetId];
- }
-}
-
-function disconnectAllKCWebSockets() {
- Object.keys(kcWebSockets).forEach(targetId => disconnectKCWebSocket(targetId));
-}
-
-function updateKCColorSwatches(targetId, colors) {
- const container = document.getElementById(`kc-swatches-${targetId}`);
- if (!container) return;
-
- const entries = Object.entries(colors);
- if (entries.length === 0) {
- container.innerHTML = `${t('kc.colors.none')}`;
- return;
- }
-
- container.innerHTML = entries.map(([name, color]) => {
- const hex = color.hex || `#${(color.r || 0).toString(16).padStart(2, '0')}${(color.g || 0).toString(16).padStart(2, '0')}${(color.b || 0).toString(16).padStart(2, '0')}`;
- return `
-
-
-
${escapeHtml(name)}
-
- `;
- }).join('');
-}
-
-// ===== PATTERN TEMPLATES =====
-
-function createPatternTemplateCard(pt) {
- const rectCount = (pt.rectangles || []).length;
- const desc = pt.description ? `${escapeHtml(pt.description)}
` : '';
- return `
-
-
-
- ${desc}
-
- ▭ ${rectCount} rect${rectCount !== 1 ? 's' : ''}
-
-
-
-
-
- `;
-}
-
-// ----- Pattern Template Editor state -----
-let patternEditorRects = [];
-let patternEditorSelectedIdx = -1;
-let patternEditorBgImage = null;
-let patternEditorInitialValues = {};
-let patternCanvasDragMode = null;
-let patternCanvasDragStart = null;
-let patternCanvasDragOrigRect = null;
-let patternEditorHoveredIdx = -1;
-let patternEditorHoverHit = null; // 'move', 'n', 's', 'e', 'w', 'nw', etc.
-
-const PATTERN_RECT_COLORS = [
- 'rgba(76,175,80,0.35)', 'rgba(33,150,243,0.35)', 'rgba(255,152,0,0.35)',
- 'rgba(156,39,176,0.35)', 'rgba(0,188,212,0.35)', 'rgba(244,67,54,0.35)',
- 'rgba(255,235,59,0.35)', 'rgba(121,85,72,0.35)',
-];
-const PATTERN_RECT_BORDERS = [
- '#4CAF50', '#2196F3', '#FF9800', '#9C27B0', '#00BCD4', '#F44336', '#FFEB3B', '#795548',
-];
-
-async function showPatternTemplateEditor(templateId = null) {
- try {
- // Load sources for background capture
- const sourcesResp = await fetchWithAuth('/picture-sources').catch(() => null);
- const sources = (sourcesResp && sourcesResp.ok) ? (await sourcesResp.json()).streams || [] : [];
-
- const bgSelect = document.getElementById('pattern-bg-source');
- bgSelect.innerHTML = '';
- sources.forEach(s => {
- const opt = document.createElement('option');
- opt.value = s.id;
- const typeIcon = s.stream_type === 'raw' ? '🖥️' : s.stream_type === 'static_image' ? '🖼️' : '🎨';
- opt.textContent = `${typeIcon} ${s.name}`;
- bgSelect.appendChild(opt);
- });
-
- patternEditorBgImage = null;
- patternEditorSelectedIdx = -1;
- patternCanvasDragMode = null;
-
- if (templateId) {
- const resp = await fetch(`${API_BASE}/pattern-templates/${templateId}`, { headers: getHeaders() });
- if (!resp.ok) throw new Error('Failed to load pattern template');
- const tmpl = await resp.json();
-
- document.getElementById('pattern-template-id').value = tmpl.id;
- document.getElementById('pattern-template-name').value = tmpl.name;
- document.getElementById('pattern-template-description').value = tmpl.description || '';
- document.getElementById('pattern-template-title').textContent = t('pattern.edit');
- patternEditorRects = (tmpl.rectangles || []).map(r => ({ ...r }));
- } else {
- document.getElementById('pattern-template-id').value = '';
- document.getElementById('pattern-template-name').value = '';
- document.getElementById('pattern-template-description').value = '';
- document.getElementById('pattern-template-title').textContent = t('pattern.add');
- patternEditorRects = [];
- }
-
- patternEditorInitialValues = {
- name: document.getElementById('pattern-template-name').value,
- description: document.getElementById('pattern-template-description').value,
- rectangles: JSON.stringify(patternEditorRects),
- };
-
- renderPatternRectList();
- renderPatternCanvas();
- _attachPatternCanvasEvents();
-
- const modal = document.getElementById('pattern-template-modal');
- modal.style.display = 'flex';
- lockBody();
- setupBackdropClose(modal, closePatternTemplateModal);
-
- document.getElementById('pattern-template-error').style.display = 'none';
- setTimeout(() => document.getElementById('pattern-template-name').focus(), 100);
- } catch (error) {
- console.error('Failed to open pattern template editor:', error);
- showToast('Failed to open pattern template editor', 'error');
- }
-}
-
-function isPatternEditorDirty() {
- return (
- document.getElementById('pattern-template-name').value !== patternEditorInitialValues.name ||
- document.getElementById('pattern-template-description').value !== patternEditorInitialValues.description ||
- JSON.stringify(patternEditorRects) !== patternEditorInitialValues.rectangles
- );
-}
-
-async function closePatternTemplateModal() {
- if (isPatternEditorDirty()) {
- const confirmed = await showConfirm(t('modal.discard_changes'));
- if (!confirmed) return;
- }
- forceClosePatternTemplateModal();
-}
-
-function forceClosePatternTemplateModal() {
- document.getElementById('pattern-template-modal').style.display = 'none';
- document.getElementById('pattern-template-error').style.display = 'none';
- unlockBody();
- patternEditorRects = [];
- patternEditorSelectedIdx = -1;
- patternEditorBgImage = null;
- patternEditorInitialValues = {};
-}
-
-async function savePatternTemplate() {
- const templateId = document.getElementById('pattern-template-id').value;
- const name = document.getElementById('pattern-template-name').value.trim();
- const description = document.getElementById('pattern-template-description').value.trim();
- const errorEl = document.getElementById('pattern-template-error');
-
- if (!name) {
- errorEl.textContent = t('pattern.error.required');
- errorEl.style.display = 'block';
- return;
- }
-
- const payload = {
- name,
- rectangles: patternEditorRects.map(r => ({
- name: r.name, x: r.x, y: r.y, width: r.width, height: r.height,
- })),
- description: description || null,
- };
-
- try {
- let response;
- if (templateId) {
- response = await fetch(`${API_BASE}/pattern-templates/${templateId}`, {
- method: 'PUT', headers: getHeaders(), body: JSON.stringify(payload),
- });
- } else {
- response = await fetch(`${API_BASE}/pattern-templates`, {
- method: 'POST', headers: getHeaders(), body: JSON.stringify(payload),
- });
- }
-
- if (response.status === 401) { handle401Error(); return; }
- if (!response.ok) {
- const err = await response.json();
- throw new Error(err.detail || 'Failed to save');
- }
-
- showToast(templateId ? t('pattern.updated') : t('pattern.created'), 'success');
- forceClosePatternTemplateModal();
- await loadTargets();
- } catch (error) {
- console.error('Error saving pattern template:', error);
- errorEl.textContent = error.message;
- errorEl.style.display = 'block';
- }
-}
-
-async function deletePatternTemplate(templateId) {
- const confirmed = await showConfirm(t('pattern.delete.confirm'));
- if (!confirmed) return;
-
- try {
- const response = await fetch(`${API_BASE}/pattern-templates/${templateId}`, {
- method: 'DELETE', headers: getHeaders(),
- });
- if (response.status === 401) { handle401Error(); return; }
- if (response.status === 409) {
- showToast(t('pattern.delete.referenced'), 'error');
- return;
- }
- if (response.ok) {
- showToast(t('pattern.deleted'), 'success');
- loadTargets();
- } else {
- const error = await response.json();
- showToast(`Failed to delete: ${error.detail}`, 'error');
- }
- } catch (error) {
- showToast('Failed to delete pattern template', 'error');
- }
-}
-
-// ----- Pattern rect list (precise coordinate inputs) -----
-
-function renderPatternRectList() {
- const container = document.getElementById('pattern-rect-list');
- if (!container) return;
-
- if (patternEditorRects.length === 0) {
- container.innerHTML = `${t('pattern.rect.empty')}
`;
- return;
- }
-
- container.innerHTML = patternEditorRects.map((rect, i) => `
-
-
-
-
-
-
-
-
- `).join('');
-}
-
-function selectPatternRect(index) {
- patternEditorSelectedIdx = (patternEditorSelectedIdx === index) ? -1 : index;
- renderPatternRectList();
- renderPatternCanvas();
-}
-
-function updatePatternRect(index, field, value) {
- if (index < 0 || index >= patternEditorRects.length) return;
- patternEditorRects[index][field] = value;
- // Clamp coordinates
- if (field !== 'name') {
- const r = patternEditorRects[index];
- r.x = Math.max(0, Math.min(1 - r.width, r.x));
- r.y = Math.max(0, Math.min(1 - r.height, r.y));
- r.width = Math.max(0.01, Math.min(1, r.width));
- r.height = Math.max(0.01, Math.min(1, r.height));
- }
- renderPatternCanvas();
-}
-
-function addPatternRect() {
- const name = `Zone ${patternEditorRects.length + 1}`;
- // Inherit size from selected rect, or default to 30%
- let w = 0.3, h = 0.3;
- if (patternEditorSelectedIdx >= 0 && patternEditorSelectedIdx < patternEditorRects.length) {
- const sel = patternEditorRects[patternEditorSelectedIdx];
- w = sel.width;
- h = sel.height;
- }
- const x = Math.min(0.5 - w / 2, 1 - w);
- const y = Math.min(0.5 - h / 2, 1 - h);
- patternEditorRects.push({ name, x: Math.max(0, x), y: Math.max(0, y), width: w, height: h });
- patternEditorSelectedIdx = patternEditorRects.length - 1;
- renderPatternRectList();
- renderPatternCanvas();
-}
-
-function deleteSelectedPatternRect() {
- if (patternEditorSelectedIdx < 0 || patternEditorSelectedIdx >= patternEditorRects.length) return;
- patternEditorRects.splice(patternEditorSelectedIdx, 1);
- patternEditorSelectedIdx = -1;
- renderPatternRectList();
- renderPatternCanvas();
-}
-
-function removePatternRect(index) {
- patternEditorRects.splice(index, 1);
- if (patternEditorSelectedIdx === index) patternEditorSelectedIdx = -1;
- else if (patternEditorSelectedIdx > index) patternEditorSelectedIdx--;
- renderPatternRectList();
- renderPatternCanvas();
-}
-
-// ----- Pattern Canvas Visual Editor -----
-
-function renderPatternCanvas() {
- const canvas = document.getElementById('pattern-canvas');
- if (!canvas) return;
- const ctx = canvas.getContext('2d');
- const w = canvas.width;
- const h = canvas.height;
-
- // Clear
- ctx.clearRect(0, 0, w, h);
-
- // Draw background image or grid
- if (patternEditorBgImage) {
- ctx.drawImage(patternEditorBgImage, 0, 0, w, h);
- } else {
- // Draw subtle grid — spacing adapts to canvas size
- ctx.fillStyle = 'rgba(128,128,128,0.05)';
- ctx.fillRect(0, 0, w, h);
- ctx.strokeStyle = 'rgba(128,128,128,0.15)';
- ctx.lineWidth = 1;
- const dpr = window.devicePixelRatio || 1;
- const gridStep = 80 * dpr; // ~80 CSS pixels between grid lines
- const colsCount = Math.max(2, Math.round(w / gridStep));
- const rowsCount = Math.max(2, Math.round(h / gridStep));
- for (let gx = 0; gx <= colsCount; gx++) {
- const x = Math.round(gx * w / colsCount) + 0.5;
- ctx.beginPath(); ctx.moveTo(x, 0); ctx.lineTo(x, h); ctx.stroke();
- }
- for (let gy = 0; gy <= rowsCount; gy++) {
- const y = Math.round(gy * h / rowsCount) + 0.5;
- ctx.beginPath(); ctx.moveTo(0, y); ctx.lineTo(w, y); ctx.stroke();
- }
- }
-
- // Draw rectangles
- const dpr = window.devicePixelRatio || 1;
- patternEditorRects.forEach((rect, i) => {
- const rx = rect.x * w;
- const ry = rect.y * h;
- const rw = rect.width * w;
- const rh = rect.height * h;
- const colorIdx = i % PATTERN_RECT_COLORS.length;
- const isSelected = (i === patternEditorSelectedIdx);
- const isHovered = (i === patternEditorHoveredIdx) && !patternCanvasDragMode;
- const isDragging = (i === patternEditorSelectedIdx) && !!patternCanvasDragMode;
-
- // Fill — brighter when hovered or being dragged
- ctx.fillStyle = PATTERN_RECT_COLORS[colorIdx];
- ctx.fillRect(rx, ry, rw, rh);
- if (isHovered || isDragging) {
- ctx.fillStyle = 'rgba(255,255,255,0.08)';
- ctx.fillRect(rx, ry, rw, rh);
- }
-
- // Border
- ctx.strokeStyle = PATTERN_RECT_BORDERS[colorIdx];
- ctx.lineWidth = isSelected ? 3 : isHovered ? 2.5 : 1.5;
- ctx.strokeRect(rx, ry, rw, rh);
-
- // Determine edge direction to highlight (during drag or hover)
- let edgeDir = null;
- if (isDragging && patternCanvasDragMode.startsWith('resize-')) {
- edgeDir = patternCanvasDragMode.replace('resize-', '');
- } else if (isHovered && patternEditorHoverHit && patternEditorHoverHit !== 'move') {
- edgeDir = patternEditorHoverHit;
- }
-
- // Draw highlighted edge/corner indicator
- if (edgeDir) {
- ctx.save();
- ctx.strokeStyle = '#fff';
- ctx.lineWidth = 3 * dpr;
- ctx.shadowColor = 'rgba(76,175,80,0.7)';
- ctx.shadowBlur = 6 * dpr;
- ctx.beginPath();
- if (edgeDir.includes('n')) { ctx.moveTo(rx, ry); ctx.lineTo(rx + rw, ry); }
- if (edgeDir.includes('s')) { ctx.moveTo(rx, ry + rh); ctx.lineTo(rx + rw, ry + rh); }
- if (edgeDir.includes('w')) { ctx.moveTo(rx, ry); ctx.lineTo(rx, ry + rh); }
- if (edgeDir.includes('e')) { ctx.moveTo(rx + rw, ry); ctx.lineTo(rx + rw, ry + rh); }
- ctx.stroke();
- ctx.restore();
- }
-
- // Name label
- ctx.fillStyle = '#fff';
- ctx.font = `${12 * dpr}px sans-serif`;
- ctx.shadowColor = 'rgba(0,0,0,0.7)';
- ctx.shadowBlur = 3;
- ctx.fillText(rect.name, rx + 4 * dpr, ry + 14 * dpr);
- ctx.shadowBlur = 0;
-
- // Delete button on hovered or selected rects (not during drag)
- if ((isHovered || isSelected) && !patternCanvasDragMode) {
- const btnR = 9 * dpr;
- const btnCx = rx + rw - btnR - 2 * dpr;
- const btnCy = ry + btnR + 2 * dpr;
- ctx.beginPath();
- ctx.arc(btnCx, btnCy, btnR, 0, Math.PI * 2);
- ctx.fillStyle = 'rgba(0,0,0,0.6)';
- ctx.fill();
- ctx.strokeStyle = 'rgba(255,255,255,0.5)';
- ctx.lineWidth = 1;
- ctx.stroke();
- const cross = btnR * 0.5;
- ctx.beginPath();
- ctx.moveTo(btnCx - cross, btnCy - cross);
- ctx.lineTo(btnCx + cross, btnCy + cross);
- ctx.moveTo(btnCx + cross, btnCy - cross);
- ctx.lineTo(btnCx - cross, btnCy + cross);
- ctx.strokeStyle = '#fff';
- ctx.lineWidth = 1.5 * dpr;
- ctx.stroke();
- }
- });
-
- // Draw "add rectangle" placement buttons (4 corners + center) when not dragging
- if (!patternCanvasDragMode) {
- const abR = 12 * dpr;
- const abMargin = 18 * dpr;
- const addBtnPositions = [
- { cx: abMargin, cy: abMargin }, // top-left
- { cx: w - abMargin, cy: abMargin }, // top-right
- { cx: w / 2, cy: h / 2 }, // center
- { cx: abMargin, cy: h - abMargin }, // bottom-left
- { cx: w - abMargin, cy: h - abMargin }, // bottom-right
- ];
- addBtnPositions.forEach(pos => {
- ctx.beginPath();
- ctx.arc(pos.cx, pos.cy, abR, 0, Math.PI * 2);
- ctx.fillStyle = 'rgba(255,255,255,0.10)';
- ctx.fill();
- ctx.strokeStyle = 'rgba(255,255,255,0.25)';
- ctx.lineWidth = 1;
- ctx.stroke();
- const pl = abR * 0.5;
- ctx.beginPath();
- ctx.moveTo(pos.cx - pl, pos.cy);
- ctx.lineTo(pos.cx + pl, pos.cy);
- ctx.moveTo(pos.cx, pos.cy - pl);
- ctx.lineTo(pos.cx, pos.cy + pl);
- ctx.strokeStyle = 'rgba(255,255,255,0.5)';
- ctx.lineWidth = 1.5 * dpr;
- ctx.stroke();
- });
- }
-
-}
-
-// Placement button positions (relative 0-1 coords for where the new rect is anchored)
-const _ADD_BTN_ANCHORS = [
- { ax: 0, ay: 0 }, // top-left: rect starts at (0,0)
- { ax: 1, ay: 0 }, // top-right: rect ends at (1,0)
- { ax: 0.5, ay: 0.5 }, // center
- { ax: 0, ay: 1 }, // bottom-left: rect starts at (0, 1-h)
- { ax: 1, ay: 1 }, // bottom-right: rect ends at (1,1)
-];
-
-function _hitTestAddButtons(mx, my, w, h) {
- const dpr = window.devicePixelRatio || 1;
- const abR = 12 * dpr;
- const abMargin = 18 * dpr;
- const positions = [
- { cx: abMargin, cy: abMargin },
- { cx: w - abMargin, cy: abMargin },
- { cx: w / 2, cy: h / 2 },
- { cx: abMargin, cy: h - abMargin },
- { cx: w - abMargin, cy: h - abMargin },
- ];
- for (let i = 0; i < positions.length; i++) {
- const dx = mx - positions[i].cx, dy = my - positions[i].cy;
- if (dx * dx + dy * dy <= (abR + 3 * dpr) * (abR + 3 * dpr)) return i;
- }
- return -1;
-}
-
-function _addRectAtAnchor(anchorIdx) {
- const anchor = _ADD_BTN_ANCHORS[anchorIdx];
- const name = `Zone ${patternEditorRects.length + 1}`;
- let rw = 0.3, rh = 0.3;
- if (patternEditorSelectedIdx >= 0 && patternEditorSelectedIdx < patternEditorRects.length) {
- const sel = patternEditorRects[patternEditorSelectedIdx];
- rw = sel.width;
- rh = sel.height;
- }
- // Position based on anchor point
- let rx = anchor.ax - rw * anchor.ax; // 0→0, 0.5→centered, 1→1-rw
- let ry = anchor.ay - rh * anchor.ay;
- rx = Math.max(0, Math.min(1 - rw, rx));
- ry = Math.max(0, Math.min(1 - rh, ry));
- patternEditorRects.push({ name, x: rx, y: ry, width: rw, height: rh });
- patternEditorSelectedIdx = patternEditorRects.length - 1;
- renderPatternRectList();
- renderPatternCanvas();
-}
-
-// Hit-test a point against a rect's edges/corners. Returns a resize direction
-// string ('n','s','e','w','nw','ne','sw','se') or 'move' if inside, or null.
-const _EDGE_THRESHOLD = 8; // pixels (in canvas coords)
-
-function _hitTestRect(mx, my, r, w, h) {
- const rx = r.x * w, ry = r.y * h, rw = r.width * w, rh = r.height * h;
- const dpr = window.devicePixelRatio || 1;
- const thr = _EDGE_THRESHOLD * dpr;
-
- const nearLeft = Math.abs(mx - rx) <= thr;
- const nearRight = Math.abs(mx - (rx + rw)) <= thr;
- const nearTop = Math.abs(my - ry) <= thr;
- const nearBottom = Math.abs(my - (ry + rh)) <= thr;
- const inHRange = mx >= rx - thr && mx <= rx + rw + thr;
- const inVRange = my >= ry - thr && my <= ry + rh + thr;
-
- // Corners first (both edges near)
- if (nearTop && nearLeft && inHRange && inVRange) return 'nw';
- if (nearTop && nearRight && inHRange && inVRange) return 'ne';
- if (nearBottom && nearLeft && inHRange && inVRange) return 'sw';
- if (nearBottom && nearRight && inHRange && inVRange) return 'se';
-
- // Edges
- if (nearTop && inHRange) return 'n';
- if (nearBottom && inHRange) return 's';
- if (nearLeft && inVRange) return 'w';
- if (nearRight && inVRange) return 'e';
-
- // Interior (move)
- if (mx >= rx && mx <= rx + rw && my >= ry && my <= ry + rh) return 'move';
-
- return null;
-}
-
-const _DIR_CURSORS = {
- 'nw': 'nwse-resize', 'se': 'nwse-resize',
- 'ne': 'nesw-resize', 'sw': 'nesw-resize',
- 'n': 'ns-resize', 's': 'ns-resize',
- 'e': 'ew-resize', 'w': 'ew-resize',
- 'move': 'grab',
-};
-
-function _hitTestDeleteButton(mx, my, rect, w, h) {
- const dpr = window.devicePixelRatio || 1;
- const btnR = 9 * dpr;
- const rx = rect.x * w, ry = rect.y * h, rw = rect.width * w;
- const btnCx = rx + rw - btnR - 2 * dpr;
- const btnCy = ry + btnR + 2 * dpr;
- const dx = mx - btnCx, dy = my - btnCy;
- return (dx * dx + dy * dy) <= (btnR + 2 * dpr) * (btnR + 2 * dpr);
-}
-
-function _patternCanvasDragMove(e) {
- if (!patternCanvasDragMode || patternEditorSelectedIdx < 0) return;
- const canvas = document.getElementById('pattern-canvas');
- const w = canvas.width;
- const h = canvas.height;
- const canvasRect = canvas.getBoundingClientRect();
- const scaleX = w / canvasRect.width;
- const scaleY = h / canvasRect.height;
- const mx = (e.clientX - canvasRect.left) * scaleX;
- const my = (e.clientY - canvasRect.top) * scaleY;
-
- const dx = (mx - patternCanvasDragStart.mx) / w;
- const dy = (my - patternCanvasDragStart.my) / h;
- const orig = patternCanvasDragOrigRect;
- const r = patternEditorRects[patternEditorSelectedIdx];
-
- if (patternCanvasDragMode === 'move') {
- r.x = Math.max(0, Math.min(1 - r.width, orig.x + dx));
- r.y = Math.max(0, Math.min(1 - r.height, orig.y + dy));
- } else if (patternCanvasDragMode.startsWith('resize-')) {
- const dir = patternCanvasDragMode.replace('resize-', '');
- let nx = orig.x, ny = orig.y, nw = orig.width, nh = orig.height;
- if (dir.includes('w')) { nx = orig.x + dx; nw = orig.width - dx; }
- if (dir.includes('e')) { nw = orig.width + dx; }
- if (dir.includes('n')) { ny = orig.y + dy; nh = orig.height - dy; }
- if (dir.includes('s')) { nh = orig.height + dy; }
- if (nw < 0.02) { nw = 0.02; if (dir.includes('w')) nx = orig.x + orig.width - 0.02; }
- if (nh < 0.02) { nh = 0.02; if (dir.includes('n')) ny = orig.y + orig.height - 0.02; }
- nx = Math.max(0, Math.min(1 - nw, nx));
- ny = Math.max(0, Math.min(1 - nh, ny));
- nw = Math.min(1, nw);
- nh = Math.min(1, nh);
- r.x = nx; r.y = ny; r.width = nw; r.height = nh;
- }
- renderPatternCanvas();
-}
-
-function _patternCanvasDragEnd(e) {
- window.removeEventListener('mousemove', _patternCanvasDragMove);
- window.removeEventListener('mouseup', _patternCanvasDragEnd);
- patternCanvasDragMode = null;
- patternCanvasDragStart = null;
- patternCanvasDragOrigRect = null;
-
- // Recalculate hover at current mouse position
- const canvas = document.getElementById('pattern-canvas');
- if (canvas) {
- const w = canvas.width;
- const h = canvas.height;
- const canvasRect = canvas.getBoundingClientRect();
- const scaleX = w / canvasRect.width;
- const scaleY = h / canvasRect.height;
- const mx = (e.clientX - canvasRect.left) * scaleX;
- const my = (e.clientY - canvasRect.top) * scaleY;
- let cursor = 'default';
- let newHoverIdx = -1;
- let newHoverHit = null;
- if (e.clientX >= canvasRect.left && e.clientX <= canvasRect.right &&
- e.clientY >= canvasRect.top && e.clientY <= canvasRect.bottom) {
- for (let i = patternEditorRects.length - 1; i >= 0; i--) {
- const hit = _hitTestRect(mx, my, patternEditorRects[i], w, h);
- if (hit) {
- cursor = _DIR_CURSORS[hit] || 'default';
- newHoverIdx = i;
- newHoverHit = hit;
- break;
- }
- }
- }
- canvas.style.cursor = cursor;
- patternEditorHoveredIdx = newHoverIdx;
- patternEditorHoverHit = newHoverHit;
- }
- renderPatternRectList();
- renderPatternCanvas();
-}
-
-function _attachPatternCanvasEvents() {
- const canvas = document.getElementById('pattern-canvas');
- if (!canvas || canvas._patternEventsAttached) return;
- canvas._patternEventsAttached = true;
-
- canvas.addEventListener('mousedown', _patternCanvasMouseDown);
- canvas.addEventListener('mousemove', _patternCanvasMouseMove);
- canvas.addEventListener('mouseleave', _patternCanvasMouseLeave);
-
- // Touch support
- canvas.addEventListener('touchstart', (e) => {
- e.preventDefault();
- const touch = e.touches[0];
- _patternCanvasMouseDown(_touchToMouseEvent(canvas, touch, 'mousedown'));
- }, { passive: false });
- canvas.addEventListener('touchmove', (e) => {
- e.preventDefault();
- const touch = e.touches[0];
- if (patternCanvasDragMode) {
- _patternCanvasDragMove({ clientX: touch.clientX, clientY: touch.clientY });
- } else {
- _patternCanvasMouseMove(_touchToMouseEvent(canvas, touch, 'mousemove'));
- }
- }, { passive: false });
- canvas.addEventListener('touchend', () => {
- if (patternCanvasDragMode) {
- window.removeEventListener('mousemove', _patternCanvasDragMove);
- window.removeEventListener('mouseup', _patternCanvasDragEnd);
- patternCanvasDragMode = null;
- patternCanvasDragStart = null;
- patternCanvasDragOrigRect = null;
- patternEditorHoveredIdx = -1;
- patternEditorHoverHit = null;
- renderPatternRectList();
- renderPatternCanvas();
- }
- });
-
- // Resize observer — update canvas internal resolution when container is resized
- const container = canvas.parentElement;
- if (container && typeof ResizeObserver !== 'undefined') {
- const ro = new ResizeObserver(() => {
- const rect = container.getBoundingClientRect();
- const dpr = window.devicePixelRatio || 1;
- canvas.width = Math.round(rect.width * dpr);
- canvas.height = Math.round(rect.height * dpr);
- renderPatternCanvas();
- });
- ro.observe(container);
- canvas._patternResizeObserver = ro;
- }
-}
-
-function _touchToMouseEvent(canvas, touch, type) {
- const rect = canvas.getBoundingClientRect();
- return { type, offsetX: touch.clientX - rect.left, offsetY: touch.clientY - rect.top, preventDefault: () => {} };
-}
-
-function _patternCanvasMouseDown(e) {
- const canvas = document.getElementById('pattern-canvas');
- const w = canvas.width;
- const h = canvas.height;
- const rect = canvas.getBoundingClientRect();
- const scaleX = w / rect.width;
- const scaleY = h / rect.height;
- const mx = (e.offsetX !== undefined ? e.offsetX : e.clientX - rect.left) * scaleX;
- const my = (e.offsetY !== undefined ? e.offsetY : e.clientY - rect.top) * scaleY;
-
- // Check delete button on hovered or selected rects first
- for (const idx of [patternEditorHoveredIdx, patternEditorSelectedIdx]) {
- if (idx >= 0 && idx < patternEditorRects.length) {
- if (_hitTestDeleteButton(mx, my, patternEditorRects[idx], w, h)) {
- patternEditorRects.splice(idx, 1);
- if (patternEditorSelectedIdx === idx) patternEditorSelectedIdx = -1;
- else if (patternEditorSelectedIdx > idx) patternEditorSelectedIdx--;
- patternEditorHoveredIdx = -1;
- patternEditorHoverHit = null;
- renderPatternRectList();
- renderPatternCanvas();
- return;
- }
- }
- }
-
- // Test all rects in reverse order (top-most first).
- for (let i = patternEditorRects.length - 1; i >= 0; i--) {
- const r = patternEditorRects[i];
- const hit = _hitTestRect(mx, my, r, w, h);
- if (!hit) continue;
-
- patternEditorSelectedIdx = i;
- patternCanvasDragStart = { mx, my };
- patternCanvasDragOrigRect = { ...r };
-
- if (hit === 'move') {
- patternCanvasDragMode = 'move';
- canvas.style.cursor = 'grabbing';
- } else {
- patternCanvasDragMode = `resize-${hit}`;
- canvas.style.cursor = _DIR_CURSORS[hit] || 'default';
- }
-
- // Capture mouse at window level for drag
- window.addEventListener('mousemove', _patternCanvasDragMove);
- window.addEventListener('mouseup', _patternCanvasDragEnd);
- e.preventDefault();
-
- renderPatternRectList();
- renderPatternCanvas();
- return;
- }
-
- // Check placement "+" buttons (corners + center)
- const addIdx = _hitTestAddButtons(mx, my, w, h);
- if (addIdx >= 0) {
- _addRectAtAnchor(addIdx);
- return;
- }
-
- // Click on empty space — deselect
- patternEditorSelectedIdx = -1;
- patternCanvasDragMode = null;
- canvas.style.cursor = 'default';
- renderPatternRectList();
- renderPatternCanvas();
-}
-
-function _patternCanvasMouseMove(e) {
- // During drag, movement is handled by window-level _patternCanvasDragMove
- if (patternCanvasDragMode) return;
-
- const canvas = document.getElementById('pattern-canvas');
- const w = canvas.width;
- const h = canvas.height;
- const rect = canvas.getBoundingClientRect();
- const scaleX = w / rect.width;
- const scaleY = h / rect.height;
- const mx = (e.offsetX !== undefined ? e.offsetX : e.clientX - rect.left) * scaleX;
- const my = (e.offsetY !== undefined ? e.offsetY : e.clientY - rect.top) * scaleY;
-
- let cursor = 'default';
- let newHoverIdx = -1;
- let newHoverHit = null;
- for (let i = patternEditorRects.length - 1; i >= 0; i--) {
- const hit = _hitTestRect(mx, my, patternEditorRects[i], w, h);
- if (hit) {
- cursor = _DIR_CURSORS[hit] || 'default';
- newHoverIdx = i;
- newHoverHit = hit;
- break;
- }
- }
- canvas.style.cursor = cursor;
- if (newHoverIdx !== patternEditorHoveredIdx || newHoverHit !== patternEditorHoverHit) {
- patternEditorHoveredIdx = newHoverIdx;
- patternEditorHoverHit = newHoverHit;
- renderPatternCanvas();
- }
-}
-
-function _patternCanvasMouseLeave() {
- // During drag, window-level listeners handle everything
- if (patternCanvasDragMode) return;
- if (patternEditorHoveredIdx !== -1) {
- patternEditorHoveredIdx = -1;
- patternEditorHoverHit = null;
- renderPatternCanvas();
- }
-}
-
-async function capturePatternBackground() {
- const sourceId = document.getElementById('pattern-bg-source').value;
- if (!sourceId) {
- showToast(t('pattern.source_for_bg.none'), 'error');
- return;
- }
-
- try {
- const resp = await fetch(`${API_BASE}/picture-sources/${sourceId}/test`, {
- method: 'POST',
- headers: getHeaders(),
- body: JSON.stringify({ capture_duration: 0 }),
- });
- if (!resp.ok) throw new Error('Failed to capture');
- const data = await resp.json();
-
- if (data.full_capture && data.full_capture.full_image) {
- const img = new Image();
- img.onload = () => {
- patternEditorBgImage = img;
- renderPatternCanvas();
- };
- img.src = data.full_capture.full_image;
- }
- } catch (error) {
- console.error('Failed to capture background:', error);
- showToast('Failed to capture background', 'error');
- }
-}
-
-// =====================================================================
-// PROFILES
-// =====================================================================
-
-let _profilesCache = null;
-
-async function loadProfiles() {
- const container = document.getElementById('profiles-content');
- if (!container) return;
-
- try {
- const resp = await fetch(`${API_BASE}/profiles`, { headers: getHeaders() });
- if (!resp.ok) throw new Error('Failed to load profiles');
- const data = await resp.json();
- _profilesCache = data.profiles;
- renderProfiles(data.profiles);
- } catch (error) {
- console.error('Failed to load profiles:', error);
- container.innerHTML = `${error.message}
`;
- }
-}
-
-function renderProfiles(profiles) {
- const container = document.getElementById('profiles-content');
-
- let html = '';
- for (const p of profiles) {
- html += createProfileCard(p);
- }
- html += `
`;
- html += '
';
-
- container.innerHTML = html;
- updateAllText();
-}
-
-function createProfileCard(profile) {
- const statusClass = !profile.enabled ? 'disabled' : profile.is_active ? 'active' : 'inactive';
- const statusText = !profile.enabled ? t('profiles.status.disabled') : profile.is_active ? t('profiles.status.active') : t('profiles.status.inactive');
-
- // Condition summary as pills
- let condPills = '';
- if (profile.conditions.length === 0) {
- condPills = `${t('profiles.conditions.empty')}`;
- } else {
- const parts = profile.conditions.map(c => {
- if (c.condition_type === 'application') {
- const apps = (c.apps || []).join(', ');
- const matchLabel = c.match_type === 'topmost' ? t('profiles.condition.application.match_type.topmost') : t('profiles.condition.application.match_type.running');
- return `${t('profiles.condition.application')}: ${apps} (${matchLabel})`;
- }
- return `${c.condition_type}`;
- });
- const logicLabel = profile.condition_logic === 'and' ? ' AND ' : ' OR ';
- condPills = parts.join(`${logicLabel}`);
- }
-
- // Target count pill
- const targetCountText = `${profile.target_ids.length} target(s)${profile.is_active ? ` (${profile.active_target_ids.length} active)` : ''}`;
-
- // Last activation timestamp
- let lastActivityMeta = '';
- if (profile.last_activated_at) {
- const ts = new Date(profile.last_activated_at);
- lastActivityMeta = `🕐 ${ts.toLocaleString()}`;
- }
-
- return `
-
-
-
-
-
-
- ${profile.condition_logic === 'and' ? 'ALL' : 'ANY'}
- ⚡ ${targetCountText}
- ${lastActivityMeta}
-
-
${condPills}
-
-
-
-
-
`;
-}
-
-async function openProfileEditor(profileId) {
- const modal = document.getElementById('profile-editor-modal');
- const titleEl = document.getElementById('profile-editor-title');
- const idInput = document.getElementById('profile-editor-id');
- const nameInput = document.getElementById('profile-editor-name');
- const enabledInput = document.getElementById('profile-editor-enabled');
- const logicSelect = document.getElementById('profile-editor-logic');
- const condList = document.getElementById('profile-conditions-list');
- const errorEl = document.getElementById('profile-editor-error');
-
- errorEl.style.display = 'none';
- condList.innerHTML = '';
-
- // Load available targets for the checklist
- await loadProfileTargetChecklist([]);
-
- if (profileId) {
- // Edit mode
- titleEl.textContent = t('profiles.edit');
- try {
- const resp = await fetch(`${API_BASE}/profiles/${profileId}`, { headers: getHeaders() });
- if (!resp.ok) throw new Error('Failed to load profile');
- const profile = await resp.json();
-
- idInput.value = profile.id;
- nameInput.value = profile.name;
- enabledInput.checked = profile.enabled;
- logicSelect.value = profile.condition_logic;
-
- // Populate conditions
- for (const c of profile.conditions) {
- addProfileConditionRow(c);
- }
-
- // Populate target checklist
- await loadProfileTargetChecklist(profile.target_ids);
- } catch (e) {
- showToast(e.message, 'error');
- return;
- }
- } else {
- // Create mode
- titleEl.textContent = t('profiles.add');
- idInput.value = '';
- nameInput.value = '';
- enabledInput.checked = true;
- logicSelect.value = 'or';
- }
-
- modal.style.display = 'flex';
- lockBody();
- updateAllText();
-}
-
-function closeProfileEditorModal() {
- document.getElementById('profile-editor-modal').style.display = 'none';
- unlockBody();
-}
-
-async function loadProfileTargetChecklist(selectedIds) {
- const container = document.getElementById('profile-targets-list');
- try {
- const resp = await fetch(`${API_BASE}/picture-targets`, { headers: getHeaders() });
- if (!resp.ok) throw new Error('Failed to load targets');
- const data = await resp.json();
- const targets = data.targets || [];
-
- if (targets.length === 0) {
- container.innerHTML = `${t('profiles.targets.empty')}`;
- return;
- }
-
- container.innerHTML = targets.map(tgt => {
- const checked = selectedIds.includes(tgt.id) ? 'checked' : '';
- return ``;
- }).join('');
- } catch (e) {
- container.innerHTML = `${e.message}`;
- }
-}
-
-function addProfileCondition() {
- addProfileConditionRow({ condition_type: 'application', apps: [], match_type: 'running' });
-}
-
-function addProfileConditionRow(condition) {
- const list = document.getElementById('profile-conditions-list');
- const row = document.createElement('div');
- row.className = 'profile-condition-row';
-
- const appsValue = (condition.apps || []).join('\n');
- const matchType = condition.match_type || 'running';
-
- row.innerHTML = `
-
-
-
-
-
-
-
-
- `;
-
- // Wire up browse button
- const browseBtn = row.querySelector('.btn-browse-apps');
- const picker = row.querySelector('.process-picker');
- browseBtn.addEventListener('click', () => toggleProcessPicker(picker, row));
-
- // Wire up search filter
- const searchInput = row.querySelector('.process-picker-search');
- searchInput.addEventListener('input', () => filterProcessPicker(picker));
-
- list.appendChild(row);
-}
-
-async function toggleProcessPicker(picker, row) {
- if (picker.style.display !== 'none') {
- picker.style.display = 'none';
- return;
- }
-
- const listEl = picker.querySelector('.process-picker-list');
- const searchEl = picker.querySelector('.process-picker-search');
- searchEl.value = '';
- listEl.innerHTML = `${t('common.loading')}
`;
- picker.style.display = '';
-
- try {
- const resp = await fetch(`${API_BASE}/system/processes`, { headers: getHeaders() });
- if (resp.status === 401) { handle401Error(); return; }
- if (!resp.ok) throw new Error('Failed to fetch processes');
- const data = await resp.json();
-
- // Get already-added apps to mark them
- const textarea = row.querySelector('.condition-apps');
- const existing = new Set(textarea.value.split('\n').map(a => a.trim().toLowerCase()).filter(Boolean));
-
- picker._processes = data.processes;
- picker._existing = existing;
- renderProcessPicker(picker, data.processes, existing);
- searchEl.focus();
- } catch (e) {
- listEl.innerHTML = `${e.message}
`;
- }
-}
-
-function renderProcessPicker(picker, processes, existing) {
- const listEl = picker.querySelector('.process-picker-list');
- if (processes.length === 0) {
- listEl.innerHTML = `${t('profiles.condition.application.no_processes')}
`;
- return;
- }
- listEl.innerHTML = processes.map(p => {
- const added = existing.has(p.toLowerCase());
- return `${escapeHtml(p)}${added ? ' ✓' : ''}
`;
- }).join('');
-
- // Click handler for each item
- listEl.querySelectorAll('.process-picker-item:not(.added)').forEach(item => {
- item.addEventListener('click', () => {
- const proc = item.dataset.process;
- const row = picker.closest('.profile-condition-row');
- const textarea = row.querySelector('.condition-apps');
- const current = textarea.value.trim();
- textarea.value = current ? current + '\n' + proc : proc;
- item.classList.add('added');
- item.textContent = proc + ' ✓';
- // Update existing set
- picker._existing.add(proc.toLowerCase());
- });
- });
-}
-
-function filterProcessPicker(picker) {
- const query = picker.querySelector('.process-picker-search').value.toLowerCase();
- const filtered = (picker._processes || []).filter(p => p.includes(query));
- renderProcessPicker(picker, filtered, picker._existing || new Set());
-}
-
-function getProfileEditorConditions() {
- const rows = document.querySelectorAll('#profile-conditions-list .profile-condition-row');
- const conditions = [];
- rows.forEach(row => {
- const matchType = row.querySelector('.condition-match-type').value;
- const appsText = row.querySelector('.condition-apps').value.trim();
- const apps = appsText ? appsText.split('\n').map(a => a.trim()).filter(Boolean) : [];
- conditions.push({ condition_type: 'application', apps, match_type: matchType });
- });
- return conditions;
-}
-
-function getProfileEditorTargetIds() {
- const checkboxes = document.querySelectorAll('#profile-targets-list input[type="checkbox"]:checked');
- return Array.from(checkboxes).map(cb => cb.value);
-}
-
-async function saveProfileEditor() {
- const idInput = document.getElementById('profile-editor-id');
- const nameInput = document.getElementById('profile-editor-name');
- const enabledInput = document.getElementById('profile-editor-enabled');
- const logicSelect = document.getElementById('profile-editor-logic');
- const errorEl = document.getElementById('profile-editor-error');
-
- const name = nameInput.value.trim();
- if (!name) {
- errorEl.textContent = 'Name is required';
- errorEl.style.display = 'block';
- return;
- }
-
- const body = {
- name,
- enabled: enabledInput.checked,
- condition_logic: logicSelect.value,
- conditions: getProfileEditorConditions(),
- target_ids: getProfileEditorTargetIds(),
- };
-
- const profileId = idInput.value;
- const isEdit = !!profileId;
-
- try {
- const url = isEdit ? `${API_BASE}/profiles/${profileId}` : `${API_BASE}/profiles`;
- const resp = await fetch(url, {
- method: isEdit ? 'PUT' : 'POST',
- headers: getHeaders(),
- body: JSON.stringify(body),
- });
- if (!resp.ok) {
- const err = await resp.json().catch(() => ({}));
- throw new Error(err.detail || 'Failed to save profile');
- }
-
- closeProfileEditorModal();
- showToast(isEdit ? 'Profile updated' : 'Profile created', 'success');
- loadProfiles();
- } catch (e) {
- errorEl.textContent = e.message;
- errorEl.style.display = 'block';
- }
-}
-
-async function toggleProfileEnabled(profileId, enable) {
- try {
- const action = enable ? 'enable' : 'disable';
- const resp = await fetch(`${API_BASE}/profiles/${profileId}/${action}`, {
- method: 'POST',
- headers: getHeaders(),
- });
- if (!resp.ok) throw new Error(`Failed to ${action} profile`);
- loadProfiles();
- } catch (e) {
- showToast(e.message, 'error');
- }
-}
-
-async function deleteProfile(profileId, profileName) {
- const msg = t('profiles.delete.confirm').replace('{name}', profileName);
- const confirmed = await showConfirm(msg);
- if (!confirmed) return;
-
- try {
- const resp = await fetch(`${API_BASE}/profiles/${profileId}`, {
- method: 'DELETE',
- headers: getHeaders(),
- });
- if (!resp.ok) throw new Error('Failed to delete profile');
- showToast('Profile deleted', 'success');
- loadProfiles();
- } catch (e) {
- showToast(e.message, 'error');
- }
-}
diff --git a/server/src/wled_controller/static/index.html b/server/src/wled_controller/static/index.html
index 05ab0fc..74480d6 100644
--- a/server/src/wled_controller/static/index.html
+++ b/server/src/wled_controller/static/index.html
@@ -1103,7 +1103,7 @@
-
+