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