Split monolithic app.js into native ES modules

Replace the single 7034-line app.js with 17 ES module files organized
into core/ (state, api, i18n, ui) and features/ (calibration, dashboard,
device-discovery, devices, displays, kc-targets, pattern-templates,
profiles, streams, tabs, targets, tutorials) with an app.js entry point
that registers ~90 onclick globals on window. No bundler needed — FastAPI
serves modules directly via <script type="module">.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-02-18 17:15:00 +03:00
parent 3bac9c4ed9
commit fb1086b309
19 changed files with 7037 additions and 7041 deletions

File diff suppressed because it is too large Load Diff

View File

@@ -1103,7 +1103,7 @@
</div> </div>
</div> </div>
<script src="/static/app.js"></script> <script type="module" src="/static/js/app.js"></script>
<script> <script>
// Initialize theme // Initialize theme
const savedTheme = localStorage.getItem('theme') || 'dark'; const savedTheme = localStorage.getItem('theme') || 'dark';
@@ -1152,7 +1152,7 @@
} }
localStorage.removeItem('wled_api_key'); localStorage.removeItem('wled_api_key');
apiKey = null; if (window.setApiKey) window.setApiKey(null);
updateAuthUI(); updateAuthUI();
showToast(t('auth.logout.success'), 'info'); showToast(t('auth.logout.success'), 'info');
@@ -1224,7 +1224,7 @@
// Store the key // Store the key
localStorage.setItem('wled_api_key', key); localStorage.setItem('wled_api_key', key);
apiKey = key; if (window.setApiKey) window.setApiKey(key);
updateAuthUI(); updateAuthUI();
closeApiKeyModal(); closeApiKeyModal();
@@ -1235,11 +1235,9 @@
loadDisplays(); loadDisplays();
loadTargetsTab(); loadTargetsTab();
// Start auto-refresh if not already running // Start auto-refresh
if (!refreshInterval) {
startAutoRefresh(); startAutoRefresh();
} }
}
</script> </script>
</body> </body>
</html> </html>

View File

@@ -0,0 +1,347 @@
/**
* Entry point — imports all modules, registers globals, initializes app.
*/
// Layer 0: state
import { apiKey, setApiKey, refreshInterval } from './core/state.js';
// Layer 1: api, i18n
import { loadServerInfo, loadDisplays, configureApiKey } from './core/api.js';
import { t, initLocale, changeLocale } from './core/i18n.js';
// Layer 2: ui
import {
toggleHint, lockBody, unlockBody, closeLightbox,
showToast, showConfirm, closeConfirmModal,
openFullImageLightbox, showOverlaySpinner, hideOverlaySpinner,
} from './core/ui.js';
// Layer 3: displays, tutorials
import {
openDisplayPicker, closeDisplayPicker, selectDisplay, formatDisplayLabel,
} from './features/displays.js';
import {
startCalibrationTutorial, startDeviceTutorial,
closeTutorial, tutorialNext, tutorialPrev,
} from './features/tutorials.js';
// Layer 4: devices, dashboard, streams, kc-targets, pattern-templates, profiles
import {
showSettings, closeDeviceSettingsModal, forceCloseDeviceSettingsModal,
saveDeviceSettings, updateBrightnessLabel, saveCardBrightness,
saveDeviceStaticColor, clearDeviceStaticColor,
toggleDevicePower, removeDevice, loadDevices,
updateSettingsBaudFpsHint,
} from './features/devices.js';
import {
loadDashboard, startDashboardWS, stopDashboardWS,
dashboardToggleProfile, dashboardStartTarget, dashboardStopTarget, dashboardStopAll,
} from './features/dashboard.js';
import {
loadPictureSources, switchStreamTab,
showAddTemplateModal, editTemplate, closeTemplateModal, saveTemplate, deleteTemplate,
showTestTemplateModal, closeTestTemplateModal, onEngineChange, runTemplateTest,
showAddStreamModal, editStream, closeStreamModal, saveStream, deleteStream,
onStreamTypeChange, onStreamDisplaySelected, onTestDisplaySelected,
showTestStreamModal, closeTestStreamModal, updateStreamTestDuration, runStreamTest,
showTestPPTemplateModal, closeTestPPTemplateModal, updatePPTestDuration, runPPTemplateTest,
showAddPPTemplateModal, editPPTemplate, closePPTemplateModal, savePPTemplate, deletePPTemplate,
addFilterFromSelect, toggleFilterExpand, removeFilter, moveFilter, updateFilterOption,
renderModalFilterList, updateCaptureDuration,
} from './features/streams.js';
import {
createKCTargetCard, testKCTarget, toggleKCTestAutoRefresh,
showKCEditor, closeKCEditorModal, forceCloseKCEditorModal, saveKCEditor,
deleteKCTarget, disconnectAllKCWebSockets,
} from './features/kc-targets.js';
import {
createPatternTemplateCard,
showPatternTemplateEditor, closePatternTemplateModal, forceClosePatternTemplateModal,
savePatternTemplate, deletePatternTemplate,
renderPatternRectList, selectPatternRect, updatePatternRect,
addPatternRect, deleteSelectedPatternRect, removePatternRect,
capturePatternBackground,
} from './features/pattern-templates.js';
import {
loadProfiles, openProfileEditor, closeProfileEditorModal,
saveProfileEditor, addProfileCondition,
toggleProfileEnabled, deleteProfile,
} from './features/profiles.js';
// Layer 5: device-discovery, targets
import {
onDeviceTypeChanged, updateBaudFpsHint, onSerialPortFocus,
showAddDevice, closeAddDeviceModal, scanForDevices, handleAddDevice,
} from './features/device-discovery.js';
import {
loadTargetsTab, loadTargets, switchTargetSubTab,
showTargetEditor, closeTargetEditorModal, forceCloseTargetEditorModal, saveTargetEditor,
startTargetProcessing, stopTargetProcessing,
startTargetOverlay, stopTargetOverlay, deleteTarget,
} from './features/targets.js';
// Layer 5: calibration
import {
showCalibration, closeCalibrationModal, forceCloseCalibrationModal, saveCalibration,
updateOffsetSkipLock, updateCalibrationPreview,
setStartPosition, toggleEdgeInputs, toggleDirection, toggleTestEdge,
} from './features/calibration.js';
// Layer 6: tabs
import { switchTab, initTabs, startAutoRefresh } from './features/tabs.js';
// ─── Register all HTML onclick / onchange / onfocus globals ───
Object.assign(window, {
// core / state (for inline script)
setApiKey,
// core / ui
toggleHint,
lockBody,
unlockBody,
closeLightbox,
showToast,
showConfirm,
closeConfirmModal,
openFullImageLightbox,
showOverlaySpinner,
hideOverlaySpinner,
// core / api + i18n
t,
configureApiKey,
loadServerInfo,
loadDisplays,
changeLocale,
// displays
openDisplayPicker,
closeDisplayPicker,
selectDisplay,
formatDisplayLabel,
// tutorials
startCalibrationTutorial,
startDeviceTutorial,
closeTutorial,
tutorialNext,
tutorialPrev,
// devices
showSettings,
closeDeviceSettingsModal,
forceCloseDeviceSettingsModal,
saveDeviceSettings,
updateBrightnessLabel,
saveCardBrightness,
saveDeviceStaticColor,
clearDeviceStaticColor,
toggleDevicePower,
removeDevice,
loadDevices,
updateSettingsBaudFpsHint,
// dashboard
loadDashboard,
startDashboardWS,
stopDashboardWS,
dashboardToggleProfile,
dashboardStartTarget,
dashboardStopTarget,
dashboardStopAll,
// streams / capture templates / PP templates
loadPictureSources,
switchStreamTab,
showAddTemplateModal,
editTemplate,
closeTemplateModal,
saveTemplate,
deleteTemplate,
showTestTemplateModal,
closeTestTemplateModal,
onEngineChange,
runTemplateTest,
updateCaptureDuration,
showAddStreamModal,
editStream,
closeStreamModal,
saveStream,
deleteStream,
onStreamTypeChange,
onStreamDisplaySelected,
onTestDisplaySelected,
showTestStreamModal,
closeTestStreamModal,
updateStreamTestDuration,
runStreamTest,
showTestPPTemplateModal,
closeTestPPTemplateModal,
updatePPTestDuration,
runPPTemplateTest,
showAddPPTemplateModal,
editPPTemplate,
closePPTemplateModal,
savePPTemplate,
deletePPTemplate,
addFilterFromSelect,
toggleFilterExpand,
removeFilter,
moveFilter,
updateFilterOption,
renderModalFilterList,
// kc-targets
createKCTargetCard,
testKCTarget,
toggleKCTestAutoRefresh,
showKCEditor,
closeKCEditorModal,
forceCloseKCEditorModal,
saveKCEditor,
deleteKCTarget,
disconnectAllKCWebSockets,
// pattern-templates
createPatternTemplateCard,
showPatternTemplateEditor,
closePatternTemplateModal,
forceClosePatternTemplateModal,
savePatternTemplate,
deletePatternTemplate,
renderPatternRectList,
selectPatternRect,
updatePatternRect,
addPatternRect,
deleteSelectedPatternRect,
removePatternRect,
capturePatternBackground,
// profiles
loadProfiles,
openProfileEditor,
closeProfileEditorModal,
saveProfileEditor,
addProfileCondition,
toggleProfileEnabled,
deleteProfile,
// device-discovery
onDeviceTypeChanged,
updateBaudFpsHint,
onSerialPortFocus,
showAddDevice,
closeAddDeviceModal,
scanForDevices,
handleAddDevice,
// targets
loadTargetsTab,
loadTargets,
switchTargetSubTab,
showTargetEditor,
closeTargetEditorModal,
forceCloseTargetEditorModal,
saveTargetEditor,
startTargetProcessing,
stopTargetProcessing,
startTargetOverlay,
stopTargetOverlay,
deleteTarget,
// calibration
showCalibration,
closeCalibrationModal,
forceCloseCalibrationModal,
saveCalibration,
updateOffsetSkipLock,
updateCalibrationPreview,
setStartPosition,
toggleEdgeInputs,
toggleDirection,
toggleTestEdge,
// tabs
switchTab,
startAutoRefresh,
});
// ─── Global Escape key handler ───
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 {
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;
}
}
}
}
});
// ─── Cleanup on page unload ───
window.addEventListener('beforeunload', () => {
if (refreshInterval) {
clearInterval(refreshInterval);
}
disconnectAllKCWebSockets();
});
// ─── Initialization ───
document.addEventListener('DOMContentLoaded', async () => {
// Initialize locale first
await initLocale();
// Load API key from localStorage
setApiKey(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) {
setTimeout(() => {
if (typeof window.showApiKeyModal === 'function') {
window.showApiKeyModal('Welcome! Please login with your API key to get started.', true);
}
}, 100);
return;
}
// User is logged in, load data
loadServerInfo();
loadDisplays();
loadTargetsTab();
// Start auto-refresh
startAutoRefresh();
});

View File

@@ -0,0 +1,128 @@
/**
* API utilities — base URL, auth headers, fetch wrapper, helpers.
*/
import { apiKey, setApiKey, refreshInterval, setRefreshInterval } from './state.js';
export const API_BASE = '/api/v1';
export function getHeaders() {
const headers = {
'Content-Type': 'application/json'
};
if (apiKey) {
headers['Authorization'] = `Bearer ${apiKey}`;
}
return headers;
}
export async function fetchWithAuth(url, options = {}) {
const fullUrl = url.startsWith('http') ? url : `${API_BASE}${url}`;
const headers = options.headers
? { ...getHeaders(), ...options.headers }
: getHeaders();
return fetch(fullUrl, { ...options, headers });
}
export function escapeHtml(text) {
if (!text) return '';
const div = document.createElement('div');
div.textContent = text;
return div.innerHTML;
}
export function isSerialDevice(type) {
return type === 'adalight' || type === 'ambiled';
}
export function handle401Error() {
localStorage.removeItem('wled_api_key');
setApiKey(null);
if (refreshInterval) {
clearInterval(refreshInterval);
setRefreshInterval(null);
}
if (typeof window.updateAuthUI === 'function') {
window.updateAuthUI();
}
if (typeof window.showApiKeyModal === 'function') {
window.showApiKeyModal('Your session has expired or the API key is invalid. Please login again.', true);
} else {
// showToast imported at call sites to avoid circular dep
import('./state.js').then(() => {
const toast = document.getElementById('toast');
if (toast) {
toast.textContent = 'Authentication failed. Please reload the page and login.';
toast.className = 'toast error show';
setTimeout(() => { toast.className = 'toast'; }, 3000);
}
});
}
}
export 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';
}
}
export 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) {
// Import setter to update shared state
const { set_cachedDisplays } = await import('./state.js');
set_cachedDisplays(data.displays);
}
} catch (error) {
console.error('Failed to load displays:', error);
}
}
export 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;
}
if (key === '') {
localStorage.removeItem('wled_api_key');
setApiKey(null);
document.getElementById('api-key-btn').style.display = 'none';
} else {
localStorage.setItem('wled_api_key', key);
setApiKey(key);
document.getElementById('api-key-btn').style.display = 'inline-block';
}
loadServerInfo();
loadDisplays();
window.loadDevices();
}

View File

@@ -0,0 +1,109 @@
/**
* Internationalization — translations, locale detection, text updates.
*/
import { apiKey } from './state.js';
let currentLocale = 'en';
let translations = {};
const supportedLocales = {
'en': 'English',
'ru': 'Русский'
};
const fallbackTranslations = {
'app.title': 'LED Grab',
'auth.placeholder': 'Enter your API key...',
'auth.button.login': 'Login'
};
export function t(key, params = {}) {
let text = translations[key] || fallbackTranslations[key] || key;
Object.keys(params).forEach(param => {
text = text.replace(new RegExp(`\\{${param}\\}`, 'g'), params[param]);
});
return text;
}
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);
if (locale !== 'en') {
return await loadTranslations('en');
}
return {};
}
}
function detectBrowserLocale() {
const browserLang = navigator.language || navigator.languages?.[0] || 'en';
const langCode = browserLang.split('-')[0];
return supportedLocales[langCode] ? langCode : 'en';
}
export async function initLocale() {
const savedLocale = localStorage.getItem('locale') || detectBrowserLocale();
await setLocale(savedLocale);
}
export async function setLocale(locale) {
if (!supportedLocales[locale]) {
locale = 'en';
}
translations = await loadTranslations(locale);
currentLocale = locale;
document.documentElement.setAttribute('data-locale', locale);
document.documentElement.setAttribute('lang', locale);
localStorage.setItem('locale', locale);
updateAllText();
updateLocaleSelect();
}
export function changeLocale() {
const select = document.getElementById('locale-select');
const newLocale = select.value;
if (newLocale && newLocale !== currentLocale) {
localStorage.setItem('locale', newLocale);
setLocale(newLocale);
}
}
function updateLocaleSelect() {
const select = document.getElementById('locale-select');
if (select) {
select.value = currentLocale;
}
}
export function updateAllText() {
document.querySelectorAll('[data-i18n]').forEach(el => {
const key = el.getAttribute('data-i18n');
el.textContent = t(key);
});
document.querySelectorAll('[data-i18n-placeholder]').forEach(el => {
const key = el.getAttribute('data-i18n-placeholder');
el.placeholder = t(key);
});
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) {
import('../core/api.js').then(({ loadDisplays }) => loadDisplays());
if (typeof window.loadTargetsTab === 'function') window.loadTargetsTab();
if (typeof window.loadPictureSources === 'function') window.loadPictureSources();
}
}

View File

@@ -0,0 +1,175 @@
/**
* Shared mutable state — all global variables live here.
*
* ES module `export let` creates live bindings: importers always see
* the latest value. But importers cannot reassign, so every variable
* gets a setter function.
*/
export let apiKey = null;
export function setApiKey(v) { apiKey = v; }
export let refreshInterval = null;
export function setRefreshInterval(v) { refreshInterval = v; }
export let kcTestAutoRefresh = null;
export function setKcTestAutoRefresh(v) { kcTestAutoRefresh = v; }
export let kcTestTargetId = null;
export function setKcTestTargetId(v) { kcTestTargetId = v; }
export let _dashboardWS = null;
export function set_dashboardWS(v) { _dashboardWS = v; }
export let _cachedDisplays = null;
export function set_cachedDisplays(v) { _cachedDisplays = v; }
export let _displayPickerCallback = null;
export function set_displayPickerCallback(v) { _displayPickerCallback = v; }
export let _displayPickerSelectedIndex = null;
export function set_displayPickerSelectedIndex(v) { _displayPickerSelectedIndex = v; }
// Calibration
export let settingsInitialValues = {};
export function setSettingsInitialValues(v) { settingsInitialValues = v; }
export let calibrationInitialValues = {};
export function setCalibrationInitialValues(v) { calibrationInitialValues = v; }
export const calibrationTestState = {};
export const EDGE_TEST_COLORS = {
top: [255, 0, 0],
right: [0, 255, 0],
bottom: [0, 100, 255],
left: [255, 255, 0]
};
// Track logged errors to avoid console spam
export const loggedErrors = new Map();
// Device brightness cache
export const _deviceBrightnessCache = {};
// Discovery state
export let _discoveryScanRunning = false;
export function set_discoveryScanRunning(v) { _discoveryScanRunning = v; }
export let _discoveryCache = {};
export function set_discoveryCache(v) { _discoveryCache = v; }
// Streams / templates state
export let _cachedStreams = [];
export function set_cachedStreams(v) { _cachedStreams = v; }
export let _cachedPPTemplates = [];
export function set_cachedPPTemplates(v) { _cachedPPTemplates = v; }
export let _cachedCaptureTemplates = [];
export function set_cachedCaptureTemplates(v) { _cachedCaptureTemplates = v; }
export let _availableFilters = [];
export function set_availableFilters(v) { _availableFilters = v; }
export let availableEngines = [];
export function setAvailableEngines(v) { availableEngines = v; }
export let currentEditingTemplateId = null;
export function setCurrentEditingTemplateId(v) { currentEditingTemplateId = v; }
export let _streamNameManuallyEdited = false;
export function set_streamNameManuallyEdited(v) { _streamNameManuallyEdited = v; }
export let _streamModalPPTemplates = [];
export function set_streamModalPPTemplates(v) { _streamModalPPTemplates = v; }
export let _templateNameManuallyEdited = false;
export function set_templateNameManuallyEdited(v) { _templateNameManuallyEdited = v; }
// PP template state
export let _modalFilters = [];
export function set_modalFilters(v) { _modalFilters = v; }
export let _ppTemplateNameManuallyEdited = false;
export function set_ppTemplateNameManuallyEdited(v) { _ppTemplateNameManuallyEdited = v; }
// Stream test state
export let _currentTestStreamId = null;
export function set_currentTestStreamId(v) { _currentTestStreamId = v; }
export let _currentTestPPTemplateId = null;
export function set_currentTestPPTemplateId(v) { _currentTestPPTemplateId = v; }
export let _lastValidatedImageSource = '';
export function set_lastValidatedImageSource(v) { _lastValidatedImageSource = v; }
// Target editor state
export let targetEditorInitialValues = {};
export function setTargetEditorInitialValues(v) { targetEditorInitialValues = v; }
export let _targetEditorDevices = [];
export function set_targetEditorDevices(v) { _targetEditorDevices = v; }
// KC editor state
export let kcEditorInitialValues = {};
export function setKcEditorInitialValues(v) { kcEditorInitialValues = v; }
export let _kcNameManuallyEdited = false;
export function set_kcNameManuallyEdited(v) { _kcNameManuallyEdited = v; }
// KC WebSockets
export const kcWebSockets = {};
// Tutorial state
export let activeTutorial = null;
export function setActiveTutorial(v) { activeTutorial = v; }
// Confirm modal
export let confirmResolve = null;
export function setConfirmResolve(v) { confirmResolve = v; }
// Dashboard loading guard
export let _dashboardLoading = false;
export function set_dashboardLoading(v) { _dashboardLoading = v; }
// Pattern template editor state
export let patternEditorRects = [];
export function setPatternEditorRects(v) { patternEditorRects = v; }
export let patternEditorSelectedIdx = -1;
export function setPatternEditorSelectedIdx(v) { patternEditorSelectedIdx = v; }
export let patternEditorBgImage = null;
export function setPatternEditorBgImage(v) { patternEditorBgImage = v; }
export let patternEditorInitialValues = {};
export function setPatternEditorInitialValues(v) { patternEditorInitialValues = v; }
export let patternCanvasDragMode = null;
export function setPatternCanvasDragMode(v) { patternCanvasDragMode = v; }
export let patternCanvasDragStart = null;
export function setPatternCanvasDragStart(v) { patternCanvasDragStart = v; }
export let patternCanvasDragOrigRect = null;
export function setPatternCanvasDragOrigRect(v) { patternCanvasDragOrigRect = v; }
export let patternEditorHoveredIdx = -1;
export function setPatternEditorHoveredIdx(v) { patternEditorHoveredIdx = v; }
export let patternEditorHoverHit = null;
export function setPatternEditorHoverHit(v) { patternEditorHoverHit = v; }
export 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)',
];
export const PATTERN_RECT_BORDERS = [
'#4CAF50', '#2196F3', '#FF9800', '#9C27B0', '#00BCD4', '#F44336', '#FFEB3B', '#795548',
];
// Profiles
export let _profilesCache = null;
export function set_profilesCache(v) { _profilesCache = v; }

View File

@@ -0,0 +1,240 @@
/**
* UI utilities — modal helpers, lightbox, toast, confirm.
*/
import { kcTestAutoRefresh, setKcTestAutoRefresh, setKcTestTargetId, confirmResolve, setConfirmResolve } from './state.js';
import { t } from './i18n.js';
export 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);
}
}
export function setupBackdropClose(modal, closeFn) {
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;
}
export function lockBody() {
const scrollY = window.scrollY;
document.body.style.top = `-${scrollY}px`;
document.body.classList.add('modal-open');
}
export 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);
}
export 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();
}
export function closeLightbox(event) {
if (event && event.target && (event.target.closest('.lightbox-content') || event.target.closest('.lightbox-refresh-btn'))) return;
// Stop KC test auto-refresh if running
stopKCTestAutoRefresh();
const lightbox = document.getElementById('image-lightbox');
lightbox.classList.remove('active');
const img = document.getElementById('lightbox-image');
if (img.src.startsWith('blob:')) URL.revokeObjectURL(img.src);
img.src = '';
img.style.display = '';
document.getElementById('lightbox-stats').style.display = 'none';
const spinner = lightbox.querySelector('.lightbox-spinner');
if (spinner) spinner.style.display = 'none';
const refreshBtn = document.getElementById('lightbox-auto-refresh');
if (refreshBtn) { refreshBtn.style.display = 'none'; refreshBtn.classList.remove('active'); }
unlockBody();
}
export function stopKCTestAutoRefresh() {
if (kcTestAutoRefresh) {
clearInterval(kcTestAutoRefresh);
setKcTestAutoRefresh(null);
}
setKcTestTargetId(null);
updateAutoRefreshButton(false);
}
export function updateAutoRefreshButton(active) {
const btn = document.getElementById('lightbox-auto-refresh');
if (!btn) return;
if (active) {
btn.classList.add('active');
btn.innerHTML = '&#x23F8;';
} else {
btn.classList.remove('active');
btn.innerHTML = '&#x25B6;';
}
}
export function showToast(message, type = 'info') {
const toast = document.getElementById('toast');
toast.textContent = message;
toast.className = `toast ${type} show`;
setTimeout(() => {
toast.className = 'toast';
}, 3000);
}
export function showConfirm(message, title = null) {
return new Promise((resolve) => {
setConfirmResolve(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();
});
}
export function closeConfirmModal(result) {
const modal = document.getElementById('confirm-modal');
modal.style.display = 'none';
unlockBody();
if (confirmResolve) {
confirmResolve(result);
setConfirmResolve(null);
}
}
export async function openFullImageLightbox(imageSource) {
try {
const { API_BASE, getHeaders } = await import('./api.js');
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);
}
}
// Overlay spinner (used by capture/stream tests)
export function showOverlaySpinner(text, duration = 0) {
const existing = document.getElementById('overlay-spinner');
if (existing) {
if (window.overlaySpinnerTimer) {
clearInterval(window.overlaySpinnerTimer);
window.overlaySpinnerTimer = null;
}
existing.remove();
}
const overlay = document.createElement('div');
overlay.id = 'overlay-spinner';
overlay.className = 'overlay-spinner';
const progressContainer = document.createElement('div');
progressContainer.className = 'progress-container';
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');
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);
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);
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);
const spinnerText = document.createElement('div');
spinnerText.className = 'spinner-text';
spinnerText.textContent = text;
overlay.appendChild(progressContainer);
overlay.appendChild(spinnerText);
document.body.appendChild(overlay);
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);
const offset = circumference - (progress * circumference);
progressCircle.style.strokeDashoffset = offset;
progressPercentage.textContent = `${percentage}%`;
if (progress >= 1) {
clearInterval(window.overlaySpinnerTimer);
window.overlaySpinnerTimer = null;
}
}, 100);
}
}
export function hideOverlaySpinner() {
if (window.overlaySpinnerTimer) {
clearInterval(window.overlaySpinnerTimer);
window.overlaySpinnerTimer = null;
}
const overlay = document.getElementById('overlay-spinner');
if (overlay) overlay.remove();
}

View File

@@ -0,0 +1,753 @@
/**
* Calibration — calibration modal, canvas, drag handlers, edge test.
*/
import {
calibrationInitialValues, setCalibrationInitialValues,
calibrationTestState, EDGE_TEST_COLORS,
} from '../core/state.js';
import { API_BASE, getHeaders, handle401Error } from '../core/api.js';
import { t } from '../core/i18n.js';
import { lockBody, unlockBody, showToast, showConfirm } from '../core/ui.js';
import { closeTutorial, startCalibrationTutorial } from './tutorials.js';
export async function showCalibration(deviceId) {
try {
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;
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 = '';
}
document.getElementById('calibration-device-id').value = device.id;
document.getElementById('cal-device-led-count-inline').textContent = device.led_count;
document.getElementById('cal-start-position').value = calibration.start_position;
document.getElementById('cal-layout').value = calibration.layout;
document.getElementById('cal-offset').value = calibration.offset || 0;
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;
document.getElementById('cal-skip-start').value = calibration.skip_leds_start || 0;
document.getElementById('cal-skip-end').value = calibration.skip_leds_end || 0;
updateOffsetSkipLock();
document.getElementById('cal-border-width').value = calibration.border_width || 10;
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 },
};
setCalibrationInitialValues({
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),
skip_start: String(calibration.skip_leds_start || 0),
skip_end: String(calibration.skip_leds_end || 0),
border_width: String(calibration.border_width || 10),
});
calibrationTestState[device.id] = new Set();
updateCalibrationPreview();
const modal = document.getElementById('calibration-modal');
modal.style.display = 'flex';
lockBody();
initSpanDrag();
requestAnimationFrame(() => {
renderCalibrationCanvas();
if (!localStorage.getItem('calibrationTutorialSeen')) {
localStorage.setItem('calibrationTutorialSeen', '1');
startCalibrationTutorial();
}
});
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 ||
document.getElementById('cal-skip-start').value !== calibrationInitialValues.skip_start ||
document.getElementById('cal-skip-end').value !== calibrationInitialValues.skip_end ||
document.getElementById('cal-border-width').value !== calibrationInitialValues.border_width
);
}
export 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();
setCalibrationInitialValues({});
}
export async function closeCalibrationModal() {
if (isCalibrationDirty()) {
const confirmed = await showConfirm(t('modal.discard_changes'));
if (!confirmed) return;
}
forceCloseCalibrationModal();
}
export function updateOffsetSkipLock() {
const offsetEl = document.getElementById('cal-offset');
const skipStartEl = document.getElementById('cal-skip-start');
const skipEndEl = document.getElementById('cal-skip-end');
const hasOffset = parseInt(offsetEl.value || 0) > 0;
const hasSkip = parseInt(skipStartEl.value || 0) > 0 || parseInt(skipEndEl.value || 0) > 0;
skipStartEl.disabled = hasOffset;
skipEndEl.disabled = hasOffset;
offsetEl.disabled = hasSkip;
}
export function updateCalibrationPreview() {
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);
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);
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');
}
});
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';
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 = '';
}
});
['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);
});
updateSpanBars();
renderCalibrationCanvas();
}
export 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;
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);
const ox = padX;
const oy = padY;
const cW = containerRect.width;
const cH = containerRect.height;
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 skipStart = parseInt(document.getElementById('cal-skip-start').value || 0);
const skipEnd = parseInt(document.getElementById('cal-skip-end').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;
const hasSkip = (skipStart > 0 || skipEnd > 0) && totalLeds > 1;
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)';
const cw = 56;
const ch = 36;
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 },
};
const toggleSize = 16;
const axisPos = {
top: oy - toggleSize - 3,
bottom: oy + cH + toggleSize + 3,
left: ox - toggleSize - 3,
right: ox + cW + toggleSize + 3,
};
const arrowInset = 12;
const arrowPos = {
top: oy + ch + arrowInset,
bottom: oy + cH - ch - arrowInset,
left: ox + cw + arrowInset,
right: ox + cW - cw - arrowInset,
};
segments.forEach(seg => {
const geo = edgeGeometry[seg.edge];
if (!geo) return;
const count = seg.led_count;
if (count === 0) return;
const edgeDisplayStart = hasSkip ? Math.max(seg.led_start, skipStart) : seg.led_start;
const edgeDisplayEnd = hasSkip ? Math.min(seg.led_start + count, totalLeds - skipEnd) : seg.led_start + count - 1;
const edgeDisplayRange = edgeDisplayEnd - edgeDisplayStart;
const toEdgeLabel = (i) => {
if (!hasSkip) return totalLeds > 0 ? (seg.led_start + i) % totalLeds : seg.led_start + i;
if (count <= 1) return edgeDisplayStart;
return Math.round(edgeDisplayStart + i / (count - 1) * edgeDisplayRange);
};
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);
}
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;
};
const placed = [];
specialTicks.forEach(i => placed.push(tickPx(i)));
for (let i = 1; i < count - 1; i++) {
if (specialTicks.has(i)) continue;
if (toEdgeLabel(i) % step === 0) {
const px = tickPx(i);
if (!placed.some(p => Math.abs(px - p) < minSpacing)) {
labelsToShow.add(i);
placed.push(px);
}
}
}
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));
}
const tickLenLong = toggleSize + 3;
const tickLenShort = 4;
ctx.strokeStyle = tickStroke;
ctx.lineWidth = 1;
ctx.fillStyle = tickFill;
ctx.font = '12px -apple-system, BlinkMacSystemFont, sans-serif';
labelsToShow.forEach(i => {
const fraction = count > 1 ? i / (count - 1) : 0.5;
const displayFraction = seg.reverse ? (1 - fraction) : fraction;
const displayLabel = toEdgeLabel(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(displayLabel), 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(displayLabel), axisX - tickDir * 1, ty);
}
});
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();
}
});
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';
}
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');
bar.addEventListener('click', e => e.stopPropagation());
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);
});
});
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);
});
});
updateSpanBars();
}
export function setStartPosition(position) {
document.getElementById('cal-start-position').value = position;
updateCalibrationPreview();
}
export function toggleEdgeInputs() {
const preview = document.querySelector('.calibration-preview');
if (preview) preview.classList.toggle('inputs-dimmed');
}
export function toggleDirection() {
const select = document.getElementById('cal-layout');
select.value = select.value === 'clockwise' ? 'counterclockwise' : 'clockwise';
updateCalibrationPreview();
}
export 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();
if (calibrationTestState[deviceId].has(edge)) calibrationTestState[deviceId].delete(edge);
else calibrationTestState[deviceId].add(edge);
const edges = {};
calibrationTestState[deviceId].forEach(e => { edges[e] = EDGE_TEST_COLORS[e]; });
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);
}
}
export 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');
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;
if (total !== deviceLedCount) {
error.textContent = `Total LEDs (${total}) must equal device LED count (${deviceLedCount})`;
error.style.display = 'block';
return;
}
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, start_position: startPosition, 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,
skip_leds_start: parseInt(document.getElementById('cal-skip-start').value || 0),
skip_leds_end: parseInt(document.getElementById('cal-skip-end').value || 0),
border_width: parseInt(document.getElementById('cal-border-width').value) || 10,
};
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();
window.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) {
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,
led_start: ledStart,
led_count: count,
reverse: shouldReverse(edge, calibration.start_position, calibration.layout)
});
ledStart += count;
}
});
return segments;
}

View File

@@ -0,0 +1,322 @@
/**
* Dashboard — real-time target status overview.
*/
import { apiKey, _dashboardWS, set_dashboardWS, _dashboardLoading, set_dashboardLoading } from '../core/state.js';
import { API_BASE, getHeaders, fetchWithAuth } from '../core/api.js';
import { t } from '../core/i18n.js';
import { escapeHtml, handle401Error } from '../core/api.js';
import { showToast } from '../core/ui.js';
function formatUptime(seconds) {
if (!seconds || seconds <= 0) return '-';
const h = Math.floor(seconds / 3600);
const m = Math.floor((seconds % 3600) / 60);
const s = Math.floor(seconds % 60);
if (h > 0) return `${h}h ${m}m`;
if (m > 0) return `${m}m ${s}s`;
return `${s}s`;
}
export async function loadDashboard() {
if (_dashboardLoading) return;
set_dashboardLoading(true);
const container = document.getElementById('dashboard-content');
if (!container) { set_dashboardLoading(false); return; }
try {
const [targetsResp, profilesResp] = await Promise.all([
fetch(`${API_BASE}/picture-targets`, { headers: getHeaders() }),
fetch(`${API_BASE}/profiles`, { headers: getHeaders() }).catch(() => null),
]);
if (targetsResp.status === 401) { handle401Error(); return; }
const targetsData = await targetsResp.json();
const targets = targetsData.targets || [];
const profilesData = profilesResp && profilesResp.ok ? await profilesResp.json() : { profiles: [] };
const profiles = profilesData.profiles || [];
if (targets.length === 0 && profiles.length === 0) {
container.innerHTML = `<div class="dashboard-no-targets">${t('dashboard.no_targets')}</div>`;
return;
}
const enriched = await Promise.all(targets.map(async (target) => {
try {
const [stateResp, metricsResp] = await Promise.all([
fetch(`${API_BASE}/picture-targets/${target.id}/state`, { headers: getHeaders() }),
fetch(`${API_BASE}/picture-targets/${target.id}/metrics`, { headers: getHeaders() }),
]);
const state = stateResp.ok ? await stateResp.json() : {};
const metrics = metricsResp.ok ? await metricsResp.json() : {};
return { ...target, state, metrics };
} catch {
return target;
}
}));
const running = enriched.filter(t => t.state && t.state.processing);
const stopped = enriched.filter(t => !t.state || !t.state.processing);
let html = '';
if (profiles.length > 0) {
const activeProfiles = profiles.filter(p => p.is_active);
const inactiveProfiles = profiles.filter(p => !p.is_active);
html += `<div class="dashboard-section">
<div class="dashboard-section-header">
${t('dashboard.section.profiles')}
<span class="dashboard-section-count">${profiles.length}</span>
</div>
${activeProfiles.map(p => renderDashboardProfile(p)).join('')}
${inactiveProfiles.map(p => renderDashboardProfile(p)).join('')}
</div>`;
}
if (running.length > 0) {
html += `<div class="dashboard-section">
<div class="dashboard-section-header">
${t('dashboard.section.running')}
<span class="dashboard-section-count">${running.length}</span>
<button class="btn btn-sm btn-danger dashboard-stop-all" onclick="dashboardStopAll()" title="${t('dashboard.stop_all')}">⏹️ ${t('dashboard.stop_all')}</button>
</div>
${running.map(target => renderDashboardTarget(target, true)).join('')}
</div>`;
}
if (stopped.length > 0) {
html += `<div class="dashboard-section">
<div class="dashboard-section-header">
${t('dashboard.section.stopped')}
<span class="dashboard-section-count">${stopped.length}</span>
</div>
${stopped.map(target => renderDashboardTarget(target, false)).join('')}
</div>`;
}
container.innerHTML = html;
} catch (error) {
console.error('Failed to load dashboard:', error);
container.innerHTML = `<div class="dashboard-no-targets">${t('dashboard.failed')}</div>`;
} finally {
set_dashboardLoading(false);
}
}
function renderDashboardTarget(target, isRunning) {
const state = target.state || {};
const metrics = target.metrics || {};
const isLed = target.target_type === 'led' || target.target_type === 'wled';
const icon = '⚡';
const typeLabel = isLed ? 'LED' : 'Key Colors';
let subtitleParts = [typeLabel];
if (isLed && state.device_name) {
subtitleParts.push(state.device_name);
}
if (isRunning) {
const fpsActual = state.fps_actual != null ? state.fps_actual.toFixed(1) : '-';
const fpsTarget = state.fps_target || (target.settings || target.key_colors_settings || {}).fps || '-';
const uptime = formatUptime(metrics.uptime_seconds);
const errors = metrics.errors_count || 0;
let healthDot = '';
if (isLed && state.device_last_checked != null) {
const cls = state.device_online ? 'health-online' : 'health-offline';
healthDot = `<span class="health-dot ${cls}"></span>`;
}
return `<div class="dashboard-target">
<div class="dashboard-target-info">
<span class="dashboard-target-icon">${icon}</span>
<div>
<div class="dashboard-target-name">${escapeHtml(target.name)}${healthDot}</div>
${subtitleParts.length ? `<div class="dashboard-target-subtitle">${escapeHtml(subtitleParts.join(' · '))}</div>` : ''}
</div>
</div>
<div class="dashboard-target-metrics">
<div class="dashboard-metric">
<div class="dashboard-metric-value">${fpsActual}/${fpsTarget}</div>
<div class="dashboard-metric-label">${t('dashboard.fps')}</div>
</div>
<div class="dashboard-metric">
<div class="dashboard-metric-value">${uptime}</div>
<div class="dashboard-metric-label">${t('dashboard.uptime')}</div>
</div>
<div class="dashboard-metric">
<div class="dashboard-metric-value">${errors}</div>
<div class="dashboard-metric-label">${t('dashboard.errors')}</div>
</div>
</div>
<div class="dashboard-target-actions">
<button class="btn btn-icon btn-warning" onclick="dashboardStopTarget('${target.id}')" title="${t('device.button.stop')}">⏸</button>
</div>
</div>`;
} else {
return `<div class="dashboard-target">
<div class="dashboard-target-info">
<span class="dashboard-target-icon">${icon}</span>
<div>
<div class="dashboard-target-name">${escapeHtml(target.name)}</div>
${subtitleParts.length ? `<div class="dashboard-target-subtitle">${escapeHtml(subtitleParts.join(' · '))}</div>` : ''}
</div>
</div>
<div class="dashboard-target-metrics"></div>
<div class="dashboard-target-actions">
<button class="btn btn-icon btn-success" onclick="dashboardStartTarget('${target.id}')" title="${t('device.button.start')}">▶</button>
</div>
</div>`;
}
}
function renderDashboardProfile(profile) {
const isActive = profile.is_active;
const isDisabled = !profile.enabled;
let condSummary = '';
if (profile.conditions.length > 0) {
const parts = profile.conditions.map(c => {
if (c.condition_type === 'application') {
const apps = (c.apps || []).join(', ');
const matchLabel = c.match_type === 'topmost' ? t('profiles.condition.application.match_type.topmost') : t('profiles.condition.application.match_type.running');
return `${apps} (${matchLabel})`;
}
return c.condition_type;
});
const logic = profile.condition_logic === 'and' ? ' & ' : ' | ';
condSummary = parts.join(logic);
}
const statusBadge = isDisabled
? `<span class="dashboard-badge-stopped">${t('profiles.status.disabled')}</span>`
: isActive
? `<span class="dashboard-badge-active">${t('profiles.status.active')}</span>`
: `<span class="dashboard-badge-stopped">${t('profiles.status.inactive')}</span>`;
const targetCount = profile.target_ids.length;
const activeCount = (profile.active_target_ids || []).length;
const targetsInfo = isActive ? `${activeCount}/${targetCount}` : `${targetCount}`;
return `<div class="dashboard-target dashboard-profile">
<div class="dashboard-target-info">
<span class="dashboard-target-icon">📋</span>
<div>
<div class="dashboard-target-name">${escapeHtml(profile.name)}</div>
${condSummary ? `<div class="dashboard-target-subtitle">${escapeHtml(condSummary)}</div>` : ''}
</div>
${statusBadge}
</div>
<div class="dashboard-target-metrics">
<div class="dashboard-metric">
<div class="dashboard-metric-value">${targetsInfo}</div>
<div class="dashboard-metric-label">${t('dashboard.targets')}</div>
</div>
</div>
<div class="dashboard-target-actions">
<button class="btn btn-icon ${profile.enabled ? 'btn-warning' : 'btn-success'}" onclick="dashboardToggleProfile('${profile.id}', ${!profile.enabled})" title="${profile.enabled ? t('profiles.status.disabled') : t('profiles.status.active')}">
${profile.enabled ? '⏸' : '▶'}
</button>
</div>
</div>`;
}
export async function dashboardToggleProfile(profileId, enable) {
try {
const endpoint = enable ? 'enable' : 'disable';
const response = await fetch(`${API_BASE}/profiles/${profileId}/${endpoint}`, {
method: 'POST',
headers: getHeaders()
});
if (response.status === 401) { handle401Error(); return; }
if (response.ok) {
loadDashboard();
}
} catch (error) {
showToast('Failed to toggle profile', 'error');
}
}
export async function dashboardStartTarget(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');
loadDashboard();
} else {
const error = await response.json();
showToast(`Failed to start: ${error.detail}`, 'error');
}
} catch (error) {
showToast('Failed to start processing', 'error');
}
}
export async function dashboardStopTarget(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');
loadDashboard();
} else {
const error = await response.json();
showToast(`Failed to stop: ${error.detail}`, 'error');
}
} catch (error) {
showToast('Failed to stop processing', 'error');
}
}
export async function dashboardStopAll() {
try {
const targetsResp = await fetch(`${API_BASE}/picture-targets`, { headers: getHeaders() });
if (targetsResp.status === 401) { handle401Error(); return; }
const data = await targetsResp.json();
const running = (data.targets || []).filter(t => t.id);
await Promise.all(running.map(t =>
fetch(`${API_BASE}/picture-targets/${t.id}/stop`, { method: 'POST', headers: getHeaders() }).catch(() => {})
));
loadDashboard();
} catch (error) {
showToast('Failed to stop all targets', 'error');
}
}
export function startDashboardWS() {
stopDashboardWS();
if (!apiKey) return;
const wsProto = location.protocol === 'https:' ? 'wss:' : 'ws:';
const url = `${wsProto}//${location.host}/api/v1/events/ws?token=${apiKey}`;
try {
set_dashboardWS(new WebSocket(url));
_dashboardWS.onmessage = (event) => {
try {
const data = JSON.parse(event.data);
if (data.type === 'state_change' || data.type === 'profile_state_changed') {
loadDashboard();
}
} catch {}
};
_dashboardWS.onclose = () => { set_dashboardWS(null); };
_dashboardWS.onerror = () => { set_dashboardWS(null); };
} catch {
set_dashboardWS(null);
}
}
export function stopDashboardWS() {
if (_dashboardWS) {
_dashboardWS.close();
set_dashboardWS(null);
}
}

View File

@@ -0,0 +1,338 @@
/**
* Device discovery — add device modal, network/serial scanning, device type switching.
*/
import {
_discoveryScanRunning, set_discoveryScanRunning,
_discoveryCache, set_discoveryCache,
settingsInitialValues,
} from '../core/state.js';
import { API_BASE, getHeaders, isSerialDevice, escapeHtml, handle401Error } from '../core/api.js';
import { t } from '../core/i18n.js';
import { lockBody, unlockBody, showToast } from '../core/ui.js';
import { _computeMaxFps, _renderFpsHint } from './devices.js';
export function onDeviceTypeChanged() {
const deviceType = document.getElementById('device-type').value;
const urlGroup = document.getElementById('device-url-group');
const urlInput = document.getElementById('device-url');
const serialGroup = document.getElementById('device-serial-port-group');
const serialSelect = document.getElementById('device-serial-port');
const ledCountGroup = document.getElementById('device-led-count-group');
const discoverySection = document.getElementById('discovery-section');
const baudRateGroup = document.getElementById('device-baud-rate-group');
if (isSerialDevice(deviceType)) {
urlGroup.style.display = 'none';
urlInput.removeAttribute('required');
serialGroup.style.display = '';
serialSelect.setAttribute('required', '');
ledCountGroup.style.display = '';
baudRateGroup.style.display = '';
// Hide discovery list — serial port dropdown replaces it
if (discoverySection) discoverySection.style.display = 'none';
// Populate from cache or show placeholder (lazy-load on focus)
if (deviceType in _discoveryCache) {
_populateSerialPortDropdown(_discoveryCache[deviceType]);
} else {
serialSelect.innerHTML = '';
const opt = document.createElement('option');
opt.value = '';
opt.textContent = t('device.serial_port.hint') || 'Click to discover ports...';
opt.disabled = true;
serialSelect.appendChild(opt);
}
updateBaudFpsHint();
} else {
urlGroup.style.display = '';
urlInput.setAttribute('required', '');
serialGroup.style.display = 'none';
serialSelect.removeAttribute('required');
ledCountGroup.style.display = 'none';
baudRateGroup.style.display = 'none';
// Show cached results or trigger scan for WLED
if (deviceType in _discoveryCache) {
_renderDiscoveryList();
} else {
scanForDevices();
}
}
}
export function updateBaudFpsHint() {
const hintEl = document.getElementById('baud-fps-hint');
const baudRate = parseInt(document.getElementById('device-baud-rate').value, 10);
const ledCount = parseInt(document.getElementById('device-led-count').value, 10);
const deviceType = document.getElementById('device-type')?.value || 'adalight';
_renderFpsHint(hintEl, baudRate, ledCount, deviceType);
}
function _renderDiscoveryList() {
const selectedType = document.getElementById('device-type').value;
const devices = _discoveryCache[selectedType];
// Serial devices: populate serial port dropdown instead of discovery list
if (isSerialDevice(selectedType)) {
_populateSerialPortDropdown(devices || []);
return;
}
// WLED and others: render discovery list cards
const list = document.getElementById('discovery-list');
const empty = document.getElementById('discovery-empty');
const section = document.getElementById('discovery-section');
if (!list || !section) return;
list.innerHTML = '';
if (!devices) {
section.style.display = 'none';
return;
}
section.style.display = 'block';
if (devices.length === 0) {
empty.style.display = 'block';
return;
}
empty.style.display = 'none';
devices.forEach(device => {
const card = document.createElement('div');
card.className = 'discovery-item' + (device.already_added ? ' discovery-item--added' : '');
const meta = [device.ip];
if (device.led_count) meta.push(device.led_count + ' LEDs');
if (device.version) meta.push('v' + device.version);
card.innerHTML = `
<div class="discovery-item-info">
<strong>${escapeHtml(device.name)}</strong>
<small>${escapeHtml(meta.join(' \u00b7 '))}</small>
</div>
${device.already_added
? '<span class="discovery-badge">' + t('device.scan.already_added') + '</span>'
: ''}
`;
if (!device.already_added) {
card.onclick = () => selectDiscoveredDevice(device);
}
list.appendChild(card);
});
}
function _populateSerialPortDropdown(devices) {
const select = document.getElementById('device-serial-port');
select.innerHTML = '';
if (devices.length === 0) {
const opt = document.createElement('option');
opt.value = '';
opt.textContent = t('device.serial_port.none') || 'No serial ports found';
opt.disabled = true;
select.appendChild(opt);
return;
}
devices.forEach(device => {
const opt = document.createElement('option');
opt.value = device.url;
opt.textContent = device.name;
if (device.already_added) {
opt.textContent += ' (' + t('device.scan.already_added') + ')';
}
select.appendChild(opt);
});
}
export function onSerialPortFocus() {
// Lazy-load: trigger discovery when user opens the serial port dropdown
const deviceType = document.getElementById('device-type')?.value || 'adalight';
if (!(deviceType in _discoveryCache)) {
scanForDevices(deviceType);
}
}
export 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';
set_discoveryCache({});
// Reset discovery section
const section = document.getElementById('discovery-section');
if (section) {
section.style.display = 'none';
document.getElementById('discovery-list').innerHTML = '';
document.getElementById('discovery-empty').style.display = 'none';
document.getElementById('discovery-loading').style.display = 'none';
}
// Reset serial port dropdown
document.getElementById('device-serial-port').innerHTML = '';
const scanBtn = document.getElementById('scan-network-btn');
if (scanBtn) scanBtn.disabled = false;
modal.style.display = 'flex';
lockBody();
onDeviceTypeChanged();
setTimeout(() => document.getElementById('device-name').focus(), 100);
}
export function closeAddDeviceModal() {
const modal = document.getElementById('add-device-modal');
modal.style.display = 'none';
unlockBody();
}
export async function scanForDevices(forceType) {
const scanType = forceType || document.getElementById('device-type')?.value || 'wled';
// Per-type guard: prevent duplicate scans for the same type
if (_discoveryScanRunning === scanType) return;
set_discoveryScanRunning(scanType);
const loading = document.getElementById('discovery-loading');
const list = document.getElementById('discovery-list');
const empty = document.getElementById('discovery-empty');
const section = document.getElementById('discovery-section');
const scanBtn = document.getElementById('scan-network-btn');
if (isSerialDevice(scanType)) {
// Show loading in the serial port dropdown
const select = document.getElementById('device-serial-port');
select.innerHTML = '';
const opt = document.createElement('option');
opt.value = '';
opt.textContent = '\u23F3';
opt.disabled = true;
select.appendChild(opt);
} else {
// Show the discovery section with loading spinner
section.style.display = 'block';
loading.style.display = 'flex';
list.innerHTML = '';
empty.style.display = 'none';
}
if (scanBtn) scanBtn.disabled = true;
try {
const response = await fetch(`${API_BASE}/devices/discover?timeout=3&device_type=${encodeURIComponent(scanType)}`, {
headers: getHeaders()
});
if (response.status === 401) { handle401Error(); return; }
loading.style.display = 'none';
if (scanBtn) scanBtn.disabled = false;
if (!response.ok) {
if (!isSerialDevice(scanType)) {
empty.style.display = 'block';
empty.querySelector('small').textContent = t('device.scan.error');
}
return;
}
const data = await response.json();
_discoveryCache[scanType] = data.devices || [];
// Only render if the user is still on this type
const currentType = document.getElementById('device-type')?.value;
if (currentType === scanType) {
_renderDiscoveryList();
}
} catch (err) {
loading.style.display = 'none';
if (scanBtn) scanBtn.disabled = false;
if (!isSerialDevice(scanType)) {
empty.style.display = 'block';
empty.querySelector('small').textContent = t('device.scan.error');
}
console.error('Device scan failed:', err);
} finally {
if (_discoveryScanRunning === scanType) {
set_discoveryScanRunning(false);
}
}
}
export function selectDiscoveredDevice(device) {
document.getElementById('device-name').value = device.name;
const typeSelect = document.getElementById('device-type');
if (typeSelect) typeSelect.value = device.device_type;
onDeviceTypeChanged();
if (isSerialDevice(device.device_type)) {
document.getElementById('device-serial-port').value = device.url;
} else {
document.getElementById('device-url').value = device.url;
}
showToast(t('device.scan.selected'), 'info');
}
export async function handleAddDevice(event) {
event.preventDefault();
const name = document.getElementById('device-name').value.trim();
const deviceType = document.getElementById('device-type')?.value || 'wled';
const url = isSerialDevice(deviceType)
? document.getElementById('device-serial-port').value
: 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, device_type: deviceType };
const ledCountInput = document.getElementById('device-led-count');
if (ledCountInput && ledCountInput.value) {
body.led_count = parseInt(ledCountInput.value, 10);
}
const baudRateSelect = document.getElementById('device-baud-rate');
if (isSerialDevice(deviceType) && baudRateSelect && baudRateSelect.value) {
body.baud_rate = parseInt(baudRateSelect.value, 10);
}
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();
// Use window.* to avoid circular imports
if (typeof window.loadDevices === 'function') await window.loadDevices();
// Auto-start device tutorial on first device add
if (!localStorage.getItem('deviceTutorialSeen')) {
localStorage.setItem('deviceTutorialSeen', '1');
setTimeout(() => {
if (typeof window.startDeviceTutorial === 'function') window.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');
}
}

View File

@@ -0,0 +1,453 @@
/**
* Device cards — settings modal, brightness, power, color.
*/
import {
settingsInitialValues, setSettingsInitialValues,
_deviceBrightnessCache,
} from '../core/state.js';
import { API_BASE, getHeaders, escapeHtml, isSerialDevice, handle401Error } from '../core/api.js';
import { t } from '../core/i18n.js';
import { lockBody, unlockBody, showToast, showConfirm } from '../core/ui.js';
export function createDeviceCard(device) {
const state = device.state || {};
const devOnline = state.device_online || false;
const devLatency = state.device_latency_ms;
const devName = state.device_name;
const devVersion = state.device_version;
const devLastChecked = state.device_last_checked;
let healthClass, healthTitle, healthLabel;
if (devLastChecked === null || devLastChecked === undefined) {
healthClass = 'health-unknown';
healthTitle = t('device.health.checking');
healthLabel = '';
} else if (devOnline) {
healthClass = 'health-online';
healthTitle = `${t('device.health.online')}`;
if (devName) healthTitle += ` - ${devName}`;
if (devVersion) healthTitle += ` v${devVersion}`;
if (devLatency !== null && devLatency !== undefined) healthTitle += ` (${Math.round(devLatency)}ms)`;
healthLabel = '';
} else {
healthClass = 'health-offline';
healthTitle = t('device.health.offline');
if (state.device_error) healthTitle += `: ${state.device_error}`;
healthLabel = `<span class="health-latency offline">${t('device.health.offline')}</span>`;
}
const ledCount = state.device_led_count || device.led_count;
return `
<div class="card" data-device-id="${device.id}">
<div class="card-top-actions">
${(device.capabilities || []).includes('power_control') ? `<button class="card-top-btn card-power-btn" onclick="toggleDevicePower('${device.id}')" title="${t('device.button.power_toggle')}">⏻</button>` : ''}
<button class="card-remove-btn" onclick="removeDevice('${device.id}')" title="${t('device.button.remove')}">&#x2715;</button>
</div>
<div class="card-header">
<div class="card-title">
<span class="health-dot ${healthClass}" title="${healthTitle}"></span>
${device.name || device.id}
${device.url && device.url.startsWith('http') ? `<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>` : (device.url && !device.url.startsWith('http') ? `<span class="device-url-badge"><span class="device-url-text">${escapeHtml(device.url)}</span></span>` : '')}
${healthLabel}
</div>
</div>
<div class="card-subtitle">
<span class="card-meta device-type-badge">${(device.device_type || 'wled').toUpperCase()}</span>
${ledCount ? `<span class="card-meta" title="${t('device.led_count')}">💡 ${ledCount}</span>` : ''}
${state.device_led_type ? `<span class="card-meta">🔌 ${state.device_led_type.replace(/ RGBW$/, '')}</span>` : ''}
<span class="card-meta" title="${state.device_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.device_rgbw ? '<span class="ch" style="background:#eee"></span>' : ''}</span></span>
${(device.capabilities || []).includes('static_color') ? `<span class="card-meta static-color-control" data-color-wrap="${device.id}"><input type="color" class="static-color-picker" value="${device.static_color ? rgbToHex(...device.static_color) : '#000000'}" data-device-color="${device.id}" onchange="saveDeviceStaticColor('${device.id}', this.value)" title="${t('device.static_color.hint')}"><button class="btn-clear-color" onclick="clearDeviceStaticColor('${device.id}')" title="${t('device.static_color.clear')}" ${!device.static_color ? 'style="display:none"' : ''}>&#x2715;</button></span>` : ''}
</div>
${(device.capabilities || []).includes('brightness_control') ? `
<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>
`;
}
export async function toggleDevicePower(deviceId) {
try {
const getResp = await fetch(`${API_BASE}/devices/${deviceId}/power`, { headers: getHeaders() });
if (getResp.status === 401) { handle401Error(); return; }
if (!getResp.ok) { showToast('Failed to get power state', 'error'); return; }
const current = await getResp.json();
const newState = !current.on;
const setResp = await fetch(`${API_BASE}/devices/${deviceId}/power`, {
method: 'PUT',
headers: { ...getHeaders(), 'Content-Type': 'application/json' },
body: JSON.stringify({ on: newState })
});
if (setResp.status === 401) { handle401Error(); return; }
if (setResp.ok) {
showToast(t(newState ? 'device.power.on_success' : 'device.power.off_success'), 'success');
} else {
const error = await setResp.json();
showToast(error.detail || 'Failed', 'error');
}
} catch (error) {
showToast('Failed to toggle power', 'error');
}
}
export function attachDeviceListeners(deviceId) {
// Add any specific event listeners here if needed
}
export 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');
window.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');
}
}
export 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();
const isAdalight = isSerialDevice(device.device_type);
document.getElementById('settings-device-id').value = device.id;
document.getElementById('settings-device-name').value = device.name;
document.getElementById('settings-health-interval').value = 30;
const urlGroup = document.getElementById('settings-url-group');
const serialGroup = document.getElementById('settings-serial-port-group');
if (isAdalight) {
urlGroup.style.display = 'none';
document.getElementById('settings-device-url').removeAttribute('required');
serialGroup.style.display = '';
_populateSettingsSerialPorts(device.url);
} else {
urlGroup.style.display = '';
document.getElementById('settings-device-url').setAttribute('required', '');
document.getElementById('settings-device-url').value = device.url;
serialGroup.style.display = 'none';
}
const caps = device.capabilities || [];
const ledCountGroup = document.getElementById('settings-led-count-group');
if (caps.includes('manual_led_count')) {
ledCountGroup.style.display = '';
document.getElementById('settings-led-count').value = device.led_count || '';
} else {
ledCountGroup.style.display = 'none';
}
const baudRateGroup = document.getElementById('settings-baud-rate-group');
if (isAdalight) {
baudRateGroup.style.display = '';
const baudSelect = document.getElementById('settings-baud-rate');
if (device.baud_rate) {
baudSelect.value = String(device.baud_rate);
} else {
baudSelect.value = '115200';
}
updateSettingsBaudFpsHint();
} else {
baudRateGroup.style.display = 'none';
}
document.getElementById('settings-auto-shutdown').checked = !!device.auto_shutdown;
setSettingsInitialValues({
name: device.name,
url: device.url,
led_count: String(device.led_count || ''),
baud_rate: String(device.baud_rate || '115200'),
device_type: device.device_type,
capabilities: caps,
state_check_interval: '30',
auto_shutdown: !!device.auto_shutdown,
});
const modal = document.getElementById('device-settings-modal');
modal.style.display = 'flex';
lockBody();
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 _getSettingsUrl() {
if (isSerialDevice(settingsInitialValues.device_type)) {
return document.getElementById('settings-serial-port').value;
}
return document.getElementById('settings-device-url').value.trim();
}
export function isSettingsDirty() {
const ledCountDirty = (settingsInitialValues.capabilities || []).includes('manual_led_count')
&& document.getElementById('settings-led-count').value !== settingsInitialValues.led_count;
return (
document.getElementById('settings-device-name').value !== settingsInitialValues.name ||
_getSettingsUrl() !== settingsInitialValues.url ||
document.getElementById('settings-health-interval').value !== settingsInitialValues.state_check_interval ||
document.getElementById('settings-auto-shutdown').checked !== settingsInitialValues.auto_shutdown ||
ledCountDirty
);
}
export function forceCloseDeviceSettingsModal() {
const modal = document.getElementById('device-settings-modal');
const error = document.getElementById('settings-error');
modal.style.display = 'none';
error.style.display = 'none';
unlockBody();
setSettingsInitialValues({});
}
export async function closeDeviceSettingsModal() {
if (isSettingsDirty()) {
const confirmed = await showConfirm(t('modal.discard_changes'));
if (!confirmed) return;
}
forceCloseDeviceSettingsModal();
}
export async function saveDeviceSettings() {
const deviceId = document.getElementById('settings-device-id').value;
const name = document.getElementById('settings-device-name').value.trim();
const url = _getSettingsUrl();
const error = document.getElementById('settings-error');
if (!name || !url) {
error.textContent = 'Please fill in all fields correctly';
error.style.display = 'block';
return;
}
try {
const body = { name, url, auto_shutdown: document.getElementById('settings-auto-shutdown').checked };
const ledCountInput = document.getElementById('settings-led-count');
if ((settingsInitialValues.capabilities || []).includes('manual_led_count') && ledCountInput.value) {
body.led_count = parseInt(ledCountInput.value, 10);
}
if (isSerialDevice(settingsInitialValues.device_type)) {
const baudVal = document.getElementById('settings-baud-rate').value;
if (baudVal) body.baud_rate = parseInt(baudVal, 10);
}
const deviceResponse = await fetch(`${API_BASE}/devices/${deviceId}`, {
method: 'PUT',
headers: getHeaders(),
body: JSON.stringify(body)
});
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();
window.loadDevices();
} catch (err) {
console.error('Failed to save device settings:', err);
error.textContent = 'Failed to save settings';
error.style.display = 'block';
}
}
// Brightness
export function updateBrightnessLabel(deviceId, value) {
const slider = document.querySelector(`[data-device-brightness="${deviceId}"]`);
if (slider) slider.title = Math.round(parseInt(value) / 255 * 100) + '%';
}
export 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');
}
}
export 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
}
}
// Static color helpers
export function rgbToHex(r, g, b) {
return '#' + [r, g, b].map(c => c.toString(16).padStart(2, '0')).join('');
}
export function hexToRgb(hex) {
const result = /^#?([a-f\d]{2})([a-f\d]{2})([a-f\d]{2})$/i.exec(hex);
return result ? [parseInt(result[1], 16), parseInt(result[2], 16), parseInt(result[3], 16)] : null;
}
export async function saveDeviceStaticColor(deviceId, hexValue) {
const rgb = hexToRgb(hexValue);
try {
await fetch(`${API_BASE}/devices/${deviceId}/color`, {
method: 'PUT',
headers: { ...getHeaders(), 'Content-Type': 'application/json' },
body: JSON.stringify({ color: rgb })
});
const wrap = document.querySelector(`[data-color-wrap="${deviceId}"]`);
if (wrap) {
const clearBtn = wrap.querySelector('.btn-clear-color');
if (clearBtn) clearBtn.style.display = '';
}
} catch (err) {
console.error('Failed to set static color:', err);
showToast('Failed to set static color', 'error');
}
}
export async function clearDeviceStaticColor(deviceId) {
try {
await fetch(`${API_BASE}/devices/${deviceId}/color`, {
method: 'PUT',
headers: { ...getHeaders(), 'Content-Type': 'application/json' },
body: JSON.stringify({ color: null })
});
const picker = document.querySelector(`[data-device-color="${deviceId}"]`);
if (picker) picker.value = '#000000';
const wrap = document.querySelector(`[data-color-wrap="${deviceId}"]`);
if (wrap) {
const clearBtn = wrap.querySelector('.btn-clear-color');
if (clearBtn) clearBtn.style.display = 'none';
}
} catch (err) {
console.error('Failed to clear static color:', err);
}
}
// FPS hint helpers (shared with device-discovery)
export function _computeMaxFps(baudRate, ledCount, deviceType) {
if (!baudRate || !ledCount || ledCount < 1) return null;
const overhead = deviceType === 'ambiled' ? 1 : 6;
const bitsPerFrame = (ledCount * 3 + overhead) * 10;
return Math.floor(baudRate / bitsPerFrame);
}
export function _renderFpsHint(hintEl, baudRate, ledCount, deviceType) {
const fps = _computeMaxFps(baudRate, ledCount, deviceType);
if (fps !== null) {
hintEl.textContent = `Max FPS ≈ ${fps}`;
hintEl.style.display = '';
} else {
hintEl.style.display = 'none';
}
}
export function updateSettingsBaudFpsHint() {
const hintEl = document.getElementById('settings-baud-fps-hint');
const baudRate = parseInt(document.getElementById('settings-baud-rate').value, 10);
const ledCount = parseInt(document.getElementById('settings-led-count').value, 10);
_renderFpsHint(hintEl, baudRate, ledCount, settingsInitialValues.device_type);
}
// Settings serial port population (used from showSettings)
async function _populateSettingsSerialPorts(currentUrl) {
const select = document.getElementById('settings-serial-port');
select.innerHTML = '';
const loadingOpt = document.createElement('option');
loadingOpt.value = currentUrl;
loadingOpt.textContent = currentUrl + ' ⏳';
select.appendChild(loadingOpt);
try {
const discoverType = settingsInitialValues.device_type || 'adalight';
const resp = await fetch(`${API_BASE}/devices/discover?timeout=2&device_type=${encodeURIComponent(discoverType)}`, {
headers: getHeaders()
});
if (!resp.ok) return;
const data = await resp.json();
const devices = data.devices || [];
select.innerHTML = '';
let currentFound = false;
devices.forEach(device => {
const opt = document.createElement('option');
opt.value = device.url;
opt.textContent = device.name;
if (device.url === currentUrl) currentFound = true;
select.appendChild(opt);
});
if (!currentFound) {
const opt = document.createElement('option');
opt.value = currentUrl;
opt.textContent = currentUrl;
select.insertBefore(opt, select.firstChild);
}
select.value = currentUrl;
} catch (err) {
console.error('Failed to discover serial ports:', err);
}
}
export async function loadDevices() {
await window.loadTargetsTab();
}

View File

@@ -0,0 +1,112 @@
/**
* Display picker lightbox — display selection for streams and tests.
*/
import {
_cachedDisplays, _displayPickerCallback, _displayPickerSelectedIndex,
set_displayPickerCallback, set_displayPickerSelectedIndex,
} from '../core/state.js';
import { t } from '../core/i18n.js';
import { loadDisplays } from '../core/api.js';
export function openDisplayPicker(callback, selectedIndex) {
set_displayPickerCallback(callback);
set_displayPickerSelectedIndex((selectedIndex !== undefined && selectedIndex !== null && selectedIndex !== '') ? Number(selectedIndex) : null);
const lightbox = document.getElementById('display-picker-lightbox');
lightbox.classList.add('active');
requestAnimationFrame(() => {
if (_cachedDisplays && _cachedDisplays.length > 0) {
renderDisplayPickerLayout(_cachedDisplays);
} else {
const canvas = document.getElementById('display-picker-canvas');
canvas.innerHTML = '<div class="loading-spinner"></div>';
loadDisplays().then(() => {
// Re-import to get updated value
import('../core/state.js').then(({ _cachedDisplays: displays }) => {
if (displays && displays.length > 0) {
renderDisplayPickerLayout(displays);
} else {
canvas.innerHTML = `<div class="loading">${t('displays.none')}</div>`;
}
});
});
}
});
}
export 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');
set_displayPickerCallback(null);
}
export function selectDisplay(displayIndex) {
// Re-read live bindings
import('../core/state.js').then(({ _displayPickerCallback: cb, _cachedDisplays: displays }) => {
if (cb) {
const display = displays ? displays.find(d => d.index === displayIndex) : null;
cb(displayIndex, display);
}
closeDisplayPicker();
});
}
export 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;
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>
`;
}
export function formatDisplayLabel(displayIndex, display) {
if (display) {
return `#${display.index}: ${display.width}×${display.height}${display.is_primary ? ' (' + t('displays.badge.primary') + ')' : ''}`;
}
return `Display ${displayIndex}`;
}

View File

@@ -0,0 +1,603 @@
/**
* Key Colors targets — cards, test lightbox, editor, WebSocket live colors.
*/
import {
kcTestAutoRefresh, setKcTestAutoRefresh,
kcTestTargetId, setKcTestTargetId,
kcEditorInitialValues, setKcEditorInitialValues,
_kcNameManuallyEdited, set_kcNameManuallyEdited,
kcWebSockets,
PATTERN_RECT_BORDERS,
} from '../core/state.js';
import { API_BASE, getHeaders, fetchWithAuth, escapeHtml, handle401Error } from '../core/api.js';
import { t } from '../core/i18n.js';
import { lockBody, unlockBody, showToast, showConfirm, setupBackdropClose } from '../core/ui.js';
export function createKCTargetCard(target, sourceMap, patternTemplateMap) {
const state = target.state || {};
const metrics = target.metrics || {};
const kcSettings = target.key_colors_settings || {};
const isProcessing = state.processing || false;
const source = sourceMap[target.picture_source_id];
const sourceName = source ? source.name : (target.picture_source_id || 'No source');
const patTmpl = patternTemplateMap[kcSettings.pattern_template_id];
const patternName = patTmpl ? patTmpl.name : 'No pattern';
const rectCount = patTmpl ? (patTmpl.rectangles || []).length : 0;
// Render initial color swatches from pre-fetched REST data
let swatchesHtml = '';
const latestColors = target.latestColors && target.latestColors.colors;
if (isProcessing && latestColors && Object.keys(latestColors).length > 0) {
swatchesHtml = Object.entries(latestColors).map(([name, color]) => `
<div class="kc-swatch">
<div class="kc-swatch-color" style="background-color: ${color.hex}" title="${color.hex}"></div>
<span class="kc-swatch-label" title="${escapeHtml(name)}">${escapeHtml(name)}</span>
</div>
`).join('');
} else if (isProcessing) {
swatchesHtml = `<span class="kc-no-colors">${t('kc.colors.none')}</span>`;
}
return `
<div class="card" data-kc-target-id="${target.id}">
<button class="card-remove-btn" onclick="deleteKCTarget('${target.id}')" title="${t('common.delete')}">&#x2715;</button>
<div class="card-header">
<div class="card-title">
${escapeHtml(target.name)}
${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-label">${t('device.metrics.actual_fps')}</div>
<div class="metric-value">${state.fps_actual?.toFixed(1) || '0.0'}</div>
</div>
<div class="metric">
<div class="metric-label">${t('device.metrics.current_fps')}</div>
<div class="metric-value">${state.fps_current ?? '-'}</div>
</div>
<div class="metric">
<div class="metric-label">${t('device.metrics.target_fps')}</div>
<div class="metric-value">${state.fps_target || 0}</div>
</div>
<div class="metric">
<div class="metric-label">${t('device.metrics.potential_fps')}</div>
<div class="metric-value">${state.fps_potential?.toFixed(0) || '-'}</div>
</div>
<div class="metric">
<div class="metric-label">${t('device.metrics.frames')}</div>
<div class="metric-value">${metrics.frames_processed || 0}</div>
</div>
<div class="metric">
<div class="metric-label">${t('device.metrics.keepalive')}</div>
<div class="metric-value">${state.frames_keepalive ?? '-'}</div>
</div>
<div class="metric">
<div class="metric-label">${t('device.metrics.errors')}</div>
<div class="metric-value">${metrics.errors_count || 0}</div>
</div>
</div>
${state.timing_total_ms != null ? `
<div class="timing-breakdown">
<div class="timing-header">
<div class="metric-label">${t('device.metrics.timing')}</div>
<div class="timing-total"><strong>${state.timing_total_ms}ms</strong></div>
</div>
<div class="timing-bar">
<span class="timing-seg timing-extract" style="flex:${state.timing_calc_colors_ms}" title="calc ${state.timing_calc_colors_ms}ms"></span>
<span class="timing-seg timing-smooth" style="flex:${state.timing_smooth_ms || 0.1}" title="smooth ${state.timing_smooth_ms}ms"></span>
<span class="timing-seg timing-send" style="flex:${state.timing_broadcast_ms}" title="broadcast ${state.timing_broadcast_ms}ms"></span>
</div>
<div class="timing-legend">
<span class="timing-legend-item"><span class="timing-dot timing-extract"></span>calc ${state.timing_calc_colors_ms}ms</span>
<span class="timing-legend-item"><span class="timing-dot timing-smooth"></span>smooth ${state.timing_smooth_ms}ms</span>
<span class="timing-legend-item"><span class="timing-dot timing-send"></span>broadcast ${state.timing_broadcast_ms}ms</span>
</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="testKCTarget('${target.id}')" title="${t('kc.test')}">
🧪
</button>
<button class="btn btn-icon btn-secondary" onclick="showKCEditor('${target.id}')" title="${t('common.edit')}">
✏️
</button>
</div>
</div>
`;
}
// ===== KEY COLORS TEST =====
export async function fetchKCTest(targetId) {
const response = await fetch(`${API_BASE}/picture-targets/${targetId}/test`, {
method: 'POST',
headers: getHeaders(),
});
if (!response.ok) {
const err = await response.json().catch(() => ({}));
throw new Error(err.detail || response.statusText);
}
return response.json();
}
export async function testKCTarget(targetId) {
setKcTestTargetId(targetId);
// Show lightbox immediately with a spinner
const lightbox = document.getElementById('image-lightbox');
const lbImg = document.getElementById('lightbox-image');
const statsEl = document.getElementById('lightbox-stats');
lbImg.style.display = 'none';
lbImg.src = '';
statsEl.style.display = 'none';
// Insert spinner if not already present
let spinner = lightbox.querySelector('.lightbox-spinner');
if (!spinner) {
spinner = document.createElement('div');
spinner.className = 'lightbox-spinner loading-spinner';
lightbox.querySelector('.lightbox-content').prepend(spinner);
}
spinner.style.display = '';
// Show auto-refresh button
const refreshBtn = document.getElementById('lightbox-auto-refresh');
if (refreshBtn) refreshBtn.style.display = '';
lightbox.classList.add('active');
lockBody();
try {
const result = await fetchKCTest(targetId);
displayKCTestResults(result);
} catch (e) {
// Use window.closeLightbox to avoid importing from ui.js circular
if (typeof window.closeLightbox === 'function') window.closeLightbox();
showToast(t('kc.test.error') + ': ' + e.message, 'error');
}
}
export function toggleKCTestAutoRefresh() {
if (kcTestAutoRefresh) {
stopKCTestAutoRefresh();
} else {
setKcTestAutoRefresh(setInterval(async () => {
if (!kcTestTargetId) return;
try {
const result = await fetchKCTest(kcTestTargetId);
displayKCTestResults(result);
} catch (e) {
stopKCTestAutoRefresh();
}
}, 1000));
updateAutoRefreshButton(true);
}
}
export function stopKCTestAutoRefresh() {
if (kcTestAutoRefresh) {
clearInterval(kcTestAutoRefresh);
setKcTestAutoRefresh(null);
}
setKcTestTargetId(null);
updateAutoRefreshButton(false);
}
export function updateAutoRefreshButton(active) {
const btn = document.getElementById('lightbox-auto-refresh');
if (!btn) return;
if (active) {
btn.classList.add('active');
btn.innerHTML = '&#x23F8;'; // pause symbol
} else {
btn.classList.remove('active');
btn.innerHTML = '&#x25B6;'; // play symbol
}
}
export function displayKCTestResults(result) {
const srcImg = new window.Image();
srcImg.onload = () => {
const canvas = document.createElement('canvas');
canvas.width = srcImg.width;
canvas.height = srcImg.height;
const ctx = canvas.getContext('2d');
// Draw captured frame
ctx.drawImage(srcImg, 0, 0);
const w = srcImg.width;
const h = srcImg.height;
// Draw each rectangle with extracted color overlay
result.rectangles.forEach((rect, i) => {
const px = rect.x * w;
const py = rect.y * h;
const pw = rect.width * w;
const ph = rect.height * h;
const color = rect.color;
const borderColor = PATTERN_RECT_BORDERS[i % PATTERN_RECT_BORDERS.length];
// Semi-transparent fill with the extracted color
ctx.fillStyle = `rgba(${color.r}, ${color.g}, ${color.b}, 0.3)`;
ctx.fillRect(px, py, pw, ph);
// Border using pattern colors for distinction
ctx.strokeStyle = borderColor;
ctx.lineWidth = 3;
ctx.strokeRect(px, py, pw, ph);
// Color swatch in top-left corner of rect
const swatchSize = Math.max(16, Math.min(32, pw * 0.15));
ctx.fillStyle = color.hex;
ctx.fillRect(px + 4, py + 4, swatchSize, swatchSize);
ctx.strokeStyle = '#fff';
ctx.lineWidth = 1;
ctx.strokeRect(px + 4, py + 4, swatchSize, swatchSize);
// Name label with shadow for readability
const fontSize = Math.max(12, Math.min(18, pw * 0.06));
ctx.font = `bold ${fontSize}px sans-serif`;
const labelX = px + swatchSize + 10;
const labelY = py + 4 + swatchSize / 2 + fontSize / 3;
ctx.shadowColor = 'rgba(0,0,0,0.8)';
ctx.shadowBlur = 4;
ctx.fillStyle = '#fff';
ctx.fillText(rect.name, labelX, labelY);
// Hex label below name
ctx.font = `${fontSize - 2}px monospace`;
ctx.fillText(color.hex, labelX, labelY + fontSize + 2);
ctx.shadowBlur = 0;
});
const dataUrl = canvas.toDataURL('image/jpeg', 0.92);
// Build stats HTML
let statsHtml = `<div style="display:flex;flex-wrap:wrap;gap:8px;align-items:center;">`;
statsHtml += `<span style="opacity:0.7;margin-right:8px;">${escapeHtml(result.pattern_template_name)} \u2022 ${escapeHtml(result.interpolation_mode)}</span>`;
result.rectangles.forEach((rect) => {
const c = rect.color;
statsHtml += `<div style="display:flex;align-items:center;gap:4px;">`;
statsHtml += `<div style="width:14px;height:14px;border-radius:3px;border:1px solid rgba(255,255,255,0.4);background:${c.hex};"></div>`;
statsHtml += `<span style="font-size:0.85em;">${escapeHtml(rect.name)} <code>${c.hex}</code></span>`;
statsHtml += `</div>`;
});
statsHtml += `</div>`;
// Hide spinner, show result in the already-open lightbox
const spinner = document.querySelector('.lightbox-spinner');
if (spinner) spinner.style.display = 'none';
const lbImg = document.getElementById('lightbox-image');
const statsEl = document.getElementById('lightbox-stats');
lbImg.src = dataUrl;
lbImg.style.display = '';
statsEl.innerHTML = statsHtml;
statsEl.style.display = '';
};
srcImg.src = result.image;
}
// ===== KEY COLORS EDITOR =====
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} \u00b7 ${patName} (${modeName})`;
}
export 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' ? '\uD83D\uDDA5\uFE0F' : s.stream_type === 'static_image' ? '\uD83D\uDDBC\uFE0F' : '\uD83C\uDFA8';
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
set_kcNameManuallyEdited(!!targetId);
document.getElementById('kc-editor-name').oninput = () => { set_kcNameManuallyEdited(true); };
sourceSelect.onchange = () => _autoGenerateKCName();
document.getElementById('kc-editor-interpolation').onchange = () => _autoGenerateKCName();
patSelect.onchange = () => _autoGenerateKCName();
if (!targetId) _autoGenerateKCName();
setKcEditorInitialValues({
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');
}
}
export 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
);
}
export async function closeKCEditorModal() {
if (isKCEditorDirty()) {
const confirmed = await showConfirm(t('modal.discard_changes'));
if (!confirmed) return;
}
forceCloseKCEditorModal();
}
export function forceCloseKCEditorModal() {
document.getElementById('kc-editor-modal').style.display = 'none';
document.getElementById('kc-editor-error').style.display = 'none';
unlockBody();
setKcEditorInitialValues({});
}
export 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();
// Use window.* to avoid circular import with targets.js
if (typeof window.loadTargetsTab === 'function') window.loadTargetsTab();
} catch (error) {
console.error('Error saving KC target:', error);
errorEl.textContent = error.message;
errorEl.style.display = 'block';
}
}
export 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');
// Use window.* to avoid circular import with targets.js
if (typeof window.loadTargetsTab === 'function') window.loadTargetsTab();
} 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 =====
export 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);
}
}
export function disconnectKCWebSocket(targetId) {
const ws = kcWebSockets[targetId];
if (ws) {
ws.close();
delete kcWebSockets[targetId];
}
}
export function disconnectAllKCWebSockets() {
Object.keys(kcWebSockets).forEach(targetId => disconnectKCWebSocket(targetId));
}
export 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]) => {
const hex = color.hex || `#${(color.r || 0).toString(16).padStart(2, '0')}${(color.g || 0).toString(16).padStart(2, '0')}${(color.b || 0).toString(16).padStart(2, '0')}`;
return `
<div class="kc-swatch">
<div class="kc-swatch-color" style="background-color: ${hex}" title="${hex}"></div>
<span class="kc-swatch-label" title="${escapeHtml(name)}">${escapeHtml(name)}</span>
</div>
`;
}).join('');
}

View File

@@ -0,0 +1,792 @@
/**
* Pattern templates — cards, visual canvas editor, rect list, drag handlers.
*/
import {
patternEditorRects, setPatternEditorRects,
patternEditorSelectedIdx, setPatternEditorSelectedIdx,
patternEditorBgImage, setPatternEditorBgImage,
patternEditorInitialValues, setPatternEditorInitialValues,
patternCanvasDragMode, setPatternCanvasDragMode,
patternCanvasDragStart, setPatternCanvasDragStart,
patternCanvasDragOrigRect, setPatternCanvasDragOrigRect,
patternEditorHoveredIdx, setPatternEditorHoveredIdx,
patternEditorHoverHit, setPatternEditorHoverHit,
PATTERN_RECT_COLORS,
PATTERN_RECT_BORDERS,
} from '../core/state.js';
import { API_BASE, getHeaders, fetchWithAuth, escapeHtml, handle401Error } from '../core/api.js';
import { t } from '../core/i18n.js';
import { lockBody, unlockBody, showToast, showConfirm, setupBackdropClose } from '../core/ui.js';
export function createPatternTemplateCard(pt) {
const rectCount = (pt.rectangles || []).length;
const desc = pt.description ? `<div class="template-description">${escapeHtml(pt.description)}</div>` : '';
return `
<div class="template-card" data-pattern-template-id="${pt.id}">
<button class="card-remove-btn" onclick="deletePatternTemplate('${pt.id}')" title="${t('common.delete')}">&#x2715;</button>
<div class="template-card-header">
<span class="template-name">📄 ${escapeHtml(pt.name)}</span>
</div>
${desc}
<div class="stream-card-props">
<span class="stream-card-prop">▭ ${rectCount} rect${rectCount !== 1 ? 's' : ''}</span>
</div>
<div class="template-card-actions">
<button class="btn btn-icon btn-secondary" onclick="showPatternTemplateEditor('${pt.id}')" title="${t('common.edit')}">✏️</button>
</div>
</div>
`;
}
export 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' ? '\uD83D\uDDA5\uFE0F' : s.stream_type === 'static_image' ? '\uD83D\uDDBC\uFE0F' : '\uD83C\uDFA8';
opt.textContent = `${typeIcon} ${s.name}`;
bgSelect.appendChild(opt);
});
setPatternEditorBgImage(null);
setPatternEditorSelectedIdx(-1);
setPatternCanvasDragMode(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');
setPatternEditorRects((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');
setPatternEditorRects([]);
}
setPatternEditorInitialValues({
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');
}
}
export function isPatternEditorDirty() {
return (
document.getElementById('pattern-template-name').value !== patternEditorInitialValues.name ||
document.getElementById('pattern-template-description').value !== patternEditorInitialValues.description ||
JSON.stringify(patternEditorRects) !== patternEditorInitialValues.rectangles
);
}
export async function closePatternTemplateModal() {
if (isPatternEditorDirty()) {
const confirmed = await showConfirm(t('modal.discard_changes'));
if (!confirmed) return;
}
forceClosePatternTemplateModal();
}
export function forceClosePatternTemplateModal() {
document.getElementById('pattern-template-modal').style.display = 'none';
document.getElementById('pattern-template-error').style.display = 'none';
unlockBody();
setPatternEditorRects([]);
setPatternEditorSelectedIdx(-1);
setPatternEditorBgImage(null);
setPatternEditorInitialValues({});
}
export 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();
// Use window.* to avoid circular import with targets.js
if (typeof window.loadTargetsTab === 'function') window.loadTargetsTab();
} catch (error) {
console.error('Error saving pattern template:', error);
errorEl.textContent = error.message;
errorEl.style.display = 'block';
}
}
export 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');
if (typeof window.loadTargetsTab === 'function') window.loadTargetsTab();
} 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) -----
export function renderPatternRectList() {
const container = document.getElementById('pattern-rect-list');
if (!container) return;
if (patternEditorRects.length === 0) {
container.innerHTML = `<div class="kc-rect-empty">${t('pattern.rect.empty')}</div>`;
return;
}
container.innerHTML = patternEditorRects.map((rect, i) => `
<div class="pattern-rect-row${i === patternEditorSelectedIdx ? ' selected' : ''}" onclick="selectPatternRect(${i})">
<input type="text" value="${escapeHtml(rect.name)}" placeholder="${t('pattern.rect.name')}" onchange="updatePatternRect(${i}, 'name', this.value)" onclick="event.stopPropagation()">
<input type="number" value="${rect.x.toFixed(2)}" min="0" max="1" step="0.01" onchange="updatePatternRect(${i}, 'x', parseFloat(this.value)||0)" onclick="event.stopPropagation()">
<input type="number" value="${rect.y.toFixed(2)}" min="0" max="1" step="0.01" onchange="updatePatternRect(${i}, 'y', parseFloat(this.value)||0)" onclick="event.stopPropagation()">
<input type="number" value="${rect.width.toFixed(2)}" min="0.01" max="1" step="0.01" onchange="updatePatternRect(${i}, 'width', parseFloat(this.value)||0.01)" onclick="event.stopPropagation()">
<input type="number" value="${rect.height.toFixed(2)}" min="0.01" max="1" step="0.01" onchange="updatePatternRect(${i}, 'height', parseFloat(this.value)||0.01)" onclick="event.stopPropagation()">
<button type="button" class="pattern-rect-remove-btn" onclick="event.stopPropagation(); removePatternRect(${i})" title="${t('pattern.rect.remove')}">&#x2715;</button>
</div>
`).join('');
}
export function selectPatternRect(index) {
setPatternEditorSelectedIdx(patternEditorSelectedIdx === index ? -1 : index);
renderPatternRectList();
renderPatternCanvas();
}
export 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();
}
export 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 });
setPatternEditorSelectedIdx(patternEditorRects.length - 1);
renderPatternRectList();
renderPatternCanvas();
}
export function deleteSelectedPatternRect() {
if (patternEditorSelectedIdx < 0 || patternEditorSelectedIdx >= patternEditorRects.length) return;
patternEditorRects.splice(patternEditorSelectedIdx, 1);
setPatternEditorSelectedIdx(-1);
renderPatternRectList();
renderPatternCanvas();
}
export function removePatternRect(index) {
patternEditorRects.splice(index, 1);
if (patternEditorSelectedIdx === index) setPatternEditorSelectedIdx(-1);
else if (patternEditorSelectedIdx > index) setPatternEditorSelectedIdx(patternEditorSelectedIdx - 1);
renderPatternRectList();
renderPatternCanvas();
}
// ----- Pattern Canvas Visual Editor -----
export 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
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;
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
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);
// Edge highlight
let edgeDir = null;
if (isDragging && patternCanvasDragMode.startsWith('resize-')) {
edgeDir = patternCanvasDragMode.replace('resize-', '');
} else if (isHovered && patternEditorHoverHit && patternEditorHoverHit !== 'move') {
edgeDir = patternEditorHoverHit;
}
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 },
{ cx: w - abMargin, cy: abMargin },
{ cx: w / 2, cy: h / 2 },
{ cx: abMargin, cy: h - abMargin },
{ cx: w - abMargin, cy: h - abMargin },
];
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 },
{ ax: 1, ay: 0 },
{ ax: 0.5, ay: 0.5 },
{ ax: 0, ay: 1 },
{ ax: 1, ay: 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;
}
let rx = anchor.ax - rw * anchor.ax;
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 });
setPatternEditorSelectedIdx(patternEditorRects.length - 1);
renderPatternRectList();
renderPatternCanvas();
}
// Hit-test a point against a rect's edges/corners.
const _EDGE_THRESHOLD = 8;
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;
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';
if (nearTop && inHRange) return 'n';
if (nearBottom && inHRange) return 's';
if (nearLeft && inVRange) return 'w';
if (nearRight && inVRange) return 'e';
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);
setPatternCanvasDragMode(null);
setPatternCanvasDragStart(null);
setPatternCanvasDragOrigRect(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;
setPatternEditorHoveredIdx(newHoverIdx);
setPatternEditorHoverHit(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);
setPatternCanvasDragMode(null);
setPatternCanvasDragStart(null);
setPatternCanvasDragOrigRect(null);
setPatternEditorHoveredIdx(-1);
setPatternEditorHoverHit(null);
renderPatternRectList();
renderPatternCanvas();
}
});
// Resize observer
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) setPatternEditorSelectedIdx(-1);
else if (patternEditorSelectedIdx > idx) setPatternEditorSelectedIdx(patternEditorSelectedIdx - 1);
setPatternEditorHoveredIdx(-1);
setPatternEditorHoverHit(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;
setPatternEditorSelectedIdx(i);
setPatternCanvasDragStart({ mx, my });
setPatternCanvasDragOrigRect({ ...r });
if (hit === 'move') {
setPatternCanvasDragMode('move');
canvas.style.cursor = 'grabbing';
} else {
setPatternCanvasDragMode(`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
setPatternEditorSelectedIdx(-1);
setPatternCanvasDragMode(null);
canvas.style.cursor = 'default';
renderPatternRectList();
renderPatternCanvas();
}
function _patternCanvasMouseMove(e) {
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) {
setPatternEditorHoveredIdx(newHoverIdx);
setPatternEditorHoverHit(newHoverHit);
renderPatternCanvas();
}
}
function _patternCanvasMouseLeave() {
if (patternCanvasDragMode) return;
if (patternEditorHoveredIdx !== -1) {
setPatternEditorHoveredIdx(-1);
setPatternEditorHoverHit(null);
renderPatternCanvas();
}
}
export 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 = () => {
setPatternEditorBgImage(img);
renderPatternCanvas();
};
img.src = data.full_capture.full_image;
}
} catch (error) {
console.error('Failed to capture background:', error);
showToast('Failed to capture background', 'error');
}
}

View File

@@ -0,0 +1,378 @@
/**
* Profiles — profile cards, editor, condition builder, process picker.
*/
import { _profilesCache, set_profilesCache } from '../core/state.js';
import { API_BASE, getHeaders, escapeHtml, handle401Error } from '../core/api.js';
import { t, updateAllText } from '../core/i18n.js';
import { lockBody, unlockBody, showToast, showConfirm } from '../core/ui.js';
export async function loadProfiles() {
const container = document.getElementById('profiles-content');
if (!container) return;
try {
const resp = await fetch(`${API_BASE}/profiles`, { headers: getHeaders() });
if (!resp.ok) throw new Error('Failed to load profiles');
const data = await resp.json();
set_profilesCache(data.profiles);
renderProfiles(data.profiles);
} catch (error) {
console.error('Failed to load profiles:', error);
container.innerHTML = `<p class="error-message">${error.message}</p>`;
}
}
function renderProfiles(profiles) {
const container = document.getElementById('profiles-content');
let html = '<div class="devices-grid">';
for (const p of profiles) {
html += createProfileCard(p);
}
html += `<div class="template-card add-template-card" onclick="openProfileEditor()">
<div class="add-template-icon">+</div>
</div>`;
html += '</div>';
container.innerHTML = html;
updateAllText();
}
function createProfileCard(profile) {
const statusClass = !profile.enabled ? 'disabled' : profile.is_active ? 'active' : 'inactive';
const statusText = !profile.enabled ? t('profiles.status.disabled') : profile.is_active ? t('profiles.status.active') : t('profiles.status.inactive');
let condPills = '';
if (profile.conditions.length === 0) {
condPills = `<span class="stream-card-prop">${t('profiles.conditions.empty')}</span>`;
} else {
const parts = profile.conditions.map(c => {
if (c.condition_type === 'application') {
const apps = (c.apps || []).join(', ');
const matchLabel = c.match_type === 'topmost' ? t('profiles.condition.application.match_type.topmost') : t('profiles.condition.application.match_type.running');
return `<span class="stream-card-prop stream-card-prop-full">${t('profiles.condition.application')}: ${apps} (${matchLabel})</span>`;
}
return `<span class="stream-card-prop">${c.condition_type}</span>`;
});
const logicLabel = profile.condition_logic === 'and' ? ' AND ' : ' OR ';
condPills = parts.join(`<span class="profile-logic-label">${logicLabel}</span>`);
}
const targetCountText = `${profile.target_ids.length} target(s)${profile.is_active ? ` (${profile.active_target_ids.length} active)` : ''}`;
let lastActivityMeta = '';
if (profile.last_activated_at) {
const ts = new Date(profile.last_activated_at);
lastActivityMeta = `<span class="card-meta" title="${t('profiles.last_activated')}">🕐 ${ts.toLocaleString()}</span>`;
}
return `
<div class="card${!profile.enabled ? ' profile-status-disabled' : ''}" data-profile-id="${profile.id}">
<div class="card-top-actions">
<button class="card-remove-btn" onclick="deleteProfile('${profile.id}', '${escapeHtml(profile.name)}')" title="${t('common.delete')}">&#x2715;</button>
</div>
<div class="card-header">
<div class="card-title">
${escapeHtml(profile.name)}
<span class="badge badge-profile-${statusClass}">${statusText}</span>
</div>
</div>
<div class="card-subtitle">
<span class="card-meta">${profile.condition_logic === 'and' ? 'ALL' : 'ANY'}</span>
<span class="card-meta">⚡ ${targetCountText}</span>
${lastActivityMeta}
</div>
<div class="stream-card-props">${condPills}</div>
<div class="card-actions">
<button class="btn btn-icon btn-secondary" onclick="openProfileEditor('${profile.id}')" title="${t('profiles.edit')}">⚙️</button>
<button class="btn btn-icon ${profile.enabled ? 'btn-warning' : 'btn-success'}" onclick="toggleProfileEnabled('${profile.id}', ${!profile.enabled})" title="${profile.enabled ? t('profiles.status.disabled') : t('profiles.status.active')}">
${profile.enabled ? '⏸' : '▶'}
</button>
</div>
</div>`;
}
export async function openProfileEditor(profileId) {
const modal = document.getElementById('profile-editor-modal');
const titleEl = document.getElementById('profile-editor-title');
const idInput = document.getElementById('profile-editor-id');
const nameInput = document.getElementById('profile-editor-name');
const enabledInput = document.getElementById('profile-editor-enabled');
const logicSelect = document.getElementById('profile-editor-logic');
const condList = document.getElementById('profile-conditions-list');
const errorEl = document.getElementById('profile-editor-error');
errorEl.style.display = 'none';
condList.innerHTML = '';
await loadProfileTargetChecklist([]);
if (profileId) {
titleEl.textContent = t('profiles.edit');
try {
const resp = await fetch(`${API_BASE}/profiles/${profileId}`, { headers: getHeaders() });
if (!resp.ok) throw new Error('Failed to load profile');
const profile = await resp.json();
idInput.value = profile.id;
nameInput.value = profile.name;
enabledInput.checked = profile.enabled;
logicSelect.value = profile.condition_logic;
for (const c of profile.conditions) {
addProfileConditionRow(c);
}
await loadProfileTargetChecklist(profile.target_ids);
} catch (e) {
showToast(e.message, 'error');
return;
}
} else {
titleEl.textContent = t('profiles.add');
idInput.value = '';
nameInput.value = '';
enabledInput.checked = true;
logicSelect.value = 'or';
}
modal.style.display = 'flex';
lockBody();
updateAllText();
}
export function closeProfileEditorModal() {
document.getElementById('profile-editor-modal').style.display = 'none';
unlockBody();
}
async function loadProfileTargetChecklist(selectedIds) {
const container = document.getElementById('profile-targets-list');
try {
const resp = await fetch(`${API_BASE}/picture-targets`, { headers: getHeaders() });
if (!resp.ok) throw new Error('Failed to load targets');
const data = await resp.json();
const targets = data.targets || [];
if (targets.length === 0) {
container.innerHTML = `<small class="text-muted">${t('profiles.targets.empty')}</small>`;
return;
}
container.innerHTML = targets.map(tgt => {
const checked = selectedIds.includes(tgt.id) ? 'checked' : '';
return `<label class="profile-target-item">
<input type="checkbox" value="${tgt.id}" ${checked}>
<span>${escapeHtml(tgt.name)}</span>
</label>`;
}).join('');
} catch (e) {
container.innerHTML = `<small class="text-muted">${e.message}</small>`;
}
}
export function addProfileCondition() {
addProfileConditionRow({ condition_type: 'application', apps: [], match_type: 'running' });
}
function addProfileConditionRow(condition) {
const list = document.getElementById('profile-conditions-list');
const row = document.createElement('div');
row.className = 'profile-condition-row';
const appsValue = (condition.apps || []).join('\n');
const matchType = condition.match_type || 'running';
row.innerHTML = `
<div class="condition-header">
<span class="condition-type-label">${t('profiles.condition.application')}</span>
<button type="button" class="btn-remove-condition" onclick="this.closest('.profile-condition-row').remove()" title="Remove">&#x2715;</button>
</div>
<div class="condition-fields">
<div class="condition-field">
<label data-i18n="profiles.condition.application.match_type">${t('profiles.condition.application.match_type')}</label>
<select class="condition-match-type">
<option value="running" ${matchType === 'running' ? 'selected' : ''}>${t('profiles.condition.application.match_type.running')}</option>
<option value="topmost" ${matchType === 'topmost' ? 'selected' : ''}>${t('profiles.condition.application.match_type.topmost')}</option>
</select>
</div>
<div class="condition-field">
<div class="condition-apps-header">
<label data-i18n="profiles.condition.application.apps">${t('profiles.condition.application.apps')}</label>
<button type="button" class="btn-browse-apps" title="${t('profiles.condition.application.browse')}">${t('profiles.condition.application.browse')}</button>
</div>
<textarea class="condition-apps" rows="3" placeholder="firefox.exe&#10;chrome.exe">${escapeHtml(appsValue)}</textarea>
<div class="process-picker" style="display:none">
<input type="text" class="process-picker-search" placeholder="${t('profiles.condition.application.search')}" autocomplete="off">
<div class="process-picker-list"></div>
</div>
</div>
</div>
`;
const browseBtn = row.querySelector('.btn-browse-apps');
const picker = row.querySelector('.process-picker');
browseBtn.addEventListener('click', () => toggleProcessPicker(picker, row));
const searchInput = row.querySelector('.process-picker-search');
searchInput.addEventListener('input', () => filterProcessPicker(picker));
list.appendChild(row);
}
async function toggleProcessPicker(picker, row) {
if (picker.style.display !== 'none') {
picker.style.display = 'none';
return;
}
const listEl = picker.querySelector('.process-picker-list');
const searchEl = picker.querySelector('.process-picker-search');
searchEl.value = '';
listEl.innerHTML = `<div class="process-picker-loading">${t('common.loading')}</div>`;
picker.style.display = '';
try {
const resp = await fetch(`${API_BASE}/system/processes`, { headers: getHeaders() });
if (resp.status === 401) { handle401Error(); return; }
if (!resp.ok) throw new Error('Failed to fetch processes');
const data = await resp.json();
const textarea = row.querySelector('.condition-apps');
const existing = new Set(textarea.value.split('\n').map(a => a.trim().toLowerCase()).filter(Boolean));
picker._processes = data.processes;
picker._existing = existing;
renderProcessPicker(picker, data.processes, existing);
searchEl.focus();
} catch (e) {
listEl.innerHTML = `<div class="process-picker-loading" style="color:var(--danger-color)">${e.message}</div>`;
}
}
function renderProcessPicker(picker, processes, existing) {
const listEl = picker.querySelector('.process-picker-list');
if (processes.length === 0) {
listEl.innerHTML = `<div class="process-picker-loading">${t('profiles.condition.application.no_processes')}</div>`;
return;
}
listEl.innerHTML = processes.map(p => {
const added = existing.has(p.toLowerCase());
return `<div class="process-picker-item${added ? ' added' : ''}" data-process="${escapeHtml(p)}">${escapeHtml(p)}${added ? ' ✓' : ''}</div>`;
}).join('');
listEl.querySelectorAll('.process-picker-item:not(.added)').forEach(item => {
item.addEventListener('click', () => {
const proc = item.dataset.process;
const row = picker.closest('.profile-condition-row');
const textarea = row.querySelector('.condition-apps');
const current = textarea.value.trim();
textarea.value = current ? current + '\n' + proc : proc;
item.classList.add('added');
item.textContent = proc + ' ✓';
picker._existing.add(proc.toLowerCase());
});
});
}
function filterProcessPicker(picker) {
const query = picker.querySelector('.process-picker-search').value.toLowerCase();
const filtered = (picker._processes || []).filter(p => p.includes(query));
renderProcessPicker(picker, filtered, picker._existing || new Set());
}
function getProfileEditorConditions() {
const rows = document.querySelectorAll('#profile-conditions-list .profile-condition-row');
const conditions = [];
rows.forEach(row => {
const matchType = row.querySelector('.condition-match-type').value;
const appsText = row.querySelector('.condition-apps').value.trim();
const apps = appsText ? appsText.split('\n').map(a => a.trim()).filter(Boolean) : [];
conditions.push({ condition_type: 'application', apps, match_type: matchType });
});
return conditions;
}
function getProfileEditorTargetIds() {
const checkboxes = document.querySelectorAll('#profile-targets-list input[type="checkbox"]:checked');
return Array.from(checkboxes).map(cb => cb.value);
}
export async function saveProfileEditor() {
const idInput = document.getElementById('profile-editor-id');
const nameInput = document.getElementById('profile-editor-name');
const enabledInput = document.getElementById('profile-editor-enabled');
const logicSelect = document.getElementById('profile-editor-logic');
const errorEl = document.getElementById('profile-editor-error');
const name = nameInput.value.trim();
if (!name) {
errorEl.textContent = 'Name is required';
errorEl.style.display = 'block';
return;
}
const body = {
name,
enabled: enabledInput.checked,
condition_logic: logicSelect.value,
conditions: getProfileEditorConditions(),
target_ids: getProfileEditorTargetIds(),
};
const profileId = idInput.value;
const isEdit = !!profileId;
try {
const url = isEdit ? `${API_BASE}/profiles/${profileId}` : `${API_BASE}/profiles`;
const resp = await fetch(url, {
method: isEdit ? 'PUT' : 'POST',
headers: getHeaders(),
body: JSON.stringify(body),
});
if (!resp.ok) {
const err = await resp.json().catch(() => ({}));
throw new Error(err.detail || 'Failed to save profile');
}
closeProfileEditorModal();
showToast(isEdit ? 'Profile updated' : 'Profile created', 'success');
loadProfiles();
} catch (e) {
errorEl.textContent = e.message;
errorEl.style.display = 'block';
}
}
export async function toggleProfileEnabled(profileId, enable) {
try {
const action = enable ? 'enable' : 'disable';
const resp = await fetch(`${API_BASE}/profiles/${profileId}/${action}`, {
method: 'POST',
headers: getHeaders(),
});
if (!resp.ok) throw new Error(`Failed to ${action} profile`);
loadProfiles();
} catch (e) {
showToast(e.message, 'error');
}
}
export async function deleteProfile(profileId, profileName) {
const msg = t('profiles.delete.confirm').replace('{name}', profileName);
const confirmed = await showConfirm(msg);
if (!confirmed) return;
try {
const resp = await fetch(`${API_BASE}/profiles/${profileId}`, {
method: 'DELETE',
headers: getHeaders(),
});
if (!resp.ok) throw new Error('Failed to delete profile');
showToast('Profile deleted', 'success');
loadProfiles();
} catch (e) {
showToast(e.message, 'error');
}
}

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,50 @@
/**
* Tab switching — switchTab, initTabs, startAutoRefresh.
*/
import { apiKey, refreshInterval, setRefreshInterval } from '../core/state.js';
export 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 === 'dashboard') {
// Use window.* to avoid circular imports with feature modules
if (typeof window.loadDashboard === 'function') window.loadDashboard();
if (typeof window.startDashboardWS === 'function') window.startDashboardWS();
} else {
if (typeof window.stopDashboardWS === 'function') window.stopDashboardWS();
if (name === 'streams') {
if (typeof window.loadPictureSources === 'function') window.loadPictureSources();
} else if (name === 'targets') {
if (typeof window.loadTargetsTab === 'function') window.loadTargetsTab();
} else if (name === 'profiles') {
if (typeof window.loadProfiles === 'function') window.loadProfiles();
}
}
}
export function initTabs() {
let saved = localStorage.getItem('activeTab');
// Migrate legacy 'devices' tab to 'targets'
if (saved === 'devices') saved = 'targets';
if (!saved || !document.getElementById(`tab-${saved}`)) saved = 'dashboard';
switchTab(saved);
}
export function startAutoRefresh() {
if (refreshInterval) {
clearInterval(refreshInterval);
}
setRefreshInterval(setInterval(() => {
if (apiKey) {
const activeTab = localStorage.getItem('activeTab') || 'dashboard';
if (activeTab === 'targets') {
if (typeof window.loadTargetsTab === 'function') window.loadTargetsTab();
} else if (activeTab === 'dashboard') {
if (typeof window.loadDashboard === 'function') window.loadDashboard();
}
}
}, 2000));
}

View File

@@ -0,0 +1,632 @@
/**
* Targets tab — combined view of devices, LED targets, KC targets, pattern templates.
*/
import {
targetEditorInitialValues, setTargetEditorInitialValues,
_targetEditorDevices, set_targetEditorDevices,
_deviceBrightnessCache,
kcWebSockets,
} from '../core/state.js';
import { API_BASE, getHeaders, fetchWithAuth, escapeHtml, handle401Error } from '../core/api.js';
import { t } from '../core/i18n.js';
import { lockBody, unlockBody, showToast, showConfirm, setupBackdropClose } from '../core/ui.js';
import { createDeviceCard, attachDeviceListeners, fetchDeviceBrightness } from './devices.js';
import { createKCTargetCard, connectKCWebSocket, disconnectKCWebSocket } from './kc-targets.js';
// createPatternTemplateCard is imported via window.* to avoid circular deps
// (pattern-templates.js calls window.loadTargetsTab)
function _updateStandbyVisibility() {
const deviceSelect = document.getElementById('target-editor-device');
const standbyGroup = document.getElementById('target-editor-standby-group');
const selectedDevice = _targetEditorDevices.find(d => d.id === deviceSelect.value);
const caps = selectedDevice?.capabilities || [];
standbyGroup.style.display = caps.includes('standby_required') ? '' : 'none';
}
export 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 || [] : [];
set_targetEditorDevices(devices);
// 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?:\/\//, '') : '';
const devType = (d.device_type || 'wled').toUpperCase();
opt.textContent = `${d.name} [${devType}]${shortUrl ? ' (' + shortUrl + ')' : ''}`;
deviceSelect.appendChild(opt);
});
deviceSelect.onchange = _updateStandbyVisibility;
// Populate source select
const sourceSelect = document.getElementById('target-editor-source');
sourceSelect.innerHTML = '';
sources.forEach(s => {
const opt = document.createElement('option');
opt.value = s.id;
const typeIcon = s.stream_type === 'raw' ? '\uD83D\uDDA5\uFE0F' : s.stream_type === 'static_image' ? '\uD83D\uDDBC\uFE0F' : '\uD83C\uDFA8';
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-fps').value = target.settings?.fps ?? 30;
document.getElementById('target-editor-fps-value').textContent = target.settings?.fps ?? 30;
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-standby-interval').value = target.settings?.standby_interval ?? 1.0;
document.getElementById('target-editor-standby-interval-value').textContent = target.settings?.standby_interval ?? 1.0;
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-fps').value = 30;
document.getElementById('target-editor-fps-value').textContent = '30';
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-standby-interval').value = 1.0;
document.getElementById('target-editor-standby-interval-value').textContent = '1.0';
document.getElementById('target-editor-title').textContent = t('targets.add');
}
// Show/hide standby interval based on selected device capabilities
_updateStandbyVisibility();
setTargetEditorInitialValues({
name: document.getElementById('target-editor-name').value,
device: deviceSelect.value,
source: sourceSelect.value,
fps: document.getElementById('target-editor-fps').value,
interpolation: document.getElementById('target-editor-interpolation').value,
smoothing: document.getElementById('target-editor-smoothing').value,
standby_interval: document.getElementById('target-editor-standby-interval').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');
}
}
export 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-fps').value !== targetEditorInitialValues.fps ||
document.getElementById('target-editor-interpolation').value !== targetEditorInitialValues.interpolation ||
document.getElementById('target-editor-smoothing').value !== targetEditorInitialValues.smoothing ||
document.getElementById('target-editor-standby-interval').value !== targetEditorInitialValues.standby_interval
);
}
export async function closeTargetEditorModal() {
if (isTargetEditorDirty()) {
const confirmed = await showConfirm(t('modal.discard_changes'));
if (!confirmed) return;
}
forceCloseTargetEditorModal();
}
export function forceCloseTargetEditorModal() {
document.getElementById('target-editor-modal').style.display = 'none';
document.getElementById('target-editor-error').style.display = 'none';
unlockBody();
setTargetEditorInitialValues({});
}
export 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 fps = parseInt(document.getElementById('target-editor-fps').value) || 30;
const interpolation = document.getElementById('target-editor-interpolation').value;
const smoothing = parseFloat(document.getElementById('target-editor-smoothing').value);
const standbyInterval = parseFloat(document.getElementById('target-editor-standby-interval').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: {
fps: fps,
interpolation_mode: interpolation,
smoothing: smoothing,
standby_interval: standbyInterval,
},
};
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 = 'led';
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 loadTargetsTab();
} catch (error) {
console.error('Error saving target:', error);
errorEl.textContent = error.message;
errorEl.style.display = 'block';
}
}
// ===== TARGETS TAB (WLED devices + targets combined) =====
export async function loadTargets() {
// Alias for backward compatibility
await loadTargetsTab();
}
export 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);
}
export 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 ledDevices = devicesWithState;
const ledTargets = targetsWithState.filter(t => t.target_type === 'led' || t.target_type === 'wled');
const kcTargets = targetsWithState.filter(t => t.target_type === 'key_colors');
// Backward compat: map stored "wled" sub-tab to "led"
let activeSubTab = localStorage.getItem('activeTargetSubTab') || 'led';
if (activeSubTab === 'wled') activeSubTab = 'led';
const subTabs = [
{ key: 'led', icon: '\uD83D\uDCA1', titleKey: 'targets.subtab.led', count: ledDevices.length + ledTargets.length },
{ key: 'key_colors', icon: '\uD83C\uDFA8', 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>`;
// Use window.createPatternTemplateCard to avoid circular import
const createPatternTemplateCard = window.createPatternTemplateCard || (() => '');
// LED panel: devices section + targets section
const ledPanel = `
<div class="target-sub-tab-panel stream-tab-panel${activeSubTab === 'led' ? ' active' : ''}" id="target-sub-tab-led">
<div class="subtab-section">
<h3 class="subtab-section-header">${t('targets.section.devices')}</h3>
<div class="devices-grid">
${ledDevices.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">
${ledTargets.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 + ledPanel + kcPanel;
// Attach event listeners and fetch brightness for device cards
devicesWithState.forEach(device => {
attachDeviceListeners(device.id);
if ((device.capabilities || []).includes('brightness_control')) {
// Only fetch from device if we don't have a cached value yet
if (device.id in _deviceBrightnessCache) {
const bri = _deviceBrightnessCache[device.id];
const slider = document.querySelector(`[data-device-brightness="${device.id}"]`);
if (slider) {
slider.value = bri;
slider.title = Math.round(bri / 255 * 100) + '%';
slider.disabled = false;
}
const wrap = document.querySelector(`[data-brightness-wrap="${device.id}"]`);
if (wrap) wrap.classList.remove('brightness-loading');
} else {
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>`;
}
}
export 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 devOnline = state.device_online || false;
let healthClass = 'health-unknown';
let healthTitle = '';
if (state.device_last_checked !== null && state.device_last_checked !== undefined) {
healthClass = devOnline ? 'health-online' : 'health-offline';
healthTitle = devOnline ? t('device.health.online') : t('device.health.offline');
}
return `
<div class="card" data-target-id="${target.id}">
<button class="card-remove-btn" onclick="deleteTarget('${target.id}')" title="${t('common.delete')}">&#x2715;</button>
<div class="card-header">
<div class="card-title">
<span class="health-dot ${healthClass}" title="${healthTitle}"></span>
${escapeHtml(target.name)}
${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" title="${t('targets.fps')}">⚡ ${settings.fps || 30}</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-label">${t('device.metrics.actual_fps')}</div>
<div class="metric-value">${state.fps_actual?.toFixed(1) || '0.0'}</div>
</div>
<div class="metric">
<div class="metric-label">${t('device.metrics.current_fps')}</div>
<div class="metric-value">${state.fps_current ?? '-'}</div>
</div>
<div class="metric">
<div class="metric-label">${t('device.metrics.target_fps')}</div>
<div class="metric-value">${state.fps_target || 0}</div>
</div>
<div class="metric">
<div class="metric-label">${t('device.metrics.potential_fps')}</div>
<div class="metric-value">${state.fps_potential?.toFixed(0) || '-'}</div>
</div>
<div class="metric">
<div class="metric-label">${t('device.metrics.frames')}</div>
<div class="metric-value">${metrics.frames_processed || 0}</div>
</div>
<div class="metric">
<div class="metric-label">${t('device.metrics.keepalive')}</div>
<div class="metric-value">${state.frames_keepalive ?? '-'}</div>
</div>
<div class="metric">
<div class="metric-label">${t('device.metrics.errors')}</div>
<div class="metric-value">${metrics.errors_count || 0}</div>
</div>
</div>
${state.timing_total_ms != null ? `
<div class="timing-breakdown">
<div class="timing-header">
<div class="metric-label">${t('device.metrics.timing')}</div>
<div class="timing-total"><strong>${state.timing_total_ms}ms</strong></div>
</div>
<div class="timing-bar">
<span class="timing-seg timing-extract" style="flex:${state.timing_extract_ms}" title="extract ${state.timing_extract_ms}ms"></span>
<span class="timing-seg timing-map" style="flex:${state.timing_map_leds_ms}" title="map ${state.timing_map_leds_ms}ms"></span>
<span class="timing-seg timing-smooth" style="flex:${state.timing_smooth_ms || 0.1}" title="smooth ${state.timing_smooth_ms}ms"></span>
<span class="timing-seg timing-send" style="flex:${state.timing_send_ms}" title="send ${state.timing_send_ms}ms"></span>
</div>
<div class="timing-legend">
<span class="timing-legend-item"><span class="timing-dot timing-extract"></span>extract ${state.timing_extract_ms}ms</span>
<span class="timing-legend-item"><span class="timing-dot timing-map"></span>map ${state.timing_map_leds_ms}ms</span>
<span class="timing-legend-item"><span class="timing-dot timing-smooth"></span>smooth ${state.timing_smooth_ms}ms</span>
<span class="timing-legend-item"><span class="timing-dot timing-send"></span>send ${state.timing_send_ms}ms</span>
</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>
${state.overlay_active ? `
<button class="btn btn-icon btn-warning" onclick="stopTargetOverlay('${target.id}')" title="${t('overlay.button.hide')}">
👁️
</button>
` : `
<button class="btn btn-icon btn-secondary" onclick="startTargetOverlay('${target.id}')" title="${t('overlay.button.show')}">
👁️
</button>
`}
</div>
</div>
`;
}
export 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');
loadTargetsTab();
} else {
const error = await response.json();
showToast(`Failed to start: ${error.detail}`, 'error');
}
} catch (error) {
showToast('Failed to start processing', 'error');
}
}
export 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');
loadTargetsTab();
} else {
const error = await response.json();
showToast(`Failed to stop: ${error.detail}`, 'error');
}
} catch (error) {
showToast('Failed to stop processing', 'error');
}
}
export async function startTargetOverlay(targetId) {
try {
const response = await fetch(`${API_BASE}/picture-targets/${targetId}/overlay/start`, {
method: 'POST',
headers: getHeaders()
});
if (response.status === 401) { handle401Error(); return; }
if (response.ok) {
showToast(t('overlay.started'), 'success');
loadTargetsTab();
} else {
const error = await response.json();
showToast(t('overlay.error.start') + ': ' + error.detail, 'error');
}
} catch (error) {
showToast(t('overlay.error.start'), 'error');
}
}
export async function stopTargetOverlay(targetId) {
try {
const response = await fetch(`${API_BASE}/picture-targets/${targetId}/overlay/stop`, {
method: 'POST',
headers: getHeaders()
});
if (response.status === 401) { handle401Error(); return; }
if (response.ok) {
showToast(t('overlay.stopped'), 'success');
loadTargetsTab();
} else {
const error = await response.json();
showToast(t('overlay.error.stop') + ': ' + error.detail, 'error');
}
} catch (error) {
showToast(t('overlay.error.stop'), 'error');
}
}
export 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');
loadTargetsTab();
} else {
const error = await response.json();
showToast(`Failed to delete: ${error.detail}`, 'error');
}
} catch (error) {
showToast('Failed to delete target', 'error');
}
}

View File

@@ -0,0 +1,214 @@
/**
* Tutorial system — generic engine, steps, tooltip positioning.
*/
import { activeTutorial, setActiveTutorial } from '../core/state.js';
import { t } from '../core/i18n.js';
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: '.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' },
{ selector: '.preview-screen-border-width', textKey: 'calibration.tip.border_width', position: 'bottom' },
{ selector: '#cal-offset', textKey: 'calibration.tip.offset', position: 'top' },
{ selector: '#cal-skip-start', textKey: 'calibration.tip.skip_leds_start', position: 'top' },
{ selector: '#cal-skip-end', textKey: 'calibration.tip.skip_leds_end', 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' }
];
export function startTutorial(config) {
closeTutorial();
const overlay = document.getElementById(config.overlayId);
if (!overlay) return;
setActiveTutorial({
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);
}
export 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)
});
}
export function startDeviceTutorial(deviceId) {
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);
}
});
}
export 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);
setActiveTutorial(null);
}
export function tutorialNext() {
if (!activeTutorial) return;
if (activeTutorial.step < activeTutorial.steps.length - 1) {
showTutorialStep(activeTutorial.step + 1);
} else {
closeTutorial();
}
}
export 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';
document.querySelectorAll('.tutorial-target').forEach(el => {
el.classList.remove('tutorial-target');
el.style.zIndex = '';
});
const target = activeTutorial.resolveTarget(step);
if (!target) return;
target.classList.add('tutorial-target');
if (isFixed) target.style.zIndex = '10001';
const targetRect = target.getBoundingClientRect();
const pad = 6;
let x, y, w, h;
if (isFixed) {
x = targetRect.left - pad;
y = targetRect.top - pad;
w = targetRect.width + pad * 2;
h = targetRect.height + pad * 2;
} else {
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;
}
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%)`;
}
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';
}
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}`;
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';
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;
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 (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));
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(); }
}