Merge pull request #948 from nick2000713/codex/fix-startup-freeze
Fix post-update UI freeze: stop password managers re-scanning the DOM + add Max Performance mode
This commit is contained in:
commit
b05b641521
8 changed files with 258 additions and 21 deletions
|
|
@ -387,6 +387,7 @@ def _initial_appearance_context():
|
|||
_request_is_firefox(),
|
||||
)
|
||||
reduce_effects = config_manager.get('ui_appearance.reduce_effects', False) is True
|
||||
max_performance = config_manager.get('ui_appearance.max_performance', False) is True
|
||||
r, g, b = _hex_to_rgb(accent)
|
||||
hue, saturation, lightness = _rgb_to_hsl(r, g, b)
|
||||
light = _hsl_to_rgb(hue, saturation, min(lightness + 0.16, 0.95))
|
||||
|
|
@ -399,6 +400,7 @@ def _initial_appearance_context():
|
|||
'initial_particles_enabled': particles_enabled,
|
||||
'initial_worker_orbs_enabled': worker_orbs_enabled,
|
||||
'initial_reduce_effects': reduce_effects,
|
||||
'initial_max_performance': max_performance,
|
||||
}
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -24,11 +24,12 @@
|
|||
window._particlesEnabled = {{ initial_particles_enabled|tojson }};
|
||||
window._workerOrbsEnabled = {{ initial_worker_orbs_enabled|tojson }};
|
||||
window._reduceEffectsActive = {{ initial_reduce_effects|tojson }};
|
||||
window._maxPerfActive = {{ initial_max_performance|tojson }};
|
||||
</script>
|
||||
{{ vite_assets('head')|safe }}
|
||||
</head>
|
||||
|
||||
<body{% if initial_reduce_effects %} class="reduce-effects"{% endif %}>
|
||||
<body class="{% if initial_reduce_effects %}reduce-effects {% endif %}{% if initial_max_performance %}max-performance{% endif %}">
|
||||
<!-- Setup Wizard Overlay -->
|
||||
<div id="setup-wizard-overlay" class="setup-wizard-overlay" style="display: none;">
|
||||
<div class="setup-wizard-container">
|
||||
|
|
@ -6438,7 +6439,14 @@
|
|||
<input type="checkbox" id="reduce-effects-enabled">
|
||||
Reduce Visual Effects
|
||||
</label>
|
||||
<small class="settings-hint">Disables backdrop blur, animations, transitions, and shadows. Significantly reduces GPU/CPU usage on low-end devices.</small>
|
||||
<small class="settings-hint">Disables backdrop blur and shadows (the GPU-heavy bits). Keeps functional motion like loading spinners. Good for low-end devices.</small>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label class="checkbox-label">
|
||||
<input type="checkbox" id="max-performance-enabled">
|
||||
Max Performance
|
||||
</label>
|
||||
<small class="settings-hint">Nuclear low-power mode for software-rendered / no-GPU setups (e.g. Docker, remote desktop). Turns off Worker Orbs, Particles, all blur/shadows AND every animation & transition (loading spinners go static). Overrides the three options above.</small>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
|
|
|||
|
|
@ -386,8 +386,8 @@ function _renderEqualizerBars(grid, data) {
|
|||
// Call embers: tiny accent sparks rise off the fill tip, spawned per
|
||||
// socket update in proportion to REAL traffic — motion strictly means
|
||||
// API calls are happening right now. Suppressed during cooldown and
|
||||
// under reduced-effects.
|
||||
if (!window._reduceEffectsActive && !cooling && realPct > 0.03) {
|
||||
// under reduced-effects / max-performance.
|
||||
if (!window._reduceEffectsActive && !window._maxPerfActive && !cooling && realPct > 0.03) {
|
||||
_spawnEmbers(bar, pct, realPct > 0.6 ? 3 : realPct > 0.25 ? 2 : 1);
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -179,6 +179,14 @@ function initAccentColorListeners() {
|
|||
applyReduceEffects(reduceEffectsCheckbox.checked);
|
||||
});
|
||||
}
|
||||
|
||||
// Max Performance toggle — apply immediately on change
|
||||
const maxPerfCheckbox = document.getElementById('max-performance-enabled');
|
||||
if (maxPerfCheckbox) {
|
||||
maxPerfCheckbox.addEventListener('change', () => {
|
||||
applyMaxPerformance(maxPerfCheckbox.checked);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
function applyReduceEffects(enabled) {
|
||||
|
|
@ -211,6 +219,67 @@ function applyReduceEffects(enabled) {
|
|||
}
|
||||
}
|
||||
|
||||
// Max Performance overrides Worker Orbs / Particles / Reduce Effects, so while it's
|
||||
// on we lock those checkboxes (greyed + visually off) and restore them when it's
|
||||
// off. We never fire their change handlers, so the user's real saved prefs
|
||||
// (window._workerOrbsEnabled / _particlesEnabled / the reduce-effects localStorage)
|
||||
// stay intact — saving reads those, not these forced-off boxes.
|
||||
function _syncMaxPerfDependentToggles(maxPerfOn) {
|
||||
const ids = ['worker-orbs-enabled', 'particles-enabled', 'reduce-effects-enabled'];
|
||||
ids.forEach(id => {
|
||||
const cb = document.getElementById(id);
|
||||
if (!cb) return;
|
||||
const group = cb.closest('.form-group');
|
||||
if (maxPerfOn) {
|
||||
cb.disabled = true;
|
||||
cb.checked = false;
|
||||
if (group) group.classList.add('setting-overridden');
|
||||
} else {
|
||||
cb.disabled = false;
|
||||
if (group) group.classList.remove('setting-overridden');
|
||||
// Restore each box to the user's real per-device preference.
|
||||
if (id === 'worker-orbs-enabled') cb.checked = window._workerOrbsEnabled !== false;
|
||||
else if (id === 'particles-enabled') cb.checked = window._particlesEnabled === true;
|
||||
else if (id === 'reduce-effects-enabled') cb.checked = localStorage.getItem('soulsync-reduce-effects') === '1';
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// Max Performance — the nuclear low-power switch for software-rendered / no-GPU
|
||||
// setups (e.g. Docker). Superset of Reduce Visual Effects: body.max-performance CSS
|
||||
// kills the expensive GPU properties AND all animation/transitions, while here we
|
||||
// halt every JS canvas loop (particles + worker orbs; cursor-glow + API sparks gate
|
||||
// on window._maxPerfActive themselves).
|
||||
function applyMaxPerformance(enabled) {
|
||||
if (enabled) {
|
||||
document.body.classList.add('max-performance');
|
||||
} else {
|
||||
document.body.classList.remove('max-performance');
|
||||
}
|
||||
window._maxPerfActive = enabled;
|
||||
localStorage.setItem('soulsync-max-performance', enabled ? '1' : '0');
|
||||
|
||||
const pcanvas = document.getElementById('page-particles-canvas');
|
||||
if (enabled) {
|
||||
if (window.pageParticles) window.pageParticles.stop();
|
||||
if (pcanvas) pcanvas.style.display = 'none';
|
||||
if (window.workerOrbs) window.workerOrbs.setPage('_disabled');
|
||||
} else {
|
||||
// Restore whatever the user's own toggles (and reduce-effects) still allow.
|
||||
const reduce = window._reduceEffectsActive === true;
|
||||
const activePage = document.querySelector('.page.active');
|
||||
const activeId = activePage ? activePage.id.replace('-page', '') : null;
|
||||
if (!reduce && window._particlesEnabled !== false) {
|
||||
if (pcanvas) pcanvas.style.display = '';
|
||||
if (window.pageParticles && activeId) window.pageParticles.setPage(activeId);
|
||||
}
|
||||
if (window._workerOrbsEnabled !== false && window.workerOrbs && activeId) {
|
||||
window.workerOrbs.setPage(activeId);
|
||||
}
|
||||
}
|
||||
_syncMaxPerfDependentToggles(enabled);
|
||||
}
|
||||
|
||||
// Bootstrap accent and reduce-effects from localStorage instantly (prevents flash)
|
||||
(function () {
|
||||
// Auto performance mode on likely-weak hardware. Only acts when this device has
|
||||
|
|
@ -264,6 +333,19 @@ function applyReduceEffects(enabled) {
|
|||
} else if (window._reduceEffectsActive) {
|
||||
document.body.classList.add('reduce-effects');
|
||||
}
|
||||
// Max Performance — device-scoped (localStorage wins over the server default,
|
||||
// same as reduce-effects). The window flag is seeded server-side in index.html
|
||||
// for a flash-free first paint; localStorage reconciles it here.
|
||||
const maxPerfSaved = localStorage.getItem('soulsync-max-performance');
|
||||
if (maxPerfSaved === '1') {
|
||||
document.body.classList.add('max-performance');
|
||||
window._maxPerfActive = true;
|
||||
} else if (maxPerfSaved === '0') {
|
||||
document.body.classList.remove('max-performance');
|
||||
window._maxPerfActive = false;
|
||||
} else if (window._maxPerfActive) {
|
||||
document.body.classList.add('max-performance');
|
||||
}
|
||||
const saved = localStorage.getItem('soulsync-accent');
|
||||
if (saved) applyAccentColor(saved);
|
||||
// Bootstrap particles setting from localStorage — OFF by default (continuous
|
||||
|
|
@ -319,6 +401,90 @@ async function bootstrapServerAppearanceSettings() {
|
|||
|
||||
bootstrapServerAppearanceSettings();
|
||||
|
||||
// ── Password-manager autofill suppression ──────────────────────────────
|
||||
// Bitwarden / 1Password / LastPass etc. attach an inline autofill overlay to
|
||||
// every <input>/<select>/<textarea> and REBUILD it on every DOM mutation. This
|
||||
// app mutates the DOM continuously (live service status, download/automation
|
||||
// progress bars, the per-second "next run" countdown, innerHTML hub rebuilds),
|
||||
// so the managers' whole-document MutationObserver storms the main thread. A
|
||||
// captured DevTools trace (2026-06-29) showed Bitwarden's
|
||||
// bootstrap-autofill-overlay.js (setupOverlayOnField / setupOverlayListeners)
|
||||
// using ~6× the CPU of the entire SoulSync app — almost the whole freeze.
|
||||
//
|
||||
// None of these fields are credentials (they're search boxes, filters, config),
|
||||
// so we mark them ignored and the managers skip them: once a field carries the
|
||||
// ignore hint, the overlay is never (re)attached, so the mutation→re-setup storm
|
||||
// stops. Real sign-in fields (password type + the auth overlays) are left alone
|
||||
// so the user can still autofill the login / PIN screen. Purely additive data-*
|
||||
// attributes — no functional effect on the app, and a no-op for any manager that
|
||||
// doesn't honour them.
|
||||
(function suppressPasswordManagerAutofill() {
|
||||
const SKIP_CONTAINERS = ['#login-overlay', '#launch-pin-overlay', '#profile-pin-dialog'];
|
||||
const isCredentialField = (el) => {
|
||||
if (el.type === 'password') return true;
|
||||
return SKIP_CONTAINERS.some(sel => typeof el.closest === 'function' && el.closest(sel));
|
||||
};
|
||||
const IGNORE_ATTRS = ['data-bwignore', 'data-1p-ignore', 'data-lpignore', 'data-form-type'];
|
||||
const tag = (el) => {
|
||||
if (el.dataset.pmTagged) return; // tagged once — never touch again
|
||||
if (isCredentialField(el)) return; // leave real login fields for the manager
|
||||
el.dataset.pmTagged = '1';
|
||||
el.setAttribute('data-bwignore', 'true'); // Bitwarden
|
||||
el.setAttribute('data-1p-ignore', ''); // 1Password
|
||||
el.setAttribute('data-lpignore', 'true'); // LastPass
|
||||
el.setAttribute('data-form-type', 'other'); // Dashlane
|
||||
if (!el.hasAttribute('autocomplete')) el.setAttribute('autocomplete', 'off');
|
||||
};
|
||||
const sweep = () => {
|
||||
document.querySelectorAll(
|
||||
'input:not([data-pm-tagged]),textarea:not([data-pm-tagged]),select:not([data-pm-tagged])'
|
||||
).forEach(tag);
|
||||
};
|
||||
|
||||
// Debounce: a burst of DOM mutations triggers at most one sweep per idle slot.
|
||||
// The `:not([data-pm-tagged])` selector makes the steady-state sweep a no-op
|
||||
// (it only ever processes freshly-added inputs), and our own attribute writes
|
||||
// don't re-arm the observer (it watches childList, not attributes).
|
||||
let pending = false, observer = null, disabled = false;
|
||||
const scheduleSweep = () => {
|
||||
if (disabled || pending) return;
|
||||
pending = true;
|
||||
const run = () => { pending = false; if (!disabled) sweep(); };
|
||||
if (typeof requestIdleCallback === 'function') requestIdleCallback(run, { timeout: 400 });
|
||||
else setTimeout(run, 300);
|
||||
};
|
||||
|
||||
const startObserving = () => {
|
||||
if (observer) return;
|
||||
observer = new MutationObserver(scheduleSweep);
|
||||
observer.observe(document.body, { childList: true, subtree: true });
|
||||
};
|
||||
const start = () => { sweep(); startObserving(); };
|
||||
if (document.readyState === 'loading') document.addEventListener('DOMContentLoaded', start);
|
||||
else start();
|
||||
|
||||
// Benchmark hook (not used by the app): toggle the suppression at runtime so a
|
||||
// before/after can be measured without rebuilding. disable() strips the ignore
|
||||
// hints + stops the observer, so password managers re-attach their autofill
|
||||
// overlay — i.e. the pre-fix "before" behaviour. enable() re-tags + resumes.
|
||||
window.__pmSuppress = {
|
||||
disable() {
|
||||
disabled = true;
|
||||
if (observer) { observer.disconnect(); observer = null; }
|
||||
document.querySelectorAll('[data-pm-tagged]').forEach((el) => {
|
||||
IGNORE_ATTRS.forEach((a) => el.removeAttribute(a));
|
||||
delete el.dataset.pmTagged;
|
||||
});
|
||||
},
|
||||
enable() {
|
||||
disabled = false;
|
||||
sweep();
|
||||
startObserving();
|
||||
},
|
||||
get isActive() { return !disabled; },
|
||||
};
|
||||
})();
|
||||
|
||||
// ── Profile System ─────────────────────────────────────────────
|
||||
let currentProfile = null;
|
||||
const PROFILE_CONTEXT_CHANGED_EVENT = 'ss:webui-profile-context-changed';
|
||||
|
|
@ -2995,7 +3161,8 @@ async function loadPageData(pageId) {
|
|||
const RECENTER_DELAY_MS = 1500;
|
||||
let recenterTimer = 0;
|
||||
|
||||
const isReduced = () => document.body.classList.contains('reduce-effects');
|
||||
const isReduced = () => document.body.classList.contains('reduce-effects')
|
||||
|| document.body.classList.contains('max-performance');
|
||||
|
||||
const gridCenter = () => {
|
||||
const r = grid.getBoundingClientRect();
|
||||
|
|
|
|||
|
|
@ -2352,8 +2352,8 @@
|
|||
// Listen for page changes from script.js
|
||||
window.pageParticles = {
|
||||
setPage(pageId) {
|
||||
// Reduce Visual Effects performance mode halts the loop entirely.
|
||||
if (window._reduceEffectsActive) { stop(); return; }
|
||||
// Reduce Visual Effects / Max Performance modes halt the loop entirely.
|
||||
if (window._reduceEffectsActive || window._maxPerfActive) { stop(); return; }
|
||||
const presetName = PAGE_PRESETS[pageId] || 'none';
|
||||
setPreset(presetName);
|
||||
},
|
||||
|
|
@ -2362,7 +2362,7 @@
|
|||
|
||||
// Auto-start for initial page (respect particles toggle)
|
||||
requestAnimationFrame(() => {
|
||||
if (window._particlesEnabled === false || window._reduceEffectsActive) return;
|
||||
if (window._particlesEnabled === false || window._reduceEffectsActive || window._maxPerfActive) return;
|
||||
const activePage = document.querySelector('.page.active');
|
||||
if (activePage) {
|
||||
const pageId = activePage.id.replace('-page', '');
|
||||
|
|
|
|||
|
|
@ -1572,6 +1572,16 @@ async function loadSettingsData() {
|
|||
if (reduceCheckbox) reduceCheckbox.checked = reduceEffects;
|
||||
applyReduceEffects(reduceEffects);
|
||||
|
||||
// Max Performance — same device-scoped resolution as reduce-effects:
|
||||
// localStorage is the per-device truth, server value the cross-device default.
|
||||
// Applied last so it can lock/override the dependent toggles above when on.
|
||||
const serverMaxPerf = settings.ui_appearance?.max_performance === true; // default false
|
||||
const localMaxPerf = localStorage.getItem('soulsync-max-performance'); // '1' | '0' | null
|
||||
const maxPerf = localMaxPerf !== null ? (localMaxPerf === '1') : serverMaxPerf;
|
||||
const maxPerfCheckbox = document.getElementById('max-performance-enabled');
|
||||
if (maxPerfCheckbox) maxPerfCheckbox.checked = maxPerf;
|
||||
applyMaxPerformance(maxPerf);
|
||||
|
||||
// Populate Logging information
|
||||
const logLevelSelect = document.getElementById('log-level-select');
|
||||
if (logLevelSelect) logLevelSelect.value = settings.logging?.level || 'INFO';
|
||||
|
|
@ -3493,9 +3503,13 @@ async function saveSettings(quiet = false) {
|
|||
accent_preset: document.getElementById('accent-preset')?.value || '#1db954',
|
||||
accent_color: document.getElementById('accent-custom-color')?.value || '#1db954',
|
||||
sidebar_visualizer: document.getElementById('sidebar-visualizer-type')?.value || 'bars',
|
||||
particles_enabled: document.getElementById('particles-enabled')?.checked !== false,
|
||||
worker_orbs_enabled: document.getElementById('worker-orbs-enabled')?.checked !== false,
|
||||
reduce_effects: document.getElementById('reduce-effects-enabled')?.checked === true
|
||||
// Read the runtime flags / localStorage, not the checkboxes: while Max
|
||||
// Performance is on it locks those boxes visually-off, but the user's real
|
||||
// saved prefs live in the flags — so saving must not clobber them.
|
||||
particles_enabled: window._particlesEnabled !== false,
|
||||
worker_orbs_enabled: window._workerOrbsEnabled !== false,
|
||||
reduce_effects: window._reduceEffectsActive === true,
|
||||
max_performance: window._maxPerfActive === true
|
||||
},
|
||||
youtube: {
|
||||
cookies_browser: document.getElementById('youtube-cookies-browser').value,
|
||||
|
|
|
|||
|
|
@ -138,7 +138,9 @@ body {
|
|||
/* In performance mode the aura loses its blur + animation, leaving two hard
|
||||
static circles — hide them entirely instead. */
|
||||
body.reduce-effects .sidebar::before,
|
||||
body.reduce-effects .sidebar::after {
|
||||
body.reduce-effects .sidebar::after,
|
||||
body.max-performance .sidebar::before,
|
||||
body.max-performance .sidebar::after {
|
||||
display: none !important;
|
||||
}
|
||||
|
||||
|
|
@ -500,12 +502,14 @@ body:not(.reduce-effects) .nav-button.active:hover {
|
|||
transparent border, so no layout shift). The expensive full-effects
|
||||
bits (gradient, transform/translateX, multi-layer box-shadow) stay
|
||||
off so hovering doesn't trigger compositing/repaint churn. */
|
||||
body.reduce-effects .nav-button:hover {
|
||||
body.reduce-effects .nav-button:hover,
|
||||
body.max-performance .nav-button:hover {
|
||||
background: rgba(255, 255, 255, 0.05);
|
||||
border-color: rgba(255, 255, 255, 0.08);
|
||||
}
|
||||
|
||||
body.reduce-effects .nav-button.active:hover {
|
||||
body.reduce-effects .nav-button.active:hover,
|
||||
body.max-performance .nav-button.active:hover {
|
||||
background: rgba(var(--accent-rgb), 0.18);
|
||||
border-color: rgba(var(--accent-rgb), 0.3);
|
||||
}
|
||||
|
|
@ -56088,6 +56092,13 @@ tr.tag-diff-same {
|
|||
#settings-page .info-icon {
|
||||
/* Single clean circle with a centred "i" (was the ⓘ glyph, which carried
|
||||
its own inner circle → a double-circle that read as off-centre). */
|
||||
/* It's a role="button" with a text "i" inside — without these, hovering the
|
||||
glyph gives the I-beam text caret on Windows (Linux happened to resolve a
|
||||
pointer). Force the button hand + make the glyph non-selectable so the
|
||||
cursor is the same on every platform. */
|
||||
cursor: pointer;
|
||||
-webkit-user-select: none;
|
||||
user-select: none;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
|
|
@ -60406,6 +60417,38 @@ body.reduce-effects #page-particles-canvas {
|
|||
display: none !important;
|
||||
}
|
||||
|
||||
/* ── Max Performance ── Nuclear low-power mode for software-rendered / no-GPU
|
||||
setups (e.g. Docker, remote desktop), where there is no compositor and even
|
||||
transform/opacity motion repaints on the CPU. Superset of Reduce Visual Effects:
|
||||
kills the expensive GPU properties AND halts ALL animation + transitions — so
|
||||
functional loading spinners go static. That's the deliberate trade-off for
|
||||
minimum CPU; the user opts in explicitly. Worker orbs, particles, the cursor
|
||||
glow and the API-monitor sparks are halted in JS (gated on window._maxPerfActive). */
|
||||
body.max-performance *,
|
||||
body.max-performance *::before,
|
||||
body.max-performance *::after {
|
||||
animation: none !important;
|
||||
transition-duration: 0s !important;
|
||||
transition-delay: 0s !important;
|
||||
backdrop-filter: none !important;
|
||||
-webkit-backdrop-filter: none !important;
|
||||
box-shadow: none !important;
|
||||
filter: none !important;
|
||||
}
|
||||
|
||||
/* Drop the always-on page-particles canvas from the compositor entirely. */
|
||||
body.max-performance #page-particles-canvas {
|
||||
display: none !important;
|
||||
}
|
||||
|
||||
/* Appearance toggles that Max Performance overrides: greyed + locked while it's on. */
|
||||
.form-group.setting-overridden {
|
||||
opacity: 0.45;
|
||||
}
|
||||
.form-group.setting-overridden .checkbox-label {
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
/* ============================================
|
||||
ACTIVE DOWNLOADS PAGE — Premium Glassmorphic
|
||||
============================================ */
|
||||
|
|
@ -63476,7 +63519,9 @@ body[data-artist-source="source"] #artist-detail-page #library-artist-enhance-bt
|
|||
}
|
||||
|
||||
body.reduce-effects .dash-card::before,
|
||||
body.reduce-effects .dash-card::after {
|
||||
body.reduce-effects .dash-card::after,
|
||||
body.max-performance .dash-card::before,
|
||||
body.max-performance .dash-card::after {
|
||||
display: none;
|
||||
}
|
||||
.dash-card:hover {
|
||||
|
|
|
|||
|
|
@ -1065,12 +1065,13 @@
|
|||
// ── Page awareness ──
|
||||
|
||||
function isEnabled() {
|
||||
// The worker-orbs toggle controls the orbs on its own — reduce-effects no
|
||||
// longer force-kills them. The orb glow is canvas radial gradients, NOT a CSS
|
||||
// blur(28px) (that's the sidebar aura orbs + frosted glass, which reduce-effects
|
||||
// still kills), so the per-frame cost is moderate, not the blur-rasterize lag.
|
||||
// If real telemetry says otherwise, revert by re-adding `&& !window._reduceEffectsActive`.
|
||||
return window._workerOrbsEnabled !== false;
|
||||
// Reduce-effects does NOT gate the orbs — they have their own toggle, so that
|
||||
// setting controls them on its own. The orbs ARE killed by Max Performance: in a
|
||||
// Docker / headless / remote-desktop container there is no GPU, so the per-frame
|
||||
// radial-gradient canvas fill rasterizes on the CPU and can saturate a core,
|
||||
// freezing the whole UI. Max Performance is the nuclear low-power switch for
|
||||
// exactly those software-rendered setups, so the canvas loop must stay off there.
|
||||
return window._workerOrbsEnabled !== false && !window._maxPerfActive;
|
||||
}
|
||||
|
||||
// ── Real telemetry → pulses ──
|
||||
|
|
|
|||
Loading…
Reference in a new issue