const API_BASE = '/api/v1';
let refreshInterval = null;
let apiKey = null;
// Track logged errors to avoid console spam
const loggedErrors = new Map(); // deviceId -> { errorCount, lastError }
// Calibration test mode state
const calibrationTestState = {}; // deviceId -> Set of active edge names
// Modal dirty tracking - stores initial values when modals open
let settingsInitialValues = {};
let calibrationInitialValues = {};
const EDGE_TEST_COLORS = {
top: [255, 0, 0],
right: [0, 255, 0],
bottom: [0, 100, 255],
left: [255, 255, 0]
};
// Modal body lock helpers - prevent layout jump when scrollbar disappears
function lockBody() {
const scrollbarWidth = window.innerWidth - document.documentElement.clientWidth;
document.body.style.paddingRight = scrollbarWidth + 'px';
document.body.classList.add('modal-open');
}
function unlockBody() {
document.body.classList.remove('modal-open');
document.body.style.paddingRight = '';
}
// Image lightbox
function openLightbox(imageSrc, statsHtml) {
const lightbox = document.getElementById('image-lightbox');
const img = document.getElementById('lightbox-image');
const statsEl = document.getElementById('lightbox-stats');
img.src = imageSrc;
if (statsHtml) {
statsEl.innerHTML = statsHtml;
statsEl.style.display = '';
} else {
statsEl.style.display = 'none';
}
lightbox.classList.add('active');
lockBody();
}
function closeLightbox(event) {
if (event && event.target && event.target.closest('.lightbox-content')) return;
const lightbox = document.getElementById('image-lightbox');
lightbox.classList.remove('active');
document.getElementById('lightbox-image').src = '';
document.getElementById('lightbox-stats').style.display = 'none';
unlockBody();
}
document.addEventListener('keydown', (e) => {
if (e.key === 'Escape' && document.getElementById('image-lightbox').classList.contains('active')) {
closeLightbox();
}
});
// Locale management
let currentLocale = 'en';
let translations = {};
const supportedLocales = {
'en': 'English',
'ru': 'Русский'
};
// Minimal inline fallback for critical UI elements
const fallbackTranslations = {
'app.title': 'WLED Screen Controller',
'auth.placeholder': 'Enter your API key...',
'auth.button.login': 'Login'
};
// Translation function
function t(key, params = {}) {
let text = translations[key] || fallbackTranslations[key] || key;
// Replace parameters like {name}, {value}, etc.
Object.keys(params).forEach(param => {
text = text.replace(new RegExp(`\\{${param}\\}`, 'g'), params[param]);
});
return text;
}
// Load translation file
async function loadTranslations(locale) {
try {
const response = await fetch(`/static/locales/${locale}.json`);
if (!response.ok) {
throw new Error(`Failed to load ${locale}.json`);
}
return await response.json();
} catch (error) {
console.error(`Error loading translations for ${locale}:`, error);
// Fallback to English if loading fails
if (locale !== 'en') {
return await loadTranslations('en');
}
return {};
}
}
// Detect browser locale
function detectBrowserLocale() {
const browserLang = navigator.language || navigator.languages?.[0] || 'en';
const langCode = browserLang.split('-')[0]; // 'en-US' -> 'en', 'ru-RU' -> 'ru'
// Only return if we support it
return supportedLocales[langCode] ? langCode : 'en';
}
// Initialize locale
async function initLocale() {
const savedLocale = localStorage.getItem('locale') || detectBrowserLocale();
await setLocale(savedLocale);
}
// Set locale
async function setLocale(locale) {
if (!supportedLocales[locale]) {
locale = 'en';
}
// Load translations for the locale
translations = await loadTranslations(locale);
currentLocale = locale;
document.documentElement.setAttribute('data-locale', locale);
document.documentElement.setAttribute('lang', locale);
localStorage.setItem('locale', locale);
// Update all text
updateAllText();
// Update locale select dropdown (if visible)
updateLocaleSelect();
}
// Change locale from dropdown
function changeLocale() {
const select = document.getElementById('locale-select');
const newLocale = select.value;
if (newLocale && newLocale !== currentLocale) {
localStorage.setItem('locale', newLocale);
setLocale(newLocale);
}
}
// Update locale select dropdown
function updateLocaleSelect() {
const select = document.getElementById('locale-select');
if (select) {
select.value = currentLocale;
}
}
// Update all text on page
function updateAllText() {
// Update all elements with data-i18n attribute
document.querySelectorAll('[data-i18n]').forEach(el => {
const key = el.getAttribute('data-i18n');
el.textContent = t(key);
});
// Update all elements with data-i18n-placeholder attribute
document.querySelectorAll('[data-i18n-placeholder]').forEach(el => {
const key = el.getAttribute('data-i18n-placeholder');
el.placeholder = t(key);
});
// Update all elements with data-i18n-title attribute
document.querySelectorAll('[data-i18n-title]').forEach(el => {
const key = el.getAttribute('data-i18n-title');
el.title = t(key);
});
// Re-render dynamic content with new translations
if (apiKey) {
loadDisplays();
loadDevices();
}
}
// Initialize app
document.addEventListener('DOMContentLoaded', async () => {
// Initialize locale first
await initLocale();
// Show content now that translations are loaded
document.body.style.visibility = 'visible';
// Load API key from localStorage
apiKey = localStorage.getItem('wled_api_key');
// Restore active tab (after API key is loaded)
initTabs();
// Setup form handler
document.getElementById('add-device-form').addEventListener('submit', handleAddDevice);
// Show modal if no API key is stored
if (!apiKey) {
// Wait for modal functions to be defined
setTimeout(() => {
if (typeof showApiKeyModal === 'function') {
showApiKeyModal('Welcome! Please login with your API key to get started.', true);
}
}, 100);
return; // Don't load data yet
}
// User is logged in, load data
loadServerInfo();
loadDisplays();
loadDevices();
// Start auto-refresh
startAutoRefresh();
});
// Helper function to add auth header if needed
function getHeaders() {
const headers = {
'Content-Type': 'application/json'
};
if (apiKey) {
headers['Authorization'] = `Bearer ${apiKey}`;
}
return headers;
}
// Fetch wrapper that automatically includes auth headers
async function fetchWithAuth(url, options = {}) {
// Build full URL if relative path provided
const fullUrl = url.startsWith('http') ? url : `${API_BASE}${url}`;
// Merge auth headers with any custom headers
const headers = options.headers
? { ...getHeaders(), ...options.headers }
: getHeaders();
// Make request with merged options
return fetch(fullUrl, {
...options,
headers
});
}
// Escape HTML to prevent XSS
function escapeHtml(text) {
if (!text) return '';
const div = document.createElement('div');
div.textContent = text;
return div.innerHTML;
}
// Handle 401 errors by showing login modal
function handle401Error() {
// Clear invalid API key
localStorage.removeItem('wled_api_key');
apiKey = null;
// Stop auto-refresh to prevent repeated 401 errors
if (refreshInterval) {
clearInterval(refreshInterval);
refreshInterval = null;
}
if (typeof updateAuthUI === 'function') {
updateAuthUI();
}
if (typeof showApiKeyModal === 'function') {
showApiKeyModal('Your session has expired or the API key is invalid. Please login again.', true);
} else {
showToast('Authentication failed. Please reload the page and login.', 'error');
}
}
// Configure API key
function configureApiKey() {
const currentKey = localStorage.getItem('wled_api_key');
const message = currentKey
? 'Current API key is set. Enter new key to update or leave blank to remove:'
: 'Enter your API key:';
const key = prompt(message);
if (key === null) {
return; // Cancelled
}
if (key === '') {
localStorage.removeItem('wled_api_key');
apiKey = null;
document.getElementById('api-key-btn').style.display = 'none';
showToast('API key removed', 'info');
} else {
localStorage.setItem('wled_api_key', key);
apiKey = key;
document.getElementById('api-key-btn').style.display = 'inline-block';
showToast('API key updated', 'success');
}
// Reload data with new key
loadServerInfo();
loadDisplays();
loadDevices();
}
// Server info
async function loadServerInfo() {
try {
const response = await fetch('/health');
const data = await response.json();
document.getElementById('version-number').textContent = `v${data.version}`;
document.getElementById('server-status').textContent = '●';
document.getElementById('server-status').className = 'status-badge online';
} catch (error) {
console.error('Failed to load server info:', error);
document.getElementById('server-status').className = 'status-badge offline';
showToast(t('server.offline'), 'error');
}
}
// Load displays
async function loadDisplays() {
try {
const response = await fetch(`${API_BASE}/config/displays`, {
headers: getHeaders()
});
if (response.status === 401) {
handle401Error();
return;
}
const data = await response.json();
const container = document.getElementById('displays-list');
if (!data.displays || data.displays.length === 0) {
container.innerHTML = `
${t('displays.none')}
`;
document.getElementById('display-layout-canvas').innerHTML = `${t('displays.none')}
`;
return;
}
// Cache and render visual layout
_cachedDisplays = data.displays;
renderDisplayLayout(data.displays);
} catch (error) {
console.error('Failed to load displays:', error);
document.getElementById('displays-list').innerHTML =
`${t('displays.failed')}
`;
document.getElementById('display-layout-canvas').innerHTML =
`${t('displays.failed')}
`;
}
}
let _cachedDisplays = null;
function switchTab(name) {
document.querySelectorAll('.tab-btn').forEach(btn => btn.classList.toggle('active', btn.dataset.tab === name));
document.querySelectorAll('.tab-panel').forEach(panel => panel.classList.toggle('active', panel.id === `tab-${name}`));
localStorage.setItem('activeTab', name);
if (name === 'displays' && _cachedDisplays) {
requestAnimationFrame(() => renderDisplayLayout(_cachedDisplays));
}
if (name === 'templates') {
loadCaptureTemplates();
}
if (name === 'streams') {
loadPictureStreams();
}
if (name === 'pp-templates') {
loadPPTemplates();
}
}
function initTabs() {
const saved = localStorage.getItem('activeTab');
if (saved && document.getElementById(`tab-${saved}`)) {
switchTab(saved);
}
}
function renderDisplayLayout(displays) {
const canvas = document.getElementById('display-layout-canvas');
if (!displays || displays.length === 0) {
canvas.innerHTML = `${t('displays.none')}
`;
return;
}
// Calculate bounding box for all displays
let minX = Infinity, minY = Infinity, maxX = -Infinity, maxY = -Infinity;
displays.forEach(display => {
minX = Math.min(minX, display.x);
minY = Math.min(minY, display.y);
maxX = Math.max(maxX, display.x + display.width);
maxY = Math.max(maxY, display.y + display.height);
});
const totalWidth = maxX - minX;
const totalHeight = maxY - minY;
// Scale factor to fit in canvas (respect available width, maintain aspect ratio)
const availableWidth = canvas.clientWidth - 60; // account for padding
const maxCanvasHeight = 350;
const scaleX = availableWidth / totalWidth;
const scaleY = maxCanvasHeight / totalHeight;
const scale = Math.min(scaleX, scaleY);
const canvasWidth = totalWidth * scale;
const canvasHeight = totalHeight * scale;
// Create display elements
const displayElements = displays.map(display => {
const left = (display.x - minX) * scale;
const top = (display.y - minY) * scale;
const width = display.width * scale;
const height = display.height * scale;
return `
(${display.x}, ${display.y})
#${display.index}
${display.name}
${display.width}×${display.height}
${display.refresh_rate}Hz
${display.is_primary ? '
★
' : ''}
`;
}).join('');
canvas.innerHTML = `
${displayElements}
`;
}
// Load devices
async function loadDevices() {
try {
const response = await fetch(`${API_BASE}/devices`, {
headers: getHeaders()
});
if (response.status === 401) {
handle401Error();
return;
}
const data = await response.json();
const devices = data.devices || [];
const container = document.getElementById('devices-list');
if (!devices || devices.length === 0) {
container.innerHTML = ``;
return;
}
// Fetch state for each device
const devicesWithState = await Promise.all(
devices.map(async (device) => {
try {
const stateResponse = await fetch(`${API_BASE}/devices/${device.id}/state`, {
headers: getHeaders()
});
const state = await stateResponse.json();
const metricsResponse = await fetch(`${API_BASE}/devices/${device.id}/metrics`, {
headers: getHeaders()
});
const metrics = await metricsResponse.json();
// Log device errors only when they change (avoid console spam)
const deviceKey = device.id;
const lastLogged = loggedErrors.get(deviceKey);
const hasNewErrors = !lastLogged ||
lastLogged.errorCount !== metrics.errors_count ||
lastLogged.lastError !== metrics.last_error;
if (metrics.errors_count > 0 && hasNewErrors) {
console.warn(`[Device: ${device.name || device.id}] Has ${metrics.errors_count} error(s)`);
// Log recent errors from state
if (state.errors && state.errors.length > 0) {
console.error('Recent errors:');
state.errors.forEach((error, index) => {
console.error(` ${index + 1}. ${error}`);
});
}
// Log last error from metrics
if (metrics.last_error) {
console.error('Last error:', metrics.last_error);
}
// Log full state and metrics for debugging
console.log('Full state:', state);
console.log('Full metrics:', metrics);
// Update tracking
loggedErrors.set(deviceKey, {
errorCount: metrics.errors_count,
lastError: metrics.last_error
});
} else if (metrics.errors_count === 0 && lastLogged) {
// Clear tracking when errors are resolved
console.log(`[Device: ${device.name || device.id}] Errors cleared`);
loggedErrors.delete(deviceKey);
}
return { ...device, state, metrics };
} catch (error) {
console.error(`Failed to load state for device ${device.id}:`, error);
return device;
}
})
);
container.innerHTML = devicesWithState.map(device => createDeviceCard(device)).join('')
+ ``;
// Update footer WLED Web UI link with first device's URL
const webuiLink = document.querySelector('.wled-webui-link');
if (webuiLink && devicesWithState.length > 0 && devicesWithState[0].url) {
webuiLink.href = devicesWithState[0].url;
webuiLink.target = '_blank';
webuiLink.rel = 'noopener';
}
// Attach event listeners
devicesWithState.forEach(device => {
attachDeviceListeners(device.id);
});
} catch (error) {
console.error('Failed to load devices:', error);
document.getElementById('devices-list').innerHTML =
`${t('devices.failed')}
`;
}
}
function createDeviceCard(device) {
const state = device.state || {};
const metrics = device.metrics || {};
const settings = device.settings || {};
const isProcessing = state.processing || false;
const brightnessPercent = Math.round((settings.brightness !== undefined ? settings.brightness : 1.0) * 100);
const statusKey = isProcessing ? 'device.status.processing' : 'device.status.idle';
const status = isProcessing ? 'processing' : 'idle';
// WLED device health indicator
const wledOnline = state.wled_online || false;
const wledLatency = state.wled_latency_ms;
const wledName = state.wled_name;
const wledVersion = state.wled_version;
const wledLastChecked = state.wled_last_checked;
let healthClass, healthTitle, healthLabel;
if (wledLastChecked === null || wledLastChecked === undefined) {
healthClass = 'health-unknown';
healthTitle = t('device.health.checking');
healthLabel = '';
} else if (wledOnline) {
healthClass = 'health-online';
healthTitle = `${t('device.health.online')}`;
if (wledName) healthTitle += ` - ${wledName}`;
if (wledVersion) healthTitle += ` v${wledVersion}`;
if (wledLatency !== null && wledLatency !== undefined) healthTitle += ` (${Math.round(wledLatency)}ms)`;
healthLabel = '';
} else {
healthClass = 'health-offline';
healthTitle = t('device.health.offline');
if (state.wled_error) healthTitle += `: ${state.wled_error}`;
healthLabel = `${t('device.health.offline')} `;
}
const ledCount = state.wled_led_count || device.led_count;
return `
✕
${wledVersion ? `v${wledVersion} ` : ''}
${ledCount ? `💡 ${ledCount} ` : ''}
${state.wled_led_type ? `🔌 ${state.wled_led_type.replace(/ RGBW$/, '')} ` : ''}
${state.wled_rgbw ? ' ' : ''}
${isProcessing ? `
${state.fps_actual?.toFixed(1) || '0.0'}
${t('device.metrics.actual_fps')}
${state.fps_target || 0}
${t('device.metrics.target_fps')}
${metrics.frames_processed || 0}
${t('device.metrics.frames')}
${metrics.errors_count || 0}
${t('device.metrics.errors')}
` : ''}
${isProcessing ? `
⏹️
` : `
▶️
`}
⚙️
📺
📐
${device.url ? `
🌐
` : ''}
?
`;
}
function attachDeviceListeners(deviceId) {
// Add any specific event listeners here if needed
}
// Device actions
async function startProcessing(deviceId) {
console.log(`[Device: ${deviceId}] Starting processing...`);
try {
const response = await fetch(`${API_BASE}/devices/${deviceId}/start`, {
method: 'POST',
headers: getHeaders()
});
if (response.status === 401) {
handle401Error();
return;
}
if (response.ok) {
console.log(`[Device: ${deviceId}] Processing started successfully`);
showToast('Processing started', 'success');
loadDevices();
} else {
const error = await response.json();
console.error(`[Device: ${deviceId}] Failed to start:`, error);
showToast(`Failed to start: ${error.detail}`, 'error');
}
} catch (error) {
console.error(`[Device: ${deviceId}] Failed to start processing:`, error);
showToast('Failed to start processing', 'error');
}
}
async function stopProcessing(deviceId) {
console.log(`[Device: ${deviceId}] Stopping processing...`);
try {
const response = await fetch(`${API_BASE}/devices/${deviceId}/stop`, {
method: 'POST',
headers: getHeaders()
});
if (response.status === 401) {
handle401Error();
return;
}
if (response.ok) {
console.log(`[Device: ${deviceId}] Processing stopped successfully`);
showToast('Processing stopped', 'success');
loadDevices();
} else {
const error = await response.json();
console.error(`[Device: ${deviceId}] Failed to stop:`, error);
showToast(`Failed to stop: ${error.detail}`, 'error');
}
} catch (error) {
console.error(`[Device: ${deviceId}] Failed to stop processing:`, error);
showToast('Failed to stop processing', 'error');
}
}
async function removeDevice(deviceId) {
const confirmed = await showConfirm(t('device.remove.confirm'));
if (!confirmed) {
return;
}
try {
const response = await fetch(`${API_BASE}/devices/${deviceId}`, {
method: 'DELETE',
headers: getHeaders()
});
if (response.status === 401) {
handle401Error();
return;
}
if (response.ok) {
showToast('Device removed', 'success');
loadDevices();
} else {
const error = await response.json();
showToast(`Failed to remove: ${error.detail}`, 'error');
}
} catch (error) {
console.error('Failed to remove device:', error);
showToast('Failed to remove device', 'error');
}
}
async function showSettings(deviceId) {
try {
const deviceResponse = await fetch(`${API_BASE}/devices/${deviceId}`, { headers: getHeaders() });
if (deviceResponse.status === 401) {
handle401Error();
return;
}
if (!deviceResponse.ok) {
showToast('Failed to load device settings', 'error');
return;
}
const device = await deviceResponse.json();
// Populate fields
document.getElementById('settings-device-id').value = device.id;
document.getElementById('settings-device-name').value = device.name;
document.getElementById('settings-device-url').value = device.url;
document.getElementById('settings-health-interval').value = device.settings.state_check_interval || 30;
// Snapshot initial values for dirty checking
settingsInitialValues = {
name: device.name,
url: device.url,
state_check_interval: String(device.settings.state_check_interval || 30),
};
// Show modal
const modal = document.getElementById('device-settings-modal');
modal.style.display = 'flex';
lockBody();
// Focus first input
setTimeout(() => {
document.getElementById('settings-device-name').focus();
}, 100);
} catch (error) {
console.error('Failed to load device settings:', error);
showToast('Failed to load device settings', 'error');
}
}
function isSettingsDirty() {
return (
document.getElementById('settings-device-name').value !== settingsInitialValues.name ||
document.getElementById('settings-device-url').value !== settingsInitialValues.url ||
document.getElementById('settings-health-interval').value !== settingsInitialValues.state_check_interval
);
}
function forceCloseDeviceSettingsModal() {
const modal = document.getElementById('device-settings-modal');
const error = document.getElementById('settings-error');
modal.style.display = 'none';
error.style.display = 'none';
unlockBody();
settingsInitialValues = {};
}
async function closeDeviceSettingsModal() {
if (isSettingsDirty()) {
const confirmed = await showConfirm(t('modal.discard_changes'));
if (!confirmed) return;
}
forceCloseDeviceSettingsModal();
}
async function saveDeviceSettings() {
const deviceId = document.getElementById('settings-device-id').value;
const name = document.getElementById('settings-device-name').value.trim();
const url = document.getElementById('settings-device-url').value.trim();
const state_check_interval = parseInt(document.getElementById('settings-health-interval').value) || 30;
const error = document.getElementById('settings-error');
// Validation
if (!name || !url) {
error.textContent = 'Please fill in all fields correctly';
error.style.display = 'block';
return;
}
try {
// Update device info (name, url)
const deviceResponse = await fetch(`${API_BASE}/devices/${deviceId}`, {
method: 'PUT',
headers: getHeaders(),
body: JSON.stringify({ name, url })
});
if (deviceResponse.status === 401) {
handle401Error();
return;
}
if (!deviceResponse.ok) {
const errorData = await deviceResponse.json();
error.textContent = `Failed to update device: ${errorData.detail}`;
error.style.display = 'block';
return;
}
// Update settings (health check interval)
const settingsResponse = await fetch(`${API_BASE}/devices/${deviceId}/settings`, {
method: 'PUT',
headers: getHeaders(),
body: JSON.stringify({ state_check_interval })
});
if (settingsResponse.status === 401) {
handle401Error();
return;
}
if (settingsResponse.ok) {
showToast(t('settings.saved'), 'success');
forceCloseDeviceSettingsModal();
loadDevices();
} else {
const errorData = await settingsResponse.json();
error.textContent = `Failed to update settings: ${errorData.detail}`;
error.style.display = 'block';
}
} catch (err) {
console.error('Failed to save device settings:', err);
error.textContent = 'Failed to save settings';
error.style.display = 'block';
}
}
// ===== Capture Settings Modal =====
let captureSettingsInitialValues = {};
async function showCaptureSettings(deviceId) {
try {
// Fetch device data, displays, templates, and settings in parallel
const [deviceResponse, displaysResponse, templatesResponse, settingsResponse] = await Promise.all([
fetch(`${API_BASE}/devices/${deviceId}`, { headers: getHeaders() }),
fetch(`${API_BASE}/config/displays`, { headers: getHeaders() }),
fetchWithAuth('/capture-templates'),
fetch(`${API_BASE}/devices/${deviceId}/settings`, { headers: getHeaders() }),
]);
if (deviceResponse.status === 401) {
handle401Error();
return;
}
if (!deviceResponse.ok) {
showToast('Failed to load capture settings', 'error');
return;
}
const device = await deviceResponse.json();
const currentSettings = settingsResponse.ok ? await settingsResponse.json() : {};
// Populate display index select
const displaySelect = document.getElementById('capture-settings-display-index');
displaySelect.innerHTML = '';
if (displaysResponse.ok) {
const displaysData = await displaysResponse.json();
(displaysData.displays || []).forEach(d => {
const opt = document.createElement('option');
opt.value = d.index;
opt.textContent = `${d.index}: ${d.width}x${d.height}${d.is_primary ? ` (${t('displays.badge.primary')})` : ''}`;
displaySelect.appendChild(opt);
});
}
if (displaySelect.options.length === 0) {
const opt = document.createElement('option');
opt.value = '0';
opt.textContent = '0';
displaySelect.appendChild(opt);
}
displaySelect.value = String(device.settings.display_index ?? 0);
// Populate FPS slider
const fpsValue = Math.max(10, Math.min(90, currentSettings.fps ?? 30));
document.getElementById('capture-settings-fps').value = fpsValue;
document.getElementById('capture-settings-fps-value').textContent = fpsValue;
// Populate capture template select
const templateSelect = document.getElementById('capture-settings-template');
templateSelect.innerHTML = '';
if (templatesResponse.ok) {
const templatesData = await templatesResponse.json();
(templatesData.templates || []).forEach(t => {
const opt = document.createElement('option');
opt.value = t.id;
const engineIcon = getEngineIcon(t.engine_type);
opt.textContent = `${engineIcon} ${t.name}`;
templateSelect.appendChild(opt);
});
}
templateSelect.value = device.capture_template_id || '';
// Store device ID, current settings snapshot, and initial values for dirty check
document.getElementById('capture-settings-device-id').value = device.id;
captureSettingsInitialValues = {
display_index: String(device.settings.display_index ?? 0),
fps: String(currentSettings.fps ?? 30),
capture_template_id: device.capture_template_id || '',
_currentSettings: currentSettings,
};
// Show modal
const modal = document.getElementById('capture-settings-modal');
modal.style.display = 'flex';
lockBody();
} catch (error) {
console.error('Failed to load capture settings:', error);
showToast('Failed to load capture settings', 'error');
}
}
function isCaptureSettingsDirty() {
return (
document.getElementById('capture-settings-display-index').value !== captureSettingsInitialValues.display_index ||
document.getElementById('capture-settings-fps').value !== captureSettingsInitialValues.fps ||
document.getElementById('capture-settings-template').value !== captureSettingsInitialValues.capture_template_id
);
}
function forceCloseCaptureSettingsModal() {
const modal = document.getElementById('capture-settings-modal');
const error = document.getElementById('capture-settings-error');
modal.style.display = 'none';
error.style.display = 'none';
unlockBody();
captureSettingsInitialValues = {};
}
async function closeCaptureSettingsModal() {
if (isCaptureSettingsDirty()) {
const confirmed = await showConfirm(t('modal.discard_changes'));
if (!confirmed) return;
}
forceCloseCaptureSettingsModal();
}
async function saveCaptureSettings() {
const deviceId = document.getElementById('capture-settings-device-id').value;
const display_index = parseInt(document.getElementById('capture-settings-display-index').value) || 0;
const fps = parseInt(document.getElementById('capture-settings-fps').value) || 30;
const capture_template_id = document.getElementById('capture-settings-template').value;
const error = document.getElementById('capture-settings-error');
try {
// Update capture template on device
const deviceResponse = await fetch(`${API_BASE}/devices/${deviceId}`, {
method: 'PUT',
headers: getHeaders(),
body: JSON.stringify({ capture_template_id })
});
if (deviceResponse.status === 401) {
handle401Error();
return;
}
if (!deviceResponse.ok) {
const errorData = await deviceResponse.json();
error.textContent = `Failed to update capture template: ${errorData.detail}`;
error.style.display = 'block';
return;
}
// Merge changed fields with current settings to avoid resetting other values
const mergedSettings = {
...(captureSettingsInitialValues._currentSettings || {}),
display_index,
fps,
};
// Update settings
const settingsResponse = await fetch(`${API_BASE}/devices/${deviceId}/settings`, {
method: 'PUT',
headers: getHeaders(),
body: JSON.stringify(mergedSettings)
});
if (settingsResponse.status === 401) {
handle401Error();
return;
}
if (settingsResponse.ok) {
// Remember last used template for new device creation
localStorage.setItem('lastCaptureTemplateId', capture_template_id);
showToast(t('settings.capture.saved'), 'success');
forceCloseCaptureSettingsModal();
loadDevices();
} else {
const errorData = await settingsResponse.json();
error.textContent = `Failed to update settings: ${errorData.detail}`;
error.style.display = 'block';
}
} catch (err) {
console.error('Failed to save capture settings:', err);
error.textContent = t('settings.capture.failed');
error.style.display = 'block';
}
}
// Card brightness controls
function updateBrightnessLabel(deviceId, value) {
const slider = document.querySelector(`[data-device-id="${deviceId}"] .brightness-slider`);
if (slider) slider.title = value + '%';
}
async function saveCardBrightness(deviceId, value) {
const brightness = parseInt(value) / 100.0;
try {
await fetch(`${API_BASE}/devices/${deviceId}/settings`, {
method: 'PUT',
headers: getHeaders(),
body: JSON.stringify({ brightness })
});
} catch (err) {
console.error('Failed to update brightness:', err);
showToast('Failed to update brightness', 'error');
}
}
// Add device modal
function showAddDevice() {
const modal = document.getElementById('add-device-modal');
const form = document.getElementById('add-device-form');
const error = document.getElementById('add-device-error');
form.reset();
error.style.display = 'none';
modal.style.display = 'flex';
lockBody();
setTimeout(() => document.getElementById('device-name').focus(), 100);
}
function closeAddDeviceModal() {
const modal = document.getElementById('add-device-modal');
modal.style.display = 'none';
unlockBody();
}
async function handleAddDevice(event) {
event.preventDefault();
const name = document.getElementById('device-name').value.trim();
const url = document.getElementById('device-url').value.trim();
const error = document.getElementById('add-device-error');
if (!name || !url) {
error.textContent = 'Please fill in all fields';
error.style.display = 'block';
return;
}
try {
const body = { name, url };
const lastTemplateId = localStorage.getItem('lastCaptureTemplateId');
if (lastTemplateId) {
body.capture_template_id = lastTemplateId;
}
const response = await fetch(`${API_BASE}/devices`, {
method: 'POST',
headers: getHeaders(),
body: JSON.stringify(body)
});
if (response.status === 401) {
handle401Error();
return;
}
if (response.ok) {
const result = await response.json();
console.log('Device added successfully:', result);
showToast('Device added successfully', 'success');
closeAddDeviceModal();
await loadDevices();
// Auto-start device tutorial on first device add
if (!localStorage.getItem('deviceTutorialSeen')) {
localStorage.setItem('deviceTutorialSeen', '1');
setTimeout(() => startDeviceTutorial(), 300);
}
} else {
const errorData = await response.json();
console.error('Failed to add device:', errorData);
error.textContent = `Failed to add device: ${errorData.detail}`;
error.style.display = 'block';
}
} catch (err) {
console.error('Failed to add device:', err);
showToast('Failed to add device', 'error');
}
}
// Auto-refresh
function startAutoRefresh() {
if (refreshInterval) {
clearInterval(refreshInterval);
}
refreshInterval = setInterval(() => {
// Only refresh if user is authenticated
if (apiKey) {
loadDevices();
}
}, 2000); // Refresh every 2 seconds
}
// Toast notifications
function showToast(message, type = 'info') {
const toast = document.getElementById('toast');
toast.textContent = message;
toast.className = `toast ${type} show`;
setTimeout(() => {
toast.className = 'toast';
}, 3000);
}
// Confirmation modal
let confirmResolve = null;
function showConfirm(message, title = null) {
return new Promise((resolve) => {
confirmResolve = resolve;
const modal = document.getElementById('confirm-modal');
const titleEl = document.getElementById('confirm-title');
const messageEl = document.getElementById('confirm-message');
const yesBtn = document.getElementById('confirm-yes-btn');
const noBtn = document.getElementById('confirm-no-btn');
titleEl.textContent = title || t('confirm.title');
messageEl.textContent = message;
yesBtn.textContent = t('confirm.yes');
noBtn.textContent = t('confirm.no');
modal.style.display = 'flex';
lockBody();
});
}
function closeConfirmModal(result) {
const modal = document.getElementById('confirm-modal');
modal.style.display = 'none';
unlockBody();
if (confirmResolve) {
confirmResolve(result);
confirmResolve = null;
}
}
// Calibration functions
async function showCalibration(deviceId) {
try {
// Fetch device data and displays in parallel
const [response, displaysResponse] = await Promise.all([
fetch(`${API_BASE}/devices/${deviceId}`, { headers: getHeaders() }),
fetch(`${API_BASE}/config/displays`, { headers: getHeaders() }),
]);
if (response.status === 401) {
handle401Error();
return;
}
if (!response.ok) {
showToast('Failed to load calibration', 'error');
return;
}
const device = await response.json();
const calibration = device.calibration;
// Set aspect ratio from device's display
const preview = document.querySelector('.calibration-preview');
if (displaysResponse.ok) {
const displaysData = await displaysResponse.json();
const displayIndex = device.settings?.display_index ?? 0;
const display = (displaysData.displays || []).find(d => d.index === displayIndex);
if (display && display.width && display.height) {
preview.style.aspectRatio = `${display.width} / ${display.height}`;
} else {
preview.style.aspectRatio = '';
}
} else {
preview.style.aspectRatio = '';
}
// Store device ID and LED count
document.getElementById('calibration-device-id').value = device.id;
document.getElementById('cal-device-led-count-inline').textContent = device.led_count;
// Set layout
document.getElementById('cal-start-position').value = calibration.start_position;
document.getElementById('cal-layout').value = calibration.layout;
document.getElementById('cal-offset').value = calibration.offset || 0;
// Set LED counts per edge
document.getElementById('cal-top-leds').value = calibration.leds_top || 0;
document.getElementById('cal-right-leds').value = calibration.leds_right || 0;
document.getElementById('cal-bottom-leds').value = calibration.leds_bottom || 0;
document.getElementById('cal-left-leds').value = calibration.leds_left || 0;
// Initialize edge spans
window.edgeSpans = {
top: { start: calibration.span_top_start ?? 0, end: calibration.span_top_end ?? 1 },
right: { start: calibration.span_right_start ?? 0, end: calibration.span_right_end ?? 1 },
bottom: { start: calibration.span_bottom_start ?? 0, end: calibration.span_bottom_end ?? 1 },
left: { start: calibration.span_left_start ?? 0, end: calibration.span_left_end ?? 1 },
};
// Snapshot initial values for dirty checking
calibrationInitialValues = {
start_position: calibration.start_position,
layout: calibration.layout,
offset: String(calibration.offset || 0),
top: String(calibration.leds_top || 0),
right: String(calibration.leds_right || 0),
bottom: String(calibration.leds_bottom || 0),
left: String(calibration.leds_left || 0),
spans: JSON.stringify(window.edgeSpans),
};
// Initialize test mode state for this device
calibrationTestState[device.id] = new Set();
// Update preview
updateCalibrationPreview();
// Show modal
const modal = document.getElementById('calibration-modal');
modal.style.display = 'flex';
lockBody();
// Initialize span drag and render canvas after layout settles
initSpanDrag();
requestAnimationFrame(() => {
renderCalibrationCanvas();
// Auto-start tutorial on first open
if (!localStorage.getItem('calibrationTutorialSeen')) {
localStorage.setItem('calibrationTutorialSeen', '1');
startCalibrationTutorial();
}
});
// Re-render on container resize (e.g. window resize changes aspect-ratio container)
if (!window._calibrationResizeObserver) {
window._calibrationResizeObserver = new ResizeObserver(() => {
updateSpanBars();
renderCalibrationCanvas();
});
}
window._calibrationResizeObserver.observe(preview);
} catch (error) {
console.error('Failed to load calibration:', error);
showToast('Failed to load calibration', 'error');
}
}
function isCalibrationDirty() {
return (
document.getElementById('cal-start-position').value !== calibrationInitialValues.start_position ||
document.getElementById('cal-layout').value !== calibrationInitialValues.layout ||
document.getElementById('cal-offset').value !== calibrationInitialValues.offset ||
document.getElementById('cal-top-leds').value !== calibrationInitialValues.top ||
document.getElementById('cal-right-leds').value !== calibrationInitialValues.right ||
document.getElementById('cal-bottom-leds').value !== calibrationInitialValues.bottom ||
document.getElementById('cal-left-leds').value !== calibrationInitialValues.left ||
JSON.stringify(window.edgeSpans) !== calibrationInitialValues.spans
);
}
function forceCloseCalibrationModal() {
closeTutorial();
const deviceId = document.getElementById('calibration-device-id').value;
if (deviceId) {
clearTestMode(deviceId);
}
if (window._calibrationResizeObserver) {
window._calibrationResizeObserver.disconnect();
}
const modal = document.getElementById('calibration-modal');
const error = document.getElementById('calibration-error');
modal.style.display = 'none';
error.style.display = 'none';
unlockBody();
calibrationInitialValues = {};
}
async function closeCalibrationModal() {
if (isCalibrationDirty()) {
const confirmed = await showConfirm(t('modal.discard_changes'));
if (!confirmed) return;
}
forceCloseCalibrationModal();
}
function updateCalibrationPreview() {
// Calculate total from edge inputs
const total = parseInt(document.getElementById('cal-top-leds').value || 0) +
parseInt(document.getElementById('cal-right-leds').value || 0) +
parseInt(document.getElementById('cal-bottom-leds').value || 0) +
parseInt(document.getElementById('cal-left-leds').value || 0);
// Warning if total doesn't match device LED count
const totalEl = document.querySelector('.preview-screen-total');
const deviceCount = parseInt(document.getElementById('cal-device-led-count-inline').textContent || 0);
const mismatch = total !== deviceCount;
document.getElementById('cal-total-leds-inline').textContent = (mismatch ? '\u26A0 ' : '') + total;
if (totalEl) {
totalEl.classList.toggle('mismatch', mismatch);
}
// Update corner dot highlights for start position
const startPos = document.getElementById('cal-start-position').value;
['top_left', 'top_right', 'bottom_left', 'bottom_right'].forEach(corner => {
const cornerEl = document.querySelector(`.preview-corner.corner-${corner.replace('_', '-')}`);
if (cornerEl) {
if (corner === startPos) {
cornerEl.classList.add('active');
} else {
cornerEl.classList.remove('active');
}
}
});
// Update direction toggle display
const direction = document.getElementById('cal-layout').value;
const dirIcon = document.getElementById('direction-icon');
const dirLabel = document.getElementById('direction-label');
if (dirIcon) dirIcon.textContent = direction === 'clockwise' ? '↻' : '↺';
if (dirLabel) dirLabel.textContent = direction === 'clockwise' ? 'CW' : 'CCW';
// Update edge highlight states
const deviceId = document.getElementById('calibration-device-id').value;
const activeEdges = calibrationTestState[deviceId] || new Set();
['top', 'right', 'bottom', 'left'].forEach(edge => {
const toggleEl = document.querySelector(`.edge-toggle.toggle-${edge}`);
if (!toggleEl) return;
if (activeEdges.has(edge)) {
const [r, g, b] = EDGE_TEST_COLORS[edge];
toggleEl.style.background = `rgba(${r}, ${g}, ${b}, 0.35)`;
toggleEl.style.boxShadow = `inset 0 0 6px rgba(${r}, ${g}, ${b}, 0.5)`;
} else {
toggleEl.style.background = '';
toggleEl.style.boxShadow = '';
}
});
// Disable edges with 0 LEDs
['top', 'right', 'bottom', 'left'].forEach(edge => {
const count = parseInt(document.getElementById(`cal-${edge}-leds`).value) || 0;
const edgeEl = document.querySelector(`.preview-edge.edge-${edge}`);
const toggleEl = document.querySelector(`.edge-toggle.toggle-${edge}`);
if (edgeEl) edgeEl.classList.toggle('edge-disabled', count === 0);
if (toggleEl) toggleEl.classList.toggle('edge-disabled', count === 0);
});
// Position span bars and render canvas overlay
updateSpanBars();
renderCalibrationCanvas();
}
function renderCalibrationCanvas() {
const canvas = document.getElementById('calibration-preview-canvas');
if (!canvas) return;
const container = canvas.parentElement;
const containerRect = container.getBoundingClientRect();
if (containerRect.width === 0 || containerRect.height === 0) return;
// Canvas extends beyond the container (matches CSS: left:-40px, top:-40px, +80px/+80px)
const padX = 40;
const padY = 40;
const dpr = window.devicePixelRatio || 1;
const canvasW = containerRect.width + padX * 2;
const canvasH = containerRect.height + padY * 2;
canvas.width = canvasW * dpr;
canvas.height = canvasH * dpr;
const ctx = canvas.getContext('2d');
ctx.scale(dpr, dpr);
ctx.clearRect(0, 0, canvasW, canvasH);
// Container origin within canvas coordinate system
const ox = padX;
const oy = padY;
const cW = containerRect.width; // container inner width
const cH = containerRect.height; // container inner height
// Read current form values
const startPos = document.getElementById('cal-start-position').value;
const layout = document.getElementById('cal-layout').value;
const offset = parseInt(document.getElementById('cal-offset').value || 0);
const calibration = {
start_position: startPos,
layout: layout,
offset: offset,
leds_top: parseInt(document.getElementById('cal-top-leds').value || 0),
leds_right: parseInt(document.getElementById('cal-right-leds').value || 0),
leds_bottom: parseInt(document.getElementById('cal-bottom-leds').value || 0),
leds_left: parseInt(document.getElementById('cal-left-leds').value || 0),
};
const segments = buildSegments(calibration);
if (segments.length === 0) return;
const totalLeds = calibration.leds_top + calibration.leds_right + calibration.leds_bottom + calibration.leds_left;
// Theme-aware colors
const isDark = document.documentElement.getAttribute('data-theme') !== 'light';
const tickStroke = isDark ? 'rgba(255, 255, 255, 0.4)' : 'rgba(0, 0, 0, 0.3)';
const tickFill = isDark ? 'rgba(255, 255, 255, 0.65)' : 'rgba(0, 0, 0, 0.6)';
const chevronStroke = isDark ? 'rgba(255, 255, 255, 0.6)' : 'rgba(0, 0, 0, 0.4)';
// Edge bar geometry (matches CSS: corner zones 56px × 36px fixed)
const cw = 56;
const ch = 36;
// Span-aware edge geometry: ticks/arrows render only within the span region
const spans = window.edgeSpans || {};
const edgeLenH = cW - 2 * cw;
const edgeLenV = cH - 2 * ch;
const edgeGeometry = {
top: { x1: ox + cw + (spans.top?.start || 0) * edgeLenH, x2: ox + cw + (spans.top?.end || 1) * edgeLenH, midY: oy + ch / 2, horizontal: true },
bottom: { x1: ox + cw + (spans.bottom?.start || 0) * edgeLenH, x2: ox + cw + (spans.bottom?.end || 1) * edgeLenH, midY: oy + cH - ch / 2, horizontal: true },
left: { y1: oy + ch + (spans.left?.start || 0) * edgeLenV, y2: oy + ch + (spans.left?.end || 1) * edgeLenV, midX: ox + cw / 2, horizontal: false },
right: { y1: oy + ch + (spans.right?.start || 0) * edgeLenV, y2: oy + ch + (spans.right?.end || 1) * edgeLenV, midX: ox + cW - cw / 2, horizontal: false },
};
// Axis positions for labels (outside the 16px toggle zones)
const toggleSize = 16;
const axisPos = {
top: oy - toggleSize - 3,
bottom: oy + cH + toggleSize + 3,
left: ox - toggleSize - 3,
right: ox + cW + toggleSize + 3,
};
// Arrow positions (inside the screen area, near each edge bar)
const arrowInset = 12;
const arrowPos = {
top: oy + ch + arrowInset,
bottom: oy + cH - ch - arrowInset,
left: ox + cw + arrowInset,
right: ox + cW - cw - arrowInset,
};
// Draw ticks and direction arrows for each segment
segments.forEach(seg => {
const geo = edgeGeometry[seg.edge];
if (!geo) return;
const count = seg.led_count;
if (count === 0) return;
// Edge boundary ticks (first/last LED on edge) and special ticks (LED 0 position)
const edgeBounds = new Set();
edgeBounds.add(0);
if (count > 1) edgeBounds.add(count - 1);
const specialTicks = new Set();
if (offset > 0 && totalLeds > 0) {
const zeroPos = (totalLeds - seg.led_start % totalLeds) % totalLeds;
if (zeroPos < count) specialTicks.add(zeroPos);
}
// Round-number ticks get priority; edge boundary labels suppressed if overlapping
const labelsToShow = new Set([...specialTicks]);
const tickLinesOnly = new Set();
if (count > 2) {
const edgeLen = geo.horizontal ? (geo.x2 - geo.x1) : (geo.y2 - geo.y1);
const maxDigits = String(totalLeds > 0 ? totalLeds - 1 : count - 1).length;
const minSpacing = geo.horizontal ? maxDigits * 7 + 8 : 22;
const allMandatory = new Set([...edgeBounds, ...specialTicks]);
const maxIntermediate = Math.max(0, 5 - allMandatory.size);
const niceSteps = [5, 10, 25, 50, 100, 250, 500];
let step = niceSteps[niceSteps.length - 1];
for (const s of niceSteps) {
if (Math.floor(count / s) <= maxIntermediate) {
step = s;
break;
}
}
const tickPx = i => {
const f = i / (count - 1);
return (seg.reverse ? (1 - f) : f) * edgeLen;
};
// Phase 1: place round-number ticks (checked against specials + each other)
const placed = [];
specialTicks.forEach(i => placed.push(tickPx(i)));
for (let i = 1; i < count - 1; i++) {
if (specialTicks.has(i)) continue;
const idx = totalLeds > 0 ? (seg.led_start + i) % totalLeds : seg.led_start + i;
if (idx % step === 0) {
const px = tickPx(i);
if (!placed.some(p => Math.abs(px - p) < minSpacing)) {
labelsToShow.add(i);
placed.push(px);
}
}
}
// Phase 2: edge boundaries — show label unless overlapping a round-number tick
edgeBounds.forEach(bi => {
if (labelsToShow.has(bi) || specialTicks.has(bi)) return;
const px = tickPx(bi);
if (placed.some(p => Math.abs(px - p) < minSpacing)) {
tickLinesOnly.add(bi);
} else {
labelsToShow.add(bi);
placed.push(px);
}
});
} else {
edgeBounds.forEach(i => labelsToShow.add(i));
}
// Tick styling
const tickLenLong = toggleSize + 3;
const tickLenShort = 4;
ctx.strokeStyle = tickStroke;
ctx.lineWidth = 1;
ctx.fillStyle = tickFill;
ctx.font = '12px -apple-system, BlinkMacSystemFont, sans-serif';
// Draw labeled ticks
labelsToShow.forEach(i => {
const fraction = count > 1 ? i / (count - 1) : 0.5;
const displayFraction = seg.reverse ? (1 - fraction) : fraction;
const ledIndex = totalLeds > 0 ? (seg.led_start + i) % totalLeds : seg.led_start + i;
const tickLen = edgeBounds.has(i) ? tickLenLong : tickLenShort;
if (geo.horizontal) {
const tx = geo.x1 + displayFraction * (geo.x2 - geo.x1);
const axisY = axisPos[seg.edge];
const tickDir = seg.edge === 'top' ? 1 : -1;
ctx.beginPath();
ctx.moveTo(tx, axisY);
ctx.lineTo(tx, axisY + tickDir * tickLen);
ctx.stroke();
ctx.textAlign = 'center';
ctx.textBaseline = seg.edge === 'top' ? 'bottom' : 'top';
ctx.fillText(String(ledIndex), tx, axisY - tickDir * 1);
} else {
const ty = geo.y1 + displayFraction * (geo.y2 - geo.y1);
const axisX = axisPos[seg.edge];
const tickDir = seg.edge === 'left' ? 1 : -1;
ctx.beginPath();
ctx.moveTo(axisX, ty);
ctx.lineTo(axisX + tickDir * tickLen, ty);
ctx.stroke();
ctx.textBaseline = 'middle';
ctx.textAlign = seg.edge === 'left' ? 'right' : 'left';
ctx.fillText(String(ledIndex), axisX - tickDir * 1, ty);
}
});
// Draw tick lines only (no labels) for suppressed edge boundaries
tickLinesOnly.forEach(i => {
const fraction = count > 1 ? i / (count - 1) : 0.5;
const displayFraction = seg.reverse ? (1 - fraction) : fraction;
if (geo.horizontal) {
const tx = geo.x1 + displayFraction * (geo.x2 - geo.x1);
const axisY = axisPos[seg.edge];
const tickDir = seg.edge === 'top' ? 1 : -1;
ctx.beginPath();
ctx.moveTo(tx, axisY);
ctx.lineTo(tx, axisY + tickDir * tickLenLong);
ctx.stroke();
} else {
const ty = geo.y1 + displayFraction * (geo.y2 - geo.y1);
const axisX = axisPos[seg.edge];
const tickDir = seg.edge === 'left' ? 1 : -1;
ctx.beginPath();
ctx.moveTo(axisX, ty);
ctx.lineTo(axisX + tickDir * tickLenLong, ty);
ctx.stroke();
}
});
// Draw direction chevron at full-edge midpoint (not affected by span)
const s = 7;
let mx, my, angle;
if (geo.horizontal) {
mx = ox + cw + edgeLenH / 2;
my = arrowPos[seg.edge];
angle = seg.reverse ? Math.PI : 0;
} else {
mx = arrowPos[seg.edge];
my = oy + ch + edgeLenV / 2;
angle = seg.reverse ? -Math.PI / 2 : Math.PI / 2;
}
ctx.save();
ctx.translate(mx, my);
ctx.rotate(angle);
ctx.fillStyle = 'rgba(76, 175, 80, 0.85)';
ctx.strokeStyle = chevronStroke;
ctx.lineWidth = 1;
ctx.lineCap = 'round';
ctx.lineJoin = 'round';
ctx.beginPath();
ctx.moveTo(-s * 0.5, -s * 0.6);
ctx.lineTo(s * 0.5, 0);
ctx.lineTo(-s * 0.5, s * 0.6);
ctx.closePath();
ctx.fill();
ctx.stroke();
ctx.restore();
});
}
function updateSpanBars() {
const spans = window.edgeSpans || {};
const container = document.querySelector('.calibration-preview');
['top', 'right', 'bottom', 'left'].forEach(edge => {
const bar = document.querySelector(`.edge-span-bar[data-edge="${edge}"]`);
if (!bar) return;
const span = spans[edge] || { start: 0, end: 1 };
const edgeEl = bar.parentElement;
const isHorizontal = (edge === 'top' || edge === 'bottom');
if (isHorizontal) {
const totalWidth = edgeEl.clientWidth;
bar.style.left = (span.start * totalWidth) + 'px';
bar.style.width = ((span.end - span.start) * totalWidth) + 'px';
} else {
const totalHeight = edgeEl.clientHeight;
bar.style.top = (span.start * totalHeight) + 'px';
bar.style.height = ((span.end - span.start) * totalHeight) + 'px';
}
// Also reposition toggle zone to match span region
if (!container) return;
const toggle = container.querySelector(`.toggle-${edge}`);
if (!toggle) return;
if (isHorizontal) {
const cornerW = 56;
const edgeW = container.clientWidth - 2 * cornerW;
toggle.style.left = (cornerW + span.start * edgeW) + 'px';
toggle.style.right = 'auto';
toggle.style.width = ((span.end - span.start) * edgeW) + 'px';
} else {
const cornerH = 36;
const edgeH = container.clientHeight - 2 * cornerH;
toggle.style.top = (cornerH + span.start * edgeH) + 'px';
toggle.style.bottom = 'auto';
toggle.style.height = ((span.end - span.start) * edgeH) + 'px';
}
});
}
function initSpanDrag() {
const MIN_SPAN = 0.05;
document.querySelectorAll('.edge-span-bar').forEach(bar => {
const edge = bar.dataset.edge;
const isHorizontal = (edge === 'top' || edge === 'bottom');
// Prevent edge click-through when interacting with span bar
bar.addEventListener('click', e => e.stopPropagation());
// Handle resize via handles
bar.querySelectorAll('.edge-span-handle').forEach(handle => {
handle.addEventListener('mousedown', e => {
const edgeLeds = parseInt(document.getElementById(`cal-${edge}-leds`).value) || 0;
if (edgeLeds === 0) return;
e.preventDefault();
e.stopPropagation();
const handleType = handle.dataset.handle;
const edgeEl = bar.parentElement;
const rect = edgeEl.getBoundingClientRect();
function onMouseMove(ev) {
const span = window.edgeSpans[edge];
let fraction;
if (isHorizontal) {
fraction = (ev.clientX - rect.left) / rect.width;
} else {
fraction = (ev.clientY - rect.top) / rect.height;
}
fraction = Math.max(0, Math.min(1, fraction));
if (handleType === 'start') {
span.start = Math.min(fraction, span.end - MIN_SPAN);
} else {
span.end = Math.max(fraction, span.start + MIN_SPAN);
}
updateSpanBars();
renderCalibrationCanvas();
}
function onMouseUp() {
document.removeEventListener('mousemove', onMouseMove);
document.removeEventListener('mouseup', onMouseUp);
}
document.addEventListener('mousemove', onMouseMove);
document.addEventListener('mouseup', onMouseUp);
});
});
// Handle body drag (move entire span)
bar.addEventListener('mousedown', e => {
if (e.target.classList.contains('edge-span-handle')) return;
e.preventDefault();
e.stopPropagation();
const edgeEl = bar.parentElement;
const rect = edgeEl.getBoundingClientRect();
const span = window.edgeSpans[edge];
const spanWidth = span.end - span.start;
let startFraction;
if (isHorizontal) {
startFraction = (e.clientX - rect.left) / rect.width;
} else {
startFraction = (e.clientY - rect.top) / rect.height;
}
const offsetInSpan = startFraction - span.start;
function onMouseMove(ev) {
let fraction;
if (isHorizontal) {
fraction = (ev.clientX - rect.left) / rect.width;
} else {
fraction = (ev.clientY - rect.top) / rect.height;
}
let newStart = fraction - offsetInSpan;
newStart = Math.max(0, Math.min(1 - spanWidth, newStart));
span.start = newStart;
span.end = newStart + spanWidth;
updateSpanBars();
renderCalibrationCanvas();
}
function onMouseUp() {
document.removeEventListener('mousemove', onMouseMove);
document.removeEventListener('mouseup', onMouseUp);
}
document.addEventListener('mousemove', onMouseMove);
document.addEventListener('mouseup', onMouseUp);
});
});
// Initial positioning
updateSpanBars();
}
function setStartPosition(position) {
document.getElementById('cal-start-position').value = position;
updateCalibrationPreview();
}
function toggleEdgeInputs() {
const preview = document.querySelector('.calibration-preview');
if (preview) preview.classList.toggle('inputs-dimmed');
}
function toggleDirection() {
const select = document.getElementById('cal-layout');
select.value = select.value === 'clockwise' ? 'counterclockwise' : 'clockwise';
updateCalibrationPreview();
}
async function toggleTestEdge(edge) {
const edgeLeds = parseInt(document.getElementById(`cal-${edge}-leds`).value) || 0;
if (edgeLeds === 0) return;
const deviceId = document.getElementById('calibration-device-id').value;
const error = document.getElementById('calibration-error');
if (!calibrationTestState[deviceId]) {
calibrationTestState[deviceId] = new Set();
}
// Toggle edge
if (calibrationTestState[deviceId].has(edge)) {
calibrationTestState[deviceId].delete(edge);
} else {
calibrationTestState[deviceId].add(edge);
}
// Build edges dict for API
const edges = {};
calibrationTestState[deviceId].forEach(e => {
edges[e] = EDGE_TEST_COLORS[e];
});
// Update visual state immediately
updateCalibrationPreview();
try {
const response = await fetch(`${API_BASE}/devices/${deviceId}/calibration/test`, {
method: 'PUT',
headers: getHeaders(),
body: JSON.stringify({ edges })
});
if (response.status === 401) {
handle401Error();
return;
}
if (!response.ok) {
const errorData = await response.json();
error.textContent = `Test failed: ${errorData.detail}`;
error.style.display = 'block';
}
} catch (err) {
console.error('Failed to toggle test edge:', err);
error.textContent = 'Failed to toggle test edge';
error.style.display = 'block';
}
}
async function clearTestMode(deviceId) {
if (!calibrationTestState[deviceId] || calibrationTestState[deviceId].size === 0) {
return;
}
calibrationTestState[deviceId] = new Set();
try {
await fetch(`${API_BASE}/devices/${deviceId}/calibration/test`, {
method: 'PUT',
headers: getHeaders(),
body: JSON.stringify({ edges: {} })
});
} catch (err) {
console.error('Failed to clear test mode:', err);
}
}
async function saveCalibration() {
const deviceId = document.getElementById('calibration-device-id').value;
const deviceLedCount = parseInt(document.getElementById('cal-device-led-count-inline').textContent);
const error = document.getElementById('calibration-error');
// Clear test mode before saving
await clearTestMode(deviceId);
updateCalibrationPreview();
const topLeds = parseInt(document.getElementById('cal-top-leds').value || 0);
const rightLeds = parseInt(document.getElementById('cal-right-leds').value || 0);
const bottomLeds = parseInt(document.getElementById('cal-bottom-leds').value || 0);
const leftLeds = parseInt(document.getElementById('cal-left-leds').value || 0);
const total = topLeds + rightLeds + bottomLeds + leftLeds;
// Validation
if (total !== deviceLedCount) {
error.textContent = `Total LEDs (${total}) must equal device LED count (${deviceLedCount})`;
error.style.display = 'block';
return;
}
// Build calibration config
const startPosition = document.getElementById('cal-start-position').value;
const layout = document.getElementById('cal-layout').value;
const offset = parseInt(document.getElementById('cal-offset').value || 0);
const spans = window.edgeSpans || {};
const calibration = {
layout: layout,
start_position: startPosition,
offset: offset,
leds_top: topLeds,
leds_right: rightLeds,
leds_bottom: bottomLeds,
leds_left: leftLeds,
span_top_start: spans.top?.start ?? 0,
span_top_end: spans.top?.end ?? 1,
span_right_start: spans.right?.start ?? 0,
span_right_end: spans.right?.end ?? 1,
span_bottom_start: spans.bottom?.start ?? 0,
span_bottom_end: spans.bottom?.end ?? 1,
span_left_start: spans.left?.start ?? 0,
span_left_end: spans.left?.end ?? 1,
};
try {
const response = await fetch(`${API_BASE}/devices/${deviceId}/calibration`, {
method: 'PUT',
headers: getHeaders(),
body: JSON.stringify(calibration)
});
if (response.status === 401) {
handle401Error();
return;
}
if (response.ok) {
showToast('Calibration saved', 'success');
forceCloseCalibrationModal();
loadDevices();
} else {
const errorData = await response.json();
error.textContent = `Failed to save: ${errorData.detail}`;
error.style.display = 'block';
}
} catch (err) {
console.error('Failed to save calibration:', err);
error.textContent = 'Failed to save calibration';
error.style.display = 'block';
}
}
function getEdgeOrder(startPosition, layout) {
const orders = {
'bottom_left_clockwise': ['left', 'top', 'right', 'bottom'],
'bottom_left_counterclockwise': ['bottom', 'right', 'top', 'left'],
'bottom_right_clockwise': ['bottom', 'left', 'top', 'right'],
'bottom_right_counterclockwise': ['right', 'top', 'left', 'bottom'],
'top_left_clockwise': ['top', 'right', 'bottom', 'left'],
'top_left_counterclockwise': ['left', 'bottom', 'right', 'top'],
'top_right_clockwise': ['right', 'bottom', 'left', 'top'],
'top_right_counterclockwise': ['top', 'left', 'bottom', 'right']
};
return orders[`${startPosition}_${layout}`] || ['left', 'top', 'right', 'bottom'];
}
function shouldReverse(edge, startPosition, layout) {
// Determine if this edge should be reversed based on LED strip direction
const reverseRules = {
'bottom_left_clockwise': { left: true, top: false, right: false, bottom: true },
'bottom_left_counterclockwise': { bottom: false, right: true, top: true, left: false },
'bottom_right_clockwise': { bottom: true, left: true, top: false, right: false },
'bottom_right_counterclockwise': { right: true, top: true, left: false, bottom: false },
'top_left_clockwise': { top: false, right: false, bottom: true, left: true },
'top_left_counterclockwise': { left: false, bottom: false, right: true, top: true },
'top_right_clockwise': { right: false, bottom: true, left: true, top: false },
'top_right_counterclockwise': { top: true, left: false, bottom: false, right: true }
};
const rules = reverseRules[`${startPosition}_${layout}`];
return rules ? rules[edge] : false;
}
function buildSegments(calibration) {
const edgeOrder = getEdgeOrder(calibration.start_position, calibration.layout);
const edgeCounts = {
top: calibration.leds_top || 0,
right: calibration.leds_right || 0,
bottom: calibration.leds_bottom || 0,
left: calibration.leds_left || 0
};
const segments = [];
let ledStart = calibration.offset || 0;
edgeOrder.forEach(edge => {
const count = edgeCounts[edge];
if (count > 0) {
segments.push({
edge: edge,
led_start: ledStart,
led_count: count,
reverse: shouldReverse(edge, calibration.start_position, calibration.layout)
});
ledStart += count;
}
});
return segments;
}
// Close modals on backdrop click (only if mousedown also started on backdrop)
let backdropMouseDownTarget = null;
document.addEventListener('mousedown', (e) => {
backdropMouseDownTarget = e.target;
});
document.addEventListener('click', (e) => {
if (!e.target.classList.contains('modal')) return;
if (backdropMouseDownTarget !== e.target) return;
if (activeTutorial) return;
const modalId = e.target.id;
// Confirm modal: backdrop click acts as Cancel
if (modalId === 'confirm-modal') {
closeConfirmModal(false);
return;
}
// Login modal: close only if cancel button is visible (not required login)
if (modalId === 'api-key-modal') {
const cancelBtn = document.getElementById('modal-cancel-btn');
if (cancelBtn && cancelBtn.style.display !== 'none') {
closeApiKeyModal();
}
return;
}
// General settings modal: dirty check
if (modalId === 'device-settings-modal') {
closeDeviceSettingsModal();
return;
}
// Capture settings modal: dirty check
if (modalId === 'capture-settings-modal') {
closeCaptureSettingsModal();
return;
}
// Calibration modal: dirty check
if (modalId === 'calibration-modal') {
closeCalibrationModal();
return;
}
// Add device modal: close on backdrop
if (modalId === 'add-device-modal') {
closeAddDeviceModal();
return;
}
});
// Cleanup on page unload
window.addEventListener('beforeunload', () => {
if (refreshInterval) {
clearInterval(refreshInterval);
}
});
// =============================================================================
// Tutorial System (generic engine)
// =============================================================================
let activeTutorial = null;
// Shape: { steps, overlay, mode, step, resolveTarget, container }
// mode: 'absolute' (within a container) or 'fixed' (viewport-level)
const calibrationTutorialSteps = [
{ selector: '#cal-top-leds', textKey: 'calibration.tip.led_count', position: 'bottom' },
{ selector: '.corner-bottom-left', textKey: 'calibration.tip.start_corner', position: 'right' },
{ selector: '.direction-toggle', textKey: 'calibration.tip.direction', position: 'bottom' },
{ selector: '.offset-control', textKey: 'calibration.tip.offset', position: 'bottom' },
{ selector: '.edge-span-bar[data-edge="top"]', textKey: 'calibration.tip.span', position: 'bottom' },
{ selector: '.toggle-top', textKey: 'calibration.tip.test', position: 'top' },
{ selector: '.preview-screen-total', textKey: 'calibration.tip.toggle_inputs', position: 'bottom' }
];
const deviceTutorialSteps = [
{ selector: '.card-subtitle', textKey: 'device.tip.metadata', position: 'bottom' },
{ selector: '.brightness-control', textKey: 'device.tip.brightness', position: 'bottom' },
{ selector: '.card-actions .btn:nth-child(1)', textKey: 'device.tip.start', position: 'top' },
{ selector: '.card-actions .btn:nth-child(2)', textKey: 'device.tip.settings', position: 'top' },
{ selector: '.card-actions .btn:nth-child(3)', textKey: 'device.tip.capture_settings', position: 'top' },
{ selector: '.card-actions .btn:nth-child(4)', textKey: 'device.tip.calibrate', position: 'top' },
{ selector: '.card-actions .btn:nth-child(5)', textKey: 'device.tip.webui', position: 'top' }
];
function startTutorial(config) {
closeTutorial();
const overlay = document.getElementById(config.overlayId);
if (!overlay) return;
activeTutorial = {
steps: config.steps,
overlay: overlay,
mode: config.mode,
step: 0,
resolveTarget: config.resolveTarget,
container: config.container
};
overlay.classList.add('active');
document.addEventListener('keydown', handleTutorialKey);
showTutorialStep(0);
}
function startCalibrationTutorial() {
const container = document.querySelector('#calibration-modal .modal-body');
if (!container) return;
startTutorial({
steps: calibrationTutorialSteps,
overlayId: 'tutorial-overlay',
mode: 'absolute',
container: container,
resolveTarget: (step) => document.querySelector(step.selector)
});
}
function startDeviceTutorial(deviceId) {
// Resolve the device ID to target (don't capture card reference — it goes stale when loadDevices rebuilds DOM)
const selector = deviceId
? `.card[data-device-id="${deviceId}"]`
: '.card[data-device-id]';
if (!document.querySelector(selector)) return;
startTutorial({
steps: deviceTutorialSteps,
overlayId: 'device-tutorial-overlay',
mode: 'fixed',
container: null,
resolveTarget: (step) => {
const card = document.querySelector(selector);
if (!card) return null;
return step.global
? document.querySelector(step.selector)
: card.querySelector(step.selector);
}
});
}
function closeTutorial() {
if (!activeTutorial) return;
activeTutorial.overlay.classList.remove('active');
document.querySelectorAll('.tutorial-target').forEach(el => {
el.classList.remove('tutorial-target');
el.style.zIndex = '';
});
document.removeEventListener('keydown', handleTutorialKey);
activeTutorial = null;
}
function tutorialNext() {
if (!activeTutorial) return;
if (activeTutorial.step < activeTutorial.steps.length - 1) {
showTutorialStep(activeTutorial.step + 1);
} else {
closeTutorial();
}
}
function tutorialPrev() {
if (!activeTutorial) return;
if (activeTutorial.step > 0) {
showTutorialStep(activeTutorial.step - 1);
}
}
function showTutorialStep(index) {
if (!activeTutorial) return;
activeTutorial.step = index;
const step = activeTutorial.steps[index];
const overlay = activeTutorial.overlay;
const isFixed = activeTutorial.mode === 'fixed';
// Remove previous target highlight
document.querySelectorAll('.tutorial-target').forEach(el => {
el.classList.remove('tutorial-target');
el.style.zIndex = '';
});
// Find and highlight target
const target = activeTutorial.resolveTarget(step);
if (!target) return;
target.classList.add('tutorial-target');
// For fixed overlays, target must be above the z-index:10000 overlay
if (isFixed) target.style.zIndex = '10001';
const targetRect = target.getBoundingClientRect();
const pad = 6;
let x, y, w, h;
if (isFixed) {
// Fixed mode: coordinates are viewport-relative
x = targetRect.left - pad;
y = targetRect.top - pad;
w = targetRect.width + pad * 2;
h = targetRect.height + pad * 2;
} else {
// Absolute mode: coordinates relative to container
const containerRect = activeTutorial.container.getBoundingClientRect();
x = targetRect.left - containerRect.left - pad;
y = targetRect.top - containerRect.top - pad;
w = targetRect.width + pad * 2;
h = targetRect.height + pad * 2;
}
// Update backdrop clip-path (polygon with rectangular cutout)
const backdrop = overlay.querySelector('.tutorial-backdrop');
if (backdrop) {
backdrop.style.clipPath = `polygon(
0% 0%, 0% 100%,
${x}px 100%, ${x}px ${y}px,
${x + w}px ${y}px, ${x + w}px ${y + h}px,
${x}px ${y + h}px, ${x}px 100%,
100% 100%, 100% 0%)`;
}
// Position ring around target
const ring = overlay.querySelector('.tutorial-ring');
if (ring) {
ring.style.left = x + 'px';
ring.style.top = y + 'px';
ring.style.width = w + 'px';
ring.style.height = h + 'px';
}
// Update tooltip content
const tooltip = overlay.querySelector('.tutorial-tooltip');
const textEl = overlay.querySelector('.tutorial-tooltip-text');
const counterEl = overlay.querySelector('.tutorial-step-counter');
if (textEl) textEl.textContent = t(step.textKey);
if (counterEl) counterEl.textContent = `${index + 1} / ${activeTutorial.steps.length}`;
// Enable/disable nav buttons
const prevBtn = overlay.querySelector('.tutorial-prev-btn');
const nextBtn = overlay.querySelector('.tutorial-next-btn');
if (prevBtn) prevBtn.disabled = (index === 0);
if (nextBtn) nextBtn.textContent = (index === activeTutorial.steps.length - 1) ? '\u2713' : '\u2192';
// Position tooltip
if (tooltip) {
positionTutorialTooltip(tooltip, x, y, w, h, step.position, isFixed);
}
}
function positionTutorialTooltip(tooltip, sx, sy, sw, sh, preferred, isFixed) {
const gap = 12;
const tooltipW = 260;
// Place offscreen to measure real height without visual flash
tooltip.setAttribute('style', 'left:-9999px;top:-9999px');
const tooltipH = tooltip.offsetHeight || 150;
const positions = {
top: { x: sx + sw / 2 - tooltipW / 2, y: sy - tooltipH - gap },
bottom: { x: sx + sw / 2 - tooltipW / 2, y: sy + sh + gap },
left: { x: sx - tooltipW - gap, y: sy + sh / 2 - tooltipH / 2 },
right: { x: sx + sw + gap, y: sy + sh / 2 - tooltipH / 2 }
};
let pos = positions[preferred] || positions.bottom;
const cW = isFixed ? window.innerWidth : activeTutorial.container.clientWidth;
const cH = isFixed ? window.innerHeight : activeTutorial.container.clientHeight;
// If preferred position overflows, try opposite
if (pos.y + tooltipH > cH || pos.y < 0 || pos.x + tooltipW > cW || pos.x < 0) {
const opposite = { top: 'bottom', bottom: 'top', left: 'right', right: 'left' };
const alt = positions[opposite[preferred]];
if (alt && alt.y >= 0 && alt.y + tooltipH <= cH && alt.x >= 0 && alt.x + tooltipW <= cW) {
pos = alt;
}
}
pos.x = Math.max(8, Math.min(cW - tooltipW - 8, pos.x));
pos.y = Math.max(8, Math.min(cH - tooltipH - 8, pos.y));
// Force-set all positioning via setAttribute to avoid any style-setting quirks
tooltip.setAttribute('style', `left:${Math.round(pos.x)}px;top:${Math.round(pos.y)}px`);
}
function handleTutorialKey(e) {
if (!activeTutorial) return;
if (e.key === 'Escape') { closeTutorial(); e.stopPropagation(); }
else if (e.key === 'ArrowRight' || e.key === 'ArrowDown') { e.preventDefault(); tutorialNext(); }
else if (e.key === 'ArrowLeft' || e.key === 'ArrowUp') { e.preventDefault(); tutorialPrev(); }
}
// ===========================
// Capture Templates Functions
// ===========================
let availableEngines = [];
let currentEditingTemplateId = null;
// Load and render capture templates
async function loadCaptureTemplates() {
try {
const response = await fetchWithAuth('/capture-templates');
if (!response.ok) {
throw new Error(`Failed to load templates: ${response.status}`);
}
const data = await response.json();
renderTemplatesList(data.templates || []);
} catch (error) {
console.error('Error loading capture templates:', error);
document.getElementById('templates-list').innerHTML = `
${t('templates.error.load')}: ${error.message}
`;
}
}
// Render templates list
function renderTemplatesList(templates) {
const container = document.getElementById('templates-list');
if (templates.length === 0) {
container.innerHTML = ``;
return;
}
const renderCard = (template) => {
const engineIcon = getEngineIcon(template.engine_type);
return `
✕
${t('templates.engine')} ${template.engine_type.toUpperCase()}
${Object.keys(template.engine_config).length > 0 ? `
${t('templates.config.show')}
${Object.entries(template.engine_config).map(([key, val]) => `
${escapeHtml(key)}
${escapeHtml(String(val))}
`).join('')}
` : `
${t('templates.config.none')}
`}
🧪
✏️
`;
};
let html = templates.map(renderCard).join('');
html += ``;
container.innerHTML = html;
}
// Get engine icon
function getEngineIcon(engineType) {
return '🖥️';
}
// Show add template modal
async function showAddTemplateModal() {
currentEditingTemplateId = null;
document.getElementById('template-modal-title').textContent = t('templates.add');
document.getElementById('template-form').reset();
document.getElementById('template-id').value = '';
document.getElementById('engine-config-section').style.display = 'none';
document.getElementById('template-error').style.display = 'none';
// Load available engines
await loadAvailableEngines();
// Show modal
const modal = document.getElementById('template-modal');
modal.style.display = 'flex';
// Add backdrop click handler to close modal
modal.onclick = function(event) {
if (event.target === modal) {
closeTemplateModal();
}
};
}
// Edit template
async function editTemplate(templateId) {
try {
const response = await fetchWithAuth(`/capture-templates/${templateId}`);
if (!response.ok) {
throw new Error(`Failed to load template: ${response.status}`);
}
const template = await response.json();
currentEditingTemplateId = templateId;
document.getElementById('template-modal-title').textContent = t('templates.edit');
document.getElementById('template-id').value = templateId;
document.getElementById('template-name').value = template.name;
// Load available engines
await loadAvailableEngines();
// Set engine and load config
document.getElementById('template-engine').value = template.engine_type;
await onEngineChange();
// Populate engine config fields
populateEngineConfig(template.engine_config);
// Load displays for test
await loadDisplaysForTest();
const testResults = document.getElementById('template-test-results');
if (testResults) testResults.style.display = 'none';
document.getElementById('template-error').style.display = 'none';
// Show modal
const modal = document.getElementById('template-modal');
modal.style.display = 'flex';
// Add backdrop click handler to close modal
modal.onclick = function(event) {
if (event.target === modal) {
closeTemplateModal();
}
};
} catch (error) {
console.error('Error loading template:', error);
showToast(t('templates.error.load') + ': ' + error.message, 'error');
}
}
// Close template modal
function closeTemplateModal() {
document.getElementById('template-modal').style.display = 'none';
currentEditingTemplateId = null;
}
// Show full-page overlay spinner with progress
function showOverlaySpinner(text, duration = 0) {
// Remove existing overlay if any
const existing = document.getElementById('overlay-spinner');
if (existing) {
// Clear any existing timer
if (window.overlaySpinnerTimer) {
clearInterval(window.overlaySpinnerTimer);
window.overlaySpinnerTimer = null;
}
existing.remove();
}
// Create overlay
const overlay = document.createElement('div');
overlay.id = 'overlay-spinner';
overlay.className = 'overlay-spinner';
// Create progress container
const progressContainer = document.createElement('div');
progressContainer.className = 'progress-container';
// Create SVG progress ring
const radius = 56;
const circumference = 2 * Math.PI * radius;
const svg = document.createElementNS('http://www.w3.org/2000/svg', 'svg');
svg.setAttribute('width', '120');
svg.setAttribute('height', '120');
svg.setAttribute('class', 'progress-ring');
// Background circle
const bgCircle = document.createElementNS('http://www.w3.org/2000/svg', 'circle');
bgCircle.setAttribute('class', 'progress-ring-bg');
bgCircle.setAttribute('cx', '60');
bgCircle.setAttribute('cy', '60');
bgCircle.setAttribute('r', radius);
// Progress circle
const progressCircle = document.createElementNS('http://www.w3.org/2000/svg', 'circle');
progressCircle.setAttribute('class', 'progress-ring-circle');
progressCircle.setAttribute('cx', '60');
progressCircle.setAttribute('cy', '60');
progressCircle.setAttribute('r', radius);
progressCircle.style.strokeDasharray = circumference;
progressCircle.style.strokeDashoffset = circumference;
svg.appendChild(bgCircle);
svg.appendChild(progressCircle);
// Create progress content (percentage display)
const progressContent = document.createElement('div');
progressContent.className = 'progress-content';
const progressPercentage = document.createElement('div');
progressPercentage.className = 'progress-percentage';
progressPercentage.textContent = '0%';
progressContent.appendChild(progressPercentage);
progressContainer.appendChild(svg);
progressContainer.appendChild(progressContent);
// Create text
const spinnerText = document.createElement('div');
spinnerText.className = 'spinner-text';
spinnerText.textContent = text;
overlay.appendChild(progressContainer);
overlay.appendChild(spinnerText);
document.body.appendChild(overlay);
// Animate progress if duration is provided
if (duration > 0) {
const startTime = Date.now();
window.overlaySpinnerTimer = setInterval(() => {
const elapsed = (Date.now() - startTime) / 1000;
const progress = Math.min(elapsed / duration, 1);
const percentage = Math.round(progress * 100);
// Update progress ring
const offset = circumference - (progress * circumference);
progressCircle.style.strokeDashoffset = offset;
// Update percentage display
progressPercentage.textContent = `${percentage}%`;
// Stop timer if complete
if (progress >= 1) {
clearInterval(window.overlaySpinnerTimer);
window.overlaySpinnerTimer = null;
}
}, 100);
}
}
// Hide full-page overlay spinner
function hideOverlaySpinner() {
// Clear timer if exists
if (window.overlaySpinnerTimer) {
clearInterval(window.overlaySpinnerTimer);
window.overlaySpinnerTimer = null;
}
const overlay = document.getElementById('overlay-spinner');
if (overlay) overlay.remove();
}
// Update capture duration and save to localStorage
function updateCaptureDuration(value) {
document.getElementById('test-template-duration-value').textContent = value;
localStorage.setItem('capture_duration', value);
}
// Restore capture duration from localStorage
function restoreCaptureDuration() {
const savedDuration = localStorage.getItem('capture_duration');
if (savedDuration) {
const durationInput = document.getElementById('test-template-duration');
const durationValue = document.getElementById('test-template-duration-value');
durationInput.value = savedDuration;
durationValue.textContent = savedDuration;
}
}
// Show test template modal
async function showTestTemplateModal(templateId) {
const templates = await fetchWithAuth('/capture-templates').then(r => r.json());
const template = templates.templates.find(t => t.id === templateId);
if (!template) {
showToast(t('templates.error.load'), 'error');
return;
}
// Store current template for testing
window.currentTestingTemplate = template;
// Load displays
await loadDisplaysForTest();
// Restore last used capture duration
restoreCaptureDuration();
// Show modal
const modal = document.getElementById('test-template-modal');
modal.style.display = 'flex';
// Add backdrop click handler to close modal
modal.onclick = function(event) {
if (event.target === modal) {
closeTestTemplateModal();
}
};
}
// Close test template modal
function closeTestTemplateModal() {
document.getElementById('test-template-modal').style.display = 'none';
window.currentTestingTemplate = null;
}
// Load available engines
async function loadAvailableEngines() {
try {
const response = await fetchWithAuth('/capture-engines');
if (!response.ok) {
throw new Error(`Failed to load engines: ${response.status}`);
}
const data = await response.json();
availableEngines = data.engines || [];
const select = document.getElementById('template-engine');
select.innerHTML = `${t('templates.engine.select')} `;
availableEngines.forEach(engine => {
const option = document.createElement('option');
option.value = engine.type;
option.textContent = `${getEngineIcon(engine.type)} ${engine.name}`;
if (!engine.available) {
option.disabled = true;
option.textContent += ` (${t('templates.engine.unavailable')})`;
}
select.appendChild(option);
});
} catch (error) {
console.error('Error loading engines:', error);
showToast(t('templates.error.engines') + ': ' + error.message, 'error');
}
}
// Handle engine selection change
async function onEngineChange() {
const engineType = document.getElementById('template-engine').value;
const configSection = document.getElementById('engine-config-section');
const configFields = document.getElementById('engine-config-fields');
if (!engineType) {
configSection.style.display = 'none';
return;
}
const engine = availableEngines.find(e => e.type === engineType);
if (!engine) {
configSection.style.display = 'none';
return;
}
// Show availability hint
const hint = document.getElementById('engine-availability-hint');
if (!engine.available) {
hint.textContent = t('templates.engine.unavailable.hint');
hint.style.display = 'block';
hint.style.color = 'var(--error-color)';
} else {
hint.style.display = 'none';
}
// Render config fields based on default_config
configFields.innerHTML = '';
const defaultConfig = engine.default_config || {};
if (Object.keys(defaultConfig).length === 0) {
configFields.innerHTML = `${t('templates.config.none')}
`;
} else {
Object.entries(defaultConfig).forEach(([key, value]) => {
const fieldType = typeof value === 'number' ? 'number' : 'text';
const fieldValue = typeof value === 'boolean' ? (value ? 'true' : 'false') : value;
const fieldHtml = `
${key}:
${typeof value === 'boolean' ? `
true
false
` : `
`}
${t('templates.config.default')}: ${JSON.stringify(value)}
`;
configFields.innerHTML += fieldHtml;
});
}
configSection.style.display = 'block';
}
// Populate engine config fields with values
function populateEngineConfig(config) {
Object.entries(config).forEach(([key, value]) => {
const field = document.getElementById(`config-${key}`);
if (field) {
if (field.tagName === 'SELECT') {
field.value = value.toString();
} else {
field.value = value;
}
}
});
}
// Collect engine config from form
function collectEngineConfig() {
const config = {};
const fields = document.querySelectorAll('[data-config-key]');
fields.forEach(field => {
const key = field.dataset.configKey;
let value = field.value;
// Type conversion
if (field.type === 'number') {
value = parseFloat(value);
} else if (field.tagName === 'SELECT' && (value === 'true' || value === 'false')) {
value = value === 'true';
}
config[key] = value;
});
return config;
}
// Load displays for test selector
async function loadDisplaysForTest() {
try {
const response = await fetchWithAuth('/config/displays');
if (!response.ok) {
throw new Error(`Failed to load displays: ${response.status}`);
}
const displaysData = await response.json();
const select = document.getElementById('test-template-display');
select.innerHTML = '';
let primaryIndex = null;
(displaysData.displays || []).forEach(display => {
const option = document.createElement('option');
option.value = display.index;
option.textContent = `Display ${display.index} (${display.width}x${display.height})`;
if (display.is_primary) {
option.textContent += ' ★';
primaryIndex = display.index;
}
select.appendChild(option);
});
// Auto-select: last used display, or primary as fallback
const lastDisplay = localStorage.getItem('lastTestDisplayIndex');
if (lastDisplay !== null && select.querySelector(`option[value="${lastDisplay}"]`)) {
select.value = lastDisplay;
} else if (primaryIndex !== null) {
select.value = String(primaryIndex);
}
} catch (error) {
console.error('Error loading displays:', error);
}
}
// Run template test
async function runTemplateTest() {
if (!window.currentTestingTemplate) {
showToast(t('templates.test.error.no_engine'), 'error');
return;
}
const displayIndex = document.getElementById('test-template-display').value;
const captureDuration = parseFloat(document.getElementById('test-template-duration').value);
if (displayIndex === '') {
showToast(t('templates.test.error.no_display'), 'error');
return;
}
const template = window.currentTestingTemplate;
// Show full-page overlay spinner with progress
showOverlaySpinner(t('templates.test.running'), captureDuration);
try {
const response = await fetchWithAuth('/capture-templates/test', {
method: 'POST',
body: JSON.stringify({
engine_type: template.engine_type,
engine_config: template.engine_config,
display_index: parseInt(displayIndex),
capture_duration: captureDuration
})
});
if (!response.ok) {
const error = await response.json();
throw new Error(error.detail || error.message || 'Test failed');
}
const result = await response.json();
localStorage.setItem('lastTestDisplayIndex', displayIndex);
displayTestResults(result);
} catch (error) {
console.error('Error running test:', error);
// Hide overlay spinner
hideOverlaySpinner();
// Show short error in snack, details are in console
showToast(t('templates.test.error.failed'), 'error');
}
}
function buildTestStatsHtml(result) {
const p = result.performance;
const res = `${result.full_capture.width}x${result.full_capture.height}`;
return `
${t('templates.test.results.duration')}: ${p.capture_duration_s.toFixed(2)}s
${t('templates.test.results.frame_count')}: ${p.frame_count}
${t('templates.test.results.actual_fps')}: ${p.actual_fps.toFixed(1)}
${t('templates.test.results.avg_capture_time')}: ${p.avg_capture_time_ms.toFixed(1)}ms
Resolution: ${res}
`;
}
// Display test results — opens lightbox with stats overlay
function displayTestResults(result) {
hideOverlaySpinner();
const fullImageSrc = result.full_capture.full_image || result.full_capture.image;
openLightbox(fullImageSrc, buildTestStatsHtml(result));
}
// Save template
async function saveTemplate() {
const templateId = document.getElementById('template-id').value;
const name = document.getElementById('template-name').value.trim();
const engineType = document.getElementById('template-engine').value;
if (!name || !engineType) {
showToast(t('templates.error.required'), 'error');
return;
}
const engineConfig = collectEngineConfig();
const payload = {
name,
engine_type: engineType,
engine_config: engineConfig
};
try {
let response;
if (templateId) {
// Update existing template
response = await fetchWithAuth(`/capture-templates/${templateId}`, {
method: 'PUT',
body: JSON.stringify(payload)
});
} else {
// Create new template
response = await fetchWithAuth('/capture-templates', {
method: 'POST',
body: JSON.stringify(payload)
});
}
if (!response.ok) {
const error = await response.json();
throw new Error(error.detail || error.message || 'Failed to save template');
}
showToast(templateId ? t('templates.updated') : t('templates.created'), 'success');
closeTemplateModal();
await loadCaptureTemplates();
} catch (error) {
console.error('Error saving template:', error);
document.getElementById('template-error').textContent = error.message;
document.getElementById('template-error').style.display = 'block';
}
}
// Delete template
async function deleteTemplate(templateId) {
const confirmed = await showConfirm(t('templates.delete.confirm'));
if (!confirmed) return;
try {
const response = await fetchWithAuth(`/capture-templates/${templateId}`, {
method: 'DELETE'
});
if (!response.ok) {
const error = await response.json();
throw new Error(error.detail || error.message || 'Failed to delete template');
}
showToast(t('templates.deleted'), 'success');
await loadCaptureTemplates();
} catch (error) {
console.error('Error deleting template:', error);
showToast(t('templates.error.delete') + ': ' + error.message, 'error');
}
}
// ===== Picture Streams =====
let _cachedStreams = [];
let _cachedPPTemplates = [];
let _availableFilters = []; // Loaded from GET /filters
async function loadPictureStreams() {
try {
const response = await fetchWithAuth('/picture-streams');
if (!response.ok) {
throw new Error(`Failed to load streams: ${response.status}`);
}
const data = await response.json();
_cachedStreams = data.streams || [];
renderPictureStreamsList(_cachedStreams);
} catch (error) {
console.error('Error loading picture streams:', error);
document.getElementById('streams-list').innerHTML = `
${t('streams.error.load')}: ${error.message}
`;
}
}
function renderPictureStreamsList(streams) {
const container = document.getElementById('streams-list');
if (streams.length === 0) {
container.innerHTML = `
+
${t('streams.add.raw')}
+
${t('streams.add.processed')}
`;
return;
}
const renderCard = (stream) => {
const typeIcon = stream.stream_type === 'raw' ? '🖥️' : '🎨';
const typeBadge = stream.stream_type === 'raw'
? `${t('streams.type.raw')} `
: `${t('streams.type.processed')} `;
let detailsHtml = '';
if (stream.stream_type === 'raw') {
detailsHtml = `
${t('streams.display')} ${stream.display_index ?? 0}
${t('streams.target_fps')} ${stream.target_fps ?? 30}
`;
} else {
// Find source stream name and PP template name
const sourceStream = _cachedStreams.find(s => s.id === stream.source_stream_id);
const sourceName = sourceStream ? escapeHtml(sourceStream.name) : (stream.source_stream_id || '-');
detailsHtml = `
${t('streams.source')} ${sourceName}
`;
}
return `
✕
${detailsHtml}
${stream.description ? `
${escapeHtml(stream.description)}
` : ''}
🧪
✏️
`;
};
const rawStreams = streams.filter(s => s.stream_type === 'raw');
const processedStreams = streams.filter(s => s.stream_type === 'processed');
let html = '';
// Screen Capture streams section
html += `
${rawStreams.map(renderCard).join('')}
+
${t('streams.add.raw')}
`;
// Processed streams section
html += `
${processedStreams.map(renderCard).join('')}
+
${t('streams.add.processed')}
`;
container.innerHTML = html;
}
function onStreamTypeChange() {
const streamType = document.getElementById('stream-type').value;
document.getElementById('stream-raw-fields').style.display = streamType === 'raw' ? '' : 'none';
document.getElementById('stream-processed-fields').style.display = streamType === 'processed' ? '' : 'none';
}
async function showAddStreamModal(presetType) {
const streamType = presetType || 'raw';
const titleKey = streamType === 'raw' ? 'streams.add.raw' : 'streams.add.processed';
document.getElementById('stream-modal-title').textContent = t(titleKey);
document.getElementById('stream-form').reset();
document.getElementById('stream-id').value = '';
document.getElementById('stream-error').style.display = 'none';
document.getElementById('stream-type').value = streamType;
onStreamTypeChange();
// Populate dropdowns
await populateStreamModalDropdowns();
const modal = document.getElementById('stream-modal');
modal.style.display = 'flex';
lockBody();
modal.onclick = (e) => { if (e.target === modal) closeStreamModal(); };
}
async function editStream(streamId) {
try {
const response = await fetchWithAuth(`/picture-streams/${streamId}`);
if (!response.ok) throw new Error(`Failed to load stream: ${response.status}`);
const stream = await response.json();
const editTitleKey = stream.stream_type === 'raw' ? 'streams.edit.raw' : 'streams.edit.processed';
document.getElementById('stream-modal-title').textContent = t(editTitleKey);
document.getElementById('stream-id').value = streamId;
document.getElementById('stream-name').value = stream.name;
document.getElementById('stream-description').value = stream.description || '';
document.getElementById('stream-error').style.display = 'none';
// Set type (hidden input)
document.getElementById('stream-type').value = stream.stream_type;
onStreamTypeChange();
// Populate dropdowns before setting values
await populateStreamModalDropdowns();
if (stream.stream_type === 'raw') {
document.getElementById('stream-display-index').value = String(stream.display_index ?? 0);
document.getElementById('stream-capture-template').value = stream.capture_template_id || '';
const fps = stream.target_fps ?? 30;
document.getElementById('stream-target-fps').value = fps;
document.getElementById('stream-target-fps-value').textContent = fps;
} else {
document.getElementById('stream-source').value = stream.source_stream_id || '';
document.getElementById('stream-pp-template').value = stream.postprocessing_template_id || '';
}
const modal = document.getElementById('stream-modal');
modal.style.display = 'flex';
lockBody();
modal.onclick = (e) => { if (e.target === modal) closeStreamModal(); };
} catch (error) {
console.error('Error loading stream:', error);
showToast(t('streams.error.load') + ': ' + error.message, 'error');
}
}
async function populateStreamModalDropdowns() {
// Load displays, capture templates, streams, and PP templates in parallel
const [displaysRes, captureTemplatesRes, streamsRes, ppTemplatesRes] = await Promise.all([
fetch(`${API_BASE}/config/displays`, { headers: getHeaders() }),
fetchWithAuth('/capture-templates'),
fetchWithAuth('/picture-streams'),
fetchWithAuth('/postprocessing-templates'),
]);
// Displays
const displaySelect = document.getElementById('stream-display-index');
displaySelect.innerHTML = '';
if (displaysRes.ok) {
const displaysData = await displaysRes.json();
(displaysData.displays || []).forEach(d => {
const opt = document.createElement('option');
opt.value = d.index;
opt.textContent = `${d.index}: ${d.width}x${d.height}${d.is_primary ? ` (${t('displays.badge.primary')})` : ''}`;
displaySelect.appendChild(opt);
});
}
if (displaySelect.options.length === 0) {
const opt = document.createElement('option');
opt.value = '0';
opt.textContent = '0';
displaySelect.appendChild(opt);
}
// Capture templates
const templateSelect = document.getElementById('stream-capture-template');
templateSelect.innerHTML = '';
if (captureTemplatesRes.ok) {
const data = await captureTemplatesRes.json();
(data.templates || []).forEach(tmpl => {
const opt = document.createElement('option');
opt.value = tmpl.id;
opt.textContent = `${getEngineIcon(tmpl.engine_type)} ${tmpl.name}`;
templateSelect.appendChild(opt);
});
}
// Source streams (all existing streams)
const sourceSelect = document.getElementById('stream-source');
sourceSelect.innerHTML = '';
if (streamsRes.ok) {
const data = await streamsRes.json();
const editingId = document.getElementById('stream-id').value;
(data.streams || []).forEach(s => {
// Don't show the current stream as a possible source
if (s.id === editingId) return;
const opt = document.createElement('option');
opt.value = s.id;
const typeLabel = s.stream_type === 'raw' ? '🖥️' : '🎨';
opt.textContent = `${typeLabel} ${s.name}`;
sourceSelect.appendChild(opt);
});
}
// PP templates
const ppSelect = document.getElementById('stream-pp-template');
ppSelect.innerHTML = '';
if (ppTemplatesRes.ok) {
const data = await ppTemplatesRes.json();
(data.templates || []).forEach(tmpl => {
const opt = document.createElement('option');
opt.value = tmpl.id;
opt.textContent = tmpl.name;
ppSelect.appendChild(opt);
});
}
}
async function saveStream() {
const streamId = document.getElementById('stream-id').value;
const name = document.getElementById('stream-name').value.trim();
const streamType = document.getElementById('stream-type').value;
const description = document.getElementById('stream-description').value.trim();
const errorEl = document.getElementById('stream-error');
if (!name) {
showToast(t('streams.error.required'), 'error');
return;
}
const payload = { name, description: description || null };
if (!streamId) {
// Creating - include stream_type
payload.stream_type = streamType;
}
if (streamType === 'raw') {
payload.display_index = parseInt(document.getElementById('stream-display-index').value) || 0;
payload.capture_template_id = document.getElementById('stream-capture-template').value;
payload.target_fps = parseInt(document.getElementById('stream-target-fps').value) || 30;
} else {
payload.source_stream_id = document.getElementById('stream-source').value;
payload.postprocessing_template_id = document.getElementById('stream-pp-template').value;
}
try {
let response;
if (streamId) {
response = await fetchWithAuth(`/picture-streams/${streamId}`, {
method: 'PUT',
body: JSON.stringify(payload)
});
} else {
response = await fetchWithAuth('/picture-streams', {
method: 'POST',
body: JSON.stringify(payload)
});
}
if (!response.ok) {
const error = await response.json();
throw new Error(error.detail || error.message || 'Failed to save stream');
}
showToast(streamId ? t('streams.updated') : t('streams.created'), 'success');
closeStreamModal();
await loadPictureStreams();
} catch (error) {
console.error('Error saving stream:', error);
errorEl.textContent = error.message;
errorEl.style.display = 'block';
}
}
async function deleteStream(streamId) {
const confirmed = await showConfirm(t('streams.delete.confirm'));
if (!confirmed) return;
try {
const response = await fetchWithAuth(`/picture-streams/${streamId}`, {
method: 'DELETE'
});
if (!response.ok) {
const error = await response.json();
throw new Error(error.detail || error.message || 'Failed to delete stream');
}
showToast(t('streams.deleted'), 'success');
await loadPictureStreams();
} catch (error) {
console.error('Error deleting stream:', error);
showToast(t('streams.error.delete') + ': ' + error.message, 'error');
}
}
function closeStreamModal() {
document.getElementById('stream-modal').style.display = 'none';
document.getElementById('stream-type').disabled = false;
unlockBody();
}
// ===== Picture Stream Test =====
let _currentTestStreamId = null;
async function showTestStreamModal(streamId) {
_currentTestStreamId = streamId;
restoreStreamTestDuration();
const modal = document.getElementById('test-stream-modal');
modal.style.display = 'flex';
lockBody();
modal.onclick = (e) => { if (e.target === modal) closeTestStreamModal(); };
}
function closeTestStreamModal() {
document.getElementById('test-stream-modal').style.display = 'none';
unlockBody();
_currentTestStreamId = null;
}
function updateStreamTestDuration(value) {
document.getElementById('test-stream-duration-value').textContent = value;
localStorage.setItem('lastStreamTestDuration', value);
}
function restoreStreamTestDuration() {
const saved = localStorage.getItem('lastStreamTestDuration') || '5';
document.getElementById('test-stream-duration').value = saved;
document.getElementById('test-stream-duration-value').textContent = saved;
}
async function runStreamTest() {
if (!_currentTestStreamId) return;
const captureDuration = parseFloat(document.getElementById('test-stream-duration').value);
showOverlaySpinner(t('streams.test.running'), captureDuration);
try {
const response = await fetchWithAuth(`/picture-streams/${_currentTestStreamId}/test`, {
method: 'POST',
body: JSON.stringify({ capture_duration: captureDuration })
});
if (!response.ok) {
const error = await response.json();
throw new Error(error.detail || error.message || 'Test failed');
}
const result = await response.json();
displayStreamTestResults(result);
} catch (error) {
console.error('Error running stream test:', error);
hideOverlaySpinner();
showToast(t('streams.test.error.failed') + ': ' + error.message, 'error');
}
}
function displayStreamTestResults(result) {
hideOverlaySpinner();
const fullImageSrc = result.full_capture.full_image || result.full_capture.image;
openLightbox(fullImageSrc, buildTestStatsHtml(result));
}
// ===== Processing Templates =====
async function loadAvailableFilters() {
try {
const response = await fetchWithAuth('/filters');
if (!response.ok) throw new Error(`Failed to load filters: ${response.status}`);
const data = await response.json();
_availableFilters = data.filters || [];
} catch (error) {
console.error('Error loading available filters:', error);
_availableFilters = [];
}
}
async function loadPPTemplates() {
try {
// Ensure filters are loaded for rendering
if (_availableFilters.length === 0) {
await loadAvailableFilters();
}
const response = await fetchWithAuth('/postprocessing-templates');
if (!response.ok) {
throw new Error(`Failed to load templates: ${response.status}`);
}
const data = await response.json();
_cachedPPTemplates = data.templates || [];
renderPPTemplatesList(_cachedPPTemplates);
} catch (error) {
console.error('Error loading PP templates:', error);
document.getElementById('pp-templates-list').innerHTML = `
${t('postprocessing.error.load')}: ${error.message}
`;
}
}
function _getFilterName(filterId) {
const key = 'filters.' + filterId;
const translated = t(key);
// Fallback to filter_name from registry if no localization
if (translated === key) {
const def = _availableFilters.find(f => f.filter_id === filterId);
return def ? def.filter_name : filterId;
}
return translated;
}
function renderPPTemplatesList(templates) {
const container = document.getElementById('pp-templates-list');
if (templates.length === 0) {
container.innerHTML = `
+
${t('postprocessing.add')}
`;
return;
}
const renderCard = (tmpl) => {
// Build config entries from filter list
const filterRows = (tmpl.filters || []).map(fi => {
const filterName = _getFilterName(fi.filter_id);
const optStr = Object.entries(fi.options || {}).map(([k, v]) => `${v}`).join(', ');
return `${escapeHtml(filterName)} ${escapeHtml(optStr)} `;
}).join('');
return `
✕
${tmpl.description ? `
${escapeHtml(tmpl.description)}
` : ''}
${t('postprocessing.config.show')}
✏️
`;
};
let html = templates.map(renderCard).join('');
html += `
+
${t('postprocessing.add')}
`;
container.innerHTML = html;
}
// --- Filter list management in PP template modal ---
let _modalFilters = []; // Current filter list being edited in modal
function _populateFilterSelect() {
const select = document.getElementById('pp-add-filter-select');
// Keep first option (placeholder)
select.innerHTML = `${t('filters.select_type')} `;
for (const f of _availableFilters) {
const name = _getFilterName(f.filter_id);
select.innerHTML += `${name} `;
}
}
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 += `
`;
if (filterDef) {
for (const opt of filterDef.options_schema) {
const currentVal = fi.options[opt.key] !== undefined ? fi.options[opt.key] : opt.default;
const inputId = `filter-${index}-${opt.key}`;
html += `
${escapeHtml(opt.label)}:
${currentVal}
`;
}
}
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();
}
function toggleFilterExpand(index) {
if (_modalFilters[index]) {
_modalFilters[index]._expanded = !_modalFilters[index]._expanded;
renderModalFilterList();
}
}
function removeFilter(index) {
_modalFilters.splice(index, 1);
renderModalFilterList();
}
function moveFilter(index, direction) {
const newIndex = index + direction;
if (newIndex < 0 || newIndex >= _modalFilters.length) return;
const tmp = _modalFilters[index];
_modalFilters[index] = _modalFilters[newIndex];
_modalFilters[newIndex] = tmp;
renderModalFilterList();
}
function updateFilterOption(filterIndex, optionKey, value) {
if (_modalFilters[filterIndex]) {
// Determine type from schema
const fi = _modalFilters[filterIndex];
const filterDef = _availableFilters.find(f => f.filter_id === fi.filter_id);
if (filterDef) {
const optDef = filterDef.options_schema.find(o => o.key === optionKey);
if (optDef && optDef.type === 'int') {
fi.options[optionKey] = parseInt(value);
} else {
fi.options[optionKey] = parseFloat(value);
}
} else {
fi.options[optionKey] = parseFloat(value);
}
}
}
function collectFilters() {
return _modalFilters.map(fi => ({
filter_id: fi.filter_id,
options: { ...fi.options },
}));
}
async function showAddPPTemplateModal() {
if (_availableFilters.length === 0) await loadAvailableFilters();
document.getElementById('pp-template-modal-title').textContent = t('postprocessing.add');
document.getElementById('pp-template-form').reset();
document.getElementById('pp-template-id').value = '';
document.getElementById('pp-template-error').style.display = 'none';
_modalFilters = [];
_populateFilterSelect();
renderModalFilterList();
const modal = document.getElementById('pp-template-modal');
modal.style.display = 'flex';
lockBody();
modal.onclick = (e) => { if (e.target === modal) closePPTemplateModal(); };
}
async function editPPTemplate(templateId) {
try {
if (_availableFilters.length === 0) await loadAvailableFilters();
const response = await fetchWithAuth(`/postprocessing-templates/${templateId}`);
if (!response.ok) throw new Error(`Failed to load template: ${response.status}`);
const tmpl = await response.json();
document.getElementById('pp-template-modal-title').textContent = t('postprocessing.edit');
document.getElementById('pp-template-id').value = templateId;
document.getElementById('pp-template-name').value = tmpl.name;
document.getElementById('pp-template-description').value = tmpl.description || '';
document.getElementById('pp-template-error').style.display = 'none';
// Load filters from template
_modalFilters = (tmpl.filters || []).map(fi => ({
filter_id: fi.filter_id,
options: { ...fi.options },
}));
_populateFilterSelect();
renderModalFilterList();
const modal = document.getElementById('pp-template-modal');
modal.style.display = 'flex';
lockBody();
modal.onclick = (e) => { if (e.target === modal) closePPTemplateModal(); };
} catch (error) {
console.error('Error loading PP template:', error);
showToast(t('postprocessing.error.load') + ': ' + error.message, 'error');
}
}
async function savePPTemplate() {
const templateId = document.getElementById('pp-template-id').value;
const name = document.getElementById('pp-template-name').value.trim();
const description = document.getElementById('pp-template-description').value.trim();
const errorEl = document.getElementById('pp-template-error');
if (!name) {
showToast(t('postprocessing.error.required'), 'error');
return;
}
const payload = {
name,
filters: collectFilters(),
description: description || null,
};
try {
let response;
if (templateId) {
response = await fetchWithAuth(`/postprocessing-templates/${templateId}`, {
method: 'PUT',
body: JSON.stringify(payload)
});
} else {
response = await fetchWithAuth('/postprocessing-templates', {
method: 'POST',
body: JSON.stringify(payload)
});
}
if (!response.ok) {
const error = await response.json();
throw new Error(error.detail || error.message || 'Failed to save template');
}
showToast(templateId ? t('postprocessing.updated') : t('postprocessing.created'), 'success');
closePPTemplateModal();
await loadPPTemplates();
} catch (error) {
console.error('Error saving PP template:', error);
errorEl.textContent = error.message;
errorEl.style.display = 'block';
}
}
async function deletePPTemplate(templateId) {
const confirmed = await showConfirm(t('postprocessing.delete.confirm'));
if (!confirmed) return;
try {
const response = await fetchWithAuth(`/postprocessing-templates/${templateId}`, {
method: 'DELETE'
});
if (!response.ok) {
const error = await response.json();
throw new Error(error.detail || error.message || 'Failed to delete template');
}
showToast(t('postprocessing.deleted'), 'success');
await loadPPTemplates();
} catch (error) {
console.error('Error deleting PP template:', error);
showToast(t('postprocessing.error.delete') + ': ' + error.message, 'error');
}
}
function closePPTemplateModal() {
document.getElementById('pp-template-modal').style.display = 'none';
_modalFilters = [];
unlockBody();
}
// ===== Device Stream Selector =====
let streamSelectorInitialValues = {};
async function showStreamSelector(deviceId) {
try {
const [deviceResponse, streamsResponse, settingsResponse] = await Promise.all([
fetch(`${API_BASE}/devices/${deviceId}`, { headers: getHeaders() }),
fetchWithAuth('/picture-streams'),
fetch(`${API_BASE}/devices/${deviceId}/settings`, { headers: getHeaders() }),
]);
if (deviceResponse.status === 401) {
handle401Error();
return;
}
if (!deviceResponse.ok) {
showToast('Failed to load device', 'error');
return;
}
const device = await deviceResponse.json();
const settings = settingsResponse.ok ? await settingsResponse.json() : {};
// Populate stream select
const streamSelect = document.getElementById('stream-selector-stream');
streamSelect.innerHTML = '';
if (streamsResponse.ok) {
const data = await streamsResponse.json();
(data.streams || []).forEach(s => {
const opt = document.createElement('option');
opt.value = s.id;
const typeIcon = s.stream_type === 'raw' ? '🖥️' : '🎨';
opt.textContent = `${typeIcon} ${s.name}`;
streamSelect.appendChild(opt);
});
}
const currentStreamId = device.picture_stream_id || '';
streamSelect.value = currentStreamId;
// Populate LED projection fields
const borderWidth = settings.border_width ?? device.settings?.border_width ?? 10;
const smoothing = settings.smoothing ?? device.settings?.smoothing ?? 0.3;
document.getElementById('stream-selector-border-width').value = borderWidth;
document.getElementById('stream-selector-interpolation').value = device.settings?.interpolation_mode || 'average';
document.getElementById('stream-selector-smoothing').value = smoothing;
document.getElementById('stream-selector-smoothing-value').textContent = smoothing;
streamSelectorInitialValues = {
stream: currentStreamId,
border_width: String(borderWidth),
interpolation: device.settings?.interpolation_mode || 'average',
smoothing: String(smoothing),
};
document.getElementById('stream-selector-device-id').value = deviceId;
document.getElementById('stream-selector-error').style.display = 'none';
// Show info about selected stream
updateStreamSelectorInfo(streamSelect.value);
streamSelect.onchange = () => updateStreamSelectorInfo(streamSelect.value);
const modal = document.getElementById('stream-selector-modal');
modal.style.display = 'flex';
lockBody();
modal.onclick = (e) => { if (e.target === modal) closeStreamSelectorModal(); };
} catch (error) {
console.error('Failed to load stream settings:', error);
showToast('Failed to load stream settings', 'error');
}
}
async function updateStreamSelectorInfo(streamId) {
const infoPanel = document.getElementById('stream-selector-info');
if (!streamId) {
infoPanel.style.display = 'none';
return;
}
try {
const response = await fetchWithAuth(`/picture-streams/${streamId}`);
if (!response.ok) {
infoPanel.style.display = 'none';
return;
}
const stream = await response.json();
let infoHtml = `${t('streams.type')} ${stream.stream_type === 'raw' ? t('streams.type.raw') : t('streams.type.processed')}
`;
if (stream.stream_type === 'raw') {
infoHtml += `${t('streams.display')} ${stream.display_index ?? 0}
`;
infoHtml += `${t('streams.target_fps')} ${stream.target_fps ?? 30}
`;
} else {
const sourceStream = _cachedStreams.find(s => s.id === stream.source_stream_id);
infoHtml += `${t('streams.source')} ${sourceStream ? escapeHtml(sourceStream.name) : stream.source_stream_id}
`;
}
infoPanel.innerHTML = infoHtml;
infoPanel.style.display = '';
} catch {
infoPanel.style.display = 'none';
}
}
async function saveStreamSelector() {
const deviceId = document.getElementById('stream-selector-device-id').value;
const pictureStreamId = document.getElementById('stream-selector-stream').value;
const borderWidth = parseInt(document.getElementById('stream-selector-border-width').value) || 10;
const interpolation = document.getElementById('stream-selector-interpolation').value;
const smoothing = parseFloat(document.getElementById('stream-selector-smoothing').value);
const errorEl = document.getElementById('stream-selector-error');
try {
// Save picture stream assignment
const response = await fetch(`${API_BASE}/devices/${deviceId}`, {
method: 'PUT',
headers: getHeaders(),
body: JSON.stringify({ picture_stream_id: pictureStreamId })
});
if (response.status === 401) {
handle401Error();
return;
}
if (!response.ok) {
const error = await response.json();
throw new Error(error.detail || error.message || 'Failed to save');
}
// Save LED projection settings — merge with existing to avoid overwriting other fields
const currentSettingsRes = await fetch(`${API_BASE}/devices/${deviceId}/settings`, { headers: getHeaders() });
const currentSettings = currentSettingsRes.ok ? await currentSettingsRes.json() : {};
const settingsResponse = await fetch(`${API_BASE}/devices/${deviceId}/settings`, {
method: 'PUT',
headers: getHeaders(),
body: JSON.stringify({ ...currentSettings, border_width: borderWidth, interpolation_mode: interpolation, smoothing: smoothing })
});
if (!settingsResponse.ok) {
const error = await settingsResponse.json();
throw new Error(error.detail || error.message || 'Failed to save settings');
}
showToast(t('device.stream_selector.saved'), 'success');
forceCloseStreamSelectorModal();
await loadDevices();
} catch (error) {
console.error('Error saving stream settings:', error);
errorEl.textContent = error.message;
errorEl.style.display = 'block';
}
}
function isStreamSettingsDirty() {
return (
document.getElementById('stream-selector-stream').value !== streamSelectorInitialValues.stream ||
document.getElementById('stream-selector-border-width').value !== streamSelectorInitialValues.border_width ||
document.getElementById('stream-selector-interpolation').value !== streamSelectorInitialValues.interpolation ||
document.getElementById('stream-selector-smoothing').value !== streamSelectorInitialValues.smoothing
);
}
async function closeStreamSelectorModal() {
if (isStreamSettingsDirty()) {
const confirmed = await showConfirm(t('modal.discard_changes'));
if (!confirmed) return;
}
forceCloseStreamSelectorModal();
}
function forceCloseStreamSelectorModal() {
document.getElementById('stream-selector-modal').style.display = 'none';
document.getElementById('stream-selector-error').style.display = 'none';
unlockBody();
streamSelectorInitialValues = {};
}