- Separate CaptureEngine into stateless factory + stateful CaptureStream session - Add LiveStream/LiveStreamManager for shared capture with reference counting - Rename PictureStream to PictureSource across storage, API, and UI - Remove legacy migration logic and unused compatibility code - Split monolithic routes.py (1935 lines) into 5 focused route modules - Split schemas.py (480 lines) into 7 schema modules with re-exports - Extract dependency injection into dedicated dependencies.py Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
4389 lines
168 KiB
JavaScript
4389 lines
168 KiB
JavaScript
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: 'capture-settings-modal', close: forceCloseCaptureSettingsModal },
|
||
{ id: 'calibration-modal', close: forceCloseCalibrationModal },
|
||
{ id: 'stream-selector-modal', close: forceCloseStreamSelectorModal },
|
||
{ id: 'add-device-modal', close: closeAddDeviceModal },
|
||
];
|
||
for (const m of modals) {
|
||
const el = document.getElementById(m.id);
|
||
if (el && el.style.display === 'flex') {
|
||
m.close();
|
||
break;
|
||
}
|
||
}
|
||
}
|
||
}
|
||
});
|
||
|
||
// Display picker lightbox
|
||
let _displayPickerCallback = null;
|
||
let _displayPickerSelectedIndex = null;
|
||
|
||
function openDisplayPicker(callback, selectedIndex) {
|
||
_displayPickerCallback = callback;
|
||
_displayPickerSelectedIndex = (selectedIndex !== undefined && selectedIndex !== null && selectedIndex !== '') ? Number(selectedIndex) : null;
|
||
const lightbox = document.getElementById('display-picker-lightbox');
|
||
const canvas = document.getElementById('display-picker-canvas');
|
||
|
||
lightbox.classList.add('active');
|
||
|
||
// Defer render to next frame so the lightbox has been laid out and canvas has dimensions
|
||
requestAnimationFrame(() => {
|
||
if (_cachedDisplays && _cachedDisplays.length > 0) {
|
||
renderDisplayPickerLayout(_cachedDisplays);
|
||
} else {
|
||
canvas.innerHTML = '<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': 'WLED Grab',
|
||
'auth.placeholder': 'Enter your API key...',
|
||
'auth.button.login': 'Login'
|
||
};
|
||
|
||
// Translation function
|
||
function t(key, params = {}) {
|
||
let text = translations[key] || fallbackTranslations[key] || key;
|
||
|
||
// Replace parameters like {name}, {value}, etc.
|
||
Object.keys(params).forEach(param => {
|
||
text = text.replace(new RegExp(`\\{${param}\\}`, 'g'), params[param]);
|
||
});
|
||
|
||
return text;
|
||
}
|
||
|
||
// Load translation file
|
||
async function loadTranslations(locale) {
|
||
try {
|
||
const response = await fetch(`/static/locales/${locale}.json`);
|
||
if (!response.ok) {
|
||
throw new Error(`Failed to load ${locale}.json`);
|
||
}
|
||
return await response.json();
|
||
} catch (error) {
|
||
console.error(`Error loading translations for ${locale}:`, error);
|
||
// Fallback to English if loading fails
|
||
if (locale !== 'en') {
|
||
return await loadTranslations('en');
|
||
}
|
||
return {};
|
||
}
|
||
}
|
||
|
||
// Detect browser locale
|
||
function detectBrowserLocale() {
|
||
const browserLang = navigator.language || navigator.languages?.[0] || 'en';
|
||
const langCode = browserLang.split('-')[0]; // 'en-US' -> 'en', 'ru-RU' -> 'ru'
|
||
|
||
// Only return if we support it
|
||
return supportedLocales[langCode] ? langCode : 'en';
|
||
}
|
||
|
||
// Initialize locale
|
||
async function initLocale() {
|
||
const savedLocale = localStorage.getItem('locale') || detectBrowserLocale();
|
||
await setLocale(savedLocale);
|
||
}
|
||
|
||
// Set locale
|
||
async function setLocale(locale) {
|
||
if (!supportedLocales[locale]) {
|
||
locale = 'en';
|
||
}
|
||
|
||
// Load translations for the locale
|
||
translations = await loadTranslations(locale);
|
||
|
||
currentLocale = locale;
|
||
document.documentElement.setAttribute('data-locale', locale);
|
||
document.documentElement.setAttribute('lang', locale);
|
||
localStorage.setItem('locale', locale);
|
||
|
||
// Update all text
|
||
updateAllText();
|
||
|
||
// Update locale select dropdown (if visible)
|
||
updateLocaleSelect();
|
||
}
|
||
|
||
// Change locale from dropdown
|
||
function changeLocale() {
|
||
const select = document.getElementById('locale-select');
|
||
const newLocale = select.value;
|
||
if (newLocale && newLocale !== currentLocale) {
|
||
localStorage.setItem('locale', newLocale);
|
||
setLocale(newLocale);
|
||
}
|
||
}
|
||
|
||
// Update locale select dropdown
|
||
function updateLocaleSelect() {
|
||
const select = document.getElementById('locale-select');
|
||
if (select) {
|
||
select.value = currentLocale;
|
||
}
|
||
}
|
||
|
||
// Update all text on page
|
||
function updateAllText() {
|
||
// Update all elements with data-i18n attribute
|
||
document.querySelectorAll('[data-i18n]').forEach(el => {
|
||
const key = el.getAttribute('data-i18n');
|
||
el.textContent = t(key);
|
||
});
|
||
|
||
// Update all elements with data-i18n-placeholder attribute
|
||
document.querySelectorAll('[data-i18n-placeholder]').forEach(el => {
|
||
const key = el.getAttribute('data-i18n-placeholder');
|
||
el.placeholder = t(key);
|
||
});
|
||
|
||
// Update all elements with data-i18n-title attribute
|
||
document.querySelectorAll('[data-i18n-title]').forEach(el => {
|
||
const key = el.getAttribute('data-i18n-title');
|
||
el.title = t(key);
|
||
});
|
||
|
||
// Re-render dynamic content with new translations
|
||
if (apiKey) {
|
||
loadDisplays();
|
||
loadDevices();
|
||
loadPictureSources();
|
||
}
|
||
}
|
||
|
||
// Initialize app
|
||
document.addEventListener('DOMContentLoaded', async () => {
|
||
// Initialize locale first
|
||
await initLocale();
|
||
|
||
// Show content now that translations are loaded
|
||
document.body.style.visibility = 'visible';
|
||
|
||
// Load API key from localStorage
|
||
apiKey = localStorage.getItem('wled_api_key');
|
||
|
||
// Restore active tab (after API key is loaded)
|
||
initTabs();
|
||
|
||
// Setup form handler
|
||
document.getElementById('add-device-form').addEventListener('submit', handleAddDevice);
|
||
|
||
// Show modal if no API key is stored
|
||
if (!apiKey) {
|
||
// Wait for modal functions to be defined
|
||
setTimeout(() => {
|
||
if (typeof showApiKeyModal === 'function') {
|
||
showApiKeyModal('Welcome! Please login with your API key to get started.', true);
|
||
}
|
||
}, 100);
|
||
return; // Don't load data yet
|
||
}
|
||
|
||
// User is logged in, load data
|
||
loadServerInfo();
|
||
loadDisplays();
|
||
loadDevices();
|
||
|
||
// Start auto-refresh
|
||
startAutoRefresh();
|
||
});
|
||
|
||
// Helper function to add auth header if needed
|
||
function getHeaders() {
|
||
const headers = {
|
||
'Content-Type': 'application/json'
|
||
};
|
||
|
||
if (apiKey) {
|
||
headers['Authorization'] = `Bearer ${apiKey}`;
|
||
}
|
||
|
||
return headers;
|
||
}
|
||
|
||
// Fetch wrapper that automatically includes auth headers
|
||
async function fetchWithAuth(url, options = {}) {
|
||
// Build full URL if relative path provided
|
||
const fullUrl = url.startsWith('http') ? url : `${API_BASE}${url}`;
|
||
|
||
// Merge auth headers with any custom headers
|
||
const headers = options.headers
|
||
? { ...getHeaders(), ...options.headers }
|
||
: getHeaders();
|
||
|
||
// Make request with merged options
|
||
return fetch(fullUrl, {
|
||
...options,
|
||
headers
|
||
});
|
||
}
|
||
|
||
// Escape HTML to prevent XSS
|
||
function escapeHtml(text) {
|
||
if (!text) return '';
|
||
const div = document.createElement('div');
|
||
div.textContent = text;
|
||
return div.innerHTML;
|
||
}
|
||
|
||
// Handle 401 errors by showing login modal
|
||
function handle401Error() {
|
||
// Clear invalid API key
|
||
localStorage.removeItem('wled_api_key');
|
||
apiKey = null;
|
||
|
||
// Stop auto-refresh to prevent repeated 401 errors
|
||
if (refreshInterval) {
|
||
clearInterval(refreshInterval);
|
||
refreshInterval = null;
|
||
}
|
||
|
||
if (typeof updateAuthUI === 'function') {
|
||
updateAuthUI();
|
||
}
|
||
|
||
if (typeof showApiKeyModal === 'function') {
|
||
showApiKeyModal('Your session has expired or the API key is invalid. Please login again.', true);
|
||
} else {
|
||
showToast('Authentication failed. Please reload the page and login.', 'error');
|
||
}
|
||
}
|
||
|
||
// Configure API key
|
||
function configureApiKey() {
|
||
const currentKey = localStorage.getItem('wled_api_key');
|
||
const message = currentKey
|
||
? 'Current API key is set. Enter new key to update or leave blank to remove:'
|
||
: 'Enter your API key:';
|
||
|
||
const key = prompt(message);
|
||
|
||
if (key === null) {
|
||
return; // Cancelled
|
||
}
|
||
|
||
if (key === '') {
|
||
localStorage.removeItem('wled_api_key');
|
||
apiKey = null;
|
||
document.getElementById('api-key-btn').style.display = 'none';
|
||
showToast('API key removed', 'info');
|
||
} else {
|
||
localStorage.setItem('wled_api_key', key);
|
||
apiKey = key;
|
||
document.getElementById('api-key-btn').style.display = 'inline-block';
|
||
showToast('API key updated', 'success');
|
||
}
|
||
|
||
// Reload data with new key
|
||
loadServerInfo();
|
||
loadDisplays();
|
||
loadDevices();
|
||
}
|
||
|
||
// Server info
|
||
async function loadServerInfo() {
|
||
try {
|
||
const response = await fetch('/health');
|
||
const data = await response.json();
|
||
|
||
document.getElementById('version-number').textContent = `v${data.version}`;
|
||
document.getElementById('server-status').textContent = '●';
|
||
document.getElementById('server-status').className = 'status-badge online';
|
||
} catch (error) {
|
||
console.error('Failed to load server info:', error);
|
||
document.getElementById('server-status').className = 'status-badge offline';
|
||
showToast(t('server.offline'), 'error');
|
||
}
|
||
}
|
||
|
||
// Load displays
|
||
async function loadDisplays() {
|
||
try {
|
||
const response = await fetch(`${API_BASE}/config/displays`, {
|
||
headers: getHeaders()
|
||
});
|
||
|
||
if (response.status === 401) {
|
||
handle401Error();
|
||
return;
|
||
}
|
||
|
||
const data = await response.json();
|
||
|
||
if (data.displays && data.displays.length > 0) {
|
||
_cachedDisplays = data.displays;
|
||
}
|
||
} catch (error) {
|
||
console.error('Failed to load displays:', error);
|
||
}
|
||
}
|
||
|
||
let _cachedDisplays = null;
|
||
|
||
function switchTab(name) {
|
||
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();
|
||
}
|
||
}
|
||
|
||
function initTabs() {
|
||
const saved = localStorage.getItem('activeTab');
|
||
if (saved && document.getElementById(`tab-${saved}`)) {
|
||
switchTab(saved);
|
||
}
|
||
}
|
||
|
||
|
||
// Load devices
|
||
async function loadDevices() {
|
||
try {
|
||
const response = await fetch(`${API_BASE}/devices`, {
|
||
headers: getHeaders()
|
||
});
|
||
|
||
if (response.status === 401) {
|
||
handle401Error();
|
||
return;
|
||
}
|
||
|
||
const data = await response.json();
|
||
const devices = data.devices || [];
|
||
|
||
const container = document.getElementById('devices-list');
|
||
|
||
if (!devices || devices.length === 0) {
|
||
container.innerHTML = `<div class="card add-device-card" onclick="showAddDevice()">
|
||
<div class="add-device-icon">+</div>
|
||
<div class="add-device-label">${t('devices.add')}</div>
|
||
</div>`;
|
||
return;
|
||
}
|
||
|
||
// Fetch state for each device
|
||
const devicesWithState = await Promise.all(
|
||
devices.map(async (device) => {
|
||
try {
|
||
const stateResponse = await fetch(`${API_BASE}/devices/${device.id}/state`, {
|
||
headers: getHeaders()
|
||
});
|
||
const state = await stateResponse.json();
|
||
|
||
const metricsResponse = await fetch(`${API_BASE}/devices/${device.id}/metrics`, {
|
||
headers: getHeaders()
|
||
});
|
||
const metrics = await metricsResponse.json();
|
||
|
||
// Log device errors only when they change (avoid console spam)
|
||
const deviceKey = device.id;
|
||
const lastLogged = loggedErrors.get(deviceKey);
|
||
const hasNewErrors = !lastLogged ||
|
||
lastLogged.errorCount !== metrics.errors_count ||
|
||
lastLogged.lastError !== metrics.last_error;
|
||
|
||
if (metrics.errors_count > 0 && hasNewErrors) {
|
||
console.warn(`[Device: ${device.name || device.id}] Has ${metrics.errors_count} error(s)`);
|
||
|
||
// Log recent errors from state
|
||
if (state.errors && state.errors.length > 0) {
|
||
console.error('Recent errors:');
|
||
state.errors.forEach((error, index) => {
|
||
console.error(` ${index + 1}. ${error}`);
|
||
});
|
||
}
|
||
|
||
// Log last error from metrics
|
||
if (metrics.last_error) {
|
||
console.error('Last error:', metrics.last_error);
|
||
}
|
||
|
||
// Log full state and metrics for debugging
|
||
console.log('Full state:', state);
|
||
console.log('Full metrics:', metrics);
|
||
|
||
// Update tracking
|
||
loggedErrors.set(deviceKey, {
|
||
errorCount: metrics.errors_count,
|
||
lastError: metrics.last_error
|
||
});
|
||
} else if (metrics.errors_count === 0 && lastLogged) {
|
||
// Clear tracking when errors are resolved
|
||
console.log(`[Device: ${device.name || device.id}] Errors cleared`);
|
||
loggedErrors.delete(deviceKey);
|
||
}
|
||
|
||
return { ...device, state, metrics };
|
||
} catch (error) {
|
||
console.error(`Failed to load state for device ${device.id}:`, error);
|
||
return device;
|
||
}
|
||
})
|
||
);
|
||
|
||
container.innerHTML = devicesWithState.map(device => createDeviceCard(device)).join('')
|
||
+ `<div class="card add-device-card" onclick="showAddDevice()">
|
||
<div class="add-device-icon">+</div>
|
||
<div class="add-device-label">${t('devices.add')}</div>
|
||
</div>`;
|
||
|
||
// Update footer WLED Web UI link with first device's URL
|
||
const webuiLink = document.querySelector('.wled-webui-link');
|
||
if (webuiLink && devicesWithState.length > 0 && devicesWithState[0].url) {
|
||
webuiLink.href = devicesWithState[0].url;
|
||
webuiLink.target = '_blank';
|
||
webuiLink.rel = 'noopener';
|
||
}
|
||
|
||
// Attach event listeners and fetch real WLED brightness
|
||
devicesWithState.forEach(device => {
|
||
attachDeviceListeners(device.id);
|
||
fetchDeviceBrightness(device.id);
|
||
});
|
||
} catch (error) {
|
||
console.error('Failed to load devices:', error);
|
||
document.getElementById('devices-list').innerHTML =
|
||
`<div class="loading">${t('devices.failed')}</div>`;
|
||
}
|
||
}
|
||
|
||
function createDeviceCard(device) {
|
||
const state = device.state || {};
|
||
const metrics = device.metrics || {};
|
||
const settings = device.settings || {};
|
||
|
||
const isProcessing = state.processing || false;
|
||
const statusKey = isProcessing ? 'device.status.processing' : 'device.status.idle';
|
||
const status = isProcessing ? 'processing' : 'idle';
|
||
|
||
// WLED device health indicator
|
||
const wledOnline = state.wled_online || false;
|
||
const wledLatency = state.wled_latency_ms;
|
||
const wledName = state.wled_name;
|
||
const wledVersion = state.wled_version;
|
||
const wledLastChecked = state.wled_last_checked;
|
||
|
||
let healthClass, healthTitle, healthLabel;
|
||
if (wledLastChecked === null || wledLastChecked === undefined) {
|
||
healthClass = 'health-unknown';
|
||
healthTitle = t('device.health.checking');
|
||
healthLabel = '';
|
||
} else if (wledOnline) {
|
||
healthClass = 'health-online';
|
||
healthTitle = `${t('device.health.online')}`;
|
||
if (wledName) healthTitle += ` - ${wledName}`;
|
||
if (wledVersion) healthTitle += ` v${wledVersion}`;
|
||
if (wledLatency !== null && wledLatency !== undefined) healthTitle += ` (${Math.round(wledLatency)}ms)`;
|
||
healthLabel = '';
|
||
} else {
|
||
healthClass = 'health-offline';
|
||
healthTitle = t('device.health.offline');
|
||
if (state.wled_error) healthTitle += `: ${state.wled_error}`;
|
||
healthLabel = `<span class="health-latency offline">${t('device.health.offline')}</span>`;
|
||
}
|
||
|
||
const ledCount = state.wled_led_count || device.led_count;
|
||
|
||
return `
|
||
<div class="card" data-device-id="${device.id}">
|
||
<button class="card-remove-btn" onclick="removeDevice('${device.id}')" title="${t('device.button.remove')}">✕</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}
|
||
${isProcessing ? `<span class="badge processing">${t('device.status.processing')}</span>` : ''}
|
||
</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="card-content">
|
||
${isProcessing ? `
|
||
<div class="metrics-grid">
|
||
<div class="metric">
|
||
<div class="metric-value">${state.fps_actual?.toFixed(1) || '0.0'}</div>
|
||
<div class="metric-label">${t('device.metrics.actual_fps')}</div>
|
||
</div>
|
||
<div class="metric">
|
||
<div class="metric-value">${state.fps_target || 0}</div>
|
||
<div class="metric-label">${t('device.metrics.target_fps')}</div>
|
||
</div>
|
||
<div class="metric">
|
||
<div class="metric-value">${metrics.frames_processed || 0}</div>
|
||
<div class="metric-label">${t('device.metrics.frames')}</div>
|
||
</div>
|
||
<div class="metric">
|
||
<div class="metric-value">${metrics.errors_count || 0}</div>
|
||
<div class="metric-label">${t('device.metrics.errors')}</div>
|
||
</div>
|
||
</div>
|
||
` : ''}
|
||
</div>
|
||
<div class="brightness-control${_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">
|
||
${isProcessing ? `
|
||
<button class="btn btn-icon btn-danger" onclick="stopProcessing('${device.id}')" title="${t('device.button.stop')}">
|
||
⏹️
|
||
</button>
|
||
` : `
|
||
<button class="btn btn-icon btn-primary" onclick="startProcessing('${device.id}')" title="${t('device.button.start')}">
|
||
▶️
|
||
</button>
|
||
`}
|
||
<button class="btn btn-icon btn-secondary" onclick="showSettings('${device.id}')" title="${t('device.button.settings')}">
|
||
⚙️
|
||
</button>
|
||
<button class="btn btn-icon btn-secondary" onclick="showStreamSelector('${device.id}')" title="${t('device.button.stream_selector')}">
|
||
📺
|
||
</button>
|
||
<button class="btn btn-icon btn-secondary" onclick="showCalibration('${device.id}')" title="${t('device.button.calibrate')}">
|
||
📐
|
||
</button>
|
||
</div>
|
||
<button class="card-tutorial-btn" onclick="startDeviceTutorial('${device.id}')" title="${t('device.tutorial.start')}">?</button>
|
||
</div>
|
||
`;
|
||
}
|
||
|
||
function attachDeviceListeners(deviceId) {
|
||
// Add any specific event listeners here if needed
|
||
}
|
||
|
||
// Device actions
|
||
async function startProcessing(deviceId) {
|
||
console.log(`[Device: ${deviceId}] Starting processing...`);
|
||
try {
|
||
const response = await fetch(`${API_BASE}/devices/${deviceId}/start`, {
|
||
method: 'POST',
|
||
headers: getHeaders()
|
||
});
|
||
|
||
if (response.status === 401) {
|
||
handle401Error();
|
||
return;
|
||
}
|
||
|
||
if (response.ok) {
|
||
console.log(`[Device: ${deviceId}] Processing started successfully`);
|
||
showToast('Processing started', 'success');
|
||
loadDevices();
|
||
} else {
|
||
const error = await response.json();
|
||
console.error(`[Device: ${deviceId}] Failed to start:`, error);
|
||
showToast(`Failed to start: ${error.detail}`, 'error');
|
||
}
|
||
} catch (error) {
|
||
console.error(`[Device: ${deviceId}] Failed to start processing:`, error);
|
||
showToast('Failed to start processing', 'error');
|
||
}
|
||
}
|
||
|
||
async function stopProcessing(deviceId) {
|
||
console.log(`[Device: ${deviceId}] Stopping processing...`);
|
||
try {
|
||
const response = await fetch(`${API_BASE}/devices/${deviceId}/stop`, {
|
||
method: 'POST',
|
||
headers: getHeaders()
|
||
});
|
||
|
||
if (response.status === 401) {
|
||
handle401Error();
|
||
return;
|
||
}
|
||
|
||
if (response.ok) {
|
||
console.log(`[Device: ${deviceId}] Processing stopped successfully`);
|
||
showToast('Processing stopped', 'success');
|
||
loadDevices();
|
||
} else {
|
||
const error = await response.json();
|
||
console.error(`[Device: ${deviceId}] Failed to stop:`, error);
|
||
showToast(`Failed to stop: ${error.detail}`, 'error');
|
||
}
|
||
} catch (error) {
|
||
console.error(`[Device: ${deviceId}] Failed to stop processing:`, error);
|
||
showToast('Failed to stop processing', 'error');
|
||
}
|
||
}
|
||
|
||
async function removeDevice(deviceId) {
|
||
const confirmed = await showConfirm(t('device.remove.confirm'));
|
||
if (!confirmed) {
|
||
return;
|
||
}
|
||
|
||
try {
|
||
const response = await fetch(`${API_BASE}/devices/${deviceId}`, {
|
||
method: 'DELETE',
|
||
headers: getHeaders()
|
||
});
|
||
|
||
if (response.status === 401) {
|
||
handle401Error();
|
||
return;
|
||
}
|
||
|
||
if (response.ok) {
|
||
showToast('Device removed', 'success');
|
||
loadDevices();
|
||
} else {
|
||
const error = await response.json();
|
||
showToast(`Failed to remove: ${error.detail}`, 'error');
|
||
}
|
||
} catch (error) {
|
||
console.error('Failed to remove device:', error);
|
||
showToast('Failed to remove device', 'error');
|
||
}
|
||
}
|
||
|
||
async function showSettings(deviceId) {
|
||
try {
|
||
const deviceResponse = await fetch(`${API_BASE}/devices/${deviceId}`, { headers: getHeaders() });
|
||
|
||
if (deviceResponse.status === 401) {
|
||
handle401Error();
|
||
return;
|
||
}
|
||
|
||
if (!deviceResponse.ok) {
|
||
showToast('Failed to load device settings', 'error');
|
||
return;
|
||
}
|
||
|
||
const device = await deviceResponse.json();
|
||
|
||
// Populate fields
|
||
document.getElementById('settings-device-id').value = device.id;
|
||
document.getElementById('settings-device-name').value = device.name;
|
||
document.getElementById('settings-device-url').value = device.url;
|
||
document.getElementById('settings-health-interval').value = device.settings.state_check_interval || 30;
|
||
|
||
// Snapshot initial values for dirty checking
|
||
settingsInitialValues = {
|
||
name: device.name,
|
||
url: device.url,
|
||
state_check_interval: String(device.settings.state_check_interval || 30),
|
||
};
|
||
|
||
// Show modal
|
||
const modal = document.getElementById('device-settings-modal');
|
||
modal.style.display = 'flex';
|
||
lockBody();
|
||
|
||
// Focus first input
|
||
setTimeout(() => {
|
||
document.getElementById('settings-device-name').focus();
|
||
}, 100);
|
||
|
||
} catch (error) {
|
||
console.error('Failed to load device settings:', error);
|
||
showToast('Failed to load device settings', 'error');
|
||
}
|
||
}
|
||
|
||
function isSettingsDirty() {
|
||
return (
|
||
document.getElementById('settings-device-name').value !== settingsInitialValues.name ||
|
||
document.getElementById('settings-device-url').value !== settingsInitialValues.url ||
|
||
document.getElementById('settings-health-interval').value !== settingsInitialValues.state_check_interval
|
||
);
|
||
}
|
||
|
||
function forceCloseDeviceSettingsModal() {
|
||
const modal = document.getElementById('device-settings-modal');
|
||
const error = document.getElementById('settings-error');
|
||
modal.style.display = 'none';
|
||
error.style.display = 'none';
|
||
unlockBody();
|
||
settingsInitialValues = {};
|
||
}
|
||
|
||
async function closeDeviceSettingsModal() {
|
||
if (isSettingsDirty()) {
|
||
const confirmed = await showConfirm(t('modal.discard_changes'));
|
||
if (!confirmed) return;
|
||
}
|
||
forceCloseDeviceSettingsModal();
|
||
}
|
||
|
||
async function saveDeviceSettings() {
|
||
const deviceId = document.getElementById('settings-device-id').value;
|
||
const name = document.getElementById('settings-device-name').value.trim();
|
||
const url = document.getElementById('settings-device-url').value.trim();
|
||
const state_check_interval = parseInt(document.getElementById('settings-health-interval').value) || 30;
|
||
const error = document.getElementById('settings-error');
|
||
|
||
// Validation
|
||
if (!name || !url) {
|
||
error.textContent = 'Please fill in all fields correctly';
|
||
error.style.display = 'block';
|
||
return;
|
||
}
|
||
|
||
try {
|
||
// Update device info (name, url)
|
||
const deviceResponse = await fetch(`${API_BASE}/devices/${deviceId}`, {
|
||
method: 'PUT',
|
||
headers: getHeaders(),
|
||
body: JSON.stringify({ name, url })
|
||
});
|
||
|
||
if (deviceResponse.status === 401) {
|
||
handle401Error();
|
||
return;
|
||
}
|
||
|
||
if (!deviceResponse.ok) {
|
||
const errorData = await deviceResponse.json();
|
||
error.textContent = `Failed to update device: ${errorData.detail}`;
|
||
error.style.display = 'block';
|
||
return;
|
||
}
|
||
|
||
// Update settings (health check interval)
|
||
const settingsResponse = await fetch(`${API_BASE}/devices/${deviceId}/settings`, {
|
||
method: 'PUT',
|
||
headers: getHeaders(),
|
||
body: JSON.stringify({ state_check_interval })
|
||
});
|
||
|
||
if (settingsResponse.status === 401) {
|
||
handle401Error();
|
||
return;
|
||
}
|
||
|
||
if (settingsResponse.ok) {
|
||
showToast(t('settings.saved'), 'success');
|
||
forceCloseDeviceSettingsModal();
|
||
loadDevices();
|
||
} else {
|
||
const errorData = await settingsResponse.json();
|
||
error.textContent = `Failed to update settings: ${errorData.detail}`;
|
||
error.style.display = 'block';
|
||
}
|
||
} catch (err) {
|
||
console.error('Failed to save device settings:', err);
|
||
error.textContent = 'Failed to save settings';
|
||
error.style.display = 'block';
|
||
}
|
||
}
|
||
|
||
// ===== Capture Settings Modal =====
|
||
|
||
let captureSettingsInitialValues = {};
|
||
|
||
async function showCaptureSettings(deviceId) {
|
||
try {
|
||
// Fetch device data, displays, templates, and settings in parallel
|
||
const [deviceResponse, displaysResponse, templatesResponse, settingsResponse] = await Promise.all([
|
||
fetch(`${API_BASE}/devices/${deviceId}`, { headers: getHeaders() }),
|
||
fetch(`${API_BASE}/config/displays`, { headers: getHeaders() }),
|
||
fetchWithAuth('/capture-templates'),
|
||
fetch(`${API_BASE}/devices/${deviceId}/settings`, { headers: getHeaders() }),
|
||
]);
|
||
|
||
if (deviceResponse.status === 401) {
|
||
handle401Error();
|
||
return;
|
||
}
|
||
|
||
if (!deviceResponse.ok) {
|
||
showToast('Failed to load capture settings', 'error');
|
||
return;
|
||
}
|
||
|
||
const device = await deviceResponse.json();
|
||
const currentSettings = settingsResponse.ok ? await settingsResponse.json() : {};
|
||
|
||
// Populate display index select
|
||
const displaySelect = document.getElementById('capture-settings-display-index');
|
||
displaySelect.innerHTML = '';
|
||
if (displaysResponse.ok) {
|
||
const displaysData = await displaysResponse.json();
|
||
(displaysData.displays || []).forEach(d => {
|
||
const opt = document.createElement('option');
|
||
opt.value = d.index;
|
||
opt.textContent = `${d.index}: ${d.width}x${d.height}${d.is_primary ? ` (${t('displays.badge.primary')})` : ''}`;
|
||
displaySelect.appendChild(opt);
|
||
});
|
||
}
|
||
if (displaySelect.options.length === 0) {
|
||
const opt = document.createElement('option');
|
||
opt.value = '0';
|
||
opt.textContent = '0';
|
||
displaySelect.appendChild(opt);
|
||
}
|
||
displaySelect.value = String(device.settings.display_index ?? 0);
|
||
|
||
// Populate FPS slider
|
||
const fpsValue = Math.max(10, Math.min(90, currentSettings.fps ?? 30));
|
||
document.getElementById('capture-settings-fps').value = fpsValue;
|
||
document.getElementById('capture-settings-fps-value').textContent = fpsValue;
|
||
|
||
// Populate capture template select
|
||
const templateSelect = document.getElementById('capture-settings-template');
|
||
templateSelect.innerHTML = '';
|
||
if (templatesResponse.ok) {
|
||
const templatesData = await templatesResponse.json();
|
||
(templatesData.templates || []).forEach(t => {
|
||
const opt = document.createElement('option');
|
||
opt.value = t.id;
|
||
const engineIcon = getEngineIcon(t.engine_type);
|
||
opt.textContent = `${engineIcon} ${t.name}`;
|
||
templateSelect.appendChild(opt);
|
||
});
|
||
}
|
||
templateSelect.value = device.capture_template_id || '';
|
||
|
||
// Store device ID, current settings snapshot, and initial values for dirty check
|
||
document.getElementById('capture-settings-device-id').value = device.id;
|
||
captureSettingsInitialValues = {
|
||
display_index: String(device.settings.display_index ?? 0),
|
||
fps: String(currentSettings.fps ?? 30),
|
||
capture_template_id: device.capture_template_id || '',
|
||
_currentSettings: currentSettings,
|
||
};
|
||
|
||
// Show modal
|
||
const modal = document.getElementById('capture-settings-modal');
|
||
modal.style.display = 'flex';
|
||
lockBody();
|
||
|
||
} catch (error) {
|
||
console.error('Failed to load capture settings:', error);
|
||
showToast('Failed to load capture settings', 'error');
|
||
}
|
||
}
|
||
|
||
function isCaptureSettingsDirty() {
|
||
return (
|
||
document.getElementById('capture-settings-display-index').value !== captureSettingsInitialValues.display_index ||
|
||
document.getElementById('capture-settings-fps').value !== captureSettingsInitialValues.fps ||
|
||
document.getElementById('capture-settings-template').value !== captureSettingsInitialValues.capture_template_id
|
||
);
|
||
}
|
||
|
||
function forceCloseCaptureSettingsModal() {
|
||
const modal = document.getElementById('capture-settings-modal');
|
||
const error = document.getElementById('capture-settings-error');
|
||
modal.style.display = 'none';
|
||
error.style.display = 'none';
|
||
unlockBody();
|
||
captureSettingsInitialValues = {};
|
||
}
|
||
|
||
async function closeCaptureSettingsModal() {
|
||
if (isCaptureSettingsDirty()) {
|
||
const confirmed = await showConfirm(t('modal.discard_changes'));
|
||
if (!confirmed) return;
|
||
}
|
||
forceCloseCaptureSettingsModal();
|
||
}
|
||
|
||
async function saveCaptureSettings() {
|
||
const deviceId = document.getElementById('capture-settings-device-id').value;
|
||
const display_index = parseInt(document.getElementById('capture-settings-display-index').value) || 0;
|
||
const fps = parseInt(document.getElementById('capture-settings-fps').value) || 30;
|
||
const capture_template_id = document.getElementById('capture-settings-template').value;
|
||
const error = document.getElementById('capture-settings-error');
|
||
|
||
try {
|
||
// Update capture template on device
|
||
const deviceResponse = await fetch(`${API_BASE}/devices/${deviceId}`, {
|
||
method: 'PUT',
|
||
headers: getHeaders(),
|
||
body: JSON.stringify({ capture_template_id })
|
||
});
|
||
|
||
if (deviceResponse.status === 401) {
|
||
handle401Error();
|
||
return;
|
||
}
|
||
|
||
if (!deviceResponse.ok) {
|
||
const errorData = await deviceResponse.json();
|
||
error.textContent = `Failed to update capture template: ${errorData.detail}`;
|
||
error.style.display = 'block';
|
||
return;
|
||
}
|
||
|
||
// Merge changed fields with current settings to avoid resetting other values
|
||
const mergedSettings = {
|
||
...(captureSettingsInitialValues._currentSettings || {}),
|
||
display_index,
|
||
fps,
|
||
};
|
||
|
||
// Update settings
|
||
const settingsResponse = await fetch(`${API_BASE}/devices/${deviceId}/settings`, {
|
||
method: 'PUT',
|
||
headers: getHeaders(),
|
||
body: JSON.stringify(mergedSettings)
|
||
});
|
||
|
||
if (settingsResponse.status === 401) {
|
||
handle401Error();
|
||
return;
|
||
}
|
||
|
||
if (settingsResponse.ok) {
|
||
// Remember last used template for new device creation
|
||
localStorage.setItem('lastCaptureTemplateId', capture_template_id);
|
||
showToast(t('settings.capture.saved'), 'success');
|
||
forceCloseCaptureSettingsModal();
|
||
loadDevices();
|
||
} else {
|
||
const errorData = await settingsResponse.json();
|
||
error.textContent = `Failed to update settings: ${errorData.detail}`;
|
||
error.style.display = 'block';
|
||
}
|
||
} catch (err) {
|
||
console.error('Failed to save capture settings:', err);
|
||
error.textContent = t('settings.capture.failed');
|
||
error.style.display = 'block';
|
||
}
|
||
}
|
||
|
||
// Brightness cache: stores last known WLED brightness per device (0-255)
|
||
const _deviceBrightnessCache = {};
|
||
|
||
// Card brightness controls — talks directly to WLED device
|
||
function updateBrightnessLabel(deviceId, value) {
|
||
const slider = document.querySelector(`[data-device-brightness="${deviceId}"]`);
|
||
if (slider) slider.title = Math.round(parseInt(value) / 255 * 100) + '%';
|
||
}
|
||
|
||
async function saveCardBrightness(deviceId, value) {
|
||
const bri = parseInt(value);
|
||
_deviceBrightnessCache[deviceId] = bri;
|
||
try {
|
||
await fetch(`${API_BASE}/devices/${deviceId}/brightness`, {
|
||
method: 'PUT',
|
||
headers: getHeaders(),
|
||
body: JSON.stringify({ brightness: bri })
|
||
});
|
||
} catch (err) {
|
||
console.error('Failed to update brightness:', err);
|
||
showToast('Failed to update brightness', 'error');
|
||
}
|
||
}
|
||
|
||
async function fetchDeviceBrightness(deviceId) {
|
||
try {
|
||
const resp = await fetch(`${API_BASE}/devices/${deviceId}/brightness`, {
|
||
headers: getHeaders()
|
||
});
|
||
if (!resp.ok) return;
|
||
const data = await resp.json();
|
||
_deviceBrightnessCache[deviceId] = data.brightness;
|
||
const slider = document.querySelector(`[data-device-brightness="${deviceId}"]`);
|
||
if (slider) {
|
||
slider.value = data.brightness;
|
||
slider.title = Math.round(data.brightness / 255 * 100) + '%';
|
||
slider.disabled = false;
|
||
}
|
||
const wrap = document.querySelector(`[data-brightness-wrap="${deviceId}"]`);
|
||
if (wrap) wrap.classList.remove('brightness-loading');
|
||
} catch (err) {
|
||
// Silently fail — device may be offline
|
||
}
|
||
}
|
||
|
||
// Add device modal
|
||
function showAddDevice() {
|
||
const modal = document.getElementById('add-device-modal');
|
||
const form = document.getElementById('add-device-form');
|
||
const error = document.getElementById('add-device-error');
|
||
form.reset();
|
||
error.style.display = 'none';
|
||
modal.style.display = 'flex';
|
||
lockBody();
|
||
setTimeout(() => document.getElementById('device-name').focus(), 100);
|
||
}
|
||
|
||
function closeAddDeviceModal() {
|
||
const modal = document.getElementById('add-device-modal');
|
||
modal.style.display = 'none';
|
||
unlockBody();
|
||
}
|
||
|
||
async function handleAddDevice(event) {
|
||
event.preventDefault();
|
||
|
||
const name = document.getElementById('device-name').value.trim();
|
||
const url = document.getElementById('device-url').value.trim();
|
||
const error = document.getElementById('add-device-error');
|
||
|
||
if (!name || !url) {
|
||
error.textContent = 'Please fill in all fields';
|
||
error.style.display = 'block';
|
||
return;
|
||
}
|
||
|
||
try {
|
||
const body = { name, url };
|
||
const lastTemplateId = localStorage.getItem('lastCaptureTemplateId');
|
||
if (lastTemplateId) {
|
||
body.capture_template_id = lastTemplateId;
|
||
}
|
||
|
||
const response = await fetch(`${API_BASE}/devices`, {
|
||
method: 'POST',
|
||
headers: getHeaders(),
|
||
body: JSON.stringify(body)
|
||
});
|
||
|
||
if (response.status === 401) {
|
||
handle401Error();
|
||
return;
|
||
}
|
||
|
||
if (response.ok) {
|
||
const result = await response.json();
|
||
console.log('Device added successfully:', result);
|
||
showToast('Device added successfully', 'success');
|
||
closeAddDeviceModal();
|
||
await loadDevices();
|
||
// Auto-start device tutorial on first device add
|
||
if (!localStorage.getItem('deviceTutorialSeen')) {
|
||
localStorage.setItem('deviceTutorialSeen', '1');
|
||
setTimeout(() => startDeviceTutorial(), 300);
|
||
}
|
||
} else {
|
||
const errorData = await response.json();
|
||
console.error('Failed to add device:', errorData);
|
||
error.textContent = `Failed to add device: ${errorData.detail}`;
|
||
error.style.display = 'block';
|
||
}
|
||
} catch (err) {
|
||
console.error('Failed to add device:', err);
|
||
showToast('Failed to add device', 'error');
|
||
}
|
||
}
|
||
|
||
// Auto-refresh
|
||
function startAutoRefresh() {
|
||
if (refreshInterval) {
|
||
clearInterval(refreshInterval);
|
||
}
|
||
|
||
refreshInterval = setInterval(() => {
|
||
// Only refresh if user is authenticated
|
||
if (apiKey) {
|
||
loadDevices();
|
||
}
|
||
}, 2000); // Refresh every 2 seconds
|
||
}
|
||
|
||
// Toast notifications
|
||
function showToast(message, type = 'info') {
|
||
const toast = document.getElementById('toast');
|
||
toast.textContent = message;
|
||
toast.className = `toast ${type} show`;
|
||
|
||
setTimeout(() => {
|
||
toast.className = 'toast';
|
||
}, 3000);
|
||
}
|
||
|
||
// Confirmation modal
|
||
let confirmResolve = null;
|
||
|
||
function showConfirm(message, title = null) {
|
||
return new Promise((resolve) => {
|
||
confirmResolve = resolve;
|
||
|
||
const modal = document.getElementById('confirm-modal');
|
||
const titleEl = document.getElementById('confirm-title');
|
||
const messageEl = document.getElementById('confirm-message');
|
||
const yesBtn = document.getElementById('confirm-yes-btn');
|
||
const noBtn = document.getElementById('confirm-no-btn');
|
||
|
||
titleEl.textContent = title || t('confirm.title');
|
||
messageEl.textContent = message;
|
||
yesBtn.textContent = t('confirm.yes');
|
||
noBtn.textContent = t('confirm.no');
|
||
|
||
modal.style.display = 'flex';
|
||
lockBody();
|
||
});
|
||
}
|
||
|
||
function closeConfirmModal(result) {
|
||
const modal = document.getElementById('confirm-modal');
|
||
modal.style.display = 'none';
|
||
unlockBody();
|
||
|
||
if (confirmResolve) {
|
||
confirmResolve(result);
|
||
confirmResolve = null;
|
||
}
|
||
}
|
||
|
||
// Calibration functions
|
||
async function showCalibration(deviceId) {
|
||
try {
|
||
// Fetch device data and displays in parallel
|
||
const [response, displaysResponse] = await Promise.all([
|
||
fetch(`${API_BASE}/devices/${deviceId}`, { headers: getHeaders() }),
|
||
fetch(`${API_BASE}/config/displays`, { headers: getHeaders() }),
|
||
]);
|
||
|
||
if (response.status === 401) {
|
||
handle401Error();
|
||
return;
|
||
}
|
||
|
||
if (!response.ok) {
|
||
showToast('Failed to load calibration', 'error');
|
||
return;
|
||
}
|
||
|
||
const device = await response.json();
|
||
const calibration = device.calibration;
|
||
|
||
// Set aspect ratio from device's display
|
||
const preview = document.querySelector('.calibration-preview');
|
||
if (displaysResponse.ok) {
|
||
const displaysData = await displaysResponse.json();
|
||
const displayIndex = device.settings?.display_index ?? 0;
|
||
const display = (displaysData.displays || []).find(d => d.index === displayIndex);
|
||
if (display && display.width && display.height) {
|
||
preview.style.aspectRatio = `${display.width} / ${display.height}`;
|
||
} else {
|
||
preview.style.aspectRatio = '';
|
||
}
|
||
} else {
|
||
preview.style.aspectRatio = '';
|
||
}
|
||
|
||
// Store device ID and LED count
|
||
document.getElementById('calibration-device-id').value = device.id;
|
||
document.getElementById('cal-device-led-count-inline').textContent = device.led_count;
|
||
|
||
// Set layout
|
||
document.getElementById('cal-start-position').value = calibration.start_position;
|
||
document.getElementById('cal-layout').value = calibration.layout;
|
||
document.getElementById('cal-offset').value = calibration.offset || 0;
|
||
|
||
// Set LED counts per edge
|
||
document.getElementById('cal-top-leds').value = calibration.leds_top || 0;
|
||
document.getElementById('cal-right-leds').value = calibration.leds_right || 0;
|
||
document.getElementById('cal-bottom-leds').value = calibration.leds_bottom || 0;
|
||
document.getElementById('cal-left-leds').value = calibration.leds_left || 0;
|
||
|
||
// Initialize edge spans
|
||
window.edgeSpans = {
|
||
top: { start: calibration.span_top_start ?? 0, end: calibration.span_top_end ?? 1 },
|
||
right: { start: calibration.span_right_start ?? 0, end: calibration.span_right_end ?? 1 },
|
||
bottom: { start: calibration.span_bottom_start ?? 0, end: calibration.span_bottom_end ?? 1 },
|
||
left: { start: calibration.span_left_start ?? 0, end: calibration.span_left_end ?? 1 },
|
||
};
|
||
|
||
// Snapshot initial values for dirty checking
|
||
calibrationInitialValues = {
|
||
start_position: calibration.start_position,
|
||
layout: calibration.layout,
|
||
offset: String(calibration.offset || 0),
|
||
top: String(calibration.leds_top || 0),
|
||
right: String(calibration.leds_right || 0),
|
||
bottom: String(calibration.leds_bottom || 0),
|
||
left: String(calibration.leds_left || 0),
|
||
spans: JSON.stringify(window.edgeSpans),
|
||
};
|
||
|
||
// Initialize test mode state for this device
|
||
calibrationTestState[device.id] = new Set();
|
||
|
||
// Update preview
|
||
updateCalibrationPreview();
|
||
|
||
// Show modal
|
||
const modal = document.getElementById('calibration-modal');
|
||
modal.style.display = 'flex';
|
||
lockBody();
|
||
|
||
// Initialize span drag and render canvas after layout settles
|
||
initSpanDrag();
|
||
requestAnimationFrame(() => {
|
||
renderCalibrationCanvas();
|
||
// Auto-start tutorial on first open
|
||
if (!localStorage.getItem('calibrationTutorialSeen')) {
|
||
localStorage.setItem('calibrationTutorialSeen', '1');
|
||
startCalibrationTutorial();
|
||
}
|
||
});
|
||
|
||
// Re-render on container resize (e.g. window resize changes aspect-ratio container)
|
||
if (!window._calibrationResizeObserver) {
|
||
window._calibrationResizeObserver = new ResizeObserver(() => {
|
||
updateSpanBars();
|
||
renderCalibrationCanvas();
|
||
});
|
||
}
|
||
window._calibrationResizeObserver.observe(preview);
|
||
|
||
} catch (error) {
|
||
console.error('Failed to load calibration:', error);
|
||
showToast('Failed to load calibration', 'error');
|
||
}
|
||
}
|
||
|
||
function isCalibrationDirty() {
|
||
return (
|
||
document.getElementById('cal-start-position').value !== calibrationInitialValues.start_position ||
|
||
document.getElementById('cal-layout').value !== calibrationInitialValues.layout ||
|
||
document.getElementById('cal-offset').value !== calibrationInitialValues.offset ||
|
||
document.getElementById('cal-top-leds').value !== calibrationInitialValues.top ||
|
||
document.getElementById('cal-right-leds').value !== calibrationInitialValues.right ||
|
||
document.getElementById('cal-bottom-leds').value !== calibrationInitialValues.bottom ||
|
||
document.getElementById('cal-left-leds').value !== calibrationInitialValues.left ||
|
||
JSON.stringify(window.edgeSpans) !== calibrationInitialValues.spans
|
||
);
|
||
}
|
||
|
||
function forceCloseCalibrationModal() {
|
||
closeTutorial();
|
||
const deviceId = document.getElementById('calibration-device-id').value;
|
||
if (deviceId) {
|
||
clearTestMode(deviceId);
|
||
}
|
||
if (window._calibrationResizeObserver) {
|
||
window._calibrationResizeObserver.disconnect();
|
||
}
|
||
const modal = document.getElementById('calibration-modal');
|
||
const error = document.getElementById('calibration-error');
|
||
modal.style.display = 'none';
|
||
error.style.display = 'none';
|
||
unlockBody();
|
||
calibrationInitialValues = {};
|
||
}
|
||
|
||
async function closeCalibrationModal() {
|
||
if (isCalibrationDirty()) {
|
||
const confirmed = await showConfirm(t('modal.discard_changes'));
|
||
if (!confirmed) return;
|
||
}
|
||
forceCloseCalibrationModal();
|
||
}
|
||
|
||
function updateCalibrationPreview() {
|
||
// Calculate total from edge inputs
|
||
const total = parseInt(document.getElementById('cal-top-leds').value || 0) +
|
||
parseInt(document.getElementById('cal-right-leds').value || 0) +
|
||
parseInt(document.getElementById('cal-bottom-leds').value || 0) +
|
||
parseInt(document.getElementById('cal-left-leds').value || 0);
|
||
// Warning if total doesn't match device LED count
|
||
const totalEl = document.querySelector('.preview-screen-total');
|
||
const deviceCount = parseInt(document.getElementById('cal-device-led-count-inline').textContent || 0);
|
||
const mismatch = total !== deviceCount;
|
||
document.getElementById('cal-total-leds-inline').textContent = (mismatch ? '\u26A0 ' : '') + total;
|
||
if (totalEl) {
|
||
totalEl.classList.toggle('mismatch', mismatch);
|
||
}
|
||
|
||
// Update corner dot highlights for start position
|
||
const startPos = document.getElementById('cal-start-position').value;
|
||
['top_left', 'top_right', 'bottom_left', 'bottom_right'].forEach(corner => {
|
||
const cornerEl = document.querySelector(`.preview-corner.corner-${corner.replace('_', '-')}`);
|
||
if (cornerEl) {
|
||
if (corner === startPos) {
|
||
cornerEl.classList.add('active');
|
||
} else {
|
||
cornerEl.classList.remove('active');
|
||
}
|
||
}
|
||
});
|
||
|
||
// Update direction toggle display
|
||
const direction = document.getElementById('cal-layout').value;
|
||
const dirIcon = document.getElementById('direction-icon');
|
||
const dirLabel = document.getElementById('direction-label');
|
||
if (dirIcon) dirIcon.textContent = direction === 'clockwise' ? '↻' : '↺';
|
||
if (dirLabel) dirLabel.textContent = direction === 'clockwise' ? 'CW' : 'CCW';
|
||
|
||
// Update edge highlight states
|
||
const deviceId = document.getElementById('calibration-device-id').value;
|
||
const activeEdges = calibrationTestState[deviceId] || new Set();
|
||
|
||
['top', 'right', 'bottom', 'left'].forEach(edge => {
|
||
const toggleEl = document.querySelector(`.edge-toggle.toggle-${edge}`);
|
||
if (!toggleEl) return;
|
||
|
||
if (activeEdges.has(edge)) {
|
||
const [r, g, b] = EDGE_TEST_COLORS[edge];
|
||
toggleEl.style.background = `rgba(${r}, ${g}, ${b}, 0.35)`;
|
||
toggleEl.style.boxShadow = `inset 0 0 6px rgba(${r}, ${g}, ${b}, 0.5)`;
|
||
} else {
|
||
toggleEl.style.background = '';
|
||
toggleEl.style.boxShadow = '';
|
||
}
|
||
});
|
||
|
||
// Disable edges with 0 LEDs
|
||
['top', 'right', 'bottom', 'left'].forEach(edge => {
|
||
const count = parseInt(document.getElementById(`cal-${edge}-leds`).value) || 0;
|
||
const edgeEl = document.querySelector(`.preview-edge.edge-${edge}`);
|
||
const toggleEl = document.querySelector(`.edge-toggle.toggle-${edge}`);
|
||
if (edgeEl) edgeEl.classList.toggle('edge-disabled', count === 0);
|
||
if (toggleEl) toggleEl.classList.toggle('edge-disabled', count === 0);
|
||
});
|
||
|
||
// Position span bars and render canvas overlay
|
||
updateSpanBars();
|
||
renderCalibrationCanvas();
|
||
}
|
||
|
||
function renderCalibrationCanvas() {
|
||
const canvas = document.getElementById('calibration-preview-canvas');
|
||
if (!canvas) return;
|
||
|
||
const container = canvas.parentElement;
|
||
const containerRect = container.getBoundingClientRect();
|
||
if (containerRect.width === 0 || containerRect.height === 0) return;
|
||
|
||
// Canvas extends beyond the container (matches CSS: left:-40px, top:-40px, +80px/+80px)
|
||
const padX = 40;
|
||
const padY = 40;
|
||
|
||
const dpr = window.devicePixelRatio || 1;
|
||
const canvasW = containerRect.width + padX * 2;
|
||
const canvasH = containerRect.height + padY * 2;
|
||
canvas.width = canvasW * dpr;
|
||
canvas.height = canvasH * dpr;
|
||
const ctx = canvas.getContext('2d');
|
||
ctx.scale(dpr, dpr);
|
||
|
||
ctx.clearRect(0, 0, canvasW, canvasH);
|
||
|
||
// Container origin within canvas coordinate system
|
||
const ox = padX;
|
||
const oy = padY;
|
||
const cW = containerRect.width; // container inner width
|
||
const cH = containerRect.height; // container inner height
|
||
|
||
// Read current form values
|
||
const startPos = document.getElementById('cal-start-position').value;
|
||
const layout = document.getElementById('cal-layout').value;
|
||
const offset = parseInt(document.getElementById('cal-offset').value || 0);
|
||
const calibration = {
|
||
start_position: startPos,
|
||
layout: layout,
|
||
offset: offset,
|
||
leds_top: parseInt(document.getElementById('cal-top-leds').value || 0),
|
||
leds_right: parseInt(document.getElementById('cal-right-leds').value || 0),
|
||
leds_bottom: parseInt(document.getElementById('cal-bottom-leds').value || 0),
|
||
leds_left: parseInt(document.getElementById('cal-left-leds').value || 0),
|
||
};
|
||
|
||
const segments = buildSegments(calibration);
|
||
if (segments.length === 0) return;
|
||
|
||
const totalLeds = calibration.leds_top + calibration.leds_right + calibration.leds_bottom + calibration.leds_left;
|
||
|
||
// Theme-aware colors
|
||
const isDark = document.documentElement.getAttribute('data-theme') !== 'light';
|
||
const tickStroke = isDark ? 'rgba(255, 255, 255, 0.4)' : 'rgba(0, 0, 0, 0.3)';
|
||
const tickFill = isDark ? 'rgba(255, 255, 255, 0.65)' : 'rgba(0, 0, 0, 0.6)';
|
||
const chevronStroke = isDark ? 'rgba(255, 255, 255, 0.6)' : 'rgba(0, 0, 0, 0.4)';
|
||
|
||
// Edge bar geometry (matches CSS: corner zones 56px × 36px fixed)
|
||
const cw = 56;
|
||
const ch = 36;
|
||
|
||
// Span-aware edge geometry: ticks/arrows render only within the span region
|
||
const spans = window.edgeSpans || {};
|
||
const edgeLenH = cW - 2 * cw;
|
||
const edgeLenV = cH - 2 * ch;
|
||
|
||
const edgeGeometry = {
|
||
top: { x1: ox + cw + (spans.top?.start || 0) * edgeLenH, x2: ox + cw + (spans.top?.end || 1) * edgeLenH, midY: oy + ch / 2, horizontal: true },
|
||
bottom: { x1: ox + cw + (spans.bottom?.start || 0) * edgeLenH, x2: ox + cw + (spans.bottom?.end || 1) * edgeLenH, midY: oy + cH - ch / 2, horizontal: true },
|
||
left: { y1: oy + ch + (spans.left?.start || 0) * edgeLenV, y2: oy + ch + (spans.left?.end || 1) * edgeLenV, midX: ox + cw / 2, horizontal: false },
|
||
right: { y1: oy + ch + (spans.right?.start || 0) * edgeLenV, y2: oy + ch + (spans.right?.end || 1) * edgeLenV, midX: ox + cW - cw / 2, horizontal: false },
|
||
};
|
||
|
||
// Axis positions for labels (outside the 16px toggle zones)
|
||
const toggleSize = 16;
|
||
const axisPos = {
|
||
top: oy - toggleSize - 3,
|
||
bottom: oy + cH + toggleSize + 3,
|
||
left: ox - toggleSize - 3,
|
||
right: ox + cW + toggleSize + 3,
|
||
};
|
||
|
||
// Arrow positions (inside the screen area, near each edge bar)
|
||
const arrowInset = 12;
|
||
const arrowPos = {
|
||
top: oy + ch + arrowInset,
|
||
bottom: oy + cH - ch - arrowInset,
|
||
left: ox + cw + arrowInset,
|
||
right: ox + cW - cw - arrowInset,
|
||
};
|
||
|
||
// Draw ticks and direction arrows for each segment
|
||
segments.forEach(seg => {
|
||
const geo = edgeGeometry[seg.edge];
|
||
if (!geo) return;
|
||
|
||
const count = seg.led_count;
|
||
if (count === 0) return;
|
||
|
||
// Edge boundary ticks (first/last LED on edge) and special ticks (LED 0 position)
|
||
const edgeBounds = new Set();
|
||
edgeBounds.add(0);
|
||
if (count > 1) edgeBounds.add(count - 1);
|
||
|
||
const specialTicks = new Set();
|
||
if (offset > 0 && totalLeds > 0) {
|
||
const zeroPos = (totalLeds - seg.led_start % totalLeds) % totalLeds;
|
||
if (zeroPos < count) specialTicks.add(zeroPos);
|
||
}
|
||
|
||
// Round-number ticks get priority; edge boundary labels suppressed if overlapping
|
||
const labelsToShow = new Set([...specialTicks]);
|
||
const tickLinesOnly = new Set();
|
||
|
||
if (count > 2) {
|
||
const edgeLen = geo.horizontal ? (geo.x2 - geo.x1) : (geo.y2 - geo.y1);
|
||
const maxDigits = String(totalLeds > 0 ? totalLeds - 1 : count - 1).length;
|
||
const minSpacing = geo.horizontal ? maxDigits * 7 + 8 : 22;
|
||
|
||
const allMandatory = new Set([...edgeBounds, ...specialTicks]);
|
||
const maxIntermediate = Math.max(0, 5 - allMandatory.size);
|
||
const niceSteps = [5, 10, 25, 50, 100, 250, 500];
|
||
let step = niceSteps[niceSteps.length - 1];
|
||
for (const s of niceSteps) {
|
||
if (Math.floor(count / s) <= maxIntermediate) {
|
||
step = s;
|
||
break;
|
||
}
|
||
}
|
||
|
||
const tickPx = i => {
|
||
const f = i / (count - 1);
|
||
return (seg.reverse ? (1 - f) : f) * edgeLen;
|
||
};
|
||
|
||
// Phase 1: place round-number ticks (checked against specials + each other)
|
||
const placed = [];
|
||
specialTicks.forEach(i => placed.push(tickPx(i)));
|
||
|
||
for (let i = 1; i < count - 1; i++) {
|
||
if (specialTicks.has(i)) continue;
|
||
const idx = totalLeds > 0 ? (seg.led_start + i) % totalLeds : seg.led_start + i;
|
||
if (idx % step === 0) {
|
||
const px = tickPx(i);
|
||
if (!placed.some(p => Math.abs(px - p) < minSpacing)) {
|
||
labelsToShow.add(i);
|
||
placed.push(px);
|
||
}
|
||
}
|
||
}
|
||
|
||
// Phase 2: edge boundaries — show label unless overlapping a round-number tick
|
||
edgeBounds.forEach(bi => {
|
||
if (labelsToShow.has(bi) || specialTicks.has(bi)) return;
|
||
const px = tickPx(bi);
|
||
if (placed.some(p => Math.abs(px - p) < minSpacing)) {
|
||
tickLinesOnly.add(bi);
|
||
} else {
|
||
labelsToShow.add(bi);
|
||
placed.push(px);
|
||
}
|
||
});
|
||
} else {
|
||
edgeBounds.forEach(i => labelsToShow.add(i));
|
||
}
|
||
|
||
// Tick styling
|
||
const tickLenLong = toggleSize + 3;
|
||
const tickLenShort = 4;
|
||
ctx.strokeStyle = tickStroke;
|
||
ctx.lineWidth = 1;
|
||
ctx.fillStyle = tickFill;
|
||
ctx.font = '12px -apple-system, BlinkMacSystemFont, sans-serif';
|
||
|
||
// Draw labeled ticks
|
||
labelsToShow.forEach(i => {
|
||
const fraction = count > 1 ? i / (count - 1) : 0.5;
|
||
const displayFraction = seg.reverse ? (1 - fraction) : fraction;
|
||
const ledIndex = totalLeds > 0 ? (seg.led_start + i) % totalLeds : seg.led_start + i;
|
||
const tickLen = edgeBounds.has(i) ? tickLenLong : tickLenShort;
|
||
|
||
if (geo.horizontal) {
|
||
const tx = geo.x1 + displayFraction * (geo.x2 - geo.x1);
|
||
const axisY = axisPos[seg.edge];
|
||
const tickDir = seg.edge === 'top' ? 1 : -1;
|
||
|
||
ctx.beginPath();
|
||
ctx.moveTo(tx, axisY);
|
||
ctx.lineTo(tx, axisY + tickDir * tickLen);
|
||
ctx.stroke();
|
||
|
||
ctx.textAlign = 'center';
|
||
ctx.textBaseline = seg.edge === 'top' ? 'bottom' : 'top';
|
||
ctx.fillText(String(ledIndex), tx, axisY - tickDir * 1);
|
||
} else {
|
||
const ty = geo.y1 + displayFraction * (geo.y2 - geo.y1);
|
||
const axisX = axisPos[seg.edge];
|
||
const tickDir = seg.edge === 'left' ? 1 : -1;
|
||
|
||
ctx.beginPath();
|
||
ctx.moveTo(axisX, ty);
|
||
ctx.lineTo(axisX + tickDir * tickLen, ty);
|
||
ctx.stroke();
|
||
|
||
ctx.textBaseline = 'middle';
|
||
ctx.textAlign = seg.edge === 'left' ? 'right' : 'left';
|
||
ctx.fillText(String(ledIndex), axisX - tickDir * 1, ty);
|
||
}
|
||
});
|
||
|
||
// Draw tick lines only (no labels) for suppressed edge boundaries
|
||
tickLinesOnly.forEach(i => {
|
||
const fraction = count > 1 ? i / (count - 1) : 0.5;
|
||
const displayFraction = seg.reverse ? (1 - fraction) : fraction;
|
||
|
||
if (geo.horizontal) {
|
||
const tx = geo.x1 + displayFraction * (geo.x2 - geo.x1);
|
||
const axisY = axisPos[seg.edge];
|
||
const tickDir = seg.edge === 'top' ? 1 : -1;
|
||
ctx.beginPath();
|
||
ctx.moveTo(tx, axisY);
|
||
ctx.lineTo(tx, axisY + tickDir * tickLenLong);
|
||
ctx.stroke();
|
||
} else {
|
||
const ty = geo.y1 + displayFraction * (geo.y2 - geo.y1);
|
||
const axisX = axisPos[seg.edge];
|
||
const tickDir = seg.edge === 'left' ? 1 : -1;
|
||
ctx.beginPath();
|
||
ctx.moveTo(axisX, ty);
|
||
ctx.lineTo(axisX + tickDir * tickLenLong, ty);
|
||
ctx.stroke();
|
||
}
|
||
});
|
||
|
||
// Draw direction chevron at full-edge midpoint (not affected by span)
|
||
const s = 7;
|
||
let mx, my, angle;
|
||
if (geo.horizontal) {
|
||
mx = ox + cw + edgeLenH / 2;
|
||
my = arrowPos[seg.edge];
|
||
angle = seg.reverse ? Math.PI : 0;
|
||
} else {
|
||
mx = arrowPos[seg.edge];
|
||
my = oy + ch + edgeLenV / 2;
|
||
angle = seg.reverse ? -Math.PI / 2 : Math.PI / 2;
|
||
}
|
||
|
||
ctx.save();
|
||
ctx.translate(mx, my);
|
||
ctx.rotate(angle);
|
||
ctx.fillStyle = 'rgba(76, 175, 80, 0.85)';
|
||
ctx.strokeStyle = chevronStroke;
|
||
ctx.lineWidth = 1;
|
||
ctx.lineCap = 'round';
|
||
ctx.lineJoin = 'round';
|
||
ctx.beginPath();
|
||
ctx.moveTo(-s * 0.5, -s * 0.6);
|
||
ctx.lineTo(s * 0.5, 0);
|
||
ctx.lineTo(-s * 0.5, s * 0.6);
|
||
ctx.closePath();
|
||
ctx.fill();
|
||
ctx.stroke();
|
||
ctx.restore();
|
||
});
|
||
|
||
}
|
||
|
||
function updateSpanBars() {
|
||
const spans = window.edgeSpans || {};
|
||
const container = document.querySelector('.calibration-preview');
|
||
['top', 'right', 'bottom', 'left'].forEach(edge => {
|
||
const bar = document.querySelector(`.edge-span-bar[data-edge="${edge}"]`);
|
||
if (!bar) return;
|
||
const span = spans[edge] || { start: 0, end: 1 };
|
||
const edgeEl = bar.parentElement;
|
||
const isHorizontal = (edge === 'top' || edge === 'bottom');
|
||
|
||
if (isHorizontal) {
|
||
const totalWidth = edgeEl.clientWidth;
|
||
bar.style.left = (span.start * totalWidth) + 'px';
|
||
bar.style.width = ((span.end - span.start) * totalWidth) + 'px';
|
||
} else {
|
||
const totalHeight = edgeEl.clientHeight;
|
||
bar.style.top = (span.start * totalHeight) + 'px';
|
||
bar.style.height = ((span.end - span.start) * totalHeight) + 'px';
|
||
}
|
||
|
||
// Also reposition toggle zone to match span region
|
||
if (!container) return;
|
||
const toggle = container.querySelector(`.toggle-${edge}`);
|
||
if (!toggle) return;
|
||
if (isHorizontal) {
|
||
const cornerW = 56;
|
||
const edgeW = container.clientWidth - 2 * cornerW;
|
||
toggle.style.left = (cornerW + span.start * edgeW) + 'px';
|
||
toggle.style.right = 'auto';
|
||
toggle.style.width = ((span.end - span.start) * edgeW) + 'px';
|
||
} else {
|
||
const cornerH = 36;
|
||
const edgeH = container.clientHeight - 2 * cornerH;
|
||
toggle.style.top = (cornerH + span.start * edgeH) + 'px';
|
||
toggle.style.bottom = 'auto';
|
||
toggle.style.height = ((span.end - span.start) * edgeH) + 'px';
|
||
}
|
||
});
|
||
}
|
||
|
||
function initSpanDrag() {
|
||
const MIN_SPAN = 0.05;
|
||
|
||
document.querySelectorAll('.edge-span-bar').forEach(bar => {
|
||
const edge = bar.dataset.edge;
|
||
const isHorizontal = (edge === 'top' || edge === 'bottom');
|
||
|
||
// Prevent edge click-through when interacting with span bar
|
||
bar.addEventListener('click', e => e.stopPropagation());
|
||
|
||
// Handle resize via handles
|
||
bar.querySelectorAll('.edge-span-handle').forEach(handle => {
|
||
handle.addEventListener('mousedown', e => {
|
||
const edgeLeds = parseInt(document.getElementById(`cal-${edge}-leds`).value) || 0;
|
||
if (edgeLeds === 0) return;
|
||
e.preventDefault();
|
||
e.stopPropagation();
|
||
const handleType = handle.dataset.handle;
|
||
const edgeEl = bar.parentElement;
|
||
const rect = edgeEl.getBoundingClientRect();
|
||
|
||
function onMouseMove(ev) {
|
||
const span = window.edgeSpans[edge];
|
||
let fraction;
|
||
if (isHorizontal) {
|
||
fraction = (ev.clientX - rect.left) / rect.width;
|
||
} else {
|
||
fraction = (ev.clientY - rect.top) / rect.height;
|
||
}
|
||
fraction = Math.max(0, Math.min(1, fraction));
|
||
|
||
if (handleType === 'start') {
|
||
span.start = Math.min(fraction, span.end - MIN_SPAN);
|
||
} else {
|
||
span.end = Math.max(fraction, span.start + MIN_SPAN);
|
||
}
|
||
|
||
updateSpanBars();
|
||
renderCalibrationCanvas();
|
||
}
|
||
|
||
function onMouseUp() {
|
||
document.removeEventListener('mousemove', onMouseMove);
|
||
document.removeEventListener('mouseup', onMouseUp);
|
||
}
|
||
|
||
document.addEventListener('mousemove', onMouseMove);
|
||
document.addEventListener('mouseup', onMouseUp);
|
||
});
|
||
});
|
||
|
||
// Handle body drag (move entire span)
|
||
bar.addEventListener('mousedown', e => {
|
||
if (e.target.classList.contains('edge-span-handle')) return;
|
||
e.preventDefault();
|
||
e.stopPropagation();
|
||
|
||
const edgeEl = bar.parentElement;
|
||
const rect = edgeEl.getBoundingClientRect();
|
||
const span = window.edgeSpans[edge];
|
||
const spanWidth = span.end - span.start;
|
||
|
||
let startFraction;
|
||
if (isHorizontal) {
|
||
startFraction = (e.clientX - rect.left) / rect.width;
|
||
} else {
|
||
startFraction = (e.clientY - rect.top) / rect.height;
|
||
}
|
||
const offsetInSpan = startFraction - span.start;
|
||
|
||
function onMouseMove(ev) {
|
||
let fraction;
|
||
if (isHorizontal) {
|
||
fraction = (ev.clientX - rect.left) / rect.width;
|
||
} else {
|
||
fraction = (ev.clientY - rect.top) / rect.height;
|
||
}
|
||
|
||
let newStart = fraction - offsetInSpan;
|
||
newStart = Math.max(0, Math.min(1 - spanWidth, newStart));
|
||
span.start = newStart;
|
||
span.end = newStart + spanWidth;
|
||
|
||
updateSpanBars();
|
||
renderCalibrationCanvas();
|
||
}
|
||
|
||
function onMouseUp() {
|
||
document.removeEventListener('mousemove', onMouseMove);
|
||
document.removeEventListener('mouseup', onMouseUp);
|
||
}
|
||
|
||
document.addEventListener('mousemove', onMouseMove);
|
||
document.addEventListener('mouseup', onMouseUp);
|
||
});
|
||
});
|
||
|
||
// Initial positioning
|
||
updateSpanBars();
|
||
}
|
||
|
||
function setStartPosition(position) {
|
||
document.getElementById('cal-start-position').value = position;
|
||
updateCalibrationPreview();
|
||
}
|
||
|
||
function toggleEdgeInputs() {
|
||
const preview = document.querySelector('.calibration-preview');
|
||
if (preview) preview.classList.toggle('inputs-dimmed');
|
||
}
|
||
|
||
function toggleDirection() {
|
||
const select = document.getElementById('cal-layout');
|
||
select.value = select.value === 'clockwise' ? 'counterclockwise' : 'clockwise';
|
||
updateCalibrationPreview();
|
||
}
|
||
|
||
async function toggleTestEdge(edge) {
|
||
const edgeLeds = parseInt(document.getElementById(`cal-${edge}-leds`).value) || 0;
|
||
if (edgeLeds === 0) return;
|
||
|
||
const deviceId = document.getElementById('calibration-device-id').value;
|
||
const error = document.getElementById('calibration-error');
|
||
|
||
if (!calibrationTestState[deviceId]) {
|
||
calibrationTestState[deviceId] = new Set();
|
||
}
|
||
|
||
// Toggle edge
|
||
if (calibrationTestState[deviceId].has(edge)) {
|
||
calibrationTestState[deviceId].delete(edge);
|
||
} else {
|
||
calibrationTestState[deviceId].add(edge);
|
||
}
|
||
|
||
// Build edges dict for API
|
||
const edges = {};
|
||
calibrationTestState[deviceId].forEach(e => {
|
||
edges[e] = EDGE_TEST_COLORS[e];
|
||
});
|
||
|
||
// Update visual state immediately
|
||
updateCalibrationPreview();
|
||
|
||
try {
|
||
const response = await fetch(`${API_BASE}/devices/${deviceId}/calibration/test`, {
|
||
method: 'PUT',
|
||
headers: getHeaders(),
|
||
body: JSON.stringify({ edges })
|
||
});
|
||
|
||
if (response.status === 401) {
|
||
handle401Error();
|
||
return;
|
||
}
|
||
|
||
if (!response.ok) {
|
||
const errorData = await response.json();
|
||
error.textContent = `Test failed: ${errorData.detail}`;
|
||
error.style.display = 'block';
|
||
}
|
||
} catch (err) {
|
||
console.error('Failed to toggle test edge:', err);
|
||
error.textContent = 'Failed to toggle test edge';
|
||
error.style.display = 'block';
|
||
}
|
||
}
|
||
|
||
async function clearTestMode(deviceId) {
|
||
if (!calibrationTestState[deviceId] || calibrationTestState[deviceId].size === 0) {
|
||
return;
|
||
}
|
||
|
||
calibrationTestState[deviceId] = new Set();
|
||
|
||
try {
|
||
await fetch(`${API_BASE}/devices/${deviceId}/calibration/test`, {
|
||
method: 'PUT',
|
||
headers: getHeaders(),
|
||
body: JSON.stringify({ edges: {} })
|
||
});
|
||
} catch (err) {
|
||
console.error('Failed to clear test mode:', err);
|
||
}
|
||
}
|
||
|
||
async function saveCalibration() {
|
||
const deviceId = document.getElementById('calibration-device-id').value;
|
||
const deviceLedCount = parseInt(document.getElementById('cal-device-led-count-inline').textContent);
|
||
const error = document.getElementById('calibration-error');
|
||
|
||
// Clear test mode before saving
|
||
await clearTestMode(deviceId);
|
||
updateCalibrationPreview();
|
||
|
||
const topLeds = parseInt(document.getElementById('cal-top-leds').value || 0);
|
||
const rightLeds = parseInt(document.getElementById('cal-right-leds').value || 0);
|
||
const bottomLeds = parseInt(document.getElementById('cal-bottom-leds').value || 0);
|
||
const leftLeds = parseInt(document.getElementById('cal-left-leds').value || 0);
|
||
const total = topLeds + rightLeds + bottomLeds + leftLeds;
|
||
|
||
// Validation
|
||
if (total !== deviceLedCount) {
|
||
error.textContent = `Total LEDs (${total}) must equal device LED count (${deviceLedCount})`;
|
||
error.style.display = 'block';
|
||
return;
|
||
}
|
||
|
||
// Build calibration config
|
||
const startPosition = document.getElementById('cal-start-position').value;
|
||
const layout = document.getElementById('cal-layout').value;
|
||
const offset = parseInt(document.getElementById('cal-offset').value || 0);
|
||
|
||
const spans = window.edgeSpans || {};
|
||
const calibration = {
|
||
layout: layout,
|
||
start_position: startPosition,
|
||
offset: offset,
|
||
leds_top: topLeds,
|
||
leds_right: rightLeds,
|
||
leds_bottom: bottomLeds,
|
||
leds_left: leftLeds,
|
||
span_top_start: spans.top?.start ?? 0,
|
||
span_top_end: spans.top?.end ?? 1,
|
||
span_right_start: spans.right?.start ?? 0,
|
||
span_right_end: spans.right?.end ?? 1,
|
||
span_bottom_start: spans.bottom?.start ?? 0,
|
||
span_bottom_end: spans.bottom?.end ?? 1,
|
||
span_left_start: spans.left?.start ?? 0,
|
||
span_left_end: spans.left?.end ?? 1,
|
||
};
|
||
|
||
try {
|
||
const response = await fetch(`${API_BASE}/devices/${deviceId}/calibration`, {
|
||
method: 'PUT',
|
||
headers: getHeaders(),
|
||
body: JSON.stringify(calibration)
|
||
});
|
||
|
||
if (response.status === 401) {
|
||
handle401Error();
|
||
return;
|
||
}
|
||
|
||
if (response.ok) {
|
||
showToast('Calibration saved', 'success');
|
||
forceCloseCalibrationModal();
|
||
loadDevices();
|
||
} else {
|
||
const errorData = await response.json();
|
||
error.textContent = `Failed to save: ${errorData.detail}`;
|
||
error.style.display = 'block';
|
||
}
|
||
} catch (err) {
|
||
console.error('Failed to save calibration:', err);
|
||
error.textContent = 'Failed to save calibration';
|
||
error.style.display = 'block';
|
||
}
|
||
}
|
||
|
||
function getEdgeOrder(startPosition, layout) {
|
||
const orders = {
|
||
'bottom_left_clockwise': ['left', 'top', 'right', 'bottom'],
|
||
'bottom_left_counterclockwise': ['bottom', 'right', 'top', 'left'],
|
||
'bottom_right_clockwise': ['bottom', 'left', 'top', 'right'],
|
||
'bottom_right_counterclockwise': ['right', 'top', 'left', 'bottom'],
|
||
'top_left_clockwise': ['top', 'right', 'bottom', 'left'],
|
||
'top_left_counterclockwise': ['left', 'bottom', 'right', 'top'],
|
||
'top_right_clockwise': ['right', 'bottom', 'left', 'top'],
|
||
'top_right_counterclockwise': ['top', 'left', 'bottom', 'right']
|
||
};
|
||
|
||
return orders[`${startPosition}_${layout}`] || ['left', 'top', 'right', 'bottom'];
|
||
}
|
||
|
||
function shouldReverse(edge, startPosition, layout) {
|
||
// Determine if this edge should be reversed based on LED strip direction
|
||
const reverseRules = {
|
||
'bottom_left_clockwise': { left: true, top: false, right: false, bottom: true },
|
||
'bottom_left_counterclockwise': { bottom: false, right: true, top: true, left: false },
|
||
'bottom_right_clockwise': { bottom: true, left: true, top: false, right: false },
|
||
'bottom_right_counterclockwise': { right: true, top: true, left: false, bottom: false },
|
||
'top_left_clockwise': { top: false, right: false, bottom: true, left: true },
|
||
'top_left_counterclockwise': { left: false, bottom: false, right: true, top: true },
|
||
'top_right_clockwise': { right: false, bottom: true, left: true, top: false },
|
||
'top_right_counterclockwise': { top: true, left: false, bottom: false, right: true }
|
||
};
|
||
|
||
const rules = reverseRules[`${startPosition}_${layout}`];
|
||
return rules ? rules[edge] : false;
|
||
}
|
||
|
||
function buildSegments(calibration) {
|
||
const edgeOrder = getEdgeOrder(calibration.start_position, calibration.layout);
|
||
const edgeCounts = {
|
||
top: calibration.leds_top || 0,
|
||
right: calibration.leds_right || 0,
|
||
bottom: calibration.leds_bottom || 0,
|
||
left: calibration.leds_left || 0
|
||
};
|
||
|
||
const segments = [];
|
||
let ledStart = calibration.offset || 0;
|
||
|
||
edgeOrder.forEach(edge => {
|
||
const count = edgeCounts[edge];
|
||
if (count > 0) {
|
||
segments.push({
|
||
edge: edge,
|
||
led_start: ledStart,
|
||
led_count: count,
|
||
reverse: shouldReverse(edge, calibration.start_position, calibration.layout)
|
||
});
|
||
ledStart += count;
|
||
}
|
||
});
|
||
|
||
return segments;
|
||
}
|
||
|
||
// Close modals on backdrop click (only if mousedown also started on backdrop)
|
||
let backdropMouseDownTarget = null;
|
||
document.addEventListener('mousedown', (e) => {
|
||
backdropMouseDownTarget = e.target;
|
||
});
|
||
document.addEventListener('click', (e) => {
|
||
if (!e.target.classList.contains('modal')) return;
|
||
if (backdropMouseDownTarget !== e.target) return;
|
||
if (activeTutorial) return;
|
||
|
||
const modalId = e.target.id;
|
||
|
||
// Confirm modal: backdrop click acts as Cancel
|
||
if (modalId === 'confirm-modal') {
|
||
closeConfirmModal(false);
|
||
return;
|
||
}
|
||
|
||
// Login modal: close only if cancel button is visible (not required login)
|
||
if (modalId === 'api-key-modal') {
|
||
const cancelBtn = document.getElementById('modal-cancel-btn');
|
||
if (cancelBtn && cancelBtn.style.display !== 'none') {
|
||
closeApiKeyModal();
|
||
}
|
||
return;
|
||
}
|
||
|
||
// General settings modal: dirty check
|
||
if (modalId === 'device-settings-modal') {
|
||
closeDeviceSettingsModal();
|
||
return;
|
||
}
|
||
|
||
// Capture settings modal: dirty check
|
||
if (modalId === 'capture-settings-modal') {
|
||
closeCaptureSettingsModal();
|
||
return;
|
||
}
|
||
|
||
// Calibration modal: dirty check
|
||
if (modalId === 'calibration-modal') {
|
||
closeCalibrationModal();
|
||
return;
|
||
}
|
||
|
||
// Add device modal: close on backdrop
|
||
if (modalId === 'add-device-modal') {
|
||
closeAddDeviceModal();
|
||
return;
|
||
}
|
||
});
|
||
|
||
// Cleanup on page unload
|
||
window.addEventListener('beforeunload', () => {
|
||
if (refreshInterval) {
|
||
clearInterval(refreshInterval);
|
||
}
|
||
});
|
||
|
||
// =============================================================================
|
||
// Tutorial System (generic engine)
|
||
// =============================================================================
|
||
|
||
let activeTutorial = null;
|
||
// Shape: { steps, overlay, mode, step, resolveTarget, container }
|
||
// mode: 'absolute' (within a container) or 'fixed' (viewport-level)
|
||
|
||
const calibrationTutorialSteps = [
|
||
{ selector: '#cal-top-leds', textKey: 'calibration.tip.led_count', position: 'bottom' },
|
||
{ selector: '.corner-bottom-left', textKey: 'calibration.tip.start_corner', position: 'right' },
|
||
{ selector: '.direction-toggle', textKey: 'calibration.tip.direction', position: 'bottom' },
|
||
{ selector: '.offset-control', textKey: 'calibration.tip.offset', position: 'bottom' },
|
||
{ selector: '.edge-span-bar[data-edge="top"]', textKey: 'calibration.tip.span', position: 'bottom' },
|
||
{ selector: '.toggle-top', textKey: 'calibration.tip.test', position: 'top' },
|
||
{ selector: '.preview-screen-total', textKey: 'calibration.tip.toggle_inputs', position: 'top' }
|
||
];
|
||
|
||
const deviceTutorialSteps = [
|
||
{ selector: '.card-subtitle', textKey: 'device.tip.metadata', position: 'bottom' },
|
||
{ selector: '.brightness-control', textKey: 'device.tip.brightness', position: 'bottom' },
|
||
{ selector: '.card-actions .btn:nth-child(1)', textKey: 'device.tip.start', position: 'top' },
|
||
{ selector: '.card-actions .btn:nth-child(2)', textKey: 'device.tip.settings', position: 'top' },
|
||
{ selector: '.card-actions .btn:nth-child(3)', textKey: 'device.tip.capture_settings', position: 'top' },
|
||
{ selector: '.card-actions .btn:nth-child(4)', textKey: 'device.tip.calibrate', position: 'top' },
|
||
{ selector: '.card-actions .btn:nth-child(5)', textKey: 'device.tip.webui', position: 'top' }
|
||
];
|
||
|
||
function startTutorial(config) {
|
||
closeTutorial();
|
||
const overlay = document.getElementById(config.overlayId);
|
||
if (!overlay) return;
|
||
|
||
activeTutorial = {
|
||
steps: config.steps,
|
||
overlay: overlay,
|
||
mode: config.mode,
|
||
step: 0,
|
||
resolveTarget: config.resolveTarget,
|
||
container: config.container
|
||
};
|
||
|
||
overlay.classList.add('active');
|
||
document.addEventListener('keydown', handleTutorialKey);
|
||
showTutorialStep(0);
|
||
}
|
||
|
||
function startCalibrationTutorial() {
|
||
const container = document.querySelector('#calibration-modal .modal-body');
|
||
if (!container) return;
|
||
startTutorial({
|
||
steps: calibrationTutorialSteps,
|
||
overlayId: 'tutorial-overlay',
|
||
mode: 'absolute',
|
||
container: container,
|
||
resolveTarget: (step) => document.querySelector(step.selector)
|
||
});
|
||
}
|
||
|
||
function startDeviceTutorial(deviceId) {
|
||
// Resolve the device ID to target (don't capture card reference — it goes stale when loadDevices rebuilds DOM)
|
||
const selector = deviceId
|
||
? `.card[data-device-id="${deviceId}"]`
|
||
: '.card[data-device-id]';
|
||
if (!document.querySelector(selector)) return;
|
||
startTutorial({
|
||
steps: deviceTutorialSteps,
|
||
overlayId: 'device-tutorial-overlay',
|
||
mode: 'fixed',
|
||
container: null,
|
||
resolveTarget: (step) => {
|
||
const card = document.querySelector(selector);
|
||
if (!card) return null;
|
||
return step.global
|
||
? document.querySelector(step.selector)
|
||
: card.querySelector(step.selector);
|
||
}
|
||
});
|
||
}
|
||
|
||
function closeTutorial() {
|
||
if (!activeTutorial) return;
|
||
activeTutorial.overlay.classList.remove('active');
|
||
document.querySelectorAll('.tutorial-target').forEach(el => {
|
||
el.classList.remove('tutorial-target');
|
||
el.style.zIndex = '';
|
||
});
|
||
document.removeEventListener('keydown', handleTutorialKey);
|
||
activeTutorial = null;
|
||
}
|
||
|
||
function tutorialNext() {
|
||
if (!activeTutorial) return;
|
||
if (activeTutorial.step < activeTutorial.steps.length - 1) {
|
||
showTutorialStep(activeTutorial.step + 1);
|
||
} else {
|
||
closeTutorial();
|
||
}
|
||
}
|
||
|
||
function tutorialPrev() {
|
||
if (!activeTutorial) return;
|
||
if (activeTutorial.step > 0) {
|
||
showTutorialStep(activeTutorial.step - 1);
|
||
}
|
||
}
|
||
|
||
function showTutorialStep(index) {
|
||
if (!activeTutorial) return;
|
||
activeTutorial.step = index;
|
||
const step = activeTutorial.steps[index];
|
||
const overlay = activeTutorial.overlay;
|
||
const isFixed = activeTutorial.mode === 'fixed';
|
||
|
||
// Remove previous target highlight
|
||
document.querySelectorAll('.tutorial-target').forEach(el => {
|
||
el.classList.remove('tutorial-target');
|
||
el.style.zIndex = '';
|
||
});
|
||
|
||
// Find and highlight target
|
||
const target = activeTutorial.resolveTarget(step);
|
||
if (!target) return;
|
||
target.classList.add('tutorial-target');
|
||
// For fixed overlays, target must be above the z-index:10000 overlay
|
||
if (isFixed) target.style.zIndex = '10001';
|
||
|
||
const targetRect = target.getBoundingClientRect();
|
||
const pad = 6;
|
||
let x, y, w, h;
|
||
|
||
if (isFixed) {
|
||
// Fixed mode: coordinates are viewport-relative
|
||
x = targetRect.left - pad;
|
||
y = targetRect.top - pad;
|
||
w = targetRect.width + pad * 2;
|
||
h = targetRect.height + pad * 2;
|
||
} else {
|
||
// Absolute mode: coordinates relative to container
|
||
const containerRect = activeTutorial.container.getBoundingClientRect();
|
||
x = targetRect.left - containerRect.left - pad;
|
||
y = targetRect.top - containerRect.top - pad;
|
||
w = targetRect.width + pad * 2;
|
||
h = targetRect.height + pad * 2;
|
||
}
|
||
|
||
// Update backdrop clip-path (polygon with rectangular cutout)
|
||
const backdrop = overlay.querySelector('.tutorial-backdrop');
|
||
if (backdrop) {
|
||
backdrop.style.clipPath = `polygon(
|
||
0% 0%, 0% 100%,
|
||
${x}px 100%, ${x}px ${y}px,
|
||
${x + w}px ${y}px, ${x + w}px ${y + h}px,
|
||
${x}px ${y + h}px, ${x}px 100%,
|
||
100% 100%, 100% 0%)`;
|
||
}
|
||
|
||
// Position ring around target
|
||
const ring = overlay.querySelector('.tutorial-ring');
|
||
if (ring) {
|
||
ring.style.left = x + 'px';
|
||
ring.style.top = y + 'px';
|
||
ring.style.width = w + 'px';
|
||
ring.style.height = h + 'px';
|
||
}
|
||
|
||
// Update tooltip content
|
||
const tooltip = overlay.querySelector('.tutorial-tooltip');
|
||
const textEl = overlay.querySelector('.tutorial-tooltip-text');
|
||
const counterEl = overlay.querySelector('.tutorial-step-counter');
|
||
if (textEl) textEl.textContent = t(step.textKey);
|
||
if (counterEl) counterEl.textContent = `${index + 1} / ${activeTutorial.steps.length}`;
|
||
|
||
// Enable/disable nav buttons
|
||
const prevBtn = overlay.querySelector('.tutorial-prev-btn');
|
||
const nextBtn = overlay.querySelector('.tutorial-next-btn');
|
||
if (prevBtn) prevBtn.disabled = (index === 0);
|
||
if (nextBtn) nextBtn.textContent = (index === activeTutorial.steps.length - 1) ? '\u2713' : '\u2192';
|
||
|
||
// Position tooltip
|
||
if (tooltip) {
|
||
positionTutorialTooltip(tooltip, x, y, w, h, step.position, isFixed);
|
||
}
|
||
}
|
||
|
||
function positionTutorialTooltip(tooltip, sx, sy, sw, sh, preferred, isFixed) {
|
||
const gap = 12;
|
||
const tooltipW = 260;
|
||
// Place offscreen to measure real height without visual flash
|
||
tooltip.setAttribute('style', 'left:-9999px;top:-9999px');
|
||
const tooltipH = tooltip.offsetHeight || 150;
|
||
|
||
const positions = {
|
||
top: { x: sx + sw / 2 - tooltipW / 2, y: sy - tooltipH - gap },
|
||
bottom: { x: sx + sw / 2 - tooltipW / 2, y: sy + sh + gap },
|
||
left: { x: sx - tooltipW - gap, y: sy + sh / 2 - tooltipH / 2 },
|
||
right: { x: sx + sw + gap, y: sy + sh / 2 - tooltipH / 2 }
|
||
};
|
||
|
||
let pos = positions[preferred] || positions.bottom;
|
||
|
||
const cW = isFixed ? window.innerWidth : activeTutorial.container.clientWidth;
|
||
const cH = isFixed ? window.innerHeight : activeTutorial.container.clientHeight;
|
||
|
||
// If preferred position overflows, try opposite
|
||
if (pos.y + tooltipH > cH || pos.y < 0 || pos.x + tooltipW > cW || pos.x < 0) {
|
||
const opposite = { top: 'bottom', bottom: 'top', left: 'right', right: 'left' };
|
||
const alt = positions[opposite[preferred]];
|
||
if (alt && alt.y >= 0 && alt.y + tooltipH <= cH && alt.x >= 0 && alt.x + tooltipW <= cW) {
|
||
pos = alt;
|
||
}
|
||
}
|
||
|
||
pos.x = Math.max(8, Math.min(cW - tooltipW - 8, pos.x));
|
||
pos.y = Math.max(8, Math.min(cH - tooltipH - 8, pos.y));
|
||
|
||
// Force-set all positioning via setAttribute to avoid any style-setting quirks
|
||
tooltip.setAttribute('style', `left:${Math.round(pos.x)}px;top:${Math.round(pos.y)}px`);
|
||
}
|
||
|
||
function handleTutorialKey(e) {
|
||
if (!activeTutorial) return;
|
||
if (e.key === 'Escape') { closeTutorial(); e.stopPropagation(); }
|
||
else if (e.key === 'ArrowRight' || e.key === 'ArrowDown') { e.preventDefault(); tutorialNext(); }
|
||
else if (e.key === 'ArrowLeft' || e.key === 'ArrowUp') { e.preventDefault(); tutorialPrev(); }
|
||
}
|
||
|
||
|
||
// ===========================
|
||
// Capture Templates Functions
|
||
// ===========================
|
||
|
||
let availableEngines = [];
|
||
let currentEditingTemplateId = null;
|
||
|
||
// Load and render capture templates
|
||
async function loadCaptureTemplates() {
|
||
try {
|
||
const response = await fetchWithAuth('/capture-templates');
|
||
if (!response.ok) {
|
||
throw new Error(`Failed to load templates: ${response.status}`);
|
||
}
|
||
const data = await response.json();
|
||
_cachedCaptureTemplates = data.templates || [];
|
||
// Re-render the streams tab which now contains template sections
|
||
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 {
|
||
Object.entries(defaultConfig).forEach(([key, value]) => {
|
||
const fieldType = typeof value === 'number' ? 'number' : 'text';
|
||
const fieldValue = typeof value === 'boolean' ? (value ? 'true' : 'false') : value;
|
||
|
||
const fieldHtml = `
|
||
<div class="form-group">
|
||
<label for="config-${key}">${key}:</label>
|
||
${typeof value === 'boolean' ? `
|
||
<select id="config-${key}" data-config-key="${key}">
|
||
<option value="true" ${value ? 'selected' : ''}>true</option>
|
||
<option value="false" ${!value ? 'selected' : ''}>false</option>
|
||
</select>
|
||
` : `
|
||
<input type="${fieldType}" id="config-${key}" data-config-key="${key}" value="${fieldValue}">
|
||
`}
|
||
<small class="form-hint">${t('templates.config.default')}: ${JSON.stringify(value)}</small>
|
||
</div>
|
||
`;
|
||
configFields.innerHTML += fieldHtml;
|
||
});
|
||
}
|
||
|
||
configSection.style.display = 'block';
|
||
}
|
||
|
||
// Populate engine config fields with values
|
||
function populateEngineConfig(config) {
|
||
Object.entries(config).forEach(([key, value]) => {
|
||
const field = document.getElementById(`config-${key}`);
|
||
if (field) {
|
||
if (field.tagName === 'SELECT') {
|
||
field.value = value.toString();
|
||
} else {
|
||
field.value = value;
|
||
}
|
||
}
|
||
});
|
||
}
|
||
|
||
// Collect engine config from form
|
||
function collectEngineConfig() {
|
||
const config = {};
|
||
const fields = document.querySelectorAll('[data-config-key]');
|
||
|
||
fields.forEach(field => {
|
||
const key = field.dataset.configKey;
|
||
let value = field.value;
|
||
|
||
// Type conversion
|
||
if (field.type === 'number') {
|
||
value = parseFloat(value);
|
||
} else if (field.tagName === 'SELECT' && (value === 'true' || value === 'false')) {
|
||
value = value === 'true';
|
||
}
|
||
|
||
config[key] = value;
|
||
});
|
||
|
||
return config;
|
||
}
|
||
|
||
// Load displays for test selector
|
||
async function loadDisplaysForTest() {
|
||
try {
|
||
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')}">✕</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')}">✕</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')}">✕</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 ? '▼' : '▶'}</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' : ''}>▲</button>
|
||
<button type="button" class="btn-filter-action" onclick="moveFilter(${index}, 1)" title="${t('filters.move_down')}" ${index === _modalFilters.length - 1 ? 'disabled' : ''}>▼</button>
|
||
<button type="button" class="btn-filter-action btn-filter-remove" onclick="removeFilter(${index})" title="${t('filters.remove')}">✕</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();
|
||
}
|
||
|
||
// ===== Device Stream Selector =====
|
||
|
||
let streamSelectorInitialValues = {};
|
||
|
||
async function showStreamSelector(deviceId) {
|
||
try {
|
||
const [deviceResponse, streamsResponse, settingsResponse] = await Promise.all([
|
||
fetch(`${API_BASE}/devices/${deviceId}`, { headers: getHeaders() }),
|
||
fetchWithAuth('/picture-sources'),
|
||
fetch(`${API_BASE}/devices/${deviceId}/settings`, { headers: getHeaders() }),
|
||
]);
|
||
|
||
if (deviceResponse.status === 401) {
|
||
handle401Error();
|
||
return;
|
||
}
|
||
|
||
if (!deviceResponse.ok) {
|
||
showToast('Failed to load device', 'error');
|
||
return;
|
||
}
|
||
|
||
const device = await deviceResponse.json();
|
||
const settings = settingsResponse.ok ? await settingsResponse.json() : {};
|
||
|
||
// Populate stream select
|
||
const streamSelect = document.getElementById('stream-selector-stream');
|
||
streamSelect.innerHTML = '';
|
||
|
||
if (streamsResponse.ok) {
|
||
const data = await streamsResponse.json();
|
||
(data.streams || []).forEach(s => {
|
||
const opt = document.createElement('option');
|
||
opt.value = s.id;
|
||
const typeIcon = s.stream_type === 'raw' ? '🖥️' : '🎨';
|
||
opt.textContent = `${typeIcon} ${s.name}`;
|
||
streamSelect.appendChild(opt);
|
||
});
|
||
}
|
||
|
||
const currentStreamId = device.picture_source_id || '';
|
||
streamSelect.value = currentStreamId;
|
||
|
||
// Populate LED projection fields
|
||
const borderWidth = settings.border_width ?? device.settings?.border_width ?? 10;
|
||
const smoothing = settings.smoothing ?? device.settings?.smoothing ?? 0.3;
|
||
document.getElementById('stream-selector-border-width').value = borderWidth;
|
||
document.getElementById('stream-selector-interpolation').value = device.settings?.interpolation_mode || 'average';
|
||
document.getElementById('stream-selector-smoothing').value = smoothing;
|
||
document.getElementById('stream-selector-smoothing-value').textContent = smoothing;
|
||
|
||
streamSelectorInitialValues = {
|
||
stream: currentStreamId,
|
||
border_width: String(borderWidth),
|
||
interpolation: device.settings?.interpolation_mode || 'average',
|
||
smoothing: String(smoothing),
|
||
};
|
||
|
||
document.getElementById('stream-selector-device-id').value = deviceId;
|
||
document.getElementById('stream-selector-error').style.display = 'none';
|
||
|
||
// Show info about selected stream
|
||
updateStreamSelectorInfo(streamSelect.value);
|
||
streamSelect.onchange = () => updateStreamSelectorInfo(streamSelect.value);
|
||
|
||
const modal = document.getElementById('stream-selector-modal');
|
||
modal.style.display = 'flex';
|
||
lockBody();
|
||
setupBackdropClose(modal, closeStreamSelectorModal);
|
||
} catch (error) {
|
||
console.error('Failed to load stream settings:', error);
|
||
showToast('Failed to load stream settings', 'error');
|
||
}
|
||
}
|
||
|
||
async function updateStreamSelectorInfo(streamId) {
|
||
const infoPanel = document.getElementById('stream-selector-info');
|
||
if (!streamId) {
|
||
infoPanel.style.display = 'none';
|
||
return;
|
||
}
|
||
|
||
try {
|
||
const response = await fetchWithAuth(`/picture-sources/${streamId}`);
|
||
if (!response.ok) {
|
||
infoPanel.style.display = 'none';
|
||
return;
|
||
}
|
||
const stream = await response.json();
|
||
|
||
const typeIcon = stream.stream_type === 'raw' ? '🖥️' : stream.stream_type === 'static_image' ? '🖼️' : '🎨';
|
||
const typeName = stream.stream_type === 'raw' ? t('streams.type.raw') : stream.stream_type === 'static_image' ? t('streams.type.static_image') : t('streams.type.processed');
|
||
|
||
let propsHtml = '';
|
||
if (stream.stream_type === 'raw') {
|
||
let capTmplName = '';
|
||
if (stream.capture_template_id) {
|
||
if (!_cachedCaptureTemplates || _cachedCaptureTemplates.length === 0) {
|
||
try {
|
||
const ctResp = await fetchWithAuth('/capture-templates');
|
||
if (ctResp.ok) { const d = await ctResp.json(); _cachedCaptureTemplates = d.templates || []; }
|
||
} catch {}
|
||
}
|
||
if (_cachedCaptureTemplates) {
|
||
const capTmpl = _cachedCaptureTemplates.find(t => t.id === stream.capture_template_id);
|
||
if (capTmpl) capTmplName = escapeHtml(capTmpl.name);
|
||
}
|
||
}
|
||
propsHtml = `
|
||
<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>` : ''}
|
||
`;
|
||
} else if (stream.stream_type === 'processed') {
|
||
if ((!_cachedStreams || _cachedStreams.length === 0) && stream.source_stream_id) {
|
||
try {
|
||
const streamsResp = await fetchWithAuth('/picture-sources');
|
||
if (streamsResp.ok) { const d = await streamsResp.json(); _cachedStreams = d.streams || []; }
|
||
} catch {}
|
||
}
|
||
const sourceStream = _cachedStreams ? _cachedStreams.find(s => s.id === stream.source_stream_id) : null;
|
||
const sourceName = sourceStream ? escapeHtml(sourceStream.name) : (stream.source_stream_id || '-');
|
||
let ppTmplName = '';
|
||
if (stream.postprocessing_template_id) {
|
||
if (!_cachedPPTemplates || _cachedPPTemplates.length === 0) {
|
||
try {
|
||
const ppResp = await fetchWithAuth('/postprocessing-templates');
|
||
if (ppResp.ok) { const d = await ppResp.json(); _cachedPPTemplates = d.templates || []; }
|
||
} catch {}
|
||
}
|
||
if (_cachedPPTemplates) {
|
||
const ppTmpl = _cachedPPTemplates.find(p => p.id === stream.postprocessing_template_id);
|
||
if (ppTmpl) ppTmplName = escapeHtml(ppTmpl.name);
|
||
}
|
||
}
|
||
propsHtml = `
|
||
<span class="stream-card-prop" title="${t('streams.source')}">📺 ${sourceName}</span>
|
||
${ppTmplName ? `<span class="stream-card-prop" title="${t('streams.pp_template')}">📋 ${ppTmplName}</span>` : ''}
|
||
`;
|
||
} else if (stream.stream_type === 'static_image') {
|
||
const src = stream.image_source || '';
|
||
propsHtml = `<span class="stream-card-prop stream-card-prop-full" title="${escapeHtml(src)}">🌐 ${escapeHtml(src)}</span>`;
|
||
}
|
||
|
||
infoPanel.innerHTML = `
|
||
<div class="stream-card-props">
|
||
<span class="stream-card-prop" title="${t('streams.type')}">${typeIcon} ${typeName}</span>
|
||
${propsHtml}
|
||
</div>
|
||
`;
|
||
infoPanel.style.display = '';
|
||
} catch {
|
||
infoPanel.style.display = 'none';
|
||
}
|
||
}
|
||
|
||
async function saveStreamSelector() {
|
||
const deviceId = document.getElementById('stream-selector-device-id').value;
|
||
const pictureSourceId = document.getElementById('stream-selector-stream').value;
|
||
const borderWidth = parseInt(document.getElementById('stream-selector-border-width').value) || 10;
|
||
const interpolation = document.getElementById('stream-selector-interpolation').value;
|
||
const smoothing = parseFloat(document.getElementById('stream-selector-smoothing').value);
|
||
const errorEl = document.getElementById('stream-selector-error');
|
||
|
||
try {
|
||
// Save picture source assignment
|
||
const response = await fetch(`${API_BASE}/devices/${deviceId}`, {
|
||
method: 'PUT',
|
||
headers: getHeaders(),
|
||
body: JSON.stringify({ picture_source_id: pictureSourceId })
|
||
});
|
||
|
||
if (response.status === 401) {
|
||
handle401Error();
|
||
return;
|
||
}
|
||
|
||
if (!response.ok) {
|
||
const error = await response.json();
|
||
throw new Error(error.detail || error.message || 'Failed to save');
|
||
}
|
||
|
||
// Save LED projection settings — merge with existing to avoid overwriting other fields
|
||
const currentSettingsRes = await fetch(`${API_BASE}/devices/${deviceId}/settings`, { headers: getHeaders() });
|
||
const currentSettings = currentSettingsRes.ok ? await currentSettingsRes.json() : {};
|
||
const settingsResponse = await fetch(`${API_BASE}/devices/${deviceId}/settings`, {
|
||
method: 'PUT',
|
||
headers: getHeaders(),
|
||
body: JSON.stringify({ ...currentSettings, border_width: borderWidth, interpolation_mode: interpolation, smoothing: smoothing })
|
||
});
|
||
|
||
if (!settingsResponse.ok) {
|
||
const error = await settingsResponse.json();
|
||
throw new Error(error.detail || error.message || 'Failed to save settings');
|
||
}
|
||
|
||
showToast(t('device.stream_selector.saved'), 'success');
|
||
forceCloseStreamSelectorModal();
|
||
await loadDevices();
|
||
} catch (error) {
|
||
console.error('Error saving stream settings:', error);
|
||
errorEl.textContent = error.message;
|
||
errorEl.style.display = 'block';
|
||
}
|
||
}
|
||
|
||
function isStreamSettingsDirty() {
|
||
return (
|
||
document.getElementById('stream-selector-stream').value !== streamSelectorInitialValues.stream ||
|
||
document.getElementById('stream-selector-border-width').value !== streamSelectorInitialValues.border_width ||
|
||
document.getElementById('stream-selector-interpolation').value !== streamSelectorInitialValues.interpolation ||
|
||
document.getElementById('stream-selector-smoothing').value !== streamSelectorInitialValues.smoothing
|
||
);
|
||
}
|
||
|
||
async function closeStreamSelectorModal() {
|
||
if (isStreamSettingsDirty()) {
|
||
const confirmed = await showConfirm(t('modal.discard_changes'));
|
||
if (!confirmed) return;
|
||
}
|
||
forceCloseStreamSelectorModal();
|
||
}
|
||
|
||
function forceCloseStreamSelectorModal() {
|
||
document.getElementById('stream-selector-modal').style.display = 'none';
|
||
document.getElementById('stream-selector-error').style.display = 'none';
|
||
unlockBody();
|
||
streamSelectorInitialValues = {};
|
||
}
|