mirror of
https://github.com/ChuckBuilds/LEDMatrix.git
synced 2026-10-05 14:55:08 +00:00
Merge origin/main
# Conflicts: # CHANGELOG.md
This commit is contained in:
@@ -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();
|
||||
|
||||
@@ -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 };
|
||||
}
|
||||
@@ -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 & 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>
|
||||
|
||||
Reference in New Issue
Block a user