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:
File diff suppressed because it is too large
Load Diff
@@ -1103,7 +1103,7 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script src="/static/app.js"></script>
|
||||
<script type="module" src="/static/js/app.js"></script>
|
||||
<script>
|
||||
// Initialize theme
|
||||
const savedTheme = localStorage.getItem('theme') || 'dark';
|
||||
@@ -1152,7 +1152,7 @@
|
||||
}
|
||||
|
||||
localStorage.removeItem('wled_api_key');
|
||||
apiKey = null;
|
||||
if (window.setApiKey) window.setApiKey(null);
|
||||
updateAuthUI();
|
||||
showToast(t('auth.logout.success'), 'info');
|
||||
|
||||
@@ -1224,7 +1224,7 @@
|
||||
|
||||
// Store the key
|
||||
localStorage.setItem('wled_api_key', key);
|
||||
apiKey = key;
|
||||
if (window.setApiKey) window.setApiKey(key);
|
||||
updateAuthUI();
|
||||
|
||||
closeApiKeyModal();
|
||||
@@ -1235,10 +1235,8 @@
|
||||
loadDisplays();
|
||||
loadTargetsTab();
|
||||
|
||||
// Start auto-refresh if not already running
|
||||
if (!refreshInterval) {
|
||||
startAutoRefresh();
|
||||
}
|
||||
// Start auto-refresh
|
||||
startAutoRefresh();
|
||||
}
|
||||
</script>
|
||||
</body>
|
||||
|
||||
347
server/src/wled_controller/static/js/app.js
Normal file
347
server/src/wled_controller/static/js/app.js
Normal 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();
|
||||
});
|
||||
128
server/src/wled_controller/static/js/core/api.js
Normal file
128
server/src/wled_controller/static/js/core/api.js
Normal 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();
|
||||
}
|
||||
109
server/src/wled_controller/static/js/core/i18n.js
Normal file
109
server/src/wled_controller/static/js/core/i18n.js
Normal 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();
|
||||
}
|
||||
}
|
||||
175
server/src/wled_controller/static/js/core/state.js
Normal file
175
server/src/wled_controller/static/js/core/state.js
Normal 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; }
|
||||
240
server/src/wled_controller/static/js/core/ui.js
Normal file
240
server/src/wled_controller/static/js/core/ui.js
Normal 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 = '⏸';
|
||||
} else {
|
||||
btn.classList.remove('active');
|
||||
btn.innerHTML = '▶';
|
||||
}
|
||||
}
|
||||
|
||||
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();
|
||||
}
|
||||
753
server/src/wled_controller/static/js/features/calibration.js
Normal file
753
server/src/wled_controller/static/js/features/calibration.js
Normal 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;
|
||||
}
|
||||
322
server/src/wled_controller/static/js/features/dashboard.js
Normal file
322
server/src/wled_controller/static/js/features/dashboard.js
Normal 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);
|
||||
}
|
||||
}
|
||||
@@ -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');
|
||||
}
|
||||
}
|
||||
453
server/src/wled_controller/static/js/features/devices.js
Normal file
453
server/src/wled_controller/static/js/features/devices.js
Normal 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')}">✕</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"' : ''}>✕</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();
|
||||
}
|
||||
112
server/src/wled_controller/static/js/features/displays.js
Normal file
112
server/src/wled_controller/static/js/features/displays.js
Normal 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}`;
|
||||
}
|
||||
603
server/src/wled_controller/static/js/features/kc-targets.js
Normal file
603
server/src/wled_controller/static/js/features/kc-targets.js
Normal 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')}">✕</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 = '⏸'; // pause symbol
|
||||
} else {
|
||||
btn.classList.remove('active');
|
||||
btn.innerHTML = '▶'; // 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('');
|
||||
}
|
||||
@@ -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')}">✕</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')}">✕</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');
|
||||
}
|
||||
}
|
||||
378
server/src/wled_controller/static/js/features/profiles.js
Normal file
378
server/src/wled_controller/static/js/features/profiles.js
Normal 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')}">✕</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">✕</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 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');
|
||||
}
|
||||
}
|
||||
1386
server/src/wled_controller/static/js/features/streams.js
Normal file
1386
server/src/wled_controller/static/js/features/streams.js
Normal file
File diff suppressed because it is too large
Load Diff
50
server/src/wled_controller/static/js/features/tabs.js
Normal file
50
server/src/wled_controller/static/js/features/tabs.js
Normal 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));
|
||||
}
|
||||
632
server/src/wled_controller/static/js/features/targets.js
Normal file
632
server/src/wled_controller/static/js/features/targets.js
Normal 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')}">✕</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');
|
||||
}
|
||||
}
|
||||
214
server/src/wled_controller/static/js/features/tutorials.js
Normal file
214
server/src/wled_controller/static/js/features/tutorials.js
Normal 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(); }
|
||||
}
|
||||
Reference in New Issue
Block a user