Files
wled-screen-controller-mixed/server/src/wled_controller/static/app.js
alexei.dolgolyov ebd6cc7d7d Add pluggable postprocessing filter system with collapsible UI
Replace hardcoded gamma/saturation/brightness fields with a flexible
filter pipeline architecture. Templates now contain an ordered list of
filter instances, each with its own options schema. Filters operate on
full images before border extraction.

- Add filter framework: base class, registry, image pool, filter instance
- Implement 6 built-in filters: brightness, saturation, gamma, downscaler, pixelate, auto crop
- Move smoothing from PP templates to device stream settings (temporal, not spatial)
- Add GET /api/v1/filters endpoint for available filter types
- Dynamic filter UI in template modal with add/remove/reorder/collapse
- Replace camera icon with display icon for screen capture streams
- Legacy migration: existing templates auto-convert flat fields to filter list

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-11 11:57:19 +03:00

3965 lines
146 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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 = `<div class="loading">${t('displays.none')}</div>`;
document.getElementById('display-layout-canvas').innerHTML = `<div class="loading">${t('displays.none')}</div>`;
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 =
`<div class="loading">${t('displays.failed')}</div>`;
document.getElementById('display-layout-canvas').innerHTML =
`<div class="loading">${t('displays.failed')}</div>`;
}
}
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 = `<div class="loading">${t('displays.none')}</div>`;
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 `
<div class="layout-display ${display.is_primary ? 'primary' : 'secondary'}"
style="left: ${left}px; top: ${top}px; width: ${width}px; height: ${height}px;"
title="${display.name}\n${display.width}×${display.height}\nPosition: (${display.x}, ${display.y})">
<div class="layout-position-label">(${display.x}, ${display.y})</div>
<div class="layout-index-label">#${display.index}</div>
<div class="layout-display-label">
<strong>${display.name}</strong>
<small>${display.width}×${display.height}</small>
<small>${display.refresh_rate}Hz</small>
</div>
${display.is_primary ? '<div class="primary-indicator">★</div>' : ''}
</div>
`;
}).join('');
canvas.innerHTML = `
<div class="layout-container" style="width: ${canvasWidth}px; height: ${canvasHeight}px; margin: 0 auto; position: relative;">
${displayElements}
</div>
`;
}
// 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 = `<div class="card add-device-card" onclick="showAddDevice()">
<div class="add-device-icon">+</div>
<div class="add-device-label">${t('devices.add')}</div>
</div>`;
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('')
+ `<div class="card add-device-card" onclick="showAddDevice()">
<div class="add-device-icon">+</div>
<div class="add-device-label">${t('devices.add')}</div>
</div>`;
// 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 =
`<div class="loading">${t('devices.failed')}</div>`;
}
}
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 = `<span class="health-latency offline">${t('device.health.offline')}</span>`;
}
const ledCount = state.wled_led_count || device.led_count;
return `
<div class="card" data-device-id="${device.id}">
<button class="card-remove-btn" onclick="removeDevice('${device.id}')" title="${t('device.button.remove')}">&#x2715;</button>
<div class="card-header">
<div class="card-title">
<span class="health-dot ${healthClass}" title="${healthTitle}"></span>
${device.name || device.id}
${device.url ? `<span class="device-url-badge" title="${escapeHtml(device.url)}">${escapeHtml(device.url.replace(/^https?:\/\//, ''))}</span>` : ''}
${healthLabel}
${isProcessing ? `<span class="badge processing">${t('device.status.processing')}</span>` : ''}
</div>
</div>
<div class="card-subtitle">
${wledVersion ? `<span class="card-meta">v${wledVersion}</span>` : ''}
${ledCount ? `<span class="card-meta" title="${t('device.led_count')}">💡 ${ledCount}</span>` : ''}
${state.wled_led_type ? `<span class="card-meta">🔌 ${state.wled_led_type.replace(/ RGBW$/, '')}</span>` : ''}
<span class="card-meta" title="${state.wled_rgbw ? 'RGBW' : 'RGB'}"><span class="channel-indicator"><span class="ch" style="background:#e53935"></span><span class="ch" style="background:#43a047"></span><span class="ch" style="background:#1e88e5"></span>${state.wled_rgbw ? '<span class="ch" style="background:#eee"></span>' : ''}</span></span>
</div>
<div class="card-content">
${isProcessing ? `
<div class="metrics-grid">
<div class="metric">
<div class="metric-value">${state.fps_actual?.toFixed(1) || '0.0'}</div>
<div class="metric-label">${t('device.metrics.actual_fps')}</div>
</div>
<div class="metric">
<div class="metric-value">${state.fps_target || 0}</div>
<div class="metric-label">${t('device.metrics.target_fps')}</div>
</div>
<div class="metric">
<div class="metric-value">${metrics.frames_processed || 0}</div>
<div class="metric-label">${t('device.metrics.frames')}</div>
</div>
<div class="metric">
<div class="metric-value">${metrics.errors_count || 0}</div>
<div class="metric-label">${t('device.metrics.errors')}</div>
</div>
</div>
` : ''}
</div>
<div class="brightness-control">
<input type="range" class="brightness-slider" min="0" max="100"
value="${brightnessPercent}"
oninput="updateBrightnessLabel('${device.id}', this.value)"
onchange="saveCardBrightness('${device.id}', this.value)"
title="${brightnessPercent}%">
</div>
<div class="card-actions">
${isProcessing ? `
<button class="btn btn-icon btn-danger" onclick="stopProcessing('${device.id}')" title="${t('device.button.stop')}">
⏹️
</button>
` : `
<button class="btn btn-icon btn-primary" onclick="startProcessing('${device.id}')" title="${t('device.button.start')}">
▶️
</button>
`}
<button class="btn btn-icon btn-secondary" onclick="showSettings('${device.id}')" title="${t('device.button.settings')}">
⚙️
</button>
<button class="btn btn-icon btn-secondary" onclick="showStreamSelector('${device.id}')" title="${t('device.button.stream_selector')}">
📺
</button>
<button class="btn btn-icon btn-secondary" onclick="showCalibration('${device.id}')" title="${t('device.button.calibrate')}">
📐
</button>
${device.url ? `
<a class="btn btn-icon btn-secondary" href="${device.url}" target="_blank" rel="noopener" title="${t('device.button.webui')}">
🌐
</a>
` : ''}
</div>
<button class="card-tutorial-btn" onclick="startDeviceTutorial('${device.id}')" title="${t('device.tutorial.start')}">?</button>
</div>
`;
}
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 = `
<div class="error-message">${t('templates.error.load')}: ${error.message}</div>
`;
}
}
// Render templates list
function renderTemplatesList(templates) {
const container = document.getElementById('templates-list');
if (templates.length === 0) {
container.innerHTML = `<div class="template-card add-template-card" onclick="showAddTemplateModal()">
<div class="add-template-icon">+</div>
<div class="add-template-label">${t('templates.add')}</div>
</div>`;
return;
}
const renderCard = (template) => {
const engineIcon = getEngineIcon(template.engine_type);
return `
<div class="template-card" data-template-id="${template.id}">
<button class="card-remove-btn" onclick="deleteTemplate('${template.id}')" title="${t('common.delete')}">&#x2715;</button>
<div class="template-card-header">
<div class="template-name">
${engineIcon} ${escapeHtml(template.name)}
</div>
</div>
<div class="template-config">
<strong>${t('templates.engine')}</strong> ${template.engine_type.toUpperCase()}
</div>
${Object.keys(template.engine_config).length > 0 ? `
<details class="template-config-details">
<summary>${t('templates.config.show')}</summary>
<table class="config-table">
${Object.entries(template.engine_config).map(([key, val]) => `
<tr>
<td class="config-key">${escapeHtml(key)}</td>
<td class="config-value">${escapeHtml(String(val))}</td>
</tr>
`).join('')}
</table>
</details>
` : `
<div class="template-no-config">${t('templates.config.none')}</div>
`}
<div class="template-card-actions">
<button class="btn btn-icon btn-secondary" onclick="showTestTemplateModal('${template.id}')" title="${t('templates.test.title')}">
🧪
</button>
<button class="btn btn-icon btn-secondary" onclick="editTemplate('${template.id}')" title="${t('common.edit')}">
✏️
</button>
</div>
</div>
`;
};
let html = templates.map(renderCard).join('');
html += `<div class="template-card add-template-card" onclick="showAddTemplateModal()">
<div class="add-template-icon">+</div>
<div class="add-template-label">${t('templates.add')}</div>
</div>`;
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 = `<option value="">${t('templates.engine.select')}</option>`;
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 = `<p class="text-muted">${t('templates.config.none')}</p>`;
} 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 = `
<div class="form-group">
<label for="config-${key}">${key}:</label>
${typeof value === 'boolean' ? `
<select id="config-${key}" data-config-key="${key}">
<option value="true" ${value ? 'selected' : ''}>true</option>
<option value="false" ${!value ? 'selected' : ''}>false</option>
</select>
` : `
<input type="${fieldType}" id="config-${key}" data-config-key="${key}" value="${fieldValue}">
`}
<small class="form-hint">${t('templates.config.default')}: ${JSON.stringify(value)}</small>
</div>
`;
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 `
<div class="stat-item"><span>${t('templates.test.results.duration')}:</span> <strong>${p.capture_duration_s.toFixed(2)}s</strong></div>
<div class="stat-item"><span>${t('templates.test.results.frame_count')}:</span> <strong>${p.frame_count}</strong></div>
<div class="stat-item"><span>${t('templates.test.results.actual_fps')}:</span> <strong>${p.actual_fps.toFixed(1)}</strong></div>
<div class="stat-item"><span>${t('templates.test.results.avg_capture_time')}:</span> <strong>${p.avg_capture_time_ms.toFixed(1)}ms</strong></div>
<div class="stat-item"><span>Resolution:</span> <strong>${res}</strong></div>
`;
}
// 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 = `
<div class="error-message">${t('streams.error.load')}: ${error.message}</div>
`;
}
}
function renderPictureStreamsList(streams) {
const container = document.getElementById('streams-list');
if (streams.length === 0) {
container.innerHTML = `
<div class="stream-group">
<div class="stream-group-header">
<span class="stream-group-icon">🖥️</span>
<span class="stream-group-title">${t('streams.group.raw')}</span>
<span class="stream-group-count">0</span>
</div>
<div class="templates-grid">
<div class="template-card add-template-card" onclick="showAddStreamModal('raw')">
<div class="add-template-icon">+</div>
<div class="add-template-label">${t('streams.add.raw')}</div>
</div>
</div>
</div>
<div class="stream-group">
<div class="stream-group-header">
<span class="stream-group-icon">🎨</span>
<span class="stream-group-title">${t('streams.group.processed')}</span>
<span class="stream-group-count">0</span>
</div>
<div class="templates-grid">
<div class="template-card add-template-card" onclick="showAddStreamModal('processed')">
<div class="add-template-icon">+</div>
<div class="add-template-label">${t('streams.add.processed')}</div>
</div>
</div>
</div>`;
return;
}
const renderCard = (stream) => {
const typeIcon = stream.stream_type === 'raw' ? '🖥️' : '🎨';
const typeBadge = stream.stream_type === 'raw'
? `<span class="badge badge-raw">${t('streams.type.raw')}</span>`
: `<span class="badge badge-processed">${t('streams.type.processed')}</span>`;
let detailsHtml = '';
if (stream.stream_type === 'raw') {
detailsHtml = `
<div class="template-config">
<strong>${t('streams.display')}</strong> ${stream.display_index ?? 0}
</div>
<div class="template-config">
<strong>${t('streams.target_fps')}</strong> ${stream.target_fps ?? 30}
</div>
`;
} 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 = `
<div class="template-config">
<strong>${t('streams.source')}</strong> ${sourceName}
</div>
`;
}
return `
<div class="template-card" data-stream-id="${stream.id}">
<button class="card-remove-btn" onclick="deleteStream('${stream.id}')" title="${t('common.delete')}">&#x2715;</button>
<div class="template-card-header">
<div class="template-name">
${typeIcon} ${escapeHtml(stream.name)}
</div>
${typeBadge}
</div>
${detailsHtml}
${stream.description ? `<div class="template-config" style="opacity:0.7;">${escapeHtml(stream.description)}</div>` : ''}
<div class="template-card-actions">
<button class="btn btn-icon btn-secondary" onclick="showTestStreamModal('${stream.id}')" title="${t('streams.test.title')}">
🧪
</button>
<button class="btn btn-icon btn-secondary" onclick="editStream('${stream.id}')" title="${t('common.edit')}">
✏️
</button>
</div>
</div>
`;
};
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 += `<div class="stream-group">
<div class="stream-group-header">
<span class="stream-group-icon">🖥️</span>
<span class="stream-group-title">${t('streams.group.raw')}</span>
<span class="stream-group-count">${rawStreams.length}</span>
</div>
<div class="templates-grid">
${rawStreams.map(renderCard).join('')}
<div class="template-card add-template-card" onclick="showAddStreamModal('raw')">
<div class="add-template-icon">+</div>
<div class="add-template-label">${t('streams.add.raw')}</div>
</div>
</div>
</div>`;
// Processed streams section
html += `<div class="stream-group">
<div class="stream-group-header">
<span class="stream-group-icon">🎨</span>
<span class="stream-group-title">${t('streams.group.processed')}</span>
<span class="stream-group-count">${processedStreams.length}</span>
</div>
<div class="templates-grid">
${processedStreams.map(renderCard).join('')}
<div class="template-card add-template-card" onclick="showAddStreamModal('processed')">
<div class="add-template-icon">+</div>
<div class="add-template-label">${t('streams.add.processed')}</div>
</div>
</div>
</div>`;
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 = `
<div class="error-message">${t('postprocessing.error.load')}: ${error.message}</div>
`;
}
}
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 = `<div class="template-card add-template-card" onclick="showAddPPTemplateModal()">
<div class="add-template-icon">+</div>
<div class="add-template-label">${t('postprocessing.add')}</div>
</div>`;
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 `<tr><td class="config-key">${escapeHtml(filterName)}</td><td class="config-value">${escapeHtml(optStr)}</td></tr>`;
}).join('');
return `
<div class="template-card" data-pp-template-id="${tmpl.id}">
<button class="card-remove-btn" onclick="deletePPTemplate('${tmpl.id}')" title="${t('common.delete')}">&#x2715;</button>
<div class="template-card-header">
<div class="template-name">
🎨 ${escapeHtml(tmpl.name)}
</div>
</div>
${tmpl.description ? `<div class="template-config" style="opacity:0.7;">${escapeHtml(tmpl.description)}</div>` : ''}
<details class="template-config-details">
<summary>${t('postprocessing.config.show')}</summary>
<table class="config-table">
${filterRows}
</table>
</details>
<div class="template-card-actions">
<button class="btn btn-icon btn-secondary" onclick="editPPTemplate('${tmpl.id}')" title="${t('common.edit')}">
✏️
</button>
</div>
</div>
`;
};
let html = templates.map(renderCard).join('');
html += `<div class="template-card add-template-card" onclick="showAddPPTemplateModal()">
<div class="add-template-icon">+</div>
<div class="add-template-label">${t('postprocessing.add')}</div>
</div>`;
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 = `<option value="">${t('filters.select_type')}</option>`;
for (const f of _availableFilters) {
const name = _getFilterName(f.filter_id);
select.innerHTML += `<option value="${f.filter_id}">${name}</option>`;
}
}
function renderModalFilterList() {
const container = document.getElementById('pp-filter-list');
if (_modalFilters.length === 0) {
container.innerHTML = `<div class="pp-filter-empty">${t('filters.empty')}</div>`;
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 += `<div class="pp-filter-card${isExpanded ? ' expanded' : ''}" data-filter-index="${index}">
<div class="pp-filter-card-header" onclick="toggleFilterExpand(${index})">
<span class="pp-filter-card-chevron">${isExpanded ? '&#x25BC;' : '&#x25B6;'}</span>
<span class="pp-filter-card-name">${escapeHtml(filterName)}</span>
${summary ? `<span class="pp-filter-card-summary">${escapeHtml(summary)}</span>` : ''}
<div class="pp-filter-card-actions" onclick="event.stopPropagation()">
<button type="button" class="btn-filter-action" onclick="moveFilter(${index}, -1)" title="${t('filters.move_up')}" ${index === 0 ? 'disabled' : ''}>&#x25B2;</button>
<button type="button" class="btn-filter-action" onclick="moveFilter(${index}, 1)" title="${t('filters.move_down')}" ${index === _modalFilters.length - 1 ? 'disabled' : ''}>&#x25BC;</button>
<button type="button" class="btn-filter-action btn-filter-remove" onclick="removeFilter(${index})" title="${t('filters.remove')}">&#x2715;</button>
</div>
</div>
<div class="pp-filter-card-options"${isExpanded ? '' : ' style="display:none"'}>`;
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 += `<div class="pp-filter-option">
<label for="${inputId}">
<span>${escapeHtml(opt.label)}:</span>
<span id="${inputId}-display">${currentVal}</span>
</label>
<input type="range" id="${inputId}"
min="${opt.min_value}" max="${opt.max_value}" step="${opt.step}" value="${currentVal}"
oninput="updateFilterOption(${index}, '${opt.key}', this.value); document.getElementById('${inputId}-display').textContent = this.value;">
</div>`;
}
}
html += `</div></div>`;
});
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 = `<div class="stream-info-type"><strong>${t('streams.type')}</strong> ${stream.stream_type === 'raw' ? t('streams.type.raw') : t('streams.type.processed')}</div>`;
if (stream.stream_type === 'raw') {
infoHtml += `<div><strong>${t('streams.display')}</strong> ${stream.display_index ?? 0}</div>`;
infoHtml += `<div><strong>${t('streams.target_fps')}</strong> ${stream.target_fps ?? 30}</div>`;
} else {
const sourceStream = _cachedStreams.find(s => s.id === stream.source_stream_id);
infoHtml += `<div><strong>${t('streams.source')}</strong> ${sourceStream ? escapeHtml(sourceStream.name) : stream.source_stream_id}</div>`;
}
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 = {};
}