Files
wled-screen-controller-mixed/server/src/wled_controller/static/app.js
alexei.dolgolyov 8d4dbbcc7f Polish Pattern Template UI: dialog sizing, KC editor layout, and conventions
- Fix dialog/canvas sizing: fit-content dialog follows canvas width, canvas
  max-width: 100% prevents overflow, horizontal resize supported
- Move pattern template dropdown above FPS/mode/smoothing in KC editor
- Remove emoji from pattern template dropdown options and auto-generated names
- Remove placeholder option from pattern template select, default to first
- Rename default pattern template from "Default" to "Full Screen"
- Add UI conventions to CLAUDE.md (hint pattern, select dropdown rules)

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-12 18:46:08 +03:00

5449 lines
211 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;
// Toggle hint description visibility next to a label
function toggleHint(btn) {
const hint = btn.closest('.label-row').nextElementSibling;
if (hint && hint.classList.contains('input-hint')) {
const visible = hint.style.display !== 'none';
hint.style.display = visible ? 'none' : 'block';
btn.classList.toggle('active', !visible);
}
}
// Backdrop click helper: only closes modal if both mousedown and mouseup were on the backdrop itself.
// Prevents accidental close when user drags text selection outside the dialog.
function setupBackdropClose(modal, closeFn) {
// Guard against duplicate listeners when called on every modal open
if (modal._backdropCloseSetup) {
modal._backdropCloseFn = closeFn;
return;
}
modal._backdropCloseFn = closeFn;
let mouseDownTarget = null;
modal.addEventListener('mousedown', (e) => { mouseDownTarget = e.target; });
modal.addEventListener('mouseup', (e) => {
if (mouseDownTarget === modal && e.target === modal && modal._backdropCloseFn) modal._backdropCloseFn();
mouseDownTarget = null;
});
modal.onclick = null;
modal._backdropCloseSetup = true;
}
// Track logged errors to avoid console spam
const loggedErrors = new Map(); // deviceId -> { errorCount, lastError }
// Calibration test mode state
const calibrationTestState = {}; // deviceId -> Set of active edge names
// Modal dirty tracking - stores initial values when modals open
let settingsInitialValues = {};
let calibrationInitialValues = {};
const EDGE_TEST_COLORS = {
top: [255, 0, 0],
right: [0, 255, 0],
bottom: [0, 100, 255],
left: [255, 255, 0]
};
// Modal body lock helpers — uses position:fixed to freeze scroll without removing scrollbar
function lockBody() {
const scrollY = window.scrollY;
document.body.style.top = `-${scrollY}px`;
document.body.classList.add('modal-open');
}
function unlockBody() {
const scrollY = parseInt(document.body.style.top || '0', 10) * -1;
document.body.classList.remove('modal-open');
document.body.style.top = '';
window.scrollTo(0, scrollY);
}
// Image lightbox
function openLightbox(imageSrc, statsHtml) {
const lightbox = document.getElementById('image-lightbox');
const img = document.getElementById('lightbox-image');
const statsEl = document.getElementById('lightbox-stats');
img.src = imageSrc;
if (statsHtml) {
statsEl.innerHTML = statsHtml;
statsEl.style.display = '';
} else {
statsEl.style.display = 'none';
}
lightbox.classList.add('active');
lockBody();
}
function closeLightbox(event) {
if (event && event.target && event.target.closest('.lightbox-content')) return;
const lightbox = document.getElementById('image-lightbox');
lightbox.classList.remove('active');
const img = document.getElementById('lightbox-image');
// Revoke blob URL if one was used
if (img.src.startsWith('blob:')) URL.revokeObjectURL(img.src);
img.src = '';
document.getElementById('lightbox-stats').style.display = 'none';
unlockBody();
}
async function openFullImageLightbox(imageSource) {
try {
const resp = await fetch(`${API_BASE}/picture-sources/full-image?source=${encodeURIComponent(imageSource)}`, {
headers: getHeaders()
});
if (!resp.ok) return;
const blob = await resp.blob();
const blobUrl = URL.createObjectURL(blob);
openLightbox(blobUrl);
} catch (err) {
console.error('Failed to load full image:', err);
}
}
document.addEventListener('keydown', (e) => {
if (e.key === 'Escape') {
// Close in order: overlay lightboxes first, then modals
if (document.getElementById('display-picker-lightbox').classList.contains('active')) {
closeDisplayPicker();
} else if (document.getElementById('image-lightbox').classList.contains('active')) {
closeLightbox();
} else {
// Close topmost visible modal
const modals = [
{ id: 'test-pp-template-modal', close: closeTestPPTemplateModal },
{ id: 'test-stream-modal', close: closeTestStreamModal },
{ id: 'test-template-modal', close: closeTestTemplateModal },
{ id: 'stream-modal', close: closeStreamModal },
{ id: 'pp-template-modal', close: closePPTemplateModal },
{ id: 'template-modal', close: closeTemplateModal },
{ id: 'device-settings-modal', close: forceCloseDeviceSettingsModal },
{ id: 'calibration-modal', close: forceCloseCalibrationModal },
{ id: 'target-editor-modal', close: forceCloseTargetEditorModal },
{ id: 'add-device-modal', close: closeAddDeviceModal },
];
for (const m of modals) {
const el = document.getElementById(m.id);
if (el && el.style.display === 'flex') {
m.close();
break;
}
}
}
}
});
// Display picker lightbox
let _displayPickerCallback = null;
let _displayPickerSelectedIndex = null;
function openDisplayPicker(callback, selectedIndex) {
_displayPickerCallback = callback;
_displayPickerSelectedIndex = (selectedIndex !== undefined && selectedIndex !== null && selectedIndex !== '') ? Number(selectedIndex) : null;
const lightbox = document.getElementById('display-picker-lightbox');
const canvas = document.getElementById('display-picker-canvas');
lightbox.classList.add('active');
// Defer render to next frame so the lightbox has been laid out and canvas has dimensions
requestAnimationFrame(() => {
if (_cachedDisplays && _cachedDisplays.length > 0) {
renderDisplayPickerLayout(_cachedDisplays);
} else {
canvas.innerHTML = '<div class="loading-spinner"></div>';
loadDisplays().then(() => {
if (_cachedDisplays && _cachedDisplays.length > 0) {
renderDisplayPickerLayout(_cachedDisplays);
} else {
canvas.innerHTML = `<div class="loading">${t('displays.none')}</div>`;
}
});
}
});
}
function closeDisplayPicker(event) {
if (event && event.target && event.target.closest('.display-picker-content')) return;
const lightbox = document.getElementById('display-picker-lightbox');
lightbox.classList.remove('active');
_displayPickerCallback = null;
}
function selectDisplay(displayIndex) {
if (_displayPickerCallback) {
const display = _cachedDisplays ? _cachedDisplays.find(d => d.index === displayIndex) : null;
_displayPickerCallback(displayIndex, display);
}
closeDisplayPicker();
}
function renderDisplayPickerLayout(displays) {
const canvas = document.getElementById('display-picker-canvas');
if (!displays || displays.length === 0) {
canvas.innerHTML = `<div class="loading">${t('displays.none')}</div>`;
return;
}
let minX = Infinity, minY = Infinity, maxX = -Infinity, maxY = -Infinity;
displays.forEach(display => {
minX = Math.min(minX, display.x);
minY = Math.min(minY, display.y);
maxX = Math.max(maxX, display.x + display.width);
maxY = Math.max(maxY, display.y + display.height);
});
const totalWidth = maxX - minX;
const totalHeight = maxY - minY;
const aspect = totalHeight / totalWidth;
// Use percentage-based positioning so layout always fits its container
const displayElements = displays.map(display => {
const leftPct = ((display.x - minX) / totalWidth) * 100;
const topPct = ((display.y - minY) / totalHeight) * 100;
const widthPct = (display.width / totalWidth) * 100;
const heightPct = (display.height / totalHeight) * 100;
const isSelected = _displayPickerSelectedIndex !== null && display.index === _displayPickerSelectedIndex;
return `
<div class="layout-display layout-display-pickable${isSelected ? ' selected' : ''}"
style="left: ${leftPct}%; top: ${topPct}%; width: ${widthPct}%; height: ${heightPct}%;"
onclick="selectDisplay(${display.index})"
title="${t('displays.picker.click_to_select')}">
<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>
</div>
`;
}).join('');
canvas.innerHTML = `
<div class="layout-container" style="width: 100%; padding-bottom: ${aspect * 100}%; position: relative;">
${displayElements}
</div>
`;
}
function formatDisplayLabel(displayIndex, display) {
if (display) {
return `#${display.index}: ${display.width}×${display.height}${display.is_primary ? ' (' + t('displays.badge.primary') + ')' : ''}`;
}
return `Display ${displayIndex}`;
}
let _streamNameManuallyEdited = false;
function onStreamDisplaySelected(displayIndex, display) {
document.getElementById('stream-display-index').value = displayIndex;
document.getElementById('stream-display-picker-label').textContent = formatDisplayLabel(displayIndex, display);
_autoGenerateStreamName();
}
let _streamModalPPTemplates = [];
function _autoGenerateStreamName() {
if (_streamNameManuallyEdited) return;
if (document.getElementById('stream-id').value) return; // editing, not creating
const streamType = document.getElementById('stream-type').value;
const nameInput = document.getElementById('stream-name');
if (streamType === 'raw') {
const displayIndex = document.getElementById('stream-display-index').value;
const templateSelect = document.getElementById('stream-capture-template');
const templateName = templateSelect.selectedOptions[0]?.dataset?.name || '';
if (displayIndex === '' || !templateName) return;
nameInput.value = `D${displayIndex}_${templateName}`;
} else if (streamType === 'processed') {
const sourceSelect = document.getElementById('stream-source');
const sourceName = sourceSelect.selectedOptions[0]?.dataset?.name || '';
const ppTemplateId = document.getElementById('stream-pp-template').value;
const ppTemplate = _streamModalPPTemplates.find(t => t.id === ppTemplateId);
if (!sourceName) return;
if (ppTemplate && ppTemplate.name) {
nameInput.value = `${sourceName} (${ppTemplate.name})`;
} else {
nameInput.value = sourceName;
}
}
}
function onTestDisplaySelected(displayIndex, display) {
document.getElementById('test-template-display').value = displayIndex;
document.getElementById('test-display-picker-label').textContent = formatDisplayLabel(displayIndex, display);
}
// Locale management
let currentLocale = 'en';
let translations = {};
const supportedLocales = {
'en': 'English',
'ru': 'Русский'
};
// Minimal inline fallback for critical UI elements
const fallbackTranslations = {
'app.title': 'LED Grab',
'auth.placeholder': 'Enter your API key...',
'auth.button.login': 'Login'
};
// Translation function
function t(key, params = {}) {
let text = translations[key] || fallbackTranslations[key] || key;
// Replace parameters like {name}, {value}, etc.
Object.keys(params).forEach(param => {
text = text.replace(new RegExp(`\\{${param}\\}`, 'g'), params[param]);
});
return text;
}
// Load translation file
async function loadTranslations(locale) {
try {
const response = await fetch(`/static/locales/${locale}.json`);
if (!response.ok) {
throw new Error(`Failed to load ${locale}.json`);
}
return await response.json();
} catch (error) {
console.error(`Error loading translations for ${locale}:`, error);
// Fallback to English if loading fails
if (locale !== 'en') {
return await loadTranslations('en');
}
return {};
}
}
// Detect browser locale
function detectBrowserLocale() {
const browserLang = navigator.language || navigator.languages?.[0] || 'en';
const langCode = browserLang.split('-')[0]; // 'en-US' -> 'en', 'ru-RU' -> 'ru'
// Only return if we support it
return supportedLocales[langCode] ? langCode : 'en';
}
// Initialize locale
async function initLocale() {
const savedLocale = localStorage.getItem('locale') || detectBrowserLocale();
await setLocale(savedLocale);
}
// Set locale
async function setLocale(locale) {
if (!supportedLocales[locale]) {
locale = 'en';
}
// Load translations for the locale
translations = await loadTranslations(locale);
currentLocale = locale;
document.documentElement.setAttribute('data-locale', locale);
document.documentElement.setAttribute('lang', locale);
localStorage.setItem('locale', locale);
// Update all text
updateAllText();
// Update locale select dropdown (if visible)
updateLocaleSelect();
}
// Change locale from dropdown
function changeLocale() {
const select = document.getElementById('locale-select');
const newLocale = select.value;
if (newLocale && newLocale !== currentLocale) {
localStorage.setItem('locale', newLocale);
setLocale(newLocale);
}
}
// Update locale select dropdown
function updateLocaleSelect() {
const select = document.getElementById('locale-select');
if (select) {
select.value = currentLocale;
}
}
// Update all text on page
function updateAllText() {
// Update all elements with data-i18n attribute
document.querySelectorAll('[data-i18n]').forEach(el => {
const key = el.getAttribute('data-i18n');
el.textContent = t(key);
});
// Update all elements with data-i18n-placeholder attribute
document.querySelectorAll('[data-i18n-placeholder]').forEach(el => {
const key = el.getAttribute('data-i18n-placeholder');
el.placeholder = t(key);
});
// Update all elements with data-i18n-title attribute
document.querySelectorAll('[data-i18n-title]').forEach(el => {
const key = el.getAttribute('data-i18n-title');
el.title = t(key);
});
// Re-render dynamic content with new translations
if (apiKey) {
loadDisplays();
loadTargetsTab();
loadPictureSources();
}
}
// Initialize app
document.addEventListener('DOMContentLoaded', async () => {
// Initialize locale first
await initLocale();
// Load API key from localStorage
apiKey = localStorage.getItem('wled_api_key');
// Restore active tab before showing content to avoid visible jump
initTabs();
// Show content now that translations are loaded and tabs are set
document.body.style.visibility = 'visible';
// Setup form handler
document.getElementById('add-device-form').addEventListener('submit', handleAddDevice);
// Show modal if no API key is stored
if (!apiKey) {
// Wait for modal functions to be defined
setTimeout(() => {
if (typeof showApiKeyModal === 'function') {
showApiKeyModal('Welcome! Please login with your API key to get started.', true);
}
}, 100);
return; // Don't load data yet
}
// User is logged in, load data
loadServerInfo();
loadDisplays();
loadTargetsTab();
// Start auto-refresh
startAutoRefresh();
});
// Helper function to add auth header if needed
function getHeaders() {
const headers = {
'Content-Type': 'application/json'
};
if (apiKey) {
headers['Authorization'] = `Bearer ${apiKey}`;
}
return headers;
}
// Fetch wrapper that automatically includes auth headers
async function fetchWithAuth(url, options = {}) {
// Build full URL if relative path provided
const fullUrl = url.startsWith('http') ? url : `${API_BASE}${url}`;
// Merge auth headers with any custom headers
const headers = options.headers
? { ...getHeaders(), ...options.headers }
: getHeaders();
// Make request with merged options
return fetch(fullUrl, {
...options,
headers
});
}
// Escape HTML to prevent XSS
function escapeHtml(text) {
if (!text) return '';
const div = document.createElement('div');
div.textContent = text;
return div.innerHTML;
}
// Handle 401 errors by showing login modal
function handle401Error() {
// Clear invalid API key
localStorage.removeItem('wled_api_key');
apiKey = null;
// Stop auto-refresh to prevent repeated 401 errors
if (refreshInterval) {
clearInterval(refreshInterval);
refreshInterval = null;
}
if (typeof updateAuthUI === 'function') {
updateAuthUI();
}
if (typeof showApiKeyModal === 'function') {
showApiKeyModal('Your session has expired or the API key is invalid. Please login again.', true);
} else {
showToast('Authentication failed. Please reload the page and login.', 'error');
}
}
// Configure API key
function configureApiKey() {
const currentKey = localStorage.getItem('wled_api_key');
const message = currentKey
? 'Current API key is set. Enter new key to update or leave blank to remove:'
: 'Enter your API key:';
const key = prompt(message);
if (key === null) {
return; // Cancelled
}
if (key === '') {
localStorage.removeItem('wled_api_key');
apiKey = null;
document.getElementById('api-key-btn').style.display = 'none';
showToast('API key removed', 'info');
} else {
localStorage.setItem('wled_api_key', key);
apiKey = key;
document.getElementById('api-key-btn').style.display = 'inline-block';
showToast('API key updated', 'success');
}
// Reload data with new key
loadServerInfo();
loadDisplays();
loadDevices();
}
// Server info
async function loadServerInfo() {
try {
const response = await fetch('/health');
const data = await response.json();
document.getElementById('version-number').textContent = `v${data.version}`;
document.getElementById('server-status').textContent = '●';
document.getElementById('server-status').className = 'status-badge online';
} catch (error) {
console.error('Failed to load server info:', error);
document.getElementById('server-status').className = 'status-badge offline';
showToast(t('server.offline'), 'error');
}
}
// Load displays
async function loadDisplays() {
try {
const response = await fetch(`${API_BASE}/config/displays`, {
headers: getHeaders()
});
if (response.status === 401) {
handle401Error();
return;
}
const data = await response.json();
if (data.displays && data.displays.length > 0) {
_cachedDisplays = data.displays;
}
} catch (error) {
console.error('Failed to load displays:', error);
}
}
let _cachedDisplays = null;
function switchTab(name) {
document.querySelectorAll('.tab-btn').forEach(btn => btn.classList.toggle('active', btn.dataset.tab === name));
document.querySelectorAll('.tab-panel').forEach(panel => panel.classList.toggle('active', panel.id === `tab-${name}`));
localStorage.setItem('activeTab', name);
if (name === 'streams') {
loadPictureSources();
} else if (name === 'targets') {
loadTargetsTab();
}
}
function initTabs() {
let saved = localStorage.getItem('activeTab');
// Migrate legacy 'devices' tab to 'targets' (devices now live inside targets)
if (saved === 'devices') saved = 'targets';
if (saved && document.getElementById(`tab-${saved}`)) {
switchTab(saved);
}
}
// Load devices
async function loadDevices() {
// Devices now render inside the combined Targets tab
await loadTargetsTab();
}
function createDeviceCard(device) {
const state = device.state || {};
// 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 ? `<a class="device-url-badge" href="${device.url}" target="_blank" rel="noopener" title="${t('device.button.webui')}"><span class="device-url-text">${escapeHtml(device.url.replace(/^https?:\/\//, ''))}</span><span class="device-url-icon">🌐</span></a>` : ''}
${healthLabel}
</div>
</div>
<div class="card-subtitle">
${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="brightness-control${_deviceBrightnessCache[device.id] == null ? ' brightness-loading' : ''}" data-brightness-wrap="${device.id}">
<input type="range" class="brightness-slider" min="0" max="255"
value="${_deviceBrightnessCache[device.id] ?? 0}" data-device-brightness="${device.id}"
oninput="updateBrightnessLabel('${device.id}', this.value)"
onchange="saveCardBrightness('${device.id}', this.value)"
title="${_deviceBrightnessCache[device.id] != null ? Math.round(_deviceBrightnessCache[device.id] / 255 * 100) + '%' : '...'}"
${_deviceBrightnessCache[device.id] == null ? 'disabled' : ''}>
</div>
<div class="card-actions">
<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="showCalibration('${device.id}')" title="${t('device.button.calibrate')}">
📐
</button>
</div>
</div>
`;
}
function attachDeviceListeners(deviceId) {
// Add any specific event listeners here if needed
}
// Device actions
async function removeDevice(deviceId) {
const confirmed = await showConfirm(t('device.remove.confirm'));
if (!confirmed) {
return;
}
try {
const response = await fetch(`${API_BASE}/devices/${deviceId}`, {
method: 'DELETE',
headers: getHeaders()
});
if (response.status === 401) {
handle401Error();
return;
}
if (response.ok) {
showToast('Device removed', 'success');
loadDevices();
} else {
const error = await response.json();
showToast(`Failed to remove: ${error.detail}`, 'error');
}
} catch (error) {
console.error('Failed to remove device:', error);
showToast('Failed to remove device', 'error');
}
}
async function showSettings(deviceId) {
try {
const deviceResponse = await fetch(`${API_BASE}/devices/${deviceId}`, { headers: getHeaders() });
if (deviceResponse.status === 401) {
handle401Error();
return;
}
if (!deviceResponse.ok) {
showToast('Failed to load device settings', 'error');
return;
}
const device = await deviceResponse.json();
// 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 = 30;
// Snapshot initial values for dirty checking
settingsInitialValues = {
name: device.name,
url: device.url,
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 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;
}
showToast(t('settings.saved'), 'success');
forceCloseDeviceSettingsModal();
loadDevices();
} catch (err) {
console.error('Failed to save device settings:', err);
error.textContent = 'Failed to save settings';
error.style.display = 'block';
}
}
// Brightness cache: stores last known WLED brightness per device (0-255)
const _deviceBrightnessCache = {};
// Card brightness controls — talks directly to WLED device
function updateBrightnessLabel(deviceId, value) {
const slider = document.querySelector(`[data-device-brightness="${deviceId}"]`);
if (slider) slider.title = Math.round(parseInt(value) / 255 * 100) + '%';
}
async function saveCardBrightness(deviceId, value) {
const bri = parseInt(value);
_deviceBrightnessCache[deviceId] = bri;
try {
await fetch(`${API_BASE}/devices/${deviceId}/brightness`, {
method: 'PUT',
headers: getHeaders(),
body: JSON.stringify({ brightness: bri })
});
} catch (err) {
console.error('Failed to update brightness:', err);
showToast('Failed to update brightness', 'error');
}
}
async function fetchDeviceBrightness(deviceId) {
try {
const resp = await fetch(`${API_BASE}/devices/${deviceId}/brightness`, {
headers: getHeaders()
});
if (!resp.ok) return;
const data = await resp.json();
_deviceBrightnessCache[deviceId] = data.brightness;
const slider = document.querySelector(`[data-device-brightness="${deviceId}"]`);
if (slider) {
slider.value = data.brightness;
slider.title = Math.round(data.brightness / 255 * 100) + '%';
slider.disabled = false;
}
const wrap = document.querySelector(`[data-brightness-wrap="${deviceId}"]`);
if (wrap) wrap.classList.remove('brightness-loading');
} catch (err) {
// Silently fail — device may be offline
}
}
// 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) {
const activeTab = localStorage.getItem('activeTab') || 'targets';
if (activeTab === 'targets') {
loadTargetsTab();
}
}
}, 2000); // Refresh every 2 seconds
}
// Toast notifications
function showToast(message, type = 'info') {
const toast = document.getElementById('toast');
toast.textContent = message;
toast.className = `toast ${type} show`;
setTimeout(() => {
toast.className = 'toast';
}, 3000);
}
// Confirmation modal
let confirmResolve = null;
function showConfirm(message, title = null) {
return new Promise((resolve) => {
confirmResolve = resolve;
const modal = document.getElementById('confirm-modal');
const titleEl = document.getElementById('confirm-title');
const messageEl = document.getElementById('confirm-message');
const yesBtn = document.getElementById('confirm-yes-btn');
const noBtn = document.getElementById('confirm-no-btn');
titleEl.textContent = title || t('confirm.title');
messageEl.textContent = message;
yesBtn.textContent = t('confirm.yes');
noBtn.textContent = t('confirm.no');
modal.style.display = 'flex';
lockBody();
});
}
function closeConfirmModal(result) {
const modal = document.getElementById('confirm-modal');
modal.style.display = 'none';
unlockBody();
if (confirmResolve) {
confirmResolve(result);
confirmResolve = null;
}
}
// Calibration functions
async function showCalibration(deviceId) {
try {
// Fetch device data and displays in parallel
const [response, displaysResponse] = await Promise.all([
fetch(`${API_BASE}/devices/${deviceId}`, { headers: getHeaders() }),
fetch(`${API_BASE}/config/displays`, { headers: getHeaders() }),
]);
if (response.status === 401) {
handle401Error();
return;
}
if (!response.ok) {
showToast('Failed to load calibration', 'error');
return;
}
const device = await response.json();
const calibration = device.calibration;
// Set aspect ratio from device's display
const preview = document.querySelector('.calibration-preview');
if (displaysResponse.ok) {
const displaysData = await displaysResponse.json();
const displayIndex = device.settings?.display_index ?? 0;
const display = (displaysData.displays || []).find(d => d.index === displayIndex);
if (display && display.width && display.height) {
preview.style.aspectRatio = `${display.width} / ${display.height}`;
} else {
preview.style.aspectRatio = '';
}
} else {
preview.style.aspectRatio = '';
}
// Store device ID and LED count
document.getElementById('calibration-device-id').value = device.id;
document.getElementById('cal-device-led-count-inline').textContent = device.led_count;
// Set layout
document.getElementById('cal-start-position').value = calibration.start_position;
document.getElementById('cal-layout').value = calibration.layout;
document.getElementById('cal-offset').value = calibration.offset || 0;
// Set LED counts per edge
document.getElementById('cal-top-leds').value = calibration.leds_top || 0;
document.getElementById('cal-right-leds').value = calibration.leds_right || 0;
document.getElementById('cal-bottom-leds').value = calibration.leds_bottom || 0;
document.getElementById('cal-left-leds').value = calibration.leds_left || 0;
// Initialize edge spans
window.edgeSpans = {
top: { start: calibration.span_top_start ?? 0, end: calibration.span_top_end ?? 1 },
right: { start: calibration.span_right_start ?? 0, end: calibration.span_right_end ?? 1 },
bottom: { start: calibration.span_bottom_start ?? 0, end: calibration.span_bottom_end ?? 1 },
left: { start: calibration.span_left_start ?? 0, end: calibration.span_left_end ?? 1 },
};
// Snapshot initial values for dirty checking
calibrationInitialValues = {
start_position: calibration.start_position,
layout: calibration.layout,
offset: String(calibration.offset || 0),
top: String(calibration.leds_top || 0),
right: String(calibration.leds_right || 0),
bottom: String(calibration.leds_bottom || 0),
left: String(calibration.leds_left || 0),
spans: JSON.stringify(window.edgeSpans),
};
// Initialize test mode state for this device
calibrationTestState[device.id] = new Set();
// Update preview
updateCalibrationPreview();
// Show modal
const modal = document.getElementById('calibration-modal');
modal.style.display = 'flex';
lockBody();
// Initialize span drag and render canvas after layout settles
initSpanDrag();
requestAnimationFrame(() => {
renderCalibrationCanvas();
// Auto-start tutorial on first open
if (!localStorage.getItem('calibrationTutorialSeen')) {
localStorage.setItem('calibrationTutorialSeen', '1');
startCalibrationTutorial();
}
});
// Re-render on container resize (e.g. window resize changes aspect-ratio container)
if (!window._calibrationResizeObserver) {
window._calibrationResizeObserver = new ResizeObserver(() => {
updateSpanBars();
renderCalibrationCanvas();
});
}
window._calibrationResizeObserver.observe(preview);
} catch (error) {
console.error('Failed to load calibration:', error);
showToast('Failed to load calibration', 'error');
}
}
function isCalibrationDirty() {
return (
document.getElementById('cal-start-position').value !== calibrationInitialValues.start_position ||
document.getElementById('cal-layout').value !== calibrationInitialValues.layout ||
document.getElementById('cal-offset').value !== calibrationInitialValues.offset ||
document.getElementById('cal-top-leds').value !== calibrationInitialValues.top ||
document.getElementById('cal-right-leds').value !== calibrationInitialValues.right ||
document.getElementById('cal-bottom-leds').value !== calibrationInitialValues.bottom ||
document.getElementById('cal-left-leds').value !== calibrationInitialValues.left ||
JSON.stringify(window.edgeSpans) !== calibrationInitialValues.spans
);
}
function forceCloseCalibrationModal() {
closeTutorial();
const deviceId = document.getElementById('calibration-device-id').value;
if (deviceId) {
clearTestMode(deviceId);
}
if (window._calibrationResizeObserver) {
window._calibrationResizeObserver.disconnect();
}
const modal = document.getElementById('calibration-modal');
const error = document.getElementById('calibration-error');
modal.style.display = 'none';
error.style.display = 'none';
unlockBody();
calibrationInitialValues = {};
}
async function closeCalibrationModal() {
if (isCalibrationDirty()) {
const confirmed = await showConfirm(t('modal.discard_changes'));
if (!confirmed) return;
}
forceCloseCalibrationModal();
}
function updateCalibrationPreview() {
// Calculate total from edge inputs
const total = parseInt(document.getElementById('cal-top-leds').value || 0) +
parseInt(document.getElementById('cal-right-leds').value || 0) +
parseInt(document.getElementById('cal-bottom-leds').value || 0) +
parseInt(document.getElementById('cal-left-leds').value || 0);
// Warning if total doesn't match device LED count
const totalEl = document.querySelector('.preview-screen-total');
const deviceCount = parseInt(document.getElementById('cal-device-led-count-inline').textContent || 0);
const mismatch = total !== deviceCount;
document.getElementById('cal-total-leds-inline').textContent = (mismatch ? '\u26A0 ' : '') + total;
if (totalEl) {
totalEl.classList.toggle('mismatch', mismatch);
}
// Update corner dot highlights for start position
const startPos = document.getElementById('cal-start-position').value;
['top_left', 'top_right', 'bottom_left', 'bottom_right'].forEach(corner => {
const cornerEl = document.querySelector(`.preview-corner.corner-${corner.replace('_', '-')}`);
if (cornerEl) {
if (corner === startPos) {
cornerEl.classList.add('active');
} else {
cornerEl.classList.remove('active');
}
}
});
// Update direction toggle display
const direction = document.getElementById('cal-layout').value;
const dirIcon = document.getElementById('direction-icon');
const dirLabel = document.getElementById('direction-label');
if (dirIcon) dirIcon.textContent = direction === 'clockwise' ? '↻' : '↺';
if (dirLabel) dirLabel.textContent = direction === 'clockwise' ? 'CW' : 'CCW';
// Update edge highlight states
const deviceId = document.getElementById('calibration-device-id').value;
const activeEdges = calibrationTestState[deviceId] || new Set();
['top', 'right', 'bottom', 'left'].forEach(edge => {
const toggleEl = document.querySelector(`.edge-toggle.toggle-${edge}`);
if (!toggleEl) return;
if (activeEdges.has(edge)) {
const [r, g, b] = EDGE_TEST_COLORS[edge];
toggleEl.style.background = `rgba(${r}, ${g}, ${b}, 0.35)`;
toggleEl.style.boxShadow = `inset 0 0 6px rgba(${r}, ${g}, ${b}, 0.5)`;
} else {
toggleEl.style.background = '';
toggleEl.style.boxShadow = '';
}
});
// Disable edges with 0 LEDs
['top', 'right', 'bottom', 'left'].forEach(edge => {
const count = parseInt(document.getElementById(`cal-${edge}-leds`).value) || 0;
const edgeEl = document.querySelector(`.preview-edge.edge-${edge}`);
const toggleEl = document.querySelector(`.edge-toggle.toggle-${edge}`);
if (edgeEl) edgeEl.classList.toggle('edge-disabled', count === 0);
if (toggleEl) toggleEl.classList.toggle('edge-disabled', count === 0);
});
// Position span bars and render canvas overlay
updateSpanBars();
renderCalibrationCanvas();
}
function renderCalibrationCanvas() {
const canvas = document.getElementById('calibration-preview-canvas');
if (!canvas) return;
const container = canvas.parentElement;
const containerRect = container.getBoundingClientRect();
if (containerRect.width === 0 || containerRect.height === 0) return;
// Canvas extends beyond the container (matches CSS: left:-40px, top:-40px, +80px/+80px)
const padX = 40;
const padY = 40;
const dpr = window.devicePixelRatio || 1;
const canvasW = containerRect.width + padX * 2;
const canvasH = containerRect.height + padY * 2;
canvas.width = canvasW * dpr;
canvas.height = canvasH * dpr;
const ctx = canvas.getContext('2d');
ctx.scale(dpr, dpr);
ctx.clearRect(0, 0, canvasW, canvasH);
// Container origin within canvas coordinate system
const ox = padX;
const oy = padY;
const cW = containerRect.width; // container inner width
const cH = containerRect.height; // container inner height
// Read current form values
const startPos = document.getElementById('cal-start-position').value;
const layout = document.getElementById('cal-layout').value;
const offset = parseInt(document.getElementById('cal-offset').value || 0);
const calibration = {
start_position: startPos,
layout: layout,
offset: offset,
leds_top: parseInt(document.getElementById('cal-top-leds').value || 0),
leds_right: parseInt(document.getElementById('cal-right-leds').value || 0),
leds_bottom: parseInt(document.getElementById('cal-bottom-leds').value || 0),
leds_left: parseInt(document.getElementById('cal-left-leds').value || 0),
};
const segments = buildSegments(calibration);
if (segments.length === 0) return;
const totalLeds = calibration.leds_top + calibration.leds_right + calibration.leds_bottom + calibration.leds_left;
// Theme-aware colors
const isDark = document.documentElement.getAttribute('data-theme') !== 'light';
const tickStroke = isDark ? 'rgba(255, 255, 255, 0.4)' : 'rgba(0, 0, 0, 0.3)';
const tickFill = isDark ? 'rgba(255, 255, 255, 0.65)' : 'rgba(0, 0, 0, 0.6)';
const chevronStroke = isDark ? 'rgba(255, 255, 255, 0.6)' : 'rgba(0, 0, 0, 0.4)';
// Edge bar geometry (matches CSS: corner zones 56px × 36px fixed)
const cw = 56;
const ch = 36;
// Span-aware edge geometry: ticks/arrows render only within the span region
const spans = window.edgeSpans || {};
const edgeLenH = cW - 2 * cw;
const edgeLenV = cH - 2 * ch;
const edgeGeometry = {
top: { x1: ox + cw + (spans.top?.start || 0) * edgeLenH, x2: ox + cw + (spans.top?.end || 1) * edgeLenH, midY: oy + ch / 2, horizontal: true },
bottom: { x1: ox + cw + (spans.bottom?.start || 0) * edgeLenH, x2: ox + cw + (spans.bottom?.end || 1) * edgeLenH, midY: oy + cH - ch / 2, horizontal: true },
left: { y1: oy + ch + (spans.left?.start || 0) * edgeLenV, y2: oy + ch + (spans.left?.end || 1) * edgeLenV, midX: ox + cw / 2, horizontal: false },
right: { y1: oy + ch + (spans.right?.start || 0) * edgeLenV, y2: oy + ch + (spans.right?.end || 1) * edgeLenV, midX: ox + cW - cw / 2, horizontal: false },
};
// Axis positions for labels (outside the 16px toggle zones)
const toggleSize = 16;
const axisPos = {
top: oy - toggleSize - 3,
bottom: oy + cH + toggleSize + 3,
left: ox - toggleSize - 3,
right: ox + cW + toggleSize + 3,
};
// Arrow positions (inside the screen area, near each edge bar)
const arrowInset = 12;
const arrowPos = {
top: oy + ch + arrowInset,
bottom: oy + cH - ch - arrowInset,
left: ox + cw + arrowInset,
right: ox + cW - cw - arrowInset,
};
// Draw ticks and direction arrows for each segment
segments.forEach(seg => {
const geo = edgeGeometry[seg.edge];
if (!geo) return;
const count = seg.led_count;
if (count === 0) return;
// Edge boundary ticks (first/last LED on edge) and special ticks (LED 0 position)
const edgeBounds = new Set();
edgeBounds.add(0);
if (count > 1) edgeBounds.add(count - 1);
const specialTicks = new Set();
if (offset > 0 && totalLeds > 0) {
const zeroPos = (totalLeds - seg.led_start % totalLeds) % totalLeds;
if (zeroPos < count) specialTicks.add(zeroPos);
}
// Round-number ticks get priority; edge boundary labels suppressed if overlapping
const labelsToShow = new Set([...specialTicks]);
const tickLinesOnly = new Set();
if (count > 2) {
const edgeLen = geo.horizontal ? (geo.x2 - geo.x1) : (geo.y2 - geo.y1);
const maxDigits = String(totalLeds > 0 ? totalLeds - 1 : count - 1).length;
const minSpacing = geo.horizontal ? maxDigits * 7 + 8 : 22;
const allMandatory = new Set([...edgeBounds, ...specialTicks]);
const maxIntermediate = Math.max(0, 5 - allMandatory.size);
const niceSteps = [5, 10, 25, 50, 100, 250, 500];
let step = niceSteps[niceSteps.length - 1];
for (const s of niceSteps) {
if (Math.floor(count / s) <= maxIntermediate) {
step = s;
break;
}
}
const tickPx = i => {
const f = i / (count - 1);
return (seg.reverse ? (1 - f) : f) * edgeLen;
};
// Phase 1: place round-number ticks (checked against specials + each other)
const placed = [];
specialTicks.forEach(i => placed.push(tickPx(i)));
for (let i = 1; i < count - 1; i++) {
if (specialTicks.has(i)) continue;
const idx = totalLeds > 0 ? (seg.led_start + i) % totalLeds : seg.led_start + i;
if (idx % step === 0) {
const px = tickPx(i);
if (!placed.some(p => Math.abs(px - p) < minSpacing)) {
labelsToShow.add(i);
placed.push(px);
}
}
}
// Phase 2: edge boundaries — show label unless overlapping a round-number tick
edgeBounds.forEach(bi => {
if (labelsToShow.has(bi) || specialTicks.has(bi)) return;
const px = tickPx(bi);
if (placed.some(p => Math.abs(px - p) < minSpacing)) {
tickLinesOnly.add(bi);
} else {
labelsToShow.add(bi);
placed.push(px);
}
});
} else {
edgeBounds.forEach(i => labelsToShow.add(i));
}
// Tick styling
const tickLenLong = toggleSize + 3;
const tickLenShort = 4;
ctx.strokeStyle = tickStroke;
ctx.lineWidth = 1;
ctx.fillStyle = tickFill;
ctx.font = '12px -apple-system, BlinkMacSystemFont, sans-serif';
// Draw labeled ticks
labelsToShow.forEach(i => {
const fraction = count > 1 ? i / (count - 1) : 0.5;
const displayFraction = seg.reverse ? (1 - fraction) : fraction;
const ledIndex = totalLeds > 0 ? (seg.led_start + i) % totalLeds : seg.led_start + i;
const tickLen = edgeBounds.has(i) ? tickLenLong : tickLenShort;
if (geo.horizontal) {
const tx = geo.x1 + displayFraction * (geo.x2 - geo.x1);
const axisY = axisPos[seg.edge];
const tickDir = seg.edge === 'top' ? 1 : -1;
ctx.beginPath();
ctx.moveTo(tx, axisY);
ctx.lineTo(tx, axisY + tickDir * tickLen);
ctx.stroke();
ctx.textAlign = 'center';
ctx.textBaseline = seg.edge === 'top' ? 'bottom' : 'top';
ctx.fillText(String(ledIndex), tx, axisY - tickDir * 1);
} else {
const ty = geo.y1 + displayFraction * (geo.y2 - geo.y1);
const axisX = axisPos[seg.edge];
const tickDir = seg.edge === 'left' ? 1 : -1;
ctx.beginPath();
ctx.moveTo(axisX, ty);
ctx.lineTo(axisX + tickDir * tickLen, ty);
ctx.stroke();
ctx.textBaseline = 'middle';
ctx.textAlign = seg.edge === 'left' ? 'right' : 'left';
ctx.fillText(String(ledIndex), axisX - tickDir * 1, ty);
}
});
// Draw tick lines only (no labels) for suppressed edge boundaries
tickLinesOnly.forEach(i => {
const fraction = count > 1 ? i / (count - 1) : 0.5;
const displayFraction = seg.reverse ? (1 - fraction) : fraction;
if (geo.horizontal) {
const tx = geo.x1 + displayFraction * (geo.x2 - geo.x1);
const axisY = axisPos[seg.edge];
const tickDir = seg.edge === 'top' ? 1 : -1;
ctx.beginPath();
ctx.moveTo(tx, axisY);
ctx.lineTo(tx, axisY + tickDir * tickLenLong);
ctx.stroke();
} else {
const ty = geo.y1 + displayFraction * (geo.y2 - geo.y1);
const axisX = axisPos[seg.edge];
const tickDir = seg.edge === 'left' ? 1 : -1;
ctx.beginPath();
ctx.moveTo(axisX, ty);
ctx.lineTo(axisX + tickDir * tickLenLong, ty);
ctx.stroke();
}
});
// Draw direction chevron at full-edge midpoint (not affected by span)
const s = 7;
let mx, my, angle;
if (geo.horizontal) {
mx = ox + cw + edgeLenH / 2;
my = arrowPos[seg.edge];
angle = seg.reverse ? Math.PI : 0;
} else {
mx = arrowPos[seg.edge];
my = oy + ch + edgeLenV / 2;
angle = seg.reverse ? -Math.PI / 2 : Math.PI / 2;
}
ctx.save();
ctx.translate(mx, my);
ctx.rotate(angle);
ctx.fillStyle = 'rgba(76, 175, 80, 0.85)';
ctx.strokeStyle = chevronStroke;
ctx.lineWidth = 1;
ctx.lineCap = 'round';
ctx.lineJoin = 'round';
ctx.beginPath();
ctx.moveTo(-s * 0.5, -s * 0.6);
ctx.lineTo(s * 0.5, 0);
ctx.lineTo(-s * 0.5, s * 0.6);
ctx.closePath();
ctx.fill();
ctx.stroke();
ctx.restore();
});
}
function updateSpanBars() {
const spans = window.edgeSpans || {};
const container = document.querySelector('.calibration-preview');
['top', 'right', 'bottom', 'left'].forEach(edge => {
const bar = document.querySelector(`.edge-span-bar[data-edge="${edge}"]`);
if (!bar) return;
const span = spans[edge] || { start: 0, end: 1 };
const edgeEl = bar.parentElement;
const isHorizontal = (edge === 'top' || edge === 'bottom');
if (isHorizontal) {
const totalWidth = edgeEl.clientWidth;
bar.style.left = (span.start * totalWidth) + 'px';
bar.style.width = ((span.end - span.start) * totalWidth) + 'px';
} else {
const totalHeight = edgeEl.clientHeight;
bar.style.top = (span.start * totalHeight) + 'px';
bar.style.height = ((span.end - span.start) * totalHeight) + 'px';
}
// Also reposition toggle zone to match span region
if (!container) return;
const toggle = container.querySelector(`.toggle-${edge}`);
if (!toggle) return;
if (isHorizontal) {
const cornerW = 56;
const edgeW = container.clientWidth - 2 * cornerW;
toggle.style.left = (cornerW + span.start * edgeW) + 'px';
toggle.style.right = 'auto';
toggle.style.width = ((span.end - span.start) * edgeW) + 'px';
} else {
const cornerH = 36;
const edgeH = container.clientHeight - 2 * cornerH;
toggle.style.top = (cornerH + span.start * edgeH) + 'px';
toggle.style.bottom = 'auto';
toggle.style.height = ((span.end - span.start) * edgeH) + 'px';
}
});
}
function initSpanDrag() {
const MIN_SPAN = 0.05;
document.querySelectorAll('.edge-span-bar').forEach(bar => {
const edge = bar.dataset.edge;
const isHorizontal = (edge === 'top' || edge === 'bottom');
// Prevent edge click-through when interacting with span bar
bar.addEventListener('click', e => e.stopPropagation());
// Handle resize via handles
bar.querySelectorAll('.edge-span-handle').forEach(handle => {
handle.addEventListener('mousedown', e => {
const edgeLeds = parseInt(document.getElementById(`cal-${edge}-leds`).value) || 0;
if (edgeLeds === 0) return;
e.preventDefault();
e.stopPropagation();
const handleType = handle.dataset.handle;
const edgeEl = bar.parentElement;
const rect = edgeEl.getBoundingClientRect();
function onMouseMove(ev) {
const span = window.edgeSpans[edge];
let fraction;
if (isHorizontal) {
fraction = (ev.clientX - rect.left) / rect.width;
} else {
fraction = (ev.clientY - rect.top) / rect.height;
}
fraction = Math.max(0, Math.min(1, fraction));
if (handleType === 'start') {
span.start = Math.min(fraction, span.end - MIN_SPAN);
} else {
span.end = Math.max(fraction, span.start + MIN_SPAN);
}
updateSpanBars();
renderCalibrationCanvas();
}
function onMouseUp() {
document.removeEventListener('mousemove', onMouseMove);
document.removeEventListener('mouseup', onMouseUp);
}
document.addEventListener('mousemove', onMouseMove);
document.addEventListener('mouseup', onMouseUp);
});
});
// Handle body drag (move entire span)
bar.addEventListener('mousedown', e => {
if (e.target.classList.contains('edge-span-handle')) return;
e.preventDefault();
e.stopPropagation();
const edgeEl = bar.parentElement;
const rect = edgeEl.getBoundingClientRect();
const span = window.edgeSpans[edge];
const spanWidth = span.end - span.start;
let startFraction;
if (isHorizontal) {
startFraction = (e.clientX - rect.left) / rect.width;
} else {
startFraction = (e.clientY - rect.top) / rect.height;
}
const offsetInSpan = startFraction - span.start;
function onMouseMove(ev) {
let fraction;
if (isHorizontal) {
fraction = (ev.clientX - rect.left) / rect.width;
} else {
fraction = (ev.clientY - rect.top) / rect.height;
}
let newStart = fraction - offsetInSpan;
newStart = Math.max(0, Math.min(1 - spanWidth, newStart));
span.start = newStart;
span.end = newStart + spanWidth;
updateSpanBars();
renderCalibrationCanvas();
}
function onMouseUp() {
document.removeEventListener('mousemove', onMouseMove);
document.removeEventListener('mouseup', onMouseUp);
}
document.addEventListener('mousemove', onMouseMove);
document.addEventListener('mouseup', onMouseUp);
});
});
// Initial positioning
updateSpanBars();
}
function setStartPosition(position) {
document.getElementById('cal-start-position').value = position;
updateCalibrationPreview();
}
function toggleEdgeInputs() {
const preview = document.querySelector('.calibration-preview');
if (preview) preview.classList.toggle('inputs-dimmed');
}
function toggleDirection() {
const select = document.getElementById('cal-layout');
select.value = select.value === 'clockwise' ? 'counterclockwise' : 'clockwise';
updateCalibrationPreview();
}
async function toggleTestEdge(edge) {
const edgeLeds = parseInt(document.getElementById(`cal-${edge}-leds`).value) || 0;
if (edgeLeds === 0) return;
const deviceId = document.getElementById('calibration-device-id').value;
const error = document.getElementById('calibration-error');
if (!calibrationTestState[deviceId]) {
calibrationTestState[deviceId] = new Set();
}
// Toggle edge
if (calibrationTestState[deviceId].has(edge)) {
calibrationTestState[deviceId].delete(edge);
} else {
calibrationTestState[deviceId].add(edge);
}
// Build edges dict for API
const edges = {};
calibrationTestState[deviceId].forEach(e => {
edges[e] = EDGE_TEST_COLORS[e];
});
// Update visual state immediately
updateCalibrationPreview();
try {
const response = await fetch(`${API_BASE}/devices/${deviceId}/calibration/test`, {
method: 'PUT',
headers: getHeaders(),
body: JSON.stringify({ edges })
});
if (response.status === 401) {
handle401Error();
return;
}
if (!response.ok) {
const errorData = await response.json();
error.textContent = `Test failed: ${errorData.detail}`;
error.style.display = 'block';
}
} catch (err) {
console.error('Failed to toggle test edge:', err);
error.textContent = 'Failed to toggle test edge';
error.style.display = 'block';
}
}
async function clearTestMode(deviceId) {
if (!calibrationTestState[deviceId] || calibrationTestState[deviceId].size === 0) {
return;
}
calibrationTestState[deviceId] = new Set();
try {
await fetch(`${API_BASE}/devices/${deviceId}/calibration/test`, {
method: 'PUT',
headers: getHeaders(),
body: JSON.stringify({ edges: {} })
});
} catch (err) {
console.error('Failed to clear test mode:', err);
}
}
async function saveCalibration() {
const deviceId = document.getElementById('calibration-device-id').value;
const deviceLedCount = parseInt(document.getElementById('cal-device-led-count-inline').textContent);
const error = document.getElementById('calibration-error');
// Clear test mode before saving
await clearTestMode(deviceId);
updateCalibrationPreview();
const topLeds = parseInt(document.getElementById('cal-top-leds').value || 0);
const rightLeds = parseInt(document.getElementById('cal-right-leds').value || 0);
const bottomLeds = parseInt(document.getElementById('cal-bottom-leds').value || 0);
const leftLeds = parseInt(document.getElementById('cal-left-leds').value || 0);
const total = topLeds + rightLeds + bottomLeds + leftLeds;
// Validation
if (total !== deviceLedCount) {
error.textContent = `Total LEDs (${total}) must equal device LED count (${deviceLedCount})`;
error.style.display = 'block';
return;
}
// Build calibration config
const startPosition = document.getElementById('cal-start-position').value;
const layout = document.getElementById('cal-layout').value;
const offset = parseInt(document.getElementById('cal-offset').value || 0);
const spans = window.edgeSpans || {};
const calibration = {
layout: layout,
start_position: startPosition,
offset: offset,
leds_top: topLeds,
leds_right: rightLeds,
leds_bottom: bottomLeds,
leds_left: leftLeds,
span_top_start: spans.top?.start ?? 0,
span_top_end: spans.top?.end ?? 1,
span_right_start: spans.right?.start ?? 0,
span_right_end: spans.right?.end ?? 1,
span_bottom_start: spans.bottom?.start ?? 0,
span_bottom_end: spans.bottom?.end ?? 1,
span_left_start: spans.left?.start ?? 0,
span_left_end: spans.left?.end ?? 1,
};
try {
const response = await fetch(`${API_BASE}/devices/${deviceId}/calibration`, {
method: 'PUT',
headers: getHeaders(),
body: JSON.stringify(calibration)
});
if (response.status === 401) {
handle401Error();
return;
}
if (response.ok) {
showToast('Calibration saved', 'success');
forceCloseCalibrationModal();
loadDevices();
} else {
const errorData = await response.json();
error.textContent = `Failed to save: ${errorData.detail}`;
error.style.display = 'block';
}
} catch (err) {
console.error('Failed to save calibration:', err);
error.textContent = 'Failed to save calibration';
error.style.display = 'block';
}
}
function getEdgeOrder(startPosition, layout) {
const orders = {
'bottom_left_clockwise': ['left', 'top', 'right', 'bottom'],
'bottom_left_counterclockwise': ['bottom', 'right', 'top', 'left'],
'bottom_right_clockwise': ['bottom', 'left', 'top', 'right'],
'bottom_right_counterclockwise': ['right', 'top', 'left', 'bottom'],
'top_left_clockwise': ['top', 'right', 'bottom', 'left'],
'top_left_counterclockwise': ['left', 'bottom', 'right', 'top'],
'top_right_clockwise': ['right', 'bottom', 'left', 'top'],
'top_right_counterclockwise': ['top', 'left', 'bottom', 'right']
};
return orders[`${startPosition}_${layout}`] || ['left', 'top', 'right', 'bottom'];
}
function shouldReverse(edge, startPosition, layout) {
// Determine if this edge should be reversed based on LED strip direction
const reverseRules = {
'bottom_left_clockwise': { left: true, top: false, right: false, bottom: true },
'bottom_left_counterclockwise': { bottom: false, right: true, top: true, left: false },
'bottom_right_clockwise': { bottom: true, left: true, top: false, right: false },
'bottom_right_counterclockwise': { right: true, top: true, left: false, bottom: false },
'top_left_clockwise': { top: false, right: false, bottom: true, left: true },
'top_left_counterclockwise': { left: false, bottom: false, right: true, top: true },
'top_right_clockwise': { right: false, bottom: true, left: true, top: false },
'top_right_counterclockwise': { top: true, left: false, bottom: false, right: true }
};
const rules = reverseRules[`${startPosition}_${layout}`];
return rules ? rules[edge] : false;
}
function buildSegments(calibration) {
const edgeOrder = getEdgeOrder(calibration.start_position, calibration.layout);
const edgeCounts = {
top: calibration.leds_top || 0,
right: calibration.leds_right || 0,
bottom: calibration.leds_bottom || 0,
left: calibration.leds_left || 0
};
const segments = [];
let ledStart = calibration.offset || 0;
edgeOrder.forEach(edge => {
const count = edgeCounts[edge];
if (count > 0) {
segments.push({
edge: edge,
led_start: ledStart,
led_count: count,
reverse: shouldReverse(edge, calibration.start_position, calibration.layout)
});
ledStart += count;
}
});
return segments;
}
// Close modals on backdrop click (only if mousedown also started on backdrop)
let backdropMouseDownTarget = null;
document.addEventListener('mousedown', (e) => {
backdropMouseDownTarget = e.target;
});
document.addEventListener('click', (e) => {
if (!e.target.classList.contains('modal')) return;
if (backdropMouseDownTarget !== e.target) return;
if (activeTutorial) return;
const modalId = e.target.id;
// Confirm modal: backdrop click acts as Cancel
if (modalId === 'confirm-modal') {
closeConfirmModal(false);
return;
}
// Login modal: close only if cancel button is visible (not required login)
if (modalId === 'api-key-modal') {
const cancelBtn = document.getElementById('modal-cancel-btn');
if (cancelBtn && cancelBtn.style.display !== 'none') {
closeApiKeyModal();
}
return;
}
// General settings modal: dirty check
if (modalId === 'device-settings-modal') {
closeDeviceSettingsModal();
return;
}
// Capture settings modal: dirty check
if (modalId === 'capture-settings-modal') {
closeCaptureSettingsModal();
return;
}
// Calibration modal: dirty check
if (modalId === 'calibration-modal') {
closeCalibrationModal();
return;
}
// Add device modal: close on backdrop
if (modalId === 'add-device-modal') {
closeAddDeviceModal();
return;
}
});
// Cleanup on page unload
window.addEventListener('beforeunload', () => {
if (refreshInterval) {
clearInterval(refreshInterval);
}
});
// =============================================================================
// Tutorial System (generic engine)
// =============================================================================
let activeTutorial = null;
// Shape: { steps, overlay, mode, step, resolveTarget, container }
// mode: 'absolute' (within a container) or 'fixed' (viewport-level)
const calibrationTutorialSteps = [
{ selector: '#cal-top-leds', textKey: 'calibration.tip.led_count', position: 'bottom' },
{ selector: '.corner-bottom-left', textKey: 'calibration.tip.start_corner', position: 'right' },
{ selector: '.direction-toggle', textKey: 'calibration.tip.direction', position: 'bottom' },
{ selector: '.offset-control', textKey: 'calibration.tip.offset', position: 'bottom' },
{ selector: '.edge-span-bar[data-edge="top"]', textKey: 'calibration.tip.span', position: 'bottom' },
{ selector: '.toggle-top', textKey: 'calibration.tip.test', position: 'top' },
{ selector: '.preview-screen-total', textKey: 'calibration.tip.toggle_inputs', position: 'top' }
];
const deviceTutorialSteps = [
{ selector: '.card-subtitle', textKey: 'device.tip.metadata', position: 'bottom' },
{ selector: '.brightness-control', textKey: 'device.tip.brightness', position: 'bottom' },
{ selector: '.card-actions .btn:nth-child(1)', textKey: 'device.tip.start', position: 'top' },
{ selector: '.card-actions .btn:nth-child(2)', textKey: 'device.tip.settings', position: 'top' },
{ selector: '.card-actions .btn:nth-child(3)', textKey: 'device.tip.capture_settings', position: 'top' },
{ selector: '.card-actions .btn:nth-child(4)', textKey: 'device.tip.calibrate', position: 'top' },
{ selector: '.card-actions .btn:nth-child(5)', textKey: 'device.tip.webui', position: 'top' }
];
function startTutorial(config) {
closeTutorial();
const overlay = document.getElementById(config.overlayId);
if (!overlay) return;
activeTutorial = {
steps: config.steps,
overlay: overlay,
mode: config.mode,
step: 0,
resolveTarget: config.resolveTarget,
container: config.container
};
overlay.classList.add('active');
document.addEventListener('keydown', handleTutorialKey);
showTutorialStep(0);
}
function startCalibrationTutorial() {
const container = document.querySelector('#calibration-modal .modal-body');
if (!container) return;
startTutorial({
steps: calibrationTutorialSteps,
overlayId: 'tutorial-overlay',
mode: 'absolute',
container: container,
resolveTarget: (step) => document.querySelector(step.selector)
});
}
function startDeviceTutorial(deviceId) {
// Resolve the device ID to target (don't capture card reference — it goes stale when loadDevices rebuilds DOM)
const selector = deviceId
? `.card[data-device-id="${deviceId}"]`
: '.card[data-device-id]';
if (!document.querySelector(selector)) return;
startTutorial({
steps: deviceTutorialSteps,
overlayId: 'device-tutorial-overlay',
mode: 'fixed',
container: null,
resolveTarget: (step) => {
const card = document.querySelector(selector);
if (!card) return null;
return step.global
? document.querySelector(step.selector)
: card.querySelector(step.selector);
}
});
}
function closeTutorial() {
if (!activeTutorial) return;
activeTutorial.overlay.classList.remove('active');
document.querySelectorAll('.tutorial-target').forEach(el => {
el.classList.remove('tutorial-target');
el.style.zIndex = '';
});
document.removeEventListener('keydown', handleTutorialKey);
activeTutorial = null;
}
function tutorialNext() {
if (!activeTutorial) return;
if (activeTutorial.step < activeTutorial.steps.length - 1) {
showTutorialStep(activeTutorial.step + 1);
} else {
closeTutorial();
}
}
function tutorialPrev() {
if (!activeTutorial) return;
if (activeTutorial.step > 0) {
showTutorialStep(activeTutorial.step - 1);
}
}
function showTutorialStep(index) {
if (!activeTutorial) return;
activeTutorial.step = index;
const step = activeTutorial.steps[index];
const overlay = activeTutorial.overlay;
const isFixed = activeTutorial.mode === 'fixed';
// Remove previous target highlight
document.querySelectorAll('.tutorial-target').forEach(el => {
el.classList.remove('tutorial-target');
el.style.zIndex = '';
});
// Find and highlight target
const target = activeTutorial.resolveTarget(step);
if (!target) return;
target.classList.add('tutorial-target');
// For fixed overlays, target must be above the z-index:10000 overlay
if (isFixed) target.style.zIndex = '10001';
const targetRect = target.getBoundingClientRect();
const pad = 6;
let x, y, w, h;
if (isFixed) {
// Fixed mode: coordinates are viewport-relative
x = targetRect.left - pad;
y = targetRect.top - pad;
w = targetRect.width + pad * 2;
h = targetRect.height + pad * 2;
} else {
// Absolute mode: coordinates relative to container
const containerRect = activeTutorial.container.getBoundingClientRect();
x = targetRect.left - containerRect.left - pad;
y = targetRect.top - containerRect.top - pad;
w = targetRect.width + pad * 2;
h = targetRect.height + pad * 2;
}
// Update backdrop clip-path (polygon with rectangular cutout)
const backdrop = overlay.querySelector('.tutorial-backdrop');
if (backdrop) {
backdrop.style.clipPath = `polygon(
0% 0%, 0% 100%,
${x}px 100%, ${x}px ${y}px,
${x + w}px ${y}px, ${x + w}px ${y + h}px,
${x}px ${y + h}px, ${x}px 100%,
100% 100%, 100% 0%)`;
}
// Position ring around target
const ring = overlay.querySelector('.tutorial-ring');
if (ring) {
ring.style.left = x + 'px';
ring.style.top = y + 'px';
ring.style.width = w + 'px';
ring.style.height = h + 'px';
}
// Update tooltip content
const tooltip = overlay.querySelector('.tutorial-tooltip');
const textEl = overlay.querySelector('.tutorial-tooltip-text');
const counterEl = overlay.querySelector('.tutorial-step-counter');
if (textEl) textEl.textContent = t(step.textKey);
if (counterEl) counterEl.textContent = `${index + 1} / ${activeTutorial.steps.length}`;
// Enable/disable nav buttons
const prevBtn = overlay.querySelector('.tutorial-prev-btn');
const nextBtn = overlay.querySelector('.tutorial-next-btn');
if (prevBtn) prevBtn.disabled = (index === 0);
if (nextBtn) nextBtn.textContent = (index === activeTutorial.steps.length - 1) ? '\u2713' : '\u2192';
// Position tooltip
if (tooltip) {
positionTutorialTooltip(tooltip, x, y, w, h, step.position, isFixed);
}
}
function positionTutorialTooltip(tooltip, sx, sy, sw, sh, preferred, isFixed) {
const gap = 12;
const tooltipW = 260;
// Place offscreen to measure real height without visual flash
tooltip.setAttribute('style', 'left:-9999px;top:-9999px');
const tooltipH = tooltip.offsetHeight || 150;
const positions = {
top: { x: sx + sw / 2 - tooltipW / 2, y: sy - tooltipH - gap },
bottom: { x: sx + sw / 2 - tooltipW / 2, y: sy + sh + gap },
left: { x: sx - tooltipW - gap, y: sy + sh / 2 - tooltipH / 2 },
right: { x: sx + sw + gap, y: sy + sh / 2 - tooltipH / 2 }
};
let pos = positions[preferred] || positions.bottom;
const cW = isFixed ? window.innerWidth : activeTutorial.container.clientWidth;
const cH = isFixed ? window.innerHeight : activeTutorial.container.clientHeight;
// If preferred position overflows, try opposite
if (pos.y + tooltipH > cH || pos.y < 0 || pos.x + tooltipW > cW || pos.x < 0) {
const opposite = { top: 'bottom', bottom: 'top', left: 'right', right: 'left' };
const alt = positions[opposite[preferred]];
if (alt && alt.y >= 0 && alt.y + tooltipH <= cH && alt.x >= 0 && alt.x + tooltipW <= cW) {
pos = alt;
}
}
pos.x = Math.max(8, Math.min(cW - tooltipW - 8, pos.x));
pos.y = Math.max(8, Math.min(cH - tooltipH - 8, pos.y));
// Force-set all positioning via setAttribute to avoid any style-setting quirks
tooltip.setAttribute('style', `left:${Math.round(pos.x)}px;top:${Math.round(pos.y)}px`);
}
function handleTutorialKey(e) {
if (!activeTutorial) return;
if (e.key === 'Escape') { closeTutorial(); e.stopPropagation(); }
else if (e.key === 'ArrowRight' || e.key === 'ArrowDown') { e.preventDefault(); tutorialNext(); }
else if (e.key === 'ArrowLeft' || e.key === 'ArrowUp') { e.preventDefault(); tutorialPrev(); }
}
// ===========================
// Capture Templates Functions
// ===========================
let availableEngines = [];
let currentEditingTemplateId = null;
// Load and render capture templates
async function loadCaptureTemplates() {
try {
const response = await fetchWithAuth('/capture-templates');
if (!response.ok) {
throw new Error(`Failed to load templates: ${response.status}`);
}
const data = await response.json();
_cachedCaptureTemplates = data.templates || [];
// Re-render the streams tab which now contains template sections
renderPictureSourcesList(_cachedStreams);
} catch (error) {
console.error('Error loading capture templates:', error);
}
}
// Get engine icon
function getEngineIcon(engineType) {
return '🚀';
}
// Show add template modal
let _templateNameManuallyEdited = false;
async function showAddTemplateModal() {
currentEditingTemplateId = null;
document.getElementById('template-modal-title').textContent = t('templates.add');
document.getElementById('template-form').reset();
document.getElementById('template-id').value = '';
document.getElementById('engine-config-section').style.display = 'none';
document.getElementById('template-error').style.display = 'none';
// Auto-name: reset flag and wire listener
_templateNameManuallyEdited = false;
document.getElementById('template-name').oninput = () => { _templateNameManuallyEdited = true; };
// Load available engines
await loadAvailableEngines();
// Show modal
const modal = document.getElementById('template-modal');
modal.style.display = 'flex';
setupBackdropClose(modal, closeTemplateModal);
}
// Edit template
async function editTemplate(templateId) {
try {
const response = await fetchWithAuth(`/capture-templates/${templateId}`);
if (!response.ok) {
throw new Error(`Failed to load template: ${response.status}`);
}
const template = await response.json();
currentEditingTemplateId = templateId;
document.getElementById('template-modal-title').textContent = t('templates.edit');
document.getElementById('template-id').value = templateId;
document.getElementById('template-name').value = template.name;
document.getElementById('template-description').value = template.description || '';
// Load available engines
await loadAvailableEngines();
// Set engine and load config
document.getElementById('template-engine').value = template.engine_type;
await onEngineChange();
// Populate engine config fields
populateEngineConfig(template.engine_config);
// Load displays for test
await loadDisplaysForTest();
const testResults = document.getElementById('template-test-results');
if (testResults) testResults.style.display = 'none';
document.getElementById('template-error').style.display = 'none';
// Show modal
const modal = document.getElementById('template-modal');
modal.style.display = 'flex';
setupBackdropClose(modal, closeTemplateModal);
} catch (error) {
console.error('Error loading template:', error);
showToast(t('templates.error.load') + ': ' + error.message, 'error');
}
}
// Close template modal
function closeTemplateModal() {
document.getElementById('template-modal').style.display = 'none';
currentEditingTemplateId = null;
}
// Show full-page overlay spinner with progress
function showOverlaySpinner(text, duration = 0) {
// Remove existing overlay if any
const existing = document.getElementById('overlay-spinner');
if (existing) {
// Clear any existing timer
if (window.overlaySpinnerTimer) {
clearInterval(window.overlaySpinnerTimer);
window.overlaySpinnerTimer = null;
}
existing.remove();
}
// Create overlay
const overlay = document.createElement('div');
overlay.id = 'overlay-spinner';
overlay.className = 'overlay-spinner';
// Create progress container
const progressContainer = document.createElement('div');
progressContainer.className = 'progress-container';
// Create SVG progress ring
const radius = 56;
const circumference = 2 * Math.PI * radius;
const svg = document.createElementNS('http://www.w3.org/2000/svg', 'svg');
svg.setAttribute('width', '120');
svg.setAttribute('height', '120');
svg.setAttribute('class', 'progress-ring');
// Background circle
const bgCircle = document.createElementNS('http://www.w3.org/2000/svg', 'circle');
bgCircle.setAttribute('class', 'progress-ring-bg');
bgCircle.setAttribute('cx', '60');
bgCircle.setAttribute('cy', '60');
bgCircle.setAttribute('r', radius);
// Progress circle
const progressCircle = document.createElementNS('http://www.w3.org/2000/svg', 'circle');
progressCircle.setAttribute('class', 'progress-ring-circle');
progressCircle.setAttribute('cx', '60');
progressCircle.setAttribute('cy', '60');
progressCircle.setAttribute('r', radius);
progressCircle.style.strokeDasharray = circumference;
progressCircle.style.strokeDashoffset = circumference;
svg.appendChild(bgCircle);
svg.appendChild(progressCircle);
// Create progress content (percentage display)
const progressContent = document.createElement('div');
progressContent.className = 'progress-content';
const progressPercentage = document.createElement('div');
progressPercentage.className = 'progress-percentage';
progressPercentage.textContent = '0%';
progressContent.appendChild(progressPercentage);
progressContainer.appendChild(svg);
progressContainer.appendChild(progressContent);
// Create text
const spinnerText = document.createElement('div');
spinnerText.className = 'spinner-text';
spinnerText.textContent = text;
overlay.appendChild(progressContainer);
overlay.appendChild(spinnerText);
document.body.appendChild(overlay);
// Animate progress if duration is provided
if (duration > 0) {
const startTime = Date.now();
window.overlaySpinnerTimer = setInterval(() => {
const elapsed = (Date.now() - startTime) / 1000;
const progress = Math.min(elapsed / duration, 1);
const percentage = Math.round(progress * 100);
// Update progress ring
const offset = circumference - (progress * circumference);
progressCircle.style.strokeDashoffset = offset;
// Update percentage display
progressPercentage.textContent = `${percentage}%`;
// Stop timer if complete
if (progress >= 1) {
clearInterval(window.overlaySpinnerTimer);
window.overlaySpinnerTimer = null;
}
}, 100);
}
}
// Hide full-page overlay spinner
function hideOverlaySpinner() {
// Clear timer if exists
if (window.overlaySpinnerTimer) {
clearInterval(window.overlaySpinnerTimer);
window.overlaySpinnerTimer = null;
}
const overlay = document.getElementById('overlay-spinner');
if (overlay) overlay.remove();
}
// Update capture duration and save to localStorage
function updateCaptureDuration(value) {
document.getElementById('test-template-duration-value').textContent = value;
localStorage.setItem('capture_duration', value);
}
// Restore capture duration from localStorage
function restoreCaptureDuration() {
const savedDuration = localStorage.getItem('capture_duration');
if (savedDuration) {
const durationInput = document.getElementById('test-template-duration');
const durationValue = document.getElementById('test-template-duration-value');
durationInput.value = savedDuration;
durationValue.textContent = savedDuration;
}
}
// Show test template modal
async function showTestTemplateModal(templateId) {
const templates = await fetchWithAuth('/capture-templates').then(r => r.json());
const template = templates.templates.find(t => t.id === templateId);
if (!template) {
showToast(t('templates.error.load'), 'error');
return;
}
// Store current template for testing
window.currentTestingTemplate = template;
// Load displays
await loadDisplaysForTest();
// Restore last used capture duration
restoreCaptureDuration();
// Show modal
const modal = document.getElementById('test-template-modal');
modal.style.display = 'flex';
setupBackdropClose(modal, closeTestTemplateModal);
}
// Close test template modal
function closeTestTemplateModal() {
document.getElementById('test-template-modal').style.display = 'none';
window.currentTestingTemplate = null;
}
// Load available engines
async function loadAvailableEngines() {
try {
const response = await fetchWithAuth('/capture-engines');
if (!response.ok) {
throw new Error(`Failed to load engines: ${response.status}`);
}
const data = await response.json();
availableEngines = data.engines || [];
const select = document.getElementById('template-engine');
select.innerHTML = '';
availableEngines.forEach(engine => {
const option = document.createElement('option');
option.value = engine.type;
option.textContent = `${getEngineIcon(engine.type)} ${engine.name}`;
if (!engine.available) {
option.disabled = true;
option.textContent += ` (${t('templates.engine.unavailable')})`;
}
select.appendChild(option);
});
// Auto-select first available engine if nothing selected
if (!select.value) {
const firstAvailable = availableEngines.find(e => e.available);
if (firstAvailable) select.value = firstAvailable.type;
}
} catch (error) {
console.error('Error loading engines:', error);
showToast(t('templates.error.engines') + ': ' + error.message, 'error');
}
}
// Handle engine selection change
async function onEngineChange() {
const engineType = document.getElementById('template-engine').value;
const configSection = document.getElementById('engine-config-section');
const configFields = document.getElementById('engine-config-fields');
if (!engineType) {
configSection.style.display = 'none';
return;
}
const engine = availableEngines.find(e => e.type === engineType);
if (!engine) {
configSection.style.display = 'none';
return;
}
// Auto-name: set template name to engine name if user hasn't edited
if (!_templateNameManuallyEdited && !document.getElementById('template-id').value) {
document.getElementById('template-name').value = engine.name || engineType;
}
// Show availability hint
const hint = document.getElementById('engine-availability-hint');
if (!engine.available) {
hint.textContent = t('templates.engine.unavailable.hint');
hint.style.display = 'block';
hint.style.color = 'var(--error-color)';
} else {
hint.style.display = 'none';
}
// Render config fields based on default_config
configFields.innerHTML = '';
const defaultConfig = engine.default_config || {};
if (Object.keys(defaultConfig).length === 0) {
configSection.style.display = 'none';
return;
} else {
let gridHtml = '<div class="config-grid">';
Object.entries(defaultConfig).forEach(([key, value]) => {
const fieldType = typeof value === 'number' ? 'number' : 'text';
const fieldValue = typeof value === 'boolean' ? (value ? 'true' : 'false') : value;
gridHtml += `
<label class="config-grid-label" for="config-${key}">${key}</label>
<div class="config-grid-value">
${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}">
`}
</div>
`;
});
gridHtml += '</div>';
configFields.innerHTML = gridHtml;
}
configSection.style.display = 'block';
}
// Populate engine config fields with values
function populateEngineConfig(config) {
Object.entries(config).forEach(([key, value]) => {
const field = document.getElementById(`config-${key}`);
if (field) {
if (field.tagName === 'SELECT') {
field.value = value.toString();
} else {
field.value = value;
}
}
});
}
// Collect engine config from form
function collectEngineConfig() {
const config = {};
const fields = document.querySelectorAll('[data-config-key]');
fields.forEach(field => {
const key = field.dataset.configKey;
let value = field.value;
// Type conversion
if (field.type === 'number') {
value = parseFloat(value);
} else if (field.tagName === 'SELECT' && (value === 'true' || value === 'false')) {
value = value === 'true';
}
config[key] = value;
});
return config;
}
// Load displays for test selector
async function loadDisplaysForTest() {
try {
if (!_cachedDisplays) {
const response = await fetchWithAuth('/config/displays');
if (!response.ok) throw new Error(`Failed to load displays: ${response.status}`);
const displaysData = await response.json();
_cachedDisplays = displaysData.displays || [];
}
// Auto-select: last used display, or primary as fallback
let selectedIndex = null;
const lastDisplay = localStorage.getItem('lastTestDisplayIndex');
if (lastDisplay !== null) {
const found = _cachedDisplays.find(d => d.index === parseInt(lastDisplay));
if (found) selectedIndex = found.index;
}
if (selectedIndex === null) {
const primary = _cachedDisplays.find(d => d.is_primary);
if (primary) selectedIndex = primary.index;
else if (_cachedDisplays.length > 0) selectedIndex = _cachedDisplays[0].index;
}
if (selectedIndex !== null) {
const display = _cachedDisplays.find(d => d.index === selectedIndex);
onTestDisplaySelected(selectedIndex, display);
}
} catch (error) {
console.error('Error loading displays:', error);
}
}
// Run template test
async function runTemplateTest() {
if (!window.currentTestingTemplate) {
showToast(t('templates.test.error.no_engine'), 'error');
return;
}
const displayIndex = document.getElementById('test-template-display').value;
const captureDuration = parseFloat(document.getElementById('test-template-duration').value);
if (displayIndex === '') {
showToast(t('templates.test.error.no_display'), 'error');
return;
}
const template = window.currentTestingTemplate;
// Show full-page overlay spinner with progress
showOverlaySpinner(t('templates.test.running'), captureDuration);
try {
const response = await fetchWithAuth('/capture-templates/test', {
method: 'POST',
body: JSON.stringify({
engine_type: template.engine_type,
engine_config: template.engine_config,
display_index: parseInt(displayIndex),
capture_duration: captureDuration
})
});
if (!response.ok) {
const error = await response.json();
throw new Error(error.detail || error.message || 'Test failed');
}
const result = await response.json();
localStorage.setItem('lastTestDisplayIndex', displayIndex);
displayTestResults(result);
} catch (error) {
console.error('Error running test:', error);
// Hide overlay spinner
hideOverlaySpinner();
// Show short error in snack, details are in console
showToast(t('templates.test.error.failed'), 'error');
}
}
function buildTestStatsHtml(result) {
const p = result.performance;
const res = `${result.full_capture.width}x${result.full_capture.height}`;
let html = `
<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>`;
if (p.frame_count > 1) {
html += `
<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>`;
}
html += `
<div class="stat-item"><span>Resolution:</span> <strong>${res}</strong></div>`;
return html;
}
// Display test results — opens lightbox with stats overlay
function displayTestResults(result) {
hideOverlaySpinner();
const fullImageSrc = result.full_capture.full_image || result.full_capture.image;
openLightbox(fullImageSrc, buildTestStatsHtml(result));
}
// Save template
async function saveTemplate() {
const templateId = document.getElementById('template-id').value;
const name = document.getElementById('template-name').value.trim();
const engineType = document.getElementById('template-engine').value;
if (!name || !engineType) {
showToast(t('templates.error.required'), 'error');
return;
}
const description = document.getElementById('template-description').value.trim();
const engineConfig = collectEngineConfig();
const payload = {
name,
engine_type: engineType,
engine_config: engineConfig,
description: description || null
};
try {
let response;
if (templateId) {
// Update existing template
response = await fetchWithAuth(`/capture-templates/${templateId}`, {
method: 'PUT',
body: JSON.stringify(payload)
});
} else {
// Create new template
response = await fetchWithAuth('/capture-templates', {
method: 'POST',
body: JSON.stringify(payload)
});
}
if (!response.ok) {
const error = await response.json();
throw new Error(error.detail || error.message || 'Failed to save template');
}
showToast(templateId ? t('templates.updated') : t('templates.created'), 'success');
closeTemplateModal();
await loadCaptureTemplates();
} catch (error) {
console.error('Error saving template:', error);
document.getElementById('template-error').textContent = error.message;
document.getElementById('template-error').style.display = 'block';
}
}
// Delete template
async function deleteTemplate(templateId) {
const confirmed = await showConfirm(t('templates.delete.confirm'));
if (!confirmed) return;
try {
const response = await fetchWithAuth(`/capture-templates/${templateId}`, {
method: 'DELETE'
});
if (!response.ok) {
const error = await response.json();
throw new Error(error.detail || error.message || 'Failed to delete template');
}
showToast(t('templates.deleted'), 'success');
await loadCaptureTemplates();
} catch (error) {
console.error('Error deleting template:', error);
showToast(t('templates.error.delete') + ': ' + error.message, 'error');
}
}
// ===== Picture Sources =====
let _cachedStreams = [];
let _cachedPPTemplates = [];
let _cachedCaptureTemplates = [];
let _availableFilters = []; // Loaded from GET /filters
async function loadPictureSources() {
try {
// Always fetch templates, filters, and streams in parallel
// since templates are now rendered inside stream sub-tabs
const [filtersResp, ppResp, captResp, streamsResp] = await Promise.all([
_availableFilters.length === 0 ? fetchWithAuth('/filters') : Promise.resolve(null),
fetchWithAuth('/postprocessing-templates'),
fetchWithAuth('/capture-templates'),
fetchWithAuth('/picture-sources')
]);
if (filtersResp && filtersResp.ok) {
const fd = await filtersResp.json();
_availableFilters = fd.filters || [];
}
if (ppResp.ok) {
const pd = await ppResp.json();
_cachedPPTemplates = pd.templates || [];
}
if (captResp.ok) {
const cd = await captResp.json();
_cachedCaptureTemplates = cd.templates || [];
}
if (!streamsResp.ok) {
throw new Error(`Failed to load streams: ${streamsResp.status}`);
}
const data = await streamsResp.json();
_cachedStreams = data.streams || [];
renderPictureSourcesList(_cachedStreams);
} catch (error) {
console.error('Error loading picture sources:', error);
document.getElementById('streams-list').innerHTML = `
<div class="error-message">${t('streams.error.load')}: ${error.message}</div>
`;
}
}
function switchStreamTab(tabKey) {
document.querySelectorAll('.stream-tab-btn').forEach(btn =>
btn.classList.toggle('active', btn.dataset.streamTab === tabKey)
);
document.querySelectorAll('.stream-tab-panel').forEach(panel =>
panel.classList.toggle('active', panel.id === `stream-tab-${tabKey}`)
);
localStorage.setItem('activeStreamTab', tabKey);
}
function renderPictureSourcesList(streams) {
const container = document.getElementById('streams-list');
const activeTab = localStorage.getItem('activeStreamTab') || 'raw';
const renderStreamCard = (stream) => {
const typeIcons = { raw: '🖥️', processed: '🎨', static_image: '🖼️' };
const typeIcon = typeIcons[stream.stream_type] || '📺';
let detailsHtml = '';
if (stream.stream_type === 'raw') {
let capTmplName = '';
if (stream.capture_template_id) {
const capTmpl = _cachedCaptureTemplates.find(t => t.id === stream.capture_template_id);
if (capTmpl) capTmplName = escapeHtml(capTmpl.name);
}
detailsHtml = `<div class="stream-card-props">
<span class="stream-card-prop" title="${t('streams.display')}">🖥️ ${stream.display_index ?? 0}</span>
<span class="stream-card-prop" title="${t('streams.target_fps')}">⚡ ${stream.target_fps ?? 30}</span>
${capTmplName ? `<span class="stream-card-prop" title="${t('streams.capture_template')}">📋 ${capTmplName}</span>` : ''}
</div>`;
} else if (stream.stream_type === 'processed') {
const sourceStream = _cachedStreams.find(s => s.id === stream.source_stream_id);
const sourceName = sourceStream ? escapeHtml(sourceStream.name) : (stream.source_stream_id || '-');
let ppTmplName = '';
if (stream.postprocessing_template_id) {
const ppTmpl = _cachedPPTemplates.find(p => p.id === stream.postprocessing_template_id);
if (ppTmpl) ppTmplName = escapeHtml(ppTmpl.name);
}
detailsHtml = `<div class="stream-card-props">
<span class="stream-card-prop" title="${t('streams.source')}">📺 ${sourceName}</span>
${ppTmplName ? `<span class="stream-card-prop" title="${t('streams.pp_template')}">📋 ${ppTmplName}</span>` : ''}
</div>`;
} else if (stream.stream_type === 'static_image') {
const src = stream.image_source || '';
detailsHtml = `<div class="stream-card-props">
<span class="stream-card-prop stream-card-prop-full" title="${escapeHtml(src)}">🌐 ${escapeHtml(src)}</span>
</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>
</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 renderCaptureTemplateCard = (template) => {
const engineIcon = getEngineIcon(template.engine_type);
const configEntries = Object.entries(template.engine_config);
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">
📋 ${escapeHtml(template.name)}
</div>
</div>
${template.description ? `<div class="template-config" style="opacity:0.7;">${escapeHtml(template.description)}</div>` : ''}
<div class="stream-card-props">
<span class="stream-card-prop" title="${t('templates.engine')}">🚀 ${template.engine_type.toUpperCase()}</span>
${configEntries.length > 0 ? `<span class="stream-card-prop" title="${t('templates.config.show')}">🔧 ${configEntries.length}</span>` : ''}
</div>
${configEntries.length > 0 ? `
<details class="template-config-details">
<summary>${t('templates.config.show')}</summary>
<table class="config-table">
${configEntries.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-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>
`;
};
const renderPPTemplateCard = (tmpl) => {
let filterChainHtml = '';
if (tmpl.filters && tmpl.filters.length > 0) {
const filterNames = tmpl.filters.map(fi => `<span class="filter-chain-item">${escapeHtml(_getFilterName(fi.filter_id))}</span>`);
filterChainHtml = `<div class="filter-chain">${filterNames.join('<span class="filter-chain-arrow">→</span>')}</div>`;
}
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>` : ''}
${filterChainHtml}
<div class="template-card-actions">
<button class="btn btn-icon btn-secondary" onclick="showTestPPTemplateModal('${tmpl.id}')" title="${t('postprocessing.test.title')}">
🧪
</button>
<button class="btn btn-icon btn-secondary" onclick="editPPTemplate('${tmpl.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');
const staticImageStreams = streams.filter(s => s.stream_type === 'static_image');
const addStreamCard = (type) => `
<div class="template-card add-template-card" onclick="showAddStreamModal('${type}')">
<div class="add-template-icon">+</div>
</div>`;
const tabs = [
{ key: 'raw', icon: '🖥️', titleKey: 'streams.group.raw', streams: rawStreams },
{ key: 'static_image', icon: '🖼️', titleKey: 'streams.group.static_image', streams: staticImageStreams },
{ key: 'processed', icon: '🎨', titleKey: 'streams.group.processed', streams: processedStreams },
];
const tabBar = `<div class="stream-tab-bar">${tabs.map(tab =>
`<button class="stream-tab-btn${tab.key === activeTab ? ' active' : ''}" data-stream-tab="${tab.key}" onclick="switchStreamTab('${tab.key}')">${tab.icon} ${t(tab.titleKey)} <span class="stream-tab-count">${tab.streams.length}</span></button>`
).join('')}</div>`;
const panels = tabs.map(tab => {
let panelContent = '';
if (tab.key === 'raw') {
// Screen Capture: streams section + capture templates section
panelContent = `
<div class="subtab-section">
<h3 class="subtab-section-header">${t('streams.section.streams')}</h3>
<div class="templates-grid">
${tab.streams.map(renderStreamCard).join('')}
${addStreamCard(tab.key)}
</div>
</div>
<div class="subtab-section">
<h3 class="subtab-section-header">${t('templates.title')}</h3>
<div class="templates-grid">
${_cachedCaptureTemplates.map(renderCaptureTemplateCard).join('')}
<div class="template-card add-template-card" onclick="showAddTemplateModal()">
<div class="add-template-icon">+</div>
</div>
</div>
</div>`;
} else if (tab.key === 'processed') {
// Processed: streams section + PP templates section
panelContent = `
<div class="subtab-section">
<h3 class="subtab-section-header">${t('streams.section.streams')}</h3>
<div class="templates-grid">
${tab.streams.map(renderStreamCard).join('')}
${addStreamCard(tab.key)}
</div>
</div>
<div class="subtab-section">
<h3 class="subtab-section-header">${t('postprocessing.title')}</h3>
<div class="templates-grid">
${_cachedPPTemplates.map(renderPPTemplateCard).join('')}
<div class="template-card add-template-card" onclick="showAddPPTemplateModal()">
<div class="add-template-icon">+</div>
</div>
</div>
</div>`;
} else {
// Static Image: just the stream cards, no section headers
panelContent = `
<div class="templates-grid">
${tab.streams.map(renderStreamCard).join('')}
${addStreamCard(tab.key)}
</div>`;
}
return `<div class="stream-tab-panel${tab.key === activeTab ? ' active' : ''}" id="stream-tab-${tab.key}">${panelContent}</div>`;
}).join('');
container.innerHTML = tabBar + panels;
}
function onStreamTypeChange() {
const streamType = document.getElementById('stream-type').value;
document.getElementById('stream-raw-fields').style.display = streamType === 'raw' ? '' : 'none';
document.getElementById('stream-processed-fields').style.display = streamType === 'processed' ? '' : 'none';
document.getElementById('stream-static-image-fields').style.display = streamType === 'static_image' ? '' : 'none';
}
async function showAddStreamModal(presetType) {
const streamType = presetType || 'raw';
const titleKeys = { raw: 'streams.add.raw', processed: 'streams.add.processed', static_image: 'streams.add.static_image' };
document.getElementById('stream-modal-title').textContent = t(titleKeys[streamType] || 'streams.add');
document.getElementById('stream-form').reset();
document.getElementById('stream-id').value = '';
document.getElementById('stream-display-index').value = '';
document.getElementById('stream-display-picker-label').textContent = t('displays.picker.select');
document.getElementById('stream-error').style.display = 'none';
document.getElementById('stream-type').value = streamType;
// Clear static image preview and wire up auto-validation
_lastValidatedImageSource = '';
const imgSrcInput = document.getElementById('stream-image-source');
imgSrcInput.value = '';
document.getElementById('stream-image-preview-container').style.display = 'none';
document.getElementById('stream-image-validation-status').style.display = 'none';
imgSrcInput.onblur = () => validateStaticImage();
imgSrcInput.onkeydown = (e) => { if (e.key === 'Enter') { e.preventDefault(); validateStaticImage(); } };
imgSrcInput.onpaste = () => setTimeout(() => validateStaticImage(), 0);
onStreamTypeChange();
// Auto-name: reset flag and wire listeners
_streamNameManuallyEdited = false;
document.getElementById('stream-name').oninput = () => { _streamNameManuallyEdited = true; };
document.getElementById('stream-capture-template').onchange = () => _autoGenerateStreamName();
document.getElementById('stream-source').onchange = () => _autoGenerateStreamName();
document.getElementById('stream-pp-template').onchange = () => _autoGenerateStreamName();
// Populate dropdowns
await populateStreamModalDropdowns();
const modal = document.getElementById('stream-modal');
modal.style.display = 'flex';
lockBody();
setupBackdropClose(modal, closeStreamModal);
}
async function editStream(streamId) {
try {
const response = await fetchWithAuth(`/picture-sources/${streamId}`);
if (!response.ok) throw new Error(`Failed to load stream: ${response.status}`);
const stream = await response.json();
const editTitleKeys = { raw: 'streams.edit.raw', processed: 'streams.edit.processed', static_image: 'streams.edit.static_image' };
document.getElementById('stream-modal-title').textContent = t(editTitleKeys[stream.stream_type] || 'streams.edit');
document.getElementById('stream-id').value = streamId;
document.getElementById('stream-name').value = stream.name;
document.getElementById('stream-description').value = stream.description || '';
document.getElementById('stream-error').style.display = 'none';
// Set type (hidden input)
document.getElementById('stream-type').value = stream.stream_type;
// Clear static image preview and wire up auto-validation
_lastValidatedImageSource = '';
const imgSrcInput = document.getElementById('stream-image-source');
document.getElementById('stream-image-preview-container').style.display = 'none';
document.getElementById('stream-image-validation-status').style.display = 'none';
imgSrcInput.onblur = () => validateStaticImage();
imgSrcInput.onkeydown = (e) => { if (e.key === 'Enter') { e.preventDefault(); validateStaticImage(); } };
imgSrcInput.onpaste = () => setTimeout(() => validateStaticImage(), 0);
onStreamTypeChange();
// Populate dropdowns before setting values
await populateStreamModalDropdowns();
if (stream.stream_type === 'raw') {
const displayIdx = stream.display_index ?? 0;
const display = _cachedDisplays ? _cachedDisplays.find(d => d.index === displayIdx) : null;
onStreamDisplaySelected(displayIdx, display);
document.getElementById('stream-capture-template').value = stream.capture_template_id || '';
const fps = stream.target_fps ?? 30;
document.getElementById('stream-target-fps').value = fps;
document.getElementById('stream-target-fps-value').textContent = fps;
} else if (stream.stream_type === 'processed') {
document.getElementById('stream-source').value = stream.source_stream_id || '';
document.getElementById('stream-pp-template').value = stream.postprocessing_template_id || '';
} else if (stream.stream_type === 'static_image') {
document.getElementById('stream-image-source').value = stream.image_source || '';
// Auto-validate to show preview
if (stream.image_source) {
validateStaticImage();
}
}
const modal = document.getElementById('stream-modal');
modal.style.display = 'flex';
lockBody();
setupBackdropClose(modal, closeStreamModal);
} catch (error) {
console.error('Error loading stream:', error);
showToast(t('streams.error.load') + ': ' + error.message, 'error');
}
}
async function populateStreamModalDropdowns() {
// Load displays, capture templates, streams, and PP templates in parallel
const [displaysRes, captureTemplatesRes, streamsRes, ppTemplatesRes] = await Promise.all([
fetch(`${API_BASE}/config/displays`, { headers: getHeaders() }),
fetchWithAuth('/capture-templates'),
fetchWithAuth('/picture-sources'),
fetchWithAuth('/postprocessing-templates'),
]);
// Displays - warm cache for display picker
if (displaysRes.ok) {
const displaysData = await displaysRes.json();
_cachedDisplays = displaysData.displays || [];
}
// Auto-select primary display if none selected yet
if (!document.getElementById('stream-display-index').value && _cachedDisplays && _cachedDisplays.length > 0) {
const primary = _cachedDisplays.find(d => d.is_primary) || _cachedDisplays[0];
onStreamDisplaySelected(primary.index, primary);
}
// Capture templates
const templateSelect = document.getElementById('stream-capture-template');
templateSelect.innerHTML = '';
if (captureTemplatesRes.ok) {
const data = await captureTemplatesRes.json();
(data.templates || []).forEach(tmpl => {
const opt = document.createElement('option');
opt.value = tmpl.id;
opt.dataset.name = tmpl.name;
opt.textContent = `${getEngineIcon(tmpl.engine_type)} ${tmpl.name}`;
templateSelect.appendChild(opt);
});
}
// Source streams (all existing streams)
const sourceSelect = document.getElementById('stream-source');
sourceSelect.innerHTML = '';
if (streamsRes.ok) {
const data = await streamsRes.json();
const editingId = document.getElementById('stream-id').value;
(data.streams || []).forEach(s => {
// Don't show the current stream as a possible source
if (s.id === editingId) return;
const opt = document.createElement('option');
opt.value = s.id;
opt.dataset.name = s.name;
const typeLabels = { raw: '🖥️', processed: '🎨', static_image: '🖼️' };
const typeLabel = typeLabels[s.stream_type] || '📺';
opt.textContent = `${typeLabel} ${s.name}`;
sourceSelect.appendChild(opt);
});
}
// PP templates
_streamModalPPTemplates = [];
const ppSelect = document.getElementById('stream-pp-template');
ppSelect.innerHTML = '';
if (ppTemplatesRes.ok) {
const data = await ppTemplatesRes.json();
_streamModalPPTemplates = data.templates || [];
_streamModalPPTemplates.forEach(tmpl => {
const opt = document.createElement('option');
opt.value = tmpl.id;
opt.textContent = tmpl.name;
ppSelect.appendChild(opt);
});
}
// Trigger auto-name after dropdowns are populated
_autoGenerateStreamName();
}
async function saveStream() {
const streamId = document.getElementById('stream-id').value;
const name = document.getElementById('stream-name').value.trim();
const streamType = document.getElementById('stream-type').value;
const description = document.getElementById('stream-description').value.trim();
const errorEl = document.getElementById('stream-error');
if (!name) {
showToast(t('streams.error.required'), 'error');
return;
}
const payload = { name, description: description || null };
if (!streamId) {
// Creating - include stream_type
payload.stream_type = streamType;
}
if (streamType === 'raw') {
payload.display_index = parseInt(document.getElementById('stream-display-index').value) || 0;
payload.capture_template_id = document.getElementById('stream-capture-template').value;
payload.target_fps = parseInt(document.getElementById('stream-target-fps').value) || 30;
} else if (streamType === 'processed') {
payload.source_stream_id = document.getElementById('stream-source').value;
payload.postprocessing_template_id = document.getElementById('stream-pp-template').value;
} else if (streamType === 'static_image') {
const imageSource = document.getElementById('stream-image-source').value.trim();
if (!imageSource) {
showToast(t('streams.error.required'), 'error');
return;
}
payload.image_source = imageSource;
}
try {
let response;
if (streamId) {
response = await fetchWithAuth(`/picture-sources/${streamId}`, {
method: 'PUT',
body: JSON.stringify(payload)
});
} else {
response = await fetchWithAuth('/picture-sources', {
method: 'POST',
body: JSON.stringify(payload)
});
}
if (!response.ok) {
const error = await response.json();
throw new Error(error.detail || error.message || 'Failed to save stream');
}
showToast(streamId ? t('streams.updated') : t('streams.created'), 'success');
closeStreamModal();
await loadPictureSources();
} catch (error) {
console.error('Error saving stream:', error);
errorEl.textContent = error.message;
errorEl.style.display = 'block';
}
}
async function deleteStream(streamId) {
const confirmed = await showConfirm(t('streams.delete.confirm'));
if (!confirmed) return;
try {
const response = await fetchWithAuth(`/picture-sources/${streamId}`, {
method: 'DELETE'
});
if (!response.ok) {
const error = await response.json();
throw new Error(error.detail || error.message || 'Failed to delete stream');
}
showToast(t('streams.deleted'), 'success');
await loadPictureSources();
} catch (error) {
console.error('Error deleting stream:', error);
showToast(t('streams.error.delete') + ': ' + error.message, 'error');
}
}
function closeStreamModal() {
document.getElementById('stream-modal').style.display = 'none';
document.getElementById('stream-type').disabled = false;
unlockBody();
}
let _lastValidatedImageSource = '';
async function validateStaticImage() {
const source = document.getElementById('stream-image-source').value.trim();
const previewContainer = document.getElementById('stream-image-preview-container');
const previewImg = document.getElementById('stream-image-preview');
const infoEl = document.getElementById('stream-image-info');
const statusEl = document.getElementById('stream-image-validation-status');
if (!source) {
_lastValidatedImageSource = '';
previewContainer.style.display = 'none';
statusEl.style.display = 'none';
return;
}
if (source === _lastValidatedImageSource) return;
// Show loading state
statusEl.textContent = t('streams.validate_image.validating');
statusEl.className = 'validation-status loading';
statusEl.style.display = 'block';
previewContainer.style.display = 'none';
try {
const response = await fetchWithAuth('/picture-sources/validate-image', {
method: 'POST',
body: JSON.stringify({ image_source: source }),
});
const data = await response.json();
_lastValidatedImageSource = source;
if (data.valid) {
previewImg.src = data.preview;
previewImg.style.cursor = 'pointer';
previewImg.onclick = () => openFullImageLightbox(source);
infoEl.textContent = `${data.width} × ${data.height} px`;
previewContainer.style.display = '';
statusEl.textContent = t('streams.validate_image.valid');
statusEl.className = 'validation-status success';
} else {
previewContainer.style.display = 'none';
statusEl.textContent = `${t('streams.validate_image.invalid')}: ${data.error}`;
statusEl.className = 'validation-status error';
}
} catch (err) {
previewContainer.style.display = 'none';
statusEl.textContent = `${t('streams.validate_image.invalid')}: ${err.message}`;
statusEl.className = 'validation-status error';
}
}
// ===== Picture Source Test =====
let _currentTestStreamId = null;
async function showTestStreamModal(streamId) {
_currentTestStreamId = streamId;
restoreStreamTestDuration();
const modal = document.getElementById('test-stream-modal');
modal.style.display = 'flex';
lockBody();
setupBackdropClose(modal, closeTestStreamModal);
}
function closeTestStreamModal() {
document.getElementById('test-stream-modal').style.display = 'none';
unlockBody();
_currentTestStreamId = null;
}
function updateStreamTestDuration(value) {
document.getElementById('test-stream-duration-value').textContent = value;
localStorage.setItem('lastStreamTestDuration', value);
}
function restoreStreamTestDuration() {
const saved = localStorage.getItem('lastStreamTestDuration') || '5';
document.getElementById('test-stream-duration').value = saved;
document.getElementById('test-stream-duration-value').textContent = saved;
}
async function runStreamTest() {
if (!_currentTestStreamId) return;
const captureDuration = parseFloat(document.getElementById('test-stream-duration').value);
showOverlaySpinner(t('streams.test.running'), captureDuration);
try {
const response = await fetchWithAuth(`/picture-sources/${_currentTestStreamId}/test`, {
method: 'POST',
body: JSON.stringify({ capture_duration: captureDuration })
});
if (!response.ok) {
const error = await response.json();
throw new Error(error.detail || error.message || 'Test failed');
}
const result = await response.json();
displayStreamTestResults(result);
} catch (error) {
console.error('Error running stream test:', error);
hideOverlaySpinner();
showToast(t('streams.test.error.failed') + ': ' + error.message, 'error');
}
}
function displayStreamTestResults(result) {
hideOverlaySpinner();
const fullImageSrc = result.full_capture.full_image || result.full_capture.image;
openLightbox(fullImageSrc, buildTestStatsHtml(result));
}
// ===== PP Template Test =====
let _currentTestPPTemplateId = null;
async function showTestPPTemplateModal(templateId) {
_currentTestPPTemplateId = templateId;
restorePPTestDuration();
// Populate source stream selector
const select = document.getElementById('test-pp-source-stream');
select.innerHTML = '';
// Ensure streams are cached
if (_cachedStreams.length === 0) {
try {
const resp = await fetchWithAuth('/picture-sources');
if (resp.ok) { const d = await resp.json(); _cachedStreams = d.streams || []; }
} catch (e) { console.warn('Could not load streams for PP test:', e); }
}
for (const s of _cachedStreams) {
const opt = document.createElement('option');
opt.value = s.id;
opt.textContent = s.name;
select.appendChild(opt);
}
// Auto-select last used stream
const lastStream = localStorage.getItem('lastPPTestStreamId');
if (lastStream && _cachedStreams.find(s => s.id === lastStream)) {
select.value = lastStream;
}
const modal = document.getElementById('test-pp-template-modal');
modal.style.display = 'flex';
lockBody();
setupBackdropClose(modal, closeTestPPTemplateModal);
}
function closeTestPPTemplateModal() {
document.getElementById('test-pp-template-modal').style.display = 'none';
unlockBody();
_currentTestPPTemplateId = null;
}
function updatePPTestDuration(value) {
document.getElementById('test-pp-duration-value').textContent = value;
localStorage.setItem('lastPPTestDuration', value);
}
function restorePPTestDuration() {
const saved = localStorage.getItem('lastPPTestDuration') || '5';
document.getElementById('test-pp-duration').value = saved;
document.getElementById('test-pp-duration-value').textContent = saved;
}
async function runPPTemplateTest() {
if (!_currentTestPPTemplateId) return;
const sourceStreamId = document.getElementById('test-pp-source-stream').value;
if (!sourceStreamId) {
showToast(t('postprocessing.test.error.no_stream'), 'error');
return;
}
localStorage.setItem('lastPPTestStreamId', sourceStreamId);
const captureDuration = parseFloat(document.getElementById('test-pp-duration').value);
showOverlaySpinner(t('postprocessing.test.running'), captureDuration);
try {
const response = await fetchWithAuth(`/postprocessing-templates/${_currentTestPPTemplateId}/test`, {
method: 'POST',
body: JSON.stringify({ source_stream_id: sourceStreamId, capture_duration: captureDuration })
});
if (!response.ok) {
const error = await response.json();
throw new Error(error.detail || error.message || 'Test failed');
}
const result = await response.json();
hideOverlaySpinner();
const fullImageSrc = result.full_capture.full_image || result.full_capture.image;
openLightbox(fullImageSrc, buildTestStatsHtml(result));
} catch (error) {
console.error('Error running PP template test:', error);
hideOverlaySpinner();
showToast(t('postprocessing.test.error.failed') + ': ' + error.message, 'error');
}
}
// ===== Processing Templates =====
async function loadAvailableFilters() {
try {
const response = await fetchWithAuth('/filters');
if (!response.ok) throw new Error(`Failed to load filters: ${response.status}`);
const data = await response.json();
_availableFilters = data.filters || [];
} catch (error) {
console.error('Error loading available filters:', error);
_availableFilters = [];
}
}
async function loadPPTemplates() {
try {
// Ensure filters are loaded for rendering
if (_availableFilters.length === 0) {
await loadAvailableFilters();
}
const response = await fetchWithAuth('/postprocessing-templates');
if (!response.ok) {
throw new Error(`Failed to load templates: ${response.status}`);
}
const data = await response.json();
_cachedPPTemplates = data.templates || [];
// Re-render the streams tab which now contains template sections
renderPictureSourcesList(_cachedStreams);
} catch (error) {
console.error('Error loading PP templates:', error);
}
}
function _getFilterName(filterId) {
const key = 'filters.' + filterId;
const translated = t(key);
// Fallback to filter_name from registry if no localization
if (translated === key) {
const def = _availableFilters.find(f => f.filter_id === filterId);
return def ? def.filter_name : filterId;
}
return translated;
}
// --- Filter list management in PP template modal ---
let _modalFilters = []; // Current filter list being edited in modal
function _populateFilterSelect() {
const select = document.getElementById('pp-add-filter-select');
// Keep first option (placeholder)
select.innerHTML = `<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}`;
if (opt.type === 'bool') {
const checked = currentVal === true || currentVal === 'true';
html += `<div class="pp-filter-option pp-filter-option-bool">
<label for="${inputId}">
<span>${escapeHtml(opt.label)}</span>
<input type="checkbox" id="${inputId}" ${checked ? 'checked' : ''}
onchange="updateFilterOption(${index}, '${opt.key}', this.checked)">
</label>
</div>`;
} else {
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();
_autoGeneratePPTemplateName();
}
function toggleFilterExpand(index) {
if (_modalFilters[index]) {
_modalFilters[index]._expanded = !_modalFilters[index]._expanded;
renderModalFilterList();
}
}
function removeFilter(index) {
_modalFilters.splice(index, 1);
renderModalFilterList();
_autoGeneratePPTemplateName();
}
function moveFilter(index, direction) {
const newIndex = index + direction;
if (newIndex < 0 || newIndex >= _modalFilters.length) return;
const tmp = _modalFilters[index];
_modalFilters[index] = _modalFilters[newIndex];
_modalFilters[newIndex] = tmp;
renderModalFilterList();
_autoGeneratePPTemplateName();
}
function updateFilterOption(filterIndex, optionKey, value) {
if (_modalFilters[filterIndex]) {
// Determine type from schema
const fi = _modalFilters[filterIndex];
const filterDef = _availableFilters.find(f => f.filter_id === fi.filter_id);
if (filterDef) {
const optDef = filterDef.options_schema.find(o => o.key === optionKey);
if (optDef && optDef.type === 'bool') {
fi.options[optionKey] = !!value;
} else if (optDef && optDef.type === 'int') {
fi.options[optionKey] = parseInt(value);
} else {
fi.options[optionKey] = parseFloat(value);
}
} else {
fi.options[optionKey] = parseFloat(value);
}
}
}
function collectFilters() {
return _modalFilters.map(fi => ({
filter_id: fi.filter_id,
options: { ...fi.options },
}));
}
let _ppTemplateNameManuallyEdited = false;
function _autoGeneratePPTemplateName() {
if (_ppTemplateNameManuallyEdited) return;
if (document.getElementById('pp-template-id').value) return; // editing, not creating
const nameInput = document.getElementById('pp-template-name');
if (_modalFilters.length > 0) {
const filterNames = _modalFilters.map(f => _getFilterName(f.filter_id)).join(' + ');
nameInput.value = filterNames;
} else {
nameInput.value = '';
}
}
async function showAddPPTemplateModal() {
if (_availableFilters.length === 0) await loadAvailableFilters();
document.getElementById('pp-template-modal-title').textContent = t('postprocessing.add');
document.getElementById('pp-template-form').reset();
document.getElementById('pp-template-id').value = '';
document.getElementById('pp-template-error').style.display = 'none';
_modalFilters = [];
// Auto-name: reset flag and wire listener
_ppTemplateNameManuallyEdited = false;
document.getElementById('pp-template-name').oninput = () => { _ppTemplateNameManuallyEdited = true; };
_populateFilterSelect();
renderModalFilterList();
const modal = document.getElementById('pp-template-modal');
modal.style.display = 'flex';
lockBody();
setupBackdropClose(modal, closePPTemplateModal);
}
async function editPPTemplate(templateId) {
try {
if (_availableFilters.length === 0) await loadAvailableFilters();
const response = await fetchWithAuth(`/postprocessing-templates/${templateId}`);
if (!response.ok) throw new Error(`Failed to load template: ${response.status}`);
const tmpl = await response.json();
document.getElementById('pp-template-modal-title').textContent = t('postprocessing.edit');
document.getElementById('pp-template-id').value = templateId;
document.getElementById('pp-template-name').value = tmpl.name;
document.getElementById('pp-template-description').value = tmpl.description || '';
document.getElementById('pp-template-error').style.display = 'none';
// Load filters from template
_modalFilters = (tmpl.filters || []).map(fi => ({
filter_id: fi.filter_id,
options: { ...fi.options },
}));
_populateFilterSelect();
renderModalFilterList();
const modal = document.getElementById('pp-template-modal');
modal.style.display = 'flex';
lockBody();
setupBackdropClose(modal, closePPTemplateModal);
} catch (error) {
console.error('Error loading PP template:', error);
showToast(t('postprocessing.error.load') + ': ' + error.message, 'error');
}
}
async function savePPTemplate() {
const templateId = document.getElementById('pp-template-id').value;
const name = document.getElementById('pp-template-name').value.trim();
const description = document.getElementById('pp-template-description').value.trim();
const errorEl = document.getElementById('pp-template-error');
if (!name) {
showToast(t('postprocessing.error.required'), 'error');
return;
}
const payload = {
name,
filters: collectFilters(),
description: description || null,
};
try {
let response;
if (templateId) {
response = await fetchWithAuth(`/postprocessing-templates/${templateId}`, {
method: 'PUT',
body: JSON.stringify(payload)
});
} else {
response = await fetchWithAuth('/postprocessing-templates', {
method: 'POST',
body: JSON.stringify(payload)
});
}
if (!response.ok) {
const error = await response.json();
throw new Error(error.detail || error.message || 'Failed to save template');
}
showToast(templateId ? t('postprocessing.updated') : t('postprocessing.created'), 'success');
closePPTemplateModal();
await loadPPTemplates();
} catch (error) {
console.error('Error saving PP template:', error);
errorEl.textContent = error.message;
errorEl.style.display = 'block';
}
}
async function deletePPTemplate(templateId) {
const confirmed = await showConfirm(t('postprocessing.delete.confirm'));
if (!confirmed) return;
try {
const response = await fetchWithAuth(`/postprocessing-templates/${templateId}`, {
method: 'DELETE'
});
if (!response.ok) {
const error = await response.json();
throw new Error(error.detail || error.message || 'Failed to delete template');
}
showToast(t('postprocessing.deleted'), 'success');
await loadPPTemplates();
} catch (error) {
console.error('Error deleting PP template:', error);
showToast(t('postprocessing.error.delete') + ': ' + error.message, 'error');
}
}
function closePPTemplateModal() {
document.getElementById('pp-template-modal').style.display = 'none';
_modalFilters = [];
unlockBody();
}
// ===== TARGET EDITOR MODAL =====
let targetEditorInitialValues = {};
async function showTargetEditor(targetId = null) {
try {
// Load devices and sources for dropdowns
const [devicesResp, sourcesResp] = await Promise.all([
fetch(`${API_BASE}/devices`, { headers: getHeaders() }),
fetchWithAuth('/picture-sources'),
]);
const devices = devicesResp.ok ? (await devicesResp.json()).devices || [] : [];
const sources = sourcesResp.ok ? (await sourcesResp.json()).streams || [] : [];
// Populate device select
const deviceSelect = document.getElementById('target-editor-device');
deviceSelect.innerHTML = '';
devices.forEach(d => {
const opt = document.createElement('option');
opt.value = d.id;
const shortUrl = d.url ? d.url.replace(/^https?:\/\//, '') : '';
opt.textContent = `${d.name}${shortUrl ? ' (' + shortUrl + ')' : ''}`;
deviceSelect.appendChild(opt);
});
// Populate source select
const sourceSelect = document.getElementById('target-editor-source');
sourceSelect.innerHTML = '<option value="">-- No source --</option>';
sources.forEach(s => {
const opt = document.createElement('option');
opt.value = s.id;
const typeIcon = s.stream_type === 'raw' ? '🖥️' : s.stream_type === 'static_image' ? '🖼️' : '🎨';
opt.textContent = `${typeIcon} ${s.name}`;
sourceSelect.appendChild(opt);
});
if (targetId) {
// Editing existing target
const resp = await fetch(`${API_BASE}/picture-targets/${targetId}`, { headers: getHeaders() });
if (!resp.ok) throw new Error('Failed to load target');
const target = await resp.json();
document.getElementById('target-editor-id').value = target.id;
document.getElementById('target-editor-name').value = target.name;
deviceSelect.value = target.device_id || '';
sourceSelect.value = target.picture_source_id || '';
document.getElementById('target-editor-border-width').value = target.settings?.border_width ?? 10;
document.getElementById('target-editor-interpolation').value = target.settings?.interpolation_mode ?? 'average';
document.getElementById('target-editor-smoothing').value = target.settings?.smoothing ?? 0.3;
document.getElementById('target-editor-smoothing-value').textContent = target.settings?.smoothing ?? 0.3;
document.getElementById('target-editor-title').textContent = t('targets.edit');
} else {
// Creating new target
document.getElementById('target-editor-id').value = '';
document.getElementById('target-editor-name').value = '';
deviceSelect.value = '';
sourceSelect.value = '';
document.getElementById('target-editor-border-width').value = 10;
document.getElementById('target-editor-interpolation').value = 'average';
document.getElementById('target-editor-smoothing').value = 0.3;
document.getElementById('target-editor-smoothing-value').textContent = '0.3';
document.getElementById('target-editor-title').textContent = t('targets.add');
}
targetEditorInitialValues = {
name: document.getElementById('target-editor-name').value,
device: deviceSelect.value,
source: sourceSelect.value,
border_width: document.getElementById('target-editor-border-width').value,
interpolation: document.getElementById('target-editor-interpolation').value,
smoothing: document.getElementById('target-editor-smoothing').value,
};
const modal = document.getElementById('target-editor-modal');
modal.style.display = 'flex';
lockBody();
setupBackdropClose(modal, closeTargetEditorModal);
document.getElementById('target-editor-error').style.display = 'none';
setTimeout(() => document.getElementById('target-editor-name').focus(), 100);
} catch (error) {
console.error('Failed to open target editor:', error);
showToast('Failed to open target editor', 'error');
}
}
function isTargetEditorDirty() {
return (
document.getElementById('target-editor-name').value !== targetEditorInitialValues.name ||
document.getElementById('target-editor-device').value !== targetEditorInitialValues.device ||
document.getElementById('target-editor-source').value !== targetEditorInitialValues.source ||
document.getElementById('target-editor-border-width').value !== targetEditorInitialValues.border_width ||
document.getElementById('target-editor-interpolation').value !== targetEditorInitialValues.interpolation ||
document.getElementById('target-editor-smoothing').value !== targetEditorInitialValues.smoothing
);
}
async function closeTargetEditorModal() {
if (isTargetEditorDirty()) {
const confirmed = await showConfirm(t('modal.discard_changes'));
if (!confirmed) return;
}
forceCloseTargetEditorModal();
}
function forceCloseTargetEditorModal() {
document.getElementById('target-editor-modal').style.display = 'none';
document.getElementById('target-editor-error').style.display = 'none';
unlockBody();
targetEditorInitialValues = {};
}
async function saveTargetEditor() {
const targetId = document.getElementById('target-editor-id').value;
const name = document.getElementById('target-editor-name').value.trim();
const deviceId = document.getElementById('target-editor-device').value;
const sourceId = document.getElementById('target-editor-source').value;
const borderWidth = parseInt(document.getElementById('target-editor-border-width').value) || 10;
const interpolation = document.getElementById('target-editor-interpolation').value;
const smoothing = parseFloat(document.getElementById('target-editor-smoothing').value);
const errorEl = document.getElementById('target-editor-error');
if (!name) {
errorEl.textContent = t('targets.error.name_required');
errorEl.style.display = 'block';
return;
}
const payload = {
name,
device_id: deviceId,
picture_source_id: sourceId,
settings: {
border_width: borderWidth,
interpolation_mode: interpolation,
smoothing: smoothing,
},
};
try {
let response;
if (targetId) {
response = await fetch(`${API_BASE}/picture-targets/${targetId}`, {
method: 'PUT',
headers: getHeaders(),
body: JSON.stringify(payload),
});
} else {
payload.target_type = 'wled';
response = await fetch(`${API_BASE}/picture-targets`, {
method: 'POST',
headers: getHeaders(),
body: JSON.stringify(payload),
});
}
if (response.status === 401) { handle401Error(); return; }
if (!response.ok) {
const err = await response.json();
throw new Error(err.detail || 'Failed to save');
}
showToast(targetId ? t('targets.updated') : t('targets.created'), 'success');
forceCloseTargetEditorModal();
await loadTargets();
} catch (error) {
console.error('Error saving target:', error);
errorEl.textContent = error.message;
errorEl.style.display = 'block';
}
}
// ===== TARGETS TAB (WLED devices + targets combined) =====
async function loadTargets() {
// Alias for backward compatibility
await loadTargetsTab();
}
function switchTargetSubTab(tabKey) {
document.querySelectorAll('.target-sub-tab-btn').forEach(btn =>
btn.classList.toggle('active', btn.dataset.targetSubTab === tabKey)
);
document.querySelectorAll('.target-sub-tab-panel').forEach(panel =>
panel.classList.toggle('active', panel.id === `target-sub-tab-${tabKey}`)
);
localStorage.setItem('activeTargetSubTab', tabKey);
}
async function loadTargetsTab() {
const container = document.getElementById('targets-panel-content');
if (!container) return;
try {
// Fetch devices, targets, sources, and pattern templates in parallel
const [devicesResp, targetsResp, sourcesResp, patResp] = await Promise.all([
fetch(`${API_BASE}/devices`, { headers: getHeaders() }),
fetch(`${API_BASE}/picture-targets`, { headers: getHeaders() }),
fetchWithAuth('/picture-sources').catch(() => null),
fetchWithAuth('/pattern-templates').catch(() => null),
]);
if (devicesResp.status === 401 || targetsResp.status === 401) {
handle401Error();
return;
}
const devicesData = await devicesResp.json();
const devices = devicesData.devices || [];
const targetsData = await targetsResp.json();
const targets = targetsData.targets || [];
let sourceMap = {};
if (sourcesResp && sourcesResp.ok) {
const srcData = await sourcesResp.json();
(srcData.streams || []).forEach(s => { sourceMap[s.id] = s; });
}
let patternTemplates = [];
let patternTemplateMap = {};
if (patResp && patResp.ok) {
const patData = await patResp.json();
patternTemplates = patData.templates || [];
patternTemplates.forEach(pt => { patternTemplateMap[pt.id] = pt; });
}
// Fetch state for each device
const devicesWithState = await Promise.all(
devices.map(async (device) => {
try {
const stateResp = await fetch(`${API_BASE}/devices/${device.id}/state`, { headers: getHeaders() });
const state = stateResp.ok ? await stateResp.json() : {};
return { ...device, state };
} catch {
return device;
}
})
);
// Fetch state + metrics for each target (+ colors for KC targets)
const targetsWithState = await Promise.all(
targets.map(async (target) => {
try {
const stateResp = await fetch(`${API_BASE}/picture-targets/${target.id}/state`, { headers: getHeaders() });
const state = stateResp.ok ? await stateResp.json() : {};
const metricsResp = await fetch(`${API_BASE}/picture-targets/${target.id}/metrics`, { headers: getHeaders() });
const metrics = metricsResp.ok ? await metricsResp.json() : {};
let latestColors = null;
if (target.target_type === 'key_colors' && state.processing) {
try {
const colorsResp = await fetch(`${API_BASE}/picture-targets/${target.id}/colors`, { headers: getHeaders() });
if (colorsResp.ok) latestColors = await colorsResp.json();
} catch {}
}
return { ...target, state, metrics, latestColors };
} catch {
return target;
}
})
);
// Build device map for target name resolution
const deviceMap = {};
devicesWithState.forEach(d => { deviceMap[d.id] = d; });
// Group by type
const wledDevices = devicesWithState;
const wledTargets = targetsWithState.filter(t => t.target_type === 'wled');
const kcTargets = targetsWithState.filter(t => t.target_type === 'key_colors');
const activeSubTab = localStorage.getItem('activeTargetSubTab') || 'wled';
const subTabs = [
{ key: 'wled', icon: '💡', titleKey: 'targets.subtab.wled', count: wledDevices.length + wledTargets.length },
{ key: 'key_colors', icon: '🎨', titleKey: 'targets.subtab.key_colors', count: kcTargets.length + patternTemplates.length },
];
const tabBar = `<div class="stream-tab-bar">${subTabs.map(tab =>
`<button class="target-sub-tab-btn stream-tab-btn${tab.key === activeSubTab ? ' active' : ''}" data-target-sub-tab="${tab.key}" onclick="switchTargetSubTab('${tab.key}')">${tab.icon} ${t(tab.titleKey)} <span class="stream-tab-count">${tab.count}</span></button>`
).join('')}</div>`;
// WLED panel: devices section + targets section
const wledPanel = `
<div class="target-sub-tab-panel stream-tab-panel${activeSubTab === 'wled' ? ' active' : ''}" id="target-sub-tab-wled">
<div class="subtab-section">
<h3 class="subtab-section-header">${t('targets.section.devices')}</h3>
<div class="devices-grid">
${wledDevices.map(device => createDeviceCard(device)).join('')}
<div class="template-card add-template-card" onclick="showAddDevice()">
<div class="add-template-icon">+</div>
</div>
</div>
</div>
<div class="subtab-section">
<h3 class="subtab-section-header">${t('targets.section.targets')}</h3>
<div class="devices-grid">
${wledTargets.map(target => createTargetCard(target, deviceMap, sourceMap)).join('')}
<div class="template-card add-template-card" onclick="showTargetEditor()">
<div class="add-template-icon">+</div>
</div>
</div>
</div>
</div>`;
// Key Colors panel
const kcPanel = `
<div class="target-sub-tab-panel stream-tab-panel${activeSubTab === 'key_colors' ? ' active' : ''}" id="target-sub-tab-key_colors">
<div class="subtab-section">
<h3 class="subtab-section-header">${t('targets.section.key_colors')}</h3>
<div class="devices-grid">
${kcTargets.map(target => createKCTargetCard(target, sourceMap, patternTemplateMap)).join('')}
<div class="template-card add-template-card" onclick="showKCEditor()">
<div class="add-template-icon">+</div>
</div>
</div>
</div>
<div class="subtab-section">
<h3 class="subtab-section-header">${t('targets.section.pattern_templates')}</h3>
<div class="templates-grid">
${patternTemplates.map(pt => createPatternTemplateCard(pt)).join('')}
<div class="template-card add-template-card" onclick="showPatternTemplateEditor()">
<div class="add-template-icon">+</div>
</div>
</div>
</div>
</div>`;
container.innerHTML = tabBar + wledPanel + kcPanel;
// Attach event listeners and fetch WLED brightness for device cards
devicesWithState.forEach(device => {
attachDeviceListeners(device.id);
fetchDeviceBrightness(device.id);
});
// Manage KC WebSockets: connect for processing, disconnect for stopped
const processingKCIds = new Set();
kcTargets.forEach(target => {
if (target.state && target.state.processing) {
processingKCIds.add(target.id);
if (!kcWebSockets[target.id]) {
connectKCWebSocket(target.id);
}
}
});
// Disconnect WebSockets for targets no longer processing
Object.keys(kcWebSockets).forEach(id => {
if (!processingKCIds.has(id)) disconnectKCWebSocket(id);
});
} catch (error) {
console.error('Failed to load targets tab:', error);
container.innerHTML = `<div class="loading">${t('targets.failed')}</div>`;
}
}
function createTargetCard(target, deviceMap, sourceMap) {
const state = target.state || {};
const metrics = target.metrics || {};
const settings = target.settings || {};
const isProcessing = state.processing || false;
const device = deviceMap[target.device_id];
const source = sourceMap[target.picture_source_id];
const deviceName = device ? device.name : (target.device_id || 'No device');
const sourceName = source ? source.name : (target.picture_source_id || 'No source');
// Health info from target state (forwarded from device)
const wledOnline = state.wled_online || false;
let healthClass = 'health-unknown';
let healthTitle = '';
if (state.wled_last_checked !== null && state.wled_last_checked !== undefined) {
healthClass = wledOnline ? 'health-online' : 'health-offline';
healthTitle = wledOnline ? t('device.health.online') : t('device.health.offline');
}
return `
<div class="card" data-target-id="${target.id}">
<button class="card-remove-btn" onclick="deleteTarget('${target.id}')" title="${t('common.delete')}">&#x2715;</button>
<div class="card-header">
<div class="card-title">
<span class="health-dot ${healthClass}" title="${healthTitle}"></span>
${escapeHtml(target.name)}
<span class="badge">${target.target_type.toUpperCase()}</span>
${isProcessing ? `<span class="badge processing">${t('device.status.processing')}</span>` : ''}
</div>
</div>
<div class="stream-card-props">
<span class="stream-card-prop" title="${t('targets.device')}">💡 ${escapeHtml(deviceName)}</span>
<span class="stream-card-prop stream-card-prop-full" title="${t('targets.source')}">📺 ${escapeHtml(sourceName)}</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="card-actions">
${isProcessing ? `
<button class="btn btn-icon btn-danger" onclick="stopTargetProcessing('${target.id}')" title="${t('device.button.stop')}">
⏹️
</button>
` : `
<button class="btn btn-icon btn-primary" onclick="startTargetProcessing('${target.id}')" title="${t('device.button.start')}">
▶️
</button>
`}
<button class="btn btn-icon btn-secondary" onclick="showTargetEditor('${target.id}')" title="${t('common.edit')}">
✏️
</button>
</div>
</div>
`;
}
async function startTargetProcessing(targetId) {
try {
const response = await fetch(`${API_BASE}/picture-targets/${targetId}/start`, {
method: 'POST',
headers: getHeaders()
});
if (response.status === 401) { handle401Error(); return; }
if (response.ok) {
showToast(t('device.started'), 'success');
loadTargets();
} else {
const error = await response.json();
showToast(`Failed to start: ${error.detail}`, 'error');
}
} catch (error) {
showToast('Failed to start processing', 'error');
}
}
async function stopTargetProcessing(targetId) {
try {
const response = await fetch(`${API_BASE}/picture-targets/${targetId}/stop`, {
method: 'POST',
headers: getHeaders()
});
if (response.status === 401) { handle401Error(); return; }
if (response.ok) {
showToast(t('device.stopped'), 'success');
loadTargets();
} else {
const error = await response.json();
showToast(`Failed to stop: ${error.detail}`, 'error');
}
} catch (error) {
showToast('Failed to stop processing', 'error');
}
}
async function deleteTarget(targetId) {
const confirmed = await showConfirm(t('targets.delete.confirm'));
if (!confirmed) return;
try {
const response = await fetch(`${API_BASE}/picture-targets/${targetId}`, {
method: 'DELETE',
headers: getHeaders()
});
if (response.status === 401) { handle401Error(); return; }
if (response.ok) {
showToast(t('targets.deleted'), 'success');
loadTargets();
} else {
const error = await response.json();
showToast(`Failed to delete: ${error.detail}`, 'error');
}
} catch (error) {
showToast('Failed to delete target', 'error');
}
}
// ===== KEY COLORS TARGET CARD =====
function createKCTargetCard(target, sourceMap, patternTemplateMap) {
const state = target.state || {};
const metrics = target.metrics || {};
const kcSettings = target.key_colors_settings || {};
const isProcessing = state.processing || false;
const source = sourceMap[target.picture_source_id];
const sourceName = source ? source.name : (target.picture_source_id || 'No source');
const patTmpl = patternTemplateMap[kcSettings.pattern_template_id];
const patternName = patTmpl ? patTmpl.name : 'No pattern';
const rectCount = patTmpl ? (patTmpl.rectangles || []).length : 0;
// Render initial color swatches from pre-fetched REST data
let swatchesHtml = '';
const latestColors = target.latestColors && target.latestColors.colors;
if (isProcessing && latestColors && Object.keys(latestColors).length > 0) {
swatchesHtml = Object.entries(latestColors).map(([name, color]) => `
<div class="kc-swatch">
<div class="kc-swatch-color" style="background-color: ${color.hex}" title="${color.hex}"></div>
<span class="kc-swatch-label" title="${escapeHtml(name)}">${escapeHtml(name)}</span>
</div>
`).join('');
} else if (isProcessing) {
swatchesHtml = `<span class="kc-no-colors">${t('kc.colors.none')}</span>`;
}
return `
<div class="card" data-kc-target-id="${target.id}">
<button class="card-remove-btn" onclick="deleteKCTarget('${target.id}')" title="${t('common.delete')}">&#x2715;</button>
<div class="card-header">
<div class="card-title">
${escapeHtml(target.name)}
<span class="badge">KEY COLORS</span>
${isProcessing ? `<span class="badge processing">${t('targets.status.processing')}</span>` : ''}
</div>
</div>
<div class="stream-card-props">
<span class="stream-card-prop" title="${t('kc.source')}">📺 ${escapeHtml(sourceName)}</span>
<span class="stream-card-prop" title="${t('kc.pattern_template')}">📄 ${escapeHtml(patternName)}</span>
<span class="stream-card-prop">▭ ${rectCount} rect${rectCount !== 1 ? 's' : ''}</span>
</div>
<div class="card-content">
<div id="kc-swatches-${target.id}" class="kc-color-swatches">
${swatchesHtml}
</div>
${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('targets.metrics.actual_fps')}</div>
</div>
<div class="metric">
<div class="metric-value">${state.fps_target || 0}</div>
<div class="metric-label">${t('targets.metrics.target_fps')}</div>
</div>
<div class="metric">
<div class="metric-value">${metrics.frames_processed || 0}</div>
<div class="metric-label">${t('targets.metrics.frames')}</div>
</div>
<div class="metric">
<div class="metric-value">${metrics.errors_count || 0}</div>
<div class="metric-label">${t('targets.metrics.errors')}</div>
</div>
</div>
` : ''}
</div>
<div class="card-actions">
${isProcessing ? `
<button class="btn btn-icon btn-danger" onclick="stopTargetProcessing('${target.id}')" title="${t('targets.button.stop')}">
⏹️
</button>
` : `
<button class="btn btn-icon btn-primary" onclick="startTargetProcessing('${target.id}')" title="${t('targets.button.start')}">
▶️
</button>
`}
<button class="btn btn-icon btn-secondary" onclick="showKCEditor('${target.id}')" title="${t('common.edit')}">
✏️
</button>
</div>
</div>
`;
}
// ===== KEY COLORS EDITOR =====
let kcEditorInitialValues = {};
let _kcNameManuallyEdited = false;
function _autoGenerateKCName() {
if (_kcNameManuallyEdited) return;
if (document.getElementById('kc-editor-id').value) return;
const sourceSelect = document.getElementById('kc-editor-source');
const sourceName = sourceSelect.selectedOptions[0]?.dataset?.name || '';
if (!sourceName) return;
const mode = document.getElementById('kc-editor-interpolation').value || 'average';
const modeName = t(`kc.interpolation.${mode}`);
const patSelect = document.getElementById('kc-editor-pattern-template');
const patName = patSelect.selectedOptions[0]?.dataset?.name || '';
document.getElementById('kc-editor-name').value = `${sourceName} · ${patName} (${modeName})`;
}
async function showKCEditor(targetId = null) {
try {
// Load sources and pattern templates in parallel
const [sourcesResp, patResp] = await Promise.all([
fetchWithAuth('/picture-sources').catch(() => null),
fetchWithAuth('/pattern-templates').catch(() => null),
]);
const sources = (sourcesResp && sourcesResp.ok) ? (await sourcesResp.json()).streams || [] : [];
const patTemplates = (patResp && patResp.ok) ? (await patResp.json()).templates || [] : [];
// Populate source select
const sourceSelect = document.getElementById('kc-editor-source');
sourceSelect.innerHTML = '';
sources.forEach(s => {
const opt = document.createElement('option');
opt.value = s.id;
opt.dataset.name = s.name;
const typeIcon = s.stream_type === 'raw' ? '🖥️' : s.stream_type === 'static_image' ? '🖼️' : '🎨';
opt.textContent = `${typeIcon} ${s.name}`;
sourceSelect.appendChild(opt);
});
// Populate pattern template select
const patSelect = document.getElementById('kc-editor-pattern-template');
patSelect.innerHTML = '';
patTemplates.forEach(pt => {
const opt = document.createElement('option');
opt.value = pt.id;
opt.dataset.name = pt.name;
const rectCount = (pt.rectangles || []).length;
opt.textContent = `${pt.name} (${rectCount} rect${rectCount !== 1 ? 's' : ''})`;
patSelect.appendChild(opt);
});
if (targetId) {
const resp = await fetch(`${API_BASE}/picture-targets/${targetId}`, { headers: getHeaders() });
if (!resp.ok) throw new Error('Failed to load target');
const target = await resp.json();
const kcSettings = target.key_colors_settings || {};
document.getElementById('kc-editor-id').value = target.id;
document.getElementById('kc-editor-name').value = target.name;
sourceSelect.value = target.picture_source_id || '';
document.getElementById('kc-editor-fps').value = kcSettings.fps ?? 10;
document.getElementById('kc-editor-fps-value').textContent = kcSettings.fps ?? 10;
document.getElementById('kc-editor-interpolation').value = kcSettings.interpolation_mode ?? 'average';
document.getElementById('kc-editor-smoothing').value = kcSettings.smoothing ?? 0.3;
document.getElementById('kc-editor-smoothing-value').textContent = kcSettings.smoothing ?? 0.3;
patSelect.value = kcSettings.pattern_template_id || '';
document.getElementById('kc-editor-title').textContent = t('kc.edit');
} else {
document.getElementById('kc-editor-id').value = '';
document.getElementById('kc-editor-name').value = '';
if (sourceSelect.options.length > 0) sourceSelect.selectedIndex = 0;
document.getElementById('kc-editor-fps').value = 10;
document.getElementById('kc-editor-fps-value').textContent = '10';
document.getElementById('kc-editor-interpolation').value = 'average';
document.getElementById('kc-editor-smoothing').value = 0.3;
document.getElementById('kc-editor-smoothing-value').textContent = '0.3';
if (patTemplates.length > 0) patSelect.value = patTemplates[0].id;
document.getElementById('kc-editor-title').textContent = t('kc.add');
}
// Auto-name
_kcNameManuallyEdited = !!targetId;
document.getElementById('kc-editor-name').oninput = () => { _kcNameManuallyEdited = true; };
sourceSelect.onchange = () => _autoGenerateKCName();
document.getElementById('kc-editor-interpolation').onchange = () => _autoGenerateKCName();
patSelect.onchange = () => _autoGenerateKCName();
if (!targetId) _autoGenerateKCName();
kcEditorInitialValues = {
name: document.getElementById('kc-editor-name').value,
source: sourceSelect.value,
fps: document.getElementById('kc-editor-fps').value,
interpolation: document.getElementById('kc-editor-interpolation').value,
smoothing: document.getElementById('kc-editor-smoothing').value,
patternTemplateId: patSelect.value,
};
const modal = document.getElementById('kc-editor-modal');
modal.style.display = 'flex';
lockBody();
setupBackdropClose(modal, closeKCEditorModal);
document.getElementById('kc-editor-error').style.display = 'none';
setTimeout(() => document.getElementById('kc-editor-name').focus(), 100);
} catch (error) {
console.error('Failed to open KC editor:', error);
showToast('Failed to open key colors editor', 'error');
}
}
function isKCEditorDirty() {
return (
document.getElementById('kc-editor-name').value !== kcEditorInitialValues.name ||
document.getElementById('kc-editor-source').value !== kcEditorInitialValues.source ||
document.getElementById('kc-editor-fps').value !== kcEditorInitialValues.fps ||
document.getElementById('kc-editor-interpolation').value !== kcEditorInitialValues.interpolation ||
document.getElementById('kc-editor-smoothing').value !== kcEditorInitialValues.smoothing ||
document.getElementById('kc-editor-pattern-template').value !== kcEditorInitialValues.patternTemplateId
);
}
async function closeKCEditorModal() {
if (isKCEditorDirty()) {
const confirmed = await showConfirm(t('modal.discard_changes'));
if (!confirmed) return;
}
forceCloseKCEditorModal();
}
function forceCloseKCEditorModal() {
document.getElementById('kc-editor-modal').style.display = 'none';
document.getElementById('kc-editor-error').style.display = 'none';
unlockBody();
kcEditorInitialValues = {};
}
async function saveKCEditor() {
const targetId = document.getElementById('kc-editor-id').value;
const name = document.getElementById('kc-editor-name').value.trim();
const sourceId = document.getElementById('kc-editor-source').value;
const fps = parseInt(document.getElementById('kc-editor-fps').value) || 10;
const interpolation = document.getElementById('kc-editor-interpolation').value;
const smoothing = parseFloat(document.getElementById('kc-editor-smoothing').value);
const patternTemplateId = document.getElementById('kc-editor-pattern-template').value;
const errorEl = document.getElementById('kc-editor-error');
if (!name) {
errorEl.textContent = t('kc.error.required');
errorEl.style.display = 'block';
return;
}
if (!patternTemplateId) {
errorEl.textContent = t('kc.error.no_pattern');
errorEl.style.display = 'block';
return;
}
const payload = {
name,
picture_source_id: sourceId,
key_colors_settings: {
fps,
interpolation_mode: interpolation,
smoothing,
pattern_template_id: patternTemplateId,
},
};
try {
let response;
if (targetId) {
response = await fetch(`${API_BASE}/picture-targets/${targetId}`, {
method: 'PUT',
headers: getHeaders(),
body: JSON.stringify(payload),
});
} else {
payload.target_type = 'key_colors';
response = await fetch(`${API_BASE}/picture-targets`, {
method: 'POST',
headers: getHeaders(),
body: JSON.stringify(payload),
});
}
if (response.status === 401) { handle401Error(); return; }
if (!response.ok) {
const err = await response.json();
throw new Error(err.detail || 'Failed to save');
}
showToast(targetId ? t('kc.updated') : t('kc.created'), 'success');
forceCloseKCEditorModal();
await loadTargets();
} catch (error) {
console.error('Error saving KC target:', error);
errorEl.textContent = error.message;
errorEl.style.display = 'block';
}
}
async function deleteKCTarget(targetId) {
const confirmed = await showConfirm(t('kc.delete.confirm'));
if (!confirmed) return;
try {
disconnectKCWebSocket(targetId);
const response = await fetch(`${API_BASE}/picture-targets/${targetId}`, {
method: 'DELETE',
headers: getHeaders()
});
if (response.status === 401) { handle401Error(); return; }
if (response.ok) {
showToast(t('kc.deleted'), 'success');
loadTargets();
} else {
const error = await response.json();
showToast(`Failed to delete: ${error.detail}`, 'error');
}
} catch (error) {
showToast('Failed to delete key colors target', 'error');
}
}
// ===== KEY COLORS WEBSOCKET =====
const kcWebSockets = {};
function connectKCWebSocket(targetId) {
// Disconnect existing connection if any
disconnectKCWebSocket(targetId);
const key = localStorage.getItem('wled_api_key');
if (!key) return;
const protocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:';
const wsUrl = `${protocol}//${window.location.host}${API_BASE}/picture-targets/${targetId}/ws?token=${encodeURIComponent(key)}`;
try {
const ws = new WebSocket(wsUrl);
ws.onmessage = (event) => {
try {
const data = JSON.parse(event.data);
updateKCColorSwatches(targetId, data.colors || {});
} catch (e) {
console.error('Failed to parse KC WebSocket message:', e);
}
};
ws.onclose = () => {
delete kcWebSockets[targetId];
};
ws.onerror = (error) => {
console.error(`KC WebSocket error for ${targetId}:`, error);
};
kcWebSockets[targetId] = ws;
} catch (error) {
console.error(`Failed to connect KC WebSocket for ${targetId}:`, error);
}
}
function disconnectKCWebSocket(targetId) {
const ws = kcWebSockets[targetId];
if (ws) {
ws.close();
delete kcWebSockets[targetId];
}
}
function disconnectAllKCWebSockets() {
Object.keys(kcWebSockets).forEach(targetId => disconnectKCWebSocket(targetId));
}
function updateKCColorSwatches(targetId, colors) {
const container = document.getElementById(`kc-swatches-${targetId}`);
if (!container) return;
const entries = Object.entries(colors);
if (entries.length === 0) {
container.innerHTML = `<span class="kc-no-colors">${t('kc.colors.none')}</span>`;
return;
}
container.innerHTML = entries.map(([name, color]) => `
<div class="kc-swatch">
<div class="kc-swatch-color" style="background-color: ${color.hex}" title="${color.hex}"></div>
<span class="kc-swatch-label" title="${escapeHtml(name)}">${escapeHtml(name)}</span>
</div>
`).join('');
}
// ===== PATTERN TEMPLATES =====
function createPatternTemplateCard(pt) {
const rectCount = (pt.rectangles || []).length;
const desc = pt.description ? `<div class="template-description">${escapeHtml(pt.description)}</div>` : '';
return `
<div class="template-card" data-pattern-template-id="${pt.id}">
<button class="card-remove-btn" onclick="deletePatternTemplate('${pt.id}')" title="${t('common.delete')}">&#x2715;</button>
<div class="template-card-header">
<span class="template-name">📄 ${escapeHtml(pt.name)}</span>
</div>
${desc}
<div class="stream-card-props">
<span class="stream-card-prop">▭ ${rectCount} rect${rectCount !== 1 ? 's' : ''}</span>
</div>
<div class="template-card-actions">
<button class="btn btn-icon btn-secondary" onclick="showPatternTemplateEditor('${pt.id}')" title="${t('common.edit')}">✏️</button>
</div>
</div>
`;
}
// ----- Pattern Template Editor state -----
let patternEditorRects = [];
let patternEditorSelectedIdx = -1;
let patternEditorBgImage = null;
let patternEditorInitialValues = {};
let patternCanvasDragMode = null;
let patternCanvasDragStart = null;
let patternCanvasDragOrigRect = null;
let patternEditorHoveredIdx = -1;
let patternEditorHoverHit = null; // 'move', 'n', 's', 'e', 'w', 'nw', etc.
const PATTERN_RECT_COLORS = [
'rgba(76,175,80,0.35)', 'rgba(33,150,243,0.35)', 'rgba(255,152,0,0.35)',
'rgba(156,39,176,0.35)', 'rgba(0,188,212,0.35)', 'rgba(244,67,54,0.35)',
'rgba(255,235,59,0.35)', 'rgba(121,85,72,0.35)',
];
const PATTERN_RECT_BORDERS = [
'#4CAF50', '#2196F3', '#FF9800', '#9C27B0', '#00BCD4', '#F44336', '#FFEB3B', '#795548',
];
async function showPatternTemplateEditor(templateId = null) {
try {
// Load sources for background capture
const sourcesResp = await fetchWithAuth('/picture-sources').catch(() => null);
const sources = (sourcesResp && sourcesResp.ok) ? (await sourcesResp.json()).streams || [] : [];
const bgSelect = document.getElementById('pattern-bg-source');
bgSelect.innerHTML = '';
sources.forEach(s => {
const opt = document.createElement('option');
opt.value = s.id;
const typeIcon = s.stream_type === 'raw' ? '🖥️' : s.stream_type === 'static_image' ? '🖼️' : '🎨';
opt.textContent = `${typeIcon} ${s.name}`;
bgSelect.appendChild(opt);
});
patternEditorBgImage = null;
patternEditorSelectedIdx = -1;
patternCanvasDragMode = null;
if (templateId) {
const resp = await fetch(`${API_BASE}/pattern-templates/${templateId}`, { headers: getHeaders() });
if (!resp.ok) throw new Error('Failed to load pattern template');
const tmpl = await resp.json();
document.getElementById('pattern-template-id').value = tmpl.id;
document.getElementById('pattern-template-name').value = tmpl.name;
document.getElementById('pattern-template-description').value = tmpl.description || '';
document.getElementById('pattern-template-title').textContent = t('pattern.edit');
patternEditorRects = (tmpl.rectangles || []).map(r => ({ ...r }));
} else {
document.getElementById('pattern-template-id').value = '';
document.getElementById('pattern-template-name').value = '';
document.getElementById('pattern-template-description').value = '';
document.getElementById('pattern-template-title').textContent = t('pattern.add');
patternEditorRects = [];
}
patternEditorInitialValues = {
name: document.getElementById('pattern-template-name').value,
description: document.getElementById('pattern-template-description').value,
rectangles: JSON.stringify(patternEditorRects),
};
renderPatternRectList();
renderPatternCanvas();
_attachPatternCanvasEvents();
const modal = document.getElementById('pattern-template-modal');
modal.style.display = 'flex';
lockBody();
setupBackdropClose(modal, closePatternTemplateModal);
document.getElementById('pattern-template-error').style.display = 'none';
setTimeout(() => document.getElementById('pattern-template-name').focus(), 100);
} catch (error) {
console.error('Failed to open pattern template editor:', error);
showToast('Failed to open pattern template editor', 'error');
}
}
function isPatternEditorDirty() {
return (
document.getElementById('pattern-template-name').value !== patternEditorInitialValues.name ||
document.getElementById('pattern-template-description').value !== patternEditorInitialValues.description ||
JSON.stringify(patternEditorRects) !== patternEditorInitialValues.rectangles
);
}
async function closePatternTemplateModal() {
if (isPatternEditorDirty()) {
const confirmed = await showConfirm(t('modal.discard_changes'));
if (!confirmed) return;
}
forceClosePatternTemplateModal();
}
function forceClosePatternTemplateModal() {
document.getElementById('pattern-template-modal').style.display = 'none';
document.getElementById('pattern-template-error').style.display = 'none';
unlockBody();
patternEditorRects = [];
patternEditorSelectedIdx = -1;
patternEditorBgImage = null;
patternEditorInitialValues = {};
}
async function savePatternTemplate() {
const templateId = document.getElementById('pattern-template-id').value;
const name = document.getElementById('pattern-template-name').value.trim();
const description = document.getElementById('pattern-template-description').value.trim();
const errorEl = document.getElementById('pattern-template-error');
if (!name) {
errorEl.textContent = t('pattern.error.required');
errorEl.style.display = 'block';
return;
}
const payload = {
name,
rectangles: patternEditorRects.map(r => ({
name: r.name, x: r.x, y: r.y, width: r.width, height: r.height,
})),
description: description || null,
};
try {
let response;
if (templateId) {
response = await fetch(`${API_BASE}/pattern-templates/${templateId}`, {
method: 'PUT', headers: getHeaders(), body: JSON.stringify(payload),
});
} else {
response = await fetch(`${API_BASE}/pattern-templates`, {
method: 'POST', headers: getHeaders(), body: JSON.stringify(payload),
});
}
if (response.status === 401) { handle401Error(); return; }
if (!response.ok) {
const err = await response.json();
throw new Error(err.detail || 'Failed to save');
}
showToast(templateId ? t('pattern.updated') : t('pattern.created'), 'success');
forceClosePatternTemplateModal();
await loadTargets();
} catch (error) {
console.error('Error saving pattern template:', error);
errorEl.textContent = error.message;
errorEl.style.display = 'block';
}
}
async function deletePatternTemplate(templateId) {
const confirmed = await showConfirm(t('pattern.delete.confirm'));
if (!confirmed) return;
try {
const response = await fetch(`${API_BASE}/pattern-templates/${templateId}`, {
method: 'DELETE', headers: getHeaders(),
});
if (response.status === 401) { handle401Error(); return; }
if (response.status === 409) {
showToast(t('pattern.delete.referenced'), 'error');
return;
}
if (response.ok) {
showToast(t('pattern.deleted'), 'success');
loadTargets();
} else {
const error = await response.json();
showToast(`Failed to delete: ${error.detail}`, 'error');
}
} catch (error) {
showToast('Failed to delete pattern template', 'error');
}
}
// ----- Pattern rect list (precise coordinate inputs) -----
function renderPatternRectList() {
const container = document.getElementById('pattern-rect-list');
if (!container) return;
if (patternEditorRects.length === 0) {
container.innerHTML = `<div class="kc-rect-empty">${t('pattern.rect.empty')}</div>`;
return;
}
container.innerHTML = patternEditorRects.map((rect, i) => `
<div class="pattern-rect-row${i === patternEditorSelectedIdx ? ' selected' : ''}" onclick="selectPatternRect(${i})">
<input type="text" value="${escapeHtml(rect.name)}" placeholder="${t('pattern.rect.name')}" onchange="updatePatternRect(${i}, 'name', this.value)" onclick="event.stopPropagation()">
<input type="number" value="${rect.x.toFixed(2)}" min="0" max="1" step="0.01" onchange="updatePatternRect(${i}, 'x', parseFloat(this.value)||0)" onclick="event.stopPropagation()">
<input type="number" value="${rect.y.toFixed(2)}" min="0" max="1" step="0.01" onchange="updatePatternRect(${i}, 'y', parseFloat(this.value)||0)" onclick="event.stopPropagation()">
<input type="number" value="${rect.width.toFixed(2)}" min="0.01" max="1" step="0.01" onchange="updatePatternRect(${i}, 'width', parseFloat(this.value)||0.01)" onclick="event.stopPropagation()">
<input type="number" value="${rect.height.toFixed(2)}" min="0.01" max="1" step="0.01" onchange="updatePatternRect(${i}, 'height', parseFloat(this.value)||0.01)" onclick="event.stopPropagation()">
<button type="button" class="pattern-rect-remove-btn" onclick="event.stopPropagation(); removePatternRect(${i})" title="${t('pattern.rect.remove')}">&#x2715;</button>
</div>
`).join('');
}
function selectPatternRect(index) {
patternEditorSelectedIdx = (patternEditorSelectedIdx === index) ? -1 : index;
renderPatternRectList();
renderPatternCanvas();
}
function updatePatternRect(index, field, value) {
if (index < 0 || index >= patternEditorRects.length) return;
patternEditorRects[index][field] = value;
// Clamp coordinates
if (field !== 'name') {
const r = patternEditorRects[index];
r.x = Math.max(0, Math.min(1 - r.width, r.x));
r.y = Math.max(0, Math.min(1 - r.height, r.y));
r.width = Math.max(0.01, Math.min(1, r.width));
r.height = Math.max(0.01, Math.min(1, r.height));
}
renderPatternCanvas();
}
function addPatternRect() {
const name = `Zone ${patternEditorRects.length + 1}`;
// Inherit size from selected rect, or default to 30%
let w = 0.3, h = 0.3;
if (patternEditorSelectedIdx >= 0 && patternEditorSelectedIdx < patternEditorRects.length) {
const sel = patternEditorRects[patternEditorSelectedIdx];
w = sel.width;
h = sel.height;
}
const x = Math.min(0.5 - w / 2, 1 - w);
const y = Math.min(0.5 - h / 2, 1 - h);
patternEditorRects.push({ name, x: Math.max(0, x), y: Math.max(0, y), width: w, height: h });
patternEditorSelectedIdx = patternEditorRects.length - 1;
renderPatternRectList();
renderPatternCanvas();
}
function deleteSelectedPatternRect() {
if (patternEditorSelectedIdx < 0 || patternEditorSelectedIdx >= patternEditorRects.length) return;
patternEditorRects.splice(patternEditorSelectedIdx, 1);
patternEditorSelectedIdx = -1;
renderPatternRectList();
renderPatternCanvas();
}
function removePatternRect(index) {
patternEditorRects.splice(index, 1);
if (patternEditorSelectedIdx === index) patternEditorSelectedIdx = -1;
else if (patternEditorSelectedIdx > index) patternEditorSelectedIdx--;
renderPatternRectList();
renderPatternCanvas();
}
// ----- Pattern Canvas Visual Editor -----
function renderPatternCanvas() {
const canvas = document.getElementById('pattern-canvas');
if (!canvas) return;
const ctx = canvas.getContext('2d');
const w = canvas.width;
const h = canvas.height;
// Clear
ctx.clearRect(0, 0, w, h);
// Draw background image or grid
if (patternEditorBgImage) {
ctx.drawImage(patternEditorBgImage, 0, 0, w, h);
} else {
// Draw subtle grid — spacing adapts to canvas size
ctx.fillStyle = 'rgba(128,128,128,0.05)';
ctx.fillRect(0, 0, w, h);
ctx.strokeStyle = 'rgba(128,128,128,0.15)';
ctx.lineWidth = 1;
const dpr = window.devicePixelRatio || 1;
const gridStep = 80 * dpr; // ~80 CSS pixels between grid lines
const colsCount = Math.max(2, Math.round(w / gridStep));
const rowsCount = Math.max(2, Math.round(h / gridStep));
for (let gx = 0; gx <= colsCount; gx++) {
const x = Math.round(gx * w / colsCount) + 0.5;
ctx.beginPath(); ctx.moveTo(x, 0); ctx.lineTo(x, h); ctx.stroke();
}
for (let gy = 0; gy <= rowsCount; gy++) {
const y = Math.round(gy * h / rowsCount) + 0.5;
ctx.beginPath(); ctx.moveTo(0, y); ctx.lineTo(w, y); ctx.stroke();
}
}
// Draw rectangles
const dpr = window.devicePixelRatio || 1;
patternEditorRects.forEach((rect, i) => {
const rx = rect.x * w;
const ry = rect.y * h;
const rw = rect.width * w;
const rh = rect.height * h;
const colorIdx = i % PATTERN_RECT_COLORS.length;
const isSelected = (i === patternEditorSelectedIdx);
const isHovered = (i === patternEditorHoveredIdx) && !patternCanvasDragMode;
const isDragging = (i === patternEditorSelectedIdx) && !!patternCanvasDragMode;
// Fill — brighter when hovered or being dragged
ctx.fillStyle = PATTERN_RECT_COLORS[colorIdx];
ctx.fillRect(rx, ry, rw, rh);
if (isHovered || isDragging) {
ctx.fillStyle = 'rgba(255,255,255,0.08)';
ctx.fillRect(rx, ry, rw, rh);
}
// Border
ctx.strokeStyle = PATTERN_RECT_BORDERS[colorIdx];
ctx.lineWidth = isSelected ? 3 : isHovered ? 2.5 : 1.5;
ctx.strokeRect(rx, ry, rw, rh);
// Determine edge direction to highlight (during drag or hover)
let edgeDir = null;
if (isDragging && patternCanvasDragMode.startsWith('resize-')) {
edgeDir = patternCanvasDragMode.replace('resize-', '');
} else if (isHovered && patternEditorHoverHit && patternEditorHoverHit !== 'move') {
edgeDir = patternEditorHoverHit;
}
// Draw highlighted edge/corner indicator
if (edgeDir) {
ctx.save();
ctx.strokeStyle = '#fff';
ctx.lineWidth = 3 * dpr;
ctx.shadowColor = 'rgba(76,175,80,0.7)';
ctx.shadowBlur = 6 * dpr;
ctx.beginPath();
if (edgeDir.includes('n')) { ctx.moveTo(rx, ry); ctx.lineTo(rx + rw, ry); }
if (edgeDir.includes('s')) { ctx.moveTo(rx, ry + rh); ctx.lineTo(rx + rw, ry + rh); }
if (edgeDir.includes('w')) { ctx.moveTo(rx, ry); ctx.lineTo(rx, ry + rh); }
if (edgeDir.includes('e')) { ctx.moveTo(rx + rw, ry); ctx.lineTo(rx + rw, ry + rh); }
ctx.stroke();
ctx.restore();
}
// Name label
ctx.fillStyle = '#fff';
ctx.font = `${12 * dpr}px sans-serif`;
ctx.shadowColor = 'rgba(0,0,0,0.7)';
ctx.shadowBlur = 3;
ctx.fillText(rect.name, rx + 4 * dpr, ry + 14 * dpr);
ctx.shadowBlur = 0;
// Delete button on hovered or selected rects (not during drag)
if ((isHovered || isSelected) && !patternCanvasDragMode) {
const btnR = 9 * dpr;
const btnCx = rx + rw - btnR - 2 * dpr;
const btnCy = ry + btnR + 2 * dpr;
ctx.beginPath();
ctx.arc(btnCx, btnCy, btnR, 0, Math.PI * 2);
ctx.fillStyle = 'rgba(0,0,0,0.6)';
ctx.fill();
ctx.strokeStyle = 'rgba(255,255,255,0.5)';
ctx.lineWidth = 1;
ctx.stroke();
const cross = btnR * 0.5;
ctx.beginPath();
ctx.moveTo(btnCx - cross, btnCy - cross);
ctx.lineTo(btnCx + cross, btnCy + cross);
ctx.moveTo(btnCx + cross, btnCy - cross);
ctx.lineTo(btnCx - cross, btnCy + cross);
ctx.strokeStyle = '#fff';
ctx.lineWidth = 1.5 * dpr;
ctx.stroke();
}
});
// Draw "add rectangle" placement buttons (4 corners + center) when not dragging
if (!patternCanvasDragMode) {
const abR = 12 * dpr;
const abMargin = 18 * dpr;
const addBtnPositions = [
{ cx: abMargin, cy: abMargin }, // top-left
{ cx: w - abMargin, cy: abMargin }, // top-right
{ cx: w / 2, cy: h / 2 }, // center
{ cx: abMargin, cy: h - abMargin }, // bottom-left
{ cx: w - abMargin, cy: h - abMargin }, // bottom-right
];
addBtnPositions.forEach(pos => {
ctx.beginPath();
ctx.arc(pos.cx, pos.cy, abR, 0, Math.PI * 2);
ctx.fillStyle = 'rgba(255,255,255,0.10)';
ctx.fill();
ctx.strokeStyle = 'rgba(255,255,255,0.25)';
ctx.lineWidth = 1;
ctx.stroke();
const pl = abR * 0.5;
ctx.beginPath();
ctx.moveTo(pos.cx - pl, pos.cy);
ctx.lineTo(pos.cx + pl, pos.cy);
ctx.moveTo(pos.cx, pos.cy - pl);
ctx.lineTo(pos.cx, pos.cy + pl);
ctx.strokeStyle = 'rgba(255,255,255,0.5)';
ctx.lineWidth = 1.5 * dpr;
ctx.stroke();
});
}
}
// Placement button positions (relative 0-1 coords for where the new rect is anchored)
const _ADD_BTN_ANCHORS = [
{ ax: 0, ay: 0 }, // top-left: rect starts at (0,0)
{ ax: 1, ay: 0 }, // top-right: rect ends at (1,0)
{ ax: 0.5, ay: 0.5 }, // center
{ ax: 0, ay: 1 }, // bottom-left: rect starts at (0, 1-h)
{ ax: 1, ay: 1 }, // bottom-right: rect ends at (1,1)
];
function _hitTestAddButtons(mx, my, w, h) {
const dpr = window.devicePixelRatio || 1;
const abR = 12 * dpr;
const abMargin = 18 * dpr;
const positions = [
{ cx: abMargin, cy: abMargin },
{ cx: w - abMargin, cy: abMargin },
{ cx: w / 2, cy: h / 2 },
{ cx: abMargin, cy: h - abMargin },
{ cx: w - abMargin, cy: h - abMargin },
];
for (let i = 0; i < positions.length; i++) {
const dx = mx - positions[i].cx, dy = my - positions[i].cy;
if (dx * dx + dy * dy <= (abR + 3 * dpr) * (abR + 3 * dpr)) return i;
}
return -1;
}
function _addRectAtAnchor(anchorIdx) {
const anchor = _ADD_BTN_ANCHORS[anchorIdx];
const name = `Zone ${patternEditorRects.length + 1}`;
let rw = 0.3, rh = 0.3;
if (patternEditorSelectedIdx >= 0 && patternEditorSelectedIdx < patternEditorRects.length) {
const sel = patternEditorRects[patternEditorSelectedIdx];
rw = sel.width;
rh = sel.height;
}
// Position based on anchor point
let rx = anchor.ax - rw * anchor.ax; // 0→0, 0.5→centered, 1→1-rw
let ry = anchor.ay - rh * anchor.ay;
rx = Math.max(0, Math.min(1 - rw, rx));
ry = Math.max(0, Math.min(1 - rh, ry));
patternEditorRects.push({ name, x: rx, y: ry, width: rw, height: rh });
patternEditorSelectedIdx = patternEditorRects.length - 1;
renderPatternRectList();
renderPatternCanvas();
}
// Hit-test a point against a rect's edges/corners. Returns a resize direction
// string ('n','s','e','w','nw','ne','sw','se') or 'move' if inside, or null.
const _EDGE_THRESHOLD = 8; // pixels (in canvas coords)
function _hitTestRect(mx, my, r, w, h) {
const rx = r.x * w, ry = r.y * h, rw = r.width * w, rh = r.height * h;
const dpr = window.devicePixelRatio || 1;
const thr = _EDGE_THRESHOLD * dpr;
const nearLeft = Math.abs(mx - rx) <= thr;
const nearRight = Math.abs(mx - (rx + rw)) <= thr;
const nearTop = Math.abs(my - ry) <= thr;
const nearBottom = Math.abs(my - (ry + rh)) <= thr;
const inHRange = mx >= rx - thr && mx <= rx + rw + thr;
const inVRange = my >= ry - thr && my <= ry + rh + thr;
// Corners first (both edges near)
if (nearTop && nearLeft && inHRange && inVRange) return 'nw';
if (nearTop && nearRight && inHRange && inVRange) return 'ne';
if (nearBottom && nearLeft && inHRange && inVRange) return 'sw';
if (nearBottom && nearRight && inHRange && inVRange) return 'se';
// Edges
if (nearTop && inHRange) return 'n';
if (nearBottom && inHRange) return 's';
if (nearLeft && inVRange) return 'w';
if (nearRight && inVRange) return 'e';
// Interior (move)
if (mx >= rx && mx <= rx + rw && my >= ry && my <= ry + rh) return 'move';
return null;
}
const _DIR_CURSORS = {
'nw': 'nwse-resize', 'se': 'nwse-resize',
'ne': 'nesw-resize', 'sw': 'nesw-resize',
'n': 'ns-resize', 's': 'ns-resize',
'e': 'ew-resize', 'w': 'ew-resize',
'move': 'grab',
};
function _hitTestDeleteButton(mx, my, rect, w, h) {
const dpr = window.devicePixelRatio || 1;
const btnR = 9 * dpr;
const rx = rect.x * w, ry = rect.y * h, rw = rect.width * w;
const btnCx = rx + rw - btnR - 2 * dpr;
const btnCy = ry + btnR + 2 * dpr;
const dx = mx - btnCx, dy = my - btnCy;
return (dx * dx + dy * dy) <= (btnR + 2 * dpr) * (btnR + 2 * dpr);
}
function _patternCanvasDragMove(e) {
if (!patternCanvasDragMode || patternEditorSelectedIdx < 0) return;
const canvas = document.getElementById('pattern-canvas');
const w = canvas.width;
const h = canvas.height;
const canvasRect = canvas.getBoundingClientRect();
const scaleX = w / canvasRect.width;
const scaleY = h / canvasRect.height;
const mx = (e.clientX - canvasRect.left) * scaleX;
const my = (e.clientY - canvasRect.top) * scaleY;
const dx = (mx - patternCanvasDragStart.mx) / w;
const dy = (my - patternCanvasDragStart.my) / h;
const orig = patternCanvasDragOrigRect;
const r = patternEditorRects[patternEditorSelectedIdx];
if (patternCanvasDragMode === 'move') {
r.x = Math.max(0, Math.min(1 - r.width, orig.x + dx));
r.y = Math.max(0, Math.min(1 - r.height, orig.y + dy));
} else if (patternCanvasDragMode.startsWith('resize-')) {
const dir = patternCanvasDragMode.replace('resize-', '');
let nx = orig.x, ny = orig.y, nw = orig.width, nh = orig.height;
if (dir.includes('w')) { nx = orig.x + dx; nw = orig.width - dx; }
if (dir.includes('e')) { nw = orig.width + dx; }
if (dir.includes('n')) { ny = orig.y + dy; nh = orig.height - dy; }
if (dir.includes('s')) { nh = orig.height + dy; }
if (nw < 0.02) { nw = 0.02; if (dir.includes('w')) nx = orig.x + orig.width - 0.02; }
if (nh < 0.02) { nh = 0.02; if (dir.includes('n')) ny = orig.y + orig.height - 0.02; }
nx = Math.max(0, Math.min(1 - nw, nx));
ny = Math.max(0, Math.min(1 - nh, ny));
nw = Math.min(1, nw);
nh = Math.min(1, nh);
r.x = nx; r.y = ny; r.width = nw; r.height = nh;
}
renderPatternCanvas();
}
function _patternCanvasDragEnd(e) {
window.removeEventListener('mousemove', _patternCanvasDragMove);
window.removeEventListener('mouseup', _patternCanvasDragEnd);
patternCanvasDragMode = null;
patternCanvasDragStart = null;
patternCanvasDragOrigRect = null;
// Recalculate hover at current mouse position
const canvas = document.getElementById('pattern-canvas');
if (canvas) {
const w = canvas.width;
const h = canvas.height;
const canvasRect = canvas.getBoundingClientRect();
const scaleX = w / canvasRect.width;
const scaleY = h / canvasRect.height;
const mx = (e.clientX - canvasRect.left) * scaleX;
const my = (e.clientY - canvasRect.top) * scaleY;
let cursor = 'default';
let newHoverIdx = -1;
let newHoverHit = null;
if (e.clientX >= canvasRect.left && e.clientX <= canvasRect.right &&
e.clientY >= canvasRect.top && e.clientY <= canvasRect.bottom) {
for (let i = patternEditorRects.length - 1; i >= 0; i--) {
const hit = _hitTestRect(mx, my, patternEditorRects[i], w, h);
if (hit) {
cursor = _DIR_CURSORS[hit] || 'default';
newHoverIdx = i;
newHoverHit = hit;
break;
}
}
}
canvas.style.cursor = cursor;
patternEditorHoveredIdx = newHoverIdx;
patternEditorHoverHit = newHoverHit;
}
renderPatternRectList();
renderPatternCanvas();
}
function _attachPatternCanvasEvents() {
const canvas = document.getElementById('pattern-canvas');
if (!canvas || canvas._patternEventsAttached) return;
canvas._patternEventsAttached = true;
canvas.addEventListener('mousedown', _patternCanvasMouseDown);
canvas.addEventListener('mousemove', _patternCanvasMouseMove);
canvas.addEventListener('mouseleave', _patternCanvasMouseLeave);
// Touch support
canvas.addEventListener('touchstart', (e) => {
e.preventDefault();
const touch = e.touches[0];
_patternCanvasMouseDown(_touchToMouseEvent(canvas, touch, 'mousedown'));
}, { passive: false });
canvas.addEventListener('touchmove', (e) => {
e.preventDefault();
const touch = e.touches[0];
if (patternCanvasDragMode) {
_patternCanvasDragMove({ clientX: touch.clientX, clientY: touch.clientY });
} else {
_patternCanvasMouseMove(_touchToMouseEvent(canvas, touch, 'mousemove'));
}
}, { passive: false });
canvas.addEventListener('touchend', () => {
if (patternCanvasDragMode) {
window.removeEventListener('mousemove', _patternCanvasDragMove);
window.removeEventListener('mouseup', _patternCanvasDragEnd);
patternCanvasDragMode = null;
patternCanvasDragStart = null;
patternCanvasDragOrigRect = null;
patternEditorHoveredIdx = -1;
patternEditorHoverHit = null;
renderPatternRectList();
renderPatternCanvas();
}
});
// Resize observer — update canvas internal resolution when container is resized
const container = canvas.parentElement;
if (container && typeof ResizeObserver !== 'undefined') {
const ro = new ResizeObserver(() => {
const rect = container.getBoundingClientRect();
const dpr = window.devicePixelRatio || 1;
canvas.width = Math.round(rect.width * dpr);
canvas.height = Math.round(rect.height * dpr);
renderPatternCanvas();
});
ro.observe(container);
canvas._patternResizeObserver = ro;
}
}
function _touchToMouseEvent(canvas, touch, type) {
const rect = canvas.getBoundingClientRect();
return { type, offsetX: touch.clientX - rect.left, offsetY: touch.clientY - rect.top, preventDefault: () => {} };
}
function _patternCanvasMouseDown(e) {
const canvas = document.getElementById('pattern-canvas');
const w = canvas.width;
const h = canvas.height;
const rect = canvas.getBoundingClientRect();
const scaleX = w / rect.width;
const scaleY = h / rect.height;
const mx = (e.offsetX !== undefined ? e.offsetX : e.clientX - rect.left) * scaleX;
const my = (e.offsetY !== undefined ? e.offsetY : e.clientY - rect.top) * scaleY;
// Check delete button on hovered or selected rects first
for (const idx of [patternEditorHoveredIdx, patternEditorSelectedIdx]) {
if (idx >= 0 && idx < patternEditorRects.length) {
if (_hitTestDeleteButton(mx, my, patternEditorRects[idx], w, h)) {
patternEditorRects.splice(idx, 1);
if (patternEditorSelectedIdx === idx) patternEditorSelectedIdx = -1;
else if (patternEditorSelectedIdx > idx) patternEditorSelectedIdx--;
patternEditorHoveredIdx = -1;
patternEditorHoverHit = null;
renderPatternRectList();
renderPatternCanvas();
return;
}
}
}
// Test all rects in reverse order (top-most first).
for (let i = patternEditorRects.length - 1; i >= 0; i--) {
const r = patternEditorRects[i];
const hit = _hitTestRect(mx, my, r, w, h);
if (!hit) continue;
patternEditorSelectedIdx = i;
patternCanvasDragStart = { mx, my };
patternCanvasDragOrigRect = { ...r };
if (hit === 'move') {
patternCanvasDragMode = 'move';
canvas.style.cursor = 'grabbing';
} else {
patternCanvasDragMode = `resize-${hit}`;
canvas.style.cursor = _DIR_CURSORS[hit] || 'default';
}
// Capture mouse at window level for drag
window.addEventListener('mousemove', _patternCanvasDragMove);
window.addEventListener('mouseup', _patternCanvasDragEnd);
e.preventDefault();
renderPatternRectList();
renderPatternCanvas();
return;
}
// Check placement "+" buttons (corners + center)
const addIdx = _hitTestAddButtons(mx, my, w, h);
if (addIdx >= 0) {
_addRectAtAnchor(addIdx);
return;
}
// Click on empty space — deselect
patternEditorSelectedIdx = -1;
patternCanvasDragMode = null;
canvas.style.cursor = 'default';
renderPatternRectList();
renderPatternCanvas();
}
function _patternCanvasMouseMove(e) {
// During drag, movement is handled by window-level _patternCanvasDragMove
if (patternCanvasDragMode) return;
const canvas = document.getElementById('pattern-canvas');
const w = canvas.width;
const h = canvas.height;
const rect = canvas.getBoundingClientRect();
const scaleX = w / rect.width;
const scaleY = h / rect.height;
const mx = (e.offsetX !== undefined ? e.offsetX : e.clientX - rect.left) * scaleX;
const my = (e.offsetY !== undefined ? e.offsetY : e.clientY - rect.top) * scaleY;
let cursor = 'default';
let newHoverIdx = -1;
let newHoverHit = null;
for (let i = patternEditorRects.length - 1; i >= 0; i--) {
const hit = _hitTestRect(mx, my, patternEditorRects[i], w, h);
if (hit) {
cursor = _DIR_CURSORS[hit] || 'default';
newHoverIdx = i;
newHoverHit = hit;
break;
}
}
canvas.style.cursor = cursor;
if (newHoverIdx !== patternEditorHoveredIdx || newHoverHit !== patternEditorHoverHit) {
patternEditorHoveredIdx = newHoverIdx;
patternEditorHoverHit = newHoverHit;
renderPatternCanvas();
}
}
function _patternCanvasMouseLeave() {
// During drag, window-level listeners handle everything
if (patternCanvasDragMode) return;
if (patternEditorHoveredIdx !== -1) {
patternEditorHoveredIdx = -1;
patternEditorHoverHit = null;
renderPatternCanvas();
}
}
async function capturePatternBackground() {
const sourceId = document.getElementById('pattern-bg-source').value;
if (!sourceId) {
showToast(t('pattern.source_for_bg.none'), 'error');
return;
}
try {
const resp = await fetch(`${API_BASE}/picture-sources/${sourceId}/test`, {
method: 'POST',
headers: getHeaders(),
body: JSON.stringify({ capture_duration: 0 }),
});
if (!resp.ok) throw new Error('Failed to capture');
const data = await resp.json();
if (data.full_capture && data.full_capture.full_image) {
const img = new Image();
img.onload = () => {
patternEditorBgImage = img;
renderPatternCanvas();
};
img.src = data.full_capture.full_image;
}
} catch (error) {
console.error('Failed to capture background:', error);
showToast('Failed to capture background', 'error');
}
}