Merge origin/main

# Conflicts:
#	CHANGELOG.md
This commit is contained in:
Chuck
2026-10-05 10:04:18 -04:00
23 changed files with 2003 additions and 404 deletions
+6
View File
@@ -11,13 +11,17 @@
import { createApi } from './api.js';
import { createFacade, installFacade } from './facade.js';
import { createRegistry } from './registry.js';
import { createVisibility } from './visibility.js';
const api = createApi();
const visibility = createVisibility({ window: window });
const registry = createRegistry({
context: {
api: api,
notify: function(message, type) { return window.LEDMatrix.notify(message, type); },
},
// Bound to one mounted page: its timers stop when it is destroyed.
mountContext: function(ctx) { return { visibility: visibility.forPage(ctx) }; },
});
const facade = installFacade(window, createFacade(window, api, registry));
@@ -45,6 +49,7 @@ const pages = {
'backup-restore': page(function() { return import('../pages/backup-restore.js'); }),
'schedule': page(function() { return import('../pages/schedule.js'); }),
'general': page(function() { return import('../pages/general.js'); }),
'display': page(function() { return import('../pages/display.js'); }),
};
Object.keys(pages).forEach(function(name) { registry.register(name, pages[name]); });
@@ -85,5 +90,6 @@ alias('cache', 'deleteCacheFile', "the Cache tab's Delete buttons");
});
aliasObject('general', 'webLogin', ['setPassword', 'disable', 'createToken', 'copyToken', 'revoke'],
"the General tab's Security buttons");
alias('display', 'updateSyncUI', "the Display tab's Role menu");
registry.start();
+14 -1
View File
@@ -8,7 +8,9 @@
* destroy(root, ctx) optional; undo anything `ctx.signal` does not.
*
* ctx is a per-mount object holding the shared services passed to
* createRegistry({ context }) (boot.js passes `api` and `notify`) plus:
* createRegistry({ context }) (boot.js passes `api` and `notify`), the fields
* returned by createRegistry({ mountContext }) for this mount (boot.js adds
* `visibility`, core/visibility.js), plus:
* ctx.root the data-page element
* ctx.name the page name
* ctx.signal an AbortSignal aborted on destroy. Pass it to
@@ -42,12 +44,16 @@ export const PAGE_ATTRIBUTE = 'data-page';
* @param {object} [options]
* @param {Document} [options.document] the document to wire (default: globalThis.document)
* @param {object} [options.context] services copied onto every page's ctx
* @param {Function} [options.mountContext] (ctx) => fields added to that mount's ctx;
* for services bound to one page (they see
* ctx.root, ctx.name and ctx.signal)
* @param {{error: Function}} [options.logger]
*/
export function createRegistry(options = {}) {
const doc = options.document || globalThis.document;
const logger = options.logger || console;
const services = options.context || {};
const mountContext = options.mountContext || null;
// The document's own AbortController: an element only accepts a signal
// from its own realm (it matters for jsdom in the tests, not in a browser).
const Controller = (doc && doc.defaultView && doc.defaultView.AbortController) || globalThis.AbortController;
@@ -105,6 +111,13 @@ export function createRegistry(options = {}) {
const controller = new Controller();
const ctx = Object.assign({}, services,
{ root: root, name: name, signal: controller.signal, state: {} });
if (mountContext) {
try {
Object.assign(ctx, mountContext(ctx));
} catch (error) {
logger.error('[LEDMatrix.pages] ' + name + ': mountContext failed:', error);
}
}
const entry = { name: name, root: root, ctx: ctx, controller: controller,
module: null, initialised: false, destroyed: false, ready: null };
mounted.set(root, entry);
@@ -0,0 +1,144 @@
/*
* core/visibility.js -- run a page's timers and polling only while the page
* is on screen: its tab is the active tab AND the browser tab is visible.
*
* Each mounted page gets its own handle as ctx.visibility (the registry asks
* boot.js for it on every mount; see createRegistry's `mountContext`):
*
* ctx.visibility.whileVisible(start, stop)
* start() runs when the page comes on screen, stop() when it leaves.
* start() should refresh at once: it also runs when the page mounts
* already on screen. Returns a function that ends the registration
* (running stop() first if it is running).
* ctx.visibility.every(ms, fn)
* fn() at once and then every `ms` while on screen; the interval is
* cleared while hidden and restarted (with an immediate fn()) when the
* page is back. Returns the same kind of end function.
* ctx.visibility.isVisible()
* true while the page is on screen.
*
* Everything a page registered ends when the page is destroyed (its
* ctx.signal aborts, after destroy()), so a page needs no teardown for it:
* a swapped-out partial leaves no interval behind.
*
* Which tab: the page's name (ctx.name), or forPage(ctx, { tab }) when a
* page's tab is named differently.
*
* Where the answer comes from: window.LEDVisibility (app-shell.js), read at
* call time. It owns the one notion of "active tab" (Alpine's activeTab plus
* the ledmatrix:tab-changed event) and already pauses the SSE streams, so
* the classic partials that still call it and the page modules agree. Each
* registration here takes its own LEDVisibility key, so two pages (or two
* timers on one page) never replace each other. Without LEDVisibility (a
* page outside base.html) the browser tab's visibility alone decides.
*
* No DOM globals are read at import time, so node tests can import this file
* and hand createVisibility() a jsdom window.
*/
/**
* @param {object} [options]
* @param {Window} [options.window] default: globalThis
* @param {Function} [options.tracker] () => an LEDVisibility-shaped object or null
* (default: window.LEDVisibility at call time)
* @param {{error: Function}} [options.logger]
*/
export function createVisibility(options = {}) {
const win = options.window || globalThis;
const doc = win.document;
const logger = options.logger || win.console || console;
const tracker = options.tracker || function() { return win.LEDVisibility || null; };
let sequence = 0;
function call(fn, label) {
try {
fn();
} catch (error) {
logger.error('[LEDMatrix.visibility] ' + label + ' failed:', error);
}
}
/**
* Without a tracker: follow document.hidden only. Same contract as
* LEDVisibility.onActive: start/stop on change, returns an unregister fn.
*/
function onDocumentVisible(start, stop) {
let running = false;
function evaluate() {
const want = !doc.hidden;
if (want === running) return;
running = want;
call(want ? start : stop, want ? 'start' : 'stop');
}
doc.addEventListener('visibilitychange', evaluate);
evaluate();
return function() {
doc.removeEventListener('visibilitychange', evaluate);
if (running) { running = false; call(stop, 'stop'); }
};
}
/**
* The handle for one mounted page. Everything it registers ends when
* ctx.signal aborts.
*/
function forPage(ctx, pageOptions = {}) {
const tab = pageOptions.tab || ctx.name;
const signal = ctx.signal;
const live = new Set();
function whileVisible(start, stop) {
if (typeof start !== 'function' || typeof stop !== 'function') {
throw new TypeError('visibility.whileVisible(start, stop) needs two functions');
}
if (signal && signal.aborted) return function() {};
const t = tracker();
const key = 'page:' + ctx.name + ':' + (++sequence);
const unregister = t
? t.onActive(tab, start, stop, key)
: onDocumentVisible(start, stop);
let ended = false;
const end = function() {
if (ended) return;
ended = true;
live.delete(end);
if (typeof unregister === 'function') call(unregister, key + ' stop');
};
live.add(end);
return end;
}
function every(ms, fn) {
if (!(ms > 0) || typeof fn !== 'function') {
throw new TypeError('visibility.every(ms, fn) needs a positive interval and a function');
}
let timer = null;
function stop() {
if (timer !== null) {
win.clearInterval(timer);
timer = null;
}
}
return whileVisible(function() {
stop();
timer = win.setInterval(function() { call(fn, 'every(' + ms + ')'); }, ms);
fn();
}, stop);
}
function isVisible() {
const t = tracker();
return t ? t.isActive(tab) : !doc.hidden;
}
if (signal) {
signal.addEventListener('abort', function() {
Array.from(live).forEach(function(end) { end(); });
}, { once: true });
}
return Object.freeze({ tab: tab, whileVisible: whileVisible, every: every, isVisible: isVisible });
}
return { forPage: forPage };
}
+324
View File
@@ -0,0 +1,324 @@
/*
* pages/display.js -- the Display tab (templates/v3/partials/display.html):
* the panel hardware, Vegas scroll mode, double-sided and multi-display
* sync settings.
*
* The form itself stays plain htmx (hx-post with json-enc, and its hx-on
* call to the shared showSaveResult, as the Rotation and General forms
* keep). This module does what the partial's inline scripts did:
* - the brightness value next to its slider
* - the live "Your display: W x H pixels" readout
* - showing the Vegas and double-sided settings only while each is on
* - the scroll-speed hint (what the panel really does with a speed), asked
* of the server 150 ms after the slider stops; a newer answer wins
* - the Vegas plugin order (the shared plugin-order-list widget, as the
* Rotation tab uses; its plugin-list request carries ctx.signal)
* - the Advanced section's toggle button (data-action="toggle-section",
* calling the shared window.toggleSection the settings search also uses)
* - the multi-display sync role, and its status, polled every 5 s through
* ctx.visibility: only while the Display tab is on screen and the
* browser tab is visible, and never after the partial is swapped out
*
* The old global updateSyncUI is a deprecated alias made in core/boot.js; it
* forwards to the export of the same name at the bottom of this file.
*/
const SYNC_POLL_MS = 5000;
const HINT_DELAY_MS = 150;
const RETRY_MS = 100;
const MAX_TRIES = 50;
const RESOLUTION_INPUTS = ['rows', 'cols', 'chain_length', 'parallel'];
// The mounted page, for the deprecated alias (boot.js).
let active = null;
function quiet(error) {
return !!error && (error.name === 'AbortError' || error.loginRequired);
}
// ── the "Your display" readout ───────────────────────────────────────────────
// width = cols x chain_length, height = rows x parallel (the chain-length
// tooltip's math), swapped for a 90/270 orientation. A custom pixel mapper
// can change it further; src/display_geometry.py models those.
function showResolution(root) {
const out = root.querySelector('#display-resolution-value');
if (!out) return;
const read = function(id) {
const el = root.querySelector('#' + id);
return el ? parseInt(el.value, 10) : NaN;
};
const rows = read('rows');
const cols = read('cols');
const chain = read('chain_length');
const parallel = read('parallel');
if ([rows, cols, chain, parallel].some(function(n) { return !Number.isFinite(n) || n <= 0; })) {
out.textContent = '—';
return;
}
let w = cols * chain;
let h = rows * parallel;
const orientation = root.querySelector('#orientation');
if (orientation && (orientation.value === '90' || orientation.value === '270')) {
[w, h] = [h, w];
}
out.textContent = w + ' × ' + h + ' pixels';
}
// ── the scroll-speed hint ────────────────────────────────────────────────────
// Only speeds that advance a whole number of pixels per refresh look smooth,
// and which those are depends on the panel, so the server works it out.
function refreshScrollSpeedHint(root, ctx) {
const win = root.ownerDocument.defaultView;
const slider = root.querySelector('#vegas_scroll_speed');
win.clearTimeout(ctx.state.hintTimer);
ctx.state.hintTimer = win.setTimeout(function() {
ctx.state.hintTimer = null;
const hint = root.querySelector('#vegas_scroll_speed_hint');
if (!hint || !slider || ctx.signal.aborted) return;
ctx.state.hintSeq = (ctx.state.hintSeq || 0) + 1;
const seq = ctx.state.hintSeq;
const q = new win.URLSearchParams({ speed: slider.value, min: slider.min, max: slider.max });
ctx.api.get('/api/v3/config/scroll-speed-advice?' + q, { signal: ctx.signal })
.then(function(body) {
if (seq !== ctx.state.hintSeq || body.status !== 'success') return;
renderScrollSpeedHint(root, hint, slider, body.data);
})
.catch(function(error) {
// A refused request keeps the last hint, as before; a failed
// or unreadable one clears it.
if (quiet(error) || error.body) return;
hint.textContent = '';
});
}, HINT_DELAY_MS);
}
function renderScrollSpeedHint(root, hint, slider, a) {
const doc = root.ownerDocument;
const win = doc.defaultView;
const ap = a.applied;
const motion = ap.pixels_per_frame + ' px every ' + ap.frame_hold +
' refresh' + (ap.frame_hold === 1 ? '' : 'es');
hint.textContent = '';
hint.className = 'mt-1 text-xs ' + (a.smooth && a.exact ? 'text-green-700' : 'text-amber-700');
const line = doc.createElement('span');
if (a.smooth && a.exact) {
line.textContent = 'Smooth on this panel (' + motion + ', ' + a.refresh_hz + ' Hz).';
} else {
line.textContent = a.requested + ' px/s will run as ' + ap.pixels_per_second +
' px/s (' + motion + ', ' + ap.steppiness + ') on this ' + a.refresh_hz +
' Hz panel.' + (a.alternatives.length ? ' Smooth speeds: ' : '');
}
hint.appendChild(line);
a.alternatives.forEach(function(alt, i) {
if (i > 0) hint.appendChild(doc.createTextNode(' '));
const value = Math.round(alt.pixels_per_second);
const btn = doc.createElement('button');
btn.type = 'button';
btn.className = 'underline font-medium';
btn.textContent = value + ' px/s';
btn.addEventListener('click', function() {
slider.value = value;
slider.dispatchEvent(new win.Event('input', { bubbles: true }));
});
hint.appendChild(btn);
});
}
// ── the Vegas plugin order ───────────────────────────────────────────────────
// The widget is a classic deferred script and normally loads before this
// module runs; the short retry covers a page that arrives first anyway.
function startPluginOrder(root, ctx) {
const container = root.querySelector('#vegas_plugin_order');
if (!container) return;
const win = root.ownerDocument.defaultView;
let tries = 0;
function attempt() {
ctx.state.orderTimer = null;
if (ctx.signal.aborted) return;
const widget = win.PluginOrderList;
if (!widget) {
if (++tries < MAX_TRIES) {
ctx.state.orderTimer = win.setTimeout(attempt, RETRY_MS);
} else {
container.textContent = 'Could not load the reorder widget — reload the page to try again.';
container.className = 'text-sm text-red-500';
}
return;
}
widget.init({
containerId: 'vegas_plugin_order',
orderInputId: 'vegas_plugin_order_value',
excludedInputId: 'vegas_excluded_plugins_value',
showVegasModeBadge: true,
signal: ctx.signal,
});
}
attempt();
}
// ── multi-display sync ───────────────────────────────────────────────────────
function syncRole(root) {
const select = root.querySelector('#sync_role');
return select ? select.value : null;
}
/** Show the status bar and the follower's Position field for the chosen role. */
function showSyncRole(root) {
const role = syncRole(root);
const bar = root.querySelector('#sync_status_bar');
const position = root.querySelector('#setting-display-sync_follower_position');
const errorDetail = root.querySelector('#sync_error_detail');
if (!bar || !position) return role;
if (role === 'standalone') {
bar.classList.add('hidden');
if (errorDetail) errorDetail.classList.add('hidden');
position.style.display = 'none';
} else {
bar.classList.remove('hidden');
position.style.display = role === 'follower' ? '' : 'none';
}
return role;
}
function pollSyncStatus(root, ctx) {
const role = syncRole(root);
if (!role || role === 'standalone' || ctx.signal.aborted) return;
ctx.api.get('/api/v3/sync/status', { signal: ctx.signal })
.then(function(body) { renderSyncStatus(root, body.data || {}); })
.catch(function(error) {
if (!quiet(error)) renderSyncStatus(root, { state: 'unknown' });
});
}
function renderSyncStatus(root, d) {
const doc = root.ownerDocument;
const content = root.querySelector('#sync_status_content');
const errorDetail = root.querySelector('#sync_error_detail');
const errorText = root.querySelector('#sync_error_text');
if (!content) return;
const state = d.state || 'unknown';
const role = d.role || 'unknown';
let icon, colorClass, text;
if (state === 'connected' || state === 'follower') {
icon = '●';
colorClass = 'bg-green-50 border-green-200 text-green-800';
const peer = d.peer_ip || d.leader_ip || 'peer';
text = role === 'leader'
? 'Follower connected — ' + peer + ' (chain ' + (d.peer_chain || '?') + ')'
: 'Receiving from leader — ' + peer;
errorDetail.classList.add('hidden');
} else if (state === 'incompatible') {
icon = '⚠';
colorClass = 'bg-yellow-50 border-yellow-200 text-yellow-800';
text = 'Follower connected but incompatible panels';
if (d.error) {
errorText.textContent = d.error;
errorDetail.classList.remove('hidden');
}
} else if (state === 'no_peer' || state === 'standalone') {
icon = '○';
colorClass = 'bg-gray-50 border-gray-200 text-gray-600';
text = role === 'leader' ? 'No follower detected' : 'Searching for leader…';
errorDetail.classList.add('hidden');
} else if (state === 'starting') {
icon = '○';
colorClass = 'bg-gray-50 border-gray-200 text-gray-500';
text = 'Display process starting…';
errorDetail.classList.add('hidden');
} else {
icon = '✕';
colorClass = 'bg-red-50 border-red-200 text-red-700';
text = 'Sync status unavailable';
errorDetail.classList.add('hidden');
}
content.className = 'flex items-start space-x-2 p-3 rounded-lg border text-sm ' + colorClass;
content.textContent = '';
const iconSpan = doc.createElement('span');
iconSpan.className = 'font-bold text-lg leading-none';
iconSpan.textContent = icon;
const textSpan = doc.createElement('span');
textSpan.textContent = text;
content.appendChild(iconSpan);
content.appendChild(textSpan);
}
/** The Role menu changed: show what goes with the new role, and ask for its status now. */
function changeSyncRole(root, ctx) {
if (showSyncRole(root) !== 'standalone') pollSyncStatus(root, ctx);
}
// ── lifecycle ────────────────────────────────────────────────────────────────
export function init(root, ctx) {
const win = root.ownerDocument.defaultView;
const on = { signal: ctx.signal };
const $ = function(id) { return root.querySelector('#' + id); };
const brightness = $('brightness');
const brightnessValue = $('brightness-value');
if (brightness && brightnessValue) {
brightness.addEventListener('input', function() { brightnessValue.textContent = brightness.value; }, on);
}
RESOLUTION_INPUTS.forEach(function(id) {
const el = $(id);
if (el) el.addEventListener('input', function() { showResolution(root); }, on);
});
const orientation = $('orientation');
if (orientation) orientation.addEventListener('change', function() { showResolution(root); }, on);
showResolution(root);
// Hidden rather than disabled, so the fields keep submitting and the
// server still sees an "off" state to persist.
[['vegas_scroll_enabled', 'vegas_scroll_settings', 'block'],
['double_sided_enabled', 'double_sided_settings', 'grid']].forEach(function(spec) {
const box = $(spec[0]);
const settings = $(spec[1]);
if (!box || !settings) return;
box.addEventListener('change', function() { settings.style.display = box.checked ? spec[2] : 'none'; }, on);
});
const speed = $('vegas_scroll_speed');
const speedValue = $('vegas_scroll_speed_value');
if (speed && speedValue) {
speed.addEventListener('input', function() {
speedValue.textContent = speed.value;
refreshScrollSpeedHint(root, ctx);
}, on);
refreshScrollSpeedHint(root, ctx);
}
root.addEventListener('click', function(event) {
const button = event.target && event.target.closest ? event.target.closest('[data-action="toggle-section"]') : null;
if (!button || !root.contains(button)) return;
if (typeof win.toggleSection === 'function') win.toggleSection(button.getAttribute('data-section'));
}, on);
const role = $('sync_role');
if (role) role.addEventListener('change', function() { changeSyncRole(root, ctx); }, on);
showSyncRole(root);
// The first request goes out as soon as the tab is on screen (at once,
// when it already is), then every 5 s while it stays there.
ctx.visibility.every(SYNC_POLL_MS, function() { pollSyncStatus(root, ctx); });
startPluginOrder(root, ctx);
active = ctx;
}
export function destroy(root, ctx) {
const win = root.ownerDocument.defaultView;
win.clearTimeout(ctx.state.hintTimer);
win.clearTimeout(ctx.state.orderTimer);
ctx.state.hintTimer = null;
ctx.state.orderTimer = null;
// The sync poll is ctx.visibility's: it stops when ctx.signal aborts.
if (active === ctx) active = null;
}
// ── the old global, kept as a deprecated alias (boot.js) ─────────────────────
/** window.updateSyncUI(): the Role menu's old onchange handler. */
export function updateSyncUI() {
if (active) changeSyncRole(active.root, active);
}
@@ -1,5 +1,5 @@
{% import 'v3/partials/_macros.html' as ui %}
<div class="bg-white rounded-lg shadow p-6">
<div class="bg-white rounded-lg shadow p-6" data-page="display">
<div class="border-b border-gray-200 pb-4 mb-6">
<h2 class="text-lg font-semibold text-gray-900">Display Settings</h2>
<p class="mt-1 text-sm text-gray-600">Configure LED matrix hardware settings and display options.</p>
@@ -168,7 +168,8 @@
class="w-full bg-gray-100 hover:bg-gray-200 px-4 py-3 flex items-center justify-between text-left transition-colors rounded-t-lg"
aria-controls="display-section-advanced-hardware"
aria-expanded="false"
onclick="toggleSection('display-section-advanced-hardware')">
data-action="toggle-section"
data-section="display-section-advanced-hardware">
<div class="flex-1">
<h4 class="font-semibold text-gray-900">
<i class="fas fa-sliders-h mr-1 text-gray-500"></i>Advanced Hardware &amp; Display Options (15)
@@ -394,42 +395,6 @@
</div>
</div> <!-- /#display-section-advanced-hardware (nested-content) -->
</div> <!-- /advanced hardware nested-section -->
<script>
// Live "Your display: W x H" readout - width = cols x chain_length,
// height = rows x parallel (same math as the chain-length tooltip),
// swapped for a 90/270 orientation. A custom pixel mapper config can
// change it further; src/display_geometry.py models those.
(function () {
const ids = ['rows', 'cols', 'chain_length', 'parallel'];
const out = document.getElementById('display-resolution-value');
if (!out) return;
function recompute() {
const v = {};
for (const id of ids) {
const el = document.getElementById(id);
v[id] = el ? parseInt(el.value, 10) : NaN;
}
if (Object.values(v).some(n => !Number.isFinite(n) || n <= 0)) {
out.textContent = '—';
return;
}
let w = v.cols * v.chain_length, h = v.rows * v.parallel;
const orientation = document.getElementById('orientation');
if (orientation && (orientation.value === '90' || orientation.value === '270')) {
[w, h] = [h, w];
}
out.textContent = w + ' × ' + h + ' pixels';
}
for (const id of ids) {
const el = document.getElementById(id);
if (el) el.addEventListener('input', recompute);
}
const orientationSelect = document.getElementById('orientation');
if (orientationSelect) orientationSelect.addEventListener('change', recompute);
recompute();
})();
</script>
</div>
<!-- Vegas Scroll Mode Settings -->
@@ -830,7 +795,7 @@ With this off a live game takes over the whole display with the full-screen scor
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
<div class="form-group" id="setting-display-sync_role" data-setting-key="sync.role">
<label for="sync_role" class="block text-sm font-medium text-gray-700">Role{{ ui.help_tip('This unit\'s part in a two-display setup.\nStandalone = sync off. Set one Pi to Leader (drives the scroll) and the other to Follower (receives frames). Restart required after changing.', 'Sync Role') }}</label>
<select id="sync_role" name="sync_role" class="form-control" onchange="updateSyncUI()">
<select id="sync_role" name="sync_role" class="form-control">
<option value="standalone" {% if main_config.get('sync', {}).get('role', 'standalone') == 'standalone' %}selected{% endif %}>Standalone (disabled)</option>
<option value="leader" {% if main_config.get('sync', {}).get('role', 'standalone') == 'leader' %}selected{% endif %}>Leader (drives scroll)</option>
<option value="follower" {% if main_config.get('sync', {}).get('role', 'standalone') == 'follower' %}selected{% endif %}>Follower (receives frames)</option>
@@ -879,261 +844,3 @@ With this off a live game takes over the whole display with the full-screen scor
</div>
</form>
</div>
<script>
// Update brightness display
document.getElementById('brightness').addEventListener('input', function() {
document.getElementById('brightness-value').textContent = this.value;
});
// Vegas Scroll Mode Settings
(function() {
function escapeHtml(text) { return window.LEDEscape.html(text); }
function escapeAttr(text) { return window.LEDEscape.attr(text); }
// Toggle settings visibility
const vegasEnabledCheckbox = document.getElementById('vegas_scroll_enabled');
const vegasSettings = document.getElementById('vegas_scroll_settings');
if (vegasEnabledCheckbox && vegasSettings) {
vegasEnabledCheckbox.addEventListener('change', function() {
vegasSettings.style.display = this.checked ? 'block' : 'none';
});
}
// Double-sided: copies/axis only mean anything while the feature is on.
// Hidden rather than disabled so the fields keep submitting and the server
// still sees an 'off' state to persist.
const doubleSidedCheckbox = document.getElementById('double_sided_enabled');
const doubleSidedSettings = document.getElementById('double_sided_settings');
if (doubleSidedCheckbox && doubleSidedSettings) {
doubleSidedCheckbox.addEventListener('change', function() {
doubleSidedSettings.style.display = this.checked ? 'grid' : 'none';
});
}
// Declared before first use: let is not hoisted usably.
let scrollHintTimer = null;
let scrollHintSeq = 0;
// Update scroll speed display
const scrollSpeedSlider = document.getElementById('vegas_scroll_speed');
const scrollSpeedValue = document.getElementById('vegas_scroll_speed_value');
if (scrollSpeedSlider && scrollSpeedValue) {
scrollSpeedSlider.addEventListener('input', function() {
scrollSpeedValue.textContent = this.value;
refreshScrollSpeedHint();
});
refreshScrollSpeedHint();
}
// Tell the user what the panel will really do with this speed. Only
// speeds that advance a whole number of pixels per refresh look smooth,
// and which those are depends on the panel, so the server works it out.
function refreshScrollSpeedHint() {
clearTimeout(scrollHintTimer);
scrollHintTimer = setTimeout(function() {
const hint = document.getElementById('vegas_scroll_speed_hint');
if (!hint) return;
const seq = ++scrollHintSeq;
const q = new URLSearchParams({
speed: scrollSpeedSlider.value,
min: scrollSpeedSlider.min,
max: scrollSpeedSlider.max
});
fetch('/api/v3/config/scroll-speed-advice?' + q)
.then(function(r) { return r.json(); })
.then(function(body) {
if (seq !== scrollHintSeq || body.status !== 'success') return;
renderScrollSpeedHint(hint, body.data);
})
.catch(function() { hint.textContent = ''; });
}, 150);
}
function renderScrollSpeedHint(hint, a) {
const ap = a.applied;
const motion = ap.pixels_per_frame + ' px every ' + ap.frame_hold +
' refresh' + (ap.frame_hold === 1 ? '' : 'es');
hint.textContent = '';
hint.className = 'mt-1 text-xs ' + (a.smooth && a.exact ? 'text-green-700' : 'text-amber-700');
const line = document.createElement('span');
if (a.smooth && a.exact) {
line.textContent = 'Smooth on this panel (' + motion + ', ' + a.refresh_hz + ' Hz).';
} else {
line.textContent = a.requested + ' px/s will run as ' + ap.pixels_per_second +
' px/s (' + motion + ', ' + ap.steppiness + ') on this ' + a.refresh_hz +
' Hz panel.' + (a.alternatives.length ? ' Smooth speeds: ' : '');
}
hint.appendChild(line);
a.alternatives.forEach(function(alt, i) {
if (i > 0) hint.appendChild(document.createTextNode(' '));
const value = Math.round(alt.pixels_per_second);
const btn = document.createElement('button');
btn.type = 'button';
btn.className = 'underline font-medium';
btn.textContent = value + ' px/s';
btn.addEventListener('click', function() {
scrollSpeedSlider.value = value;
scrollSpeedSlider.dispatchEvent(new Event('input', {bubbles: true}));
});
hint.appendChild(btn);
});
}
// Initialize plugin order list via the shared drag-and-drop module
// (static/v3/js/widgets/plugin-order-list.js) — the same component the
// Durations tab uses for the primary rotation order.
function initPluginOrderList(attempt) {
const container = document.getElementById('vegas_plugin_order');
if (!container) return;
if (!window.PluginOrderList) {
// Widget script is deferred; retry briefly, then surface a real
// error instead of waiting forever.
if ((attempt || 0) < 50) {
setTimeout(function() { initPluginOrderList((attempt || 0) + 1); }, 100);
} else {
container.textContent = 'Could not load the reorder widget — reload the page to try again.';
container.className = 'text-sm text-red-500';
}
return;
}
window.PluginOrderList.init({
containerId: 'vegas_plugin_order',
orderInputId: 'vegas_plugin_order_value',
excludedInputId: 'vegas_excluded_plugins_value',
showVegasModeBadge: true
});
}
// Initialize on DOM ready
if (document.readyState === 'loading') {
document.addEventListener('DOMContentLoaded', initPluginOrderList);
} else {
initPluginOrderList();
}
})();
// Multi-Display Sync UI
(function() {
function updateSyncUI() {
const role = document.getElementById('sync_role').value;
const bar = document.getElementById('sync_status_bar');
const posGroup = document.getElementById('setting-display-sync_follower_position');
if (role === 'standalone') {
bar.classList.add('hidden');
document.getElementById('sync_error_detail').classList.add('hidden');
posGroup.style.display = 'none';
} else {
bar.classList.remove('hidden');
posGroup.style.display = role === 'follower' ? '' : 'none';
pollSyncStatus();
}
}
window.updateSyncUI = updateSyncUI;
function pollSyncStatus() {
const role = document.getElementById('sync_role') && document.getElementById('sync_role').value;
if (!role || role === 'standalone') return;
fetch('/api/v3/sync/status')
.then(r => r.json())
.then(resp => {
const d = resp.data || {};
renderSyncStatus(d);
})
.catch(() => {
renderSyncStatus({state: 'unknown'});
});
}
function renderSyncStatus(d) {
const content = document.getElementById('sync_status_content');
const errorDetail = document.getElementById('sync_error_detail');
const errorText = document.getElementById('sync_error_text');
if (!content) return;
const state = d.state || 'unknown';
const role = d.role || 'unknown';
let icon, colorClass, text;
if (state === 'connected' || state === 'follower') {
icon = '●';
colorClass = 'bg-green-50 border-green-200 text-green-800';
const peer = d.peer_ip || d.leader_ip || 'peer';
text = role === 'leader'
? `Follower connected — ${peer} (chain ${d.peer_chain || '?'})`
: `Receiving from leader — ${peer}`;
errorDetail.classList.add('hidden');
} else if (state === 'incompatible') {
icon = '⚠';
colorClass = 'bg-yellow-50 border-yellow-200 text-yellow-800';
text = `Follower connected but incompatible panels`;
if (d.error) {
errorText.textContent = d.error;
errorDetail.classList.remove('hidden');
}
} else if (state === 'no_peer' || state === 'standalone') {
icon = '○';
colorClass = 'bg-gray-50 border-gray-200 text-gray-600';
text = role === 'leader' ? 'No follower detected' : 'Searching for leader…';
errorDetail.classList.add('hidden');
} else if (state === 'starting') {
icon = '○';
colorClass = 'bg-gray-50 border-gray-200 text-gray-500';
text = 'Display process starting…';
errorDetail.classList.add('hidden');
} else {
icon = '✕';
colorClass = 'bg-red-50 border-red-200 text-red-700';
text = 'Sync status unavailable';
errorDetail.classList.add('hidden');
}
content.className = `flex items-start space-x-2 p-3 rounded-lg border text-sm ${colorClass}`;
content.textContent = '';
const iconSpan = document.createElement('span');
iconSpan.className = 'font-bold text-lg leading-none';
iconSpan.textContent = icon;
const textSpan = document.createElement('span');
textSpan.textContent = text;
content.appendChild(iconSpan);
content.appendChild(textSpan);
}
// Initial UI state, then the 5s sync-status poll — only while the Display
// tab is active and the page is visible. LEDVisibility keys the
// registration, so a re-run of this partial replaces the previous timer.
function stopSyncTimer() {
if (window.syncStatusInterval) {
clearInterval(window.syncStatusInterval);
window.syncStatusInterval = null;
}
}
function startSyncTimer() {
stopSyncTimer();
pollSyncStatus();
window.syncStatusInterval = setInterval(pollSyncStatus, 5000);
}
function startSyncPolling() {
updateSyncUI();
window.LEDVisibility.onActive('display', startSyncTimer, stopSyncTimer, 'display-sync-status');
}
if (document.readyState === 'loading') {
document.addEventListener('DOMContentLoaded', startSyncPolling);
} else {
startSyncPolling();
}
})();
</script>