mirror of
https://github.com/ChuckBuilds/LEDMatrix.git
synced 2026-10-20 21:49:16 +00:00
* feat(schedule): per-plugin schedules and hide-while-live Users asked to turn off news and stocks on gameday so only scores show. Each plugin can now have its own schedule (global or per-day window in which it may show) plus hide_when_live, which hides it whenever any plugin reports a live game. A hidden plugin is skipped in the rotation, the live takeover and the Vegas ticker; it stays loaded, and on-demand still shows it. - src/plugin_schedule.py: the window rule, now shared with the display schedule (_check_schedule calls schedule_state) - core config key plugin_schedules; GET/POST /api/v3/config/plugin-schedules - Schedule page: Plugin Schedules card, one picker per enabled plugin Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * fix(schedule): tolerate wrong-shaped schedules, quiet skipped modes - schedule_state treats a non-string mode, non-dict days or a non-dict day entry as missing instead of raising in the display loop - the run loop no longer warns "No plugin manager found" for a mode it skips: _plugin_to_draw / _plugin_for_mode already log why (a scheduled-off plugin at DEBUG) - Schedule page DOM test: every form is marked data-reports-result, not exactly two (each plugin adds one) Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * fix(schedule): guard plugin_schedules shape and the cross-thread cache - a plugin_schedules that is not a dict (list, string) hides nothing instead of raising in the display loop - the hidden-set cache keys on the schedules themselves and is published as one tuple, so a Vegas-thread call still holding the pre-reload config cannot leave its answer standing after a reload; the separate invalidation in _refresh_config_cache is gone Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * fix(schedule): no live hold on a scheduled-off mode; blank an empty rotation - _still_live ignores a plugin its schedule hides, so a rotation landing on a scheduled-off live mode advances instead of holding a mode it skips - once a whole rotation has had nothing to show (every plugin empty or scheduled off), the panel is cleared once instead of leaving the last screen up Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * fix(schedule): push the empty-rotation blank to the web preview clear() alone blanked the matrix but left the preview snapshot on the last frame (seen on ledpi with every plugin scheduled off). Follow it with update_display(), as the scheduled-off blank does. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * fix(schedule): write the empty-rotation blank to the preview The preview throttle skipped the blank frame (written 12 s after the last snapshot), and write_owed_snapshot() only runs after a display() call, which an all-empty rotation never makes: on ledpi the preview kept the startup splash while the panel was dark. Flush the owed snapshot on each paused empty pass. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * fix(schedule): quiet the once-a-second logs when nothing can show With every plugin scheduled off (seen on ledpi), the loop retries once a second and journaled three to four lines each time: Vegas's "No plugins available" WARNING, the coordinator's "Failed to initialize stream manager" ERROR, the 0-plugin rotation summary and "Switching to mode". Vegas now warns once until its list fills again, the coordinator line is DEBUG (initialize only fails for an empty list), and the mode-switch line is DEBUG once a whole rotation has been empty. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * fix(schedule): end the scroll before the empty blank; Vegas sees hidden changes sooner - the empty-rotation blank is controller-drawn, so it ends the last scroll's state first (_end_scroll_before_core_screen), as the scheduled-off blank does - StreamManager.refresh() rebuilds the plugin list when the hidden set changed, even inside its 30 s interval, so a newly hidden plugin leaves at the next scroll group rather than staying for later ones Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
196 lines
7.7 KiB
JavaScript
196 lines
7.7 KiB
JavaScript
/*
|
|
* pages/schedule.js -- the Schedule tab (templates/v3/partials/schedule.html):
|
|
* when the display is on, and when it dims.
|
|
*
|
|
* Both forms stay plain htmx (hx-post with json-enc). This module:
|
|
* - draws the two schedule-picker widgets from the saved config the
|
|
* partial carries in data-schedule-config / data-dim-schedule-config
|
|
* - reports each form's save in one notification. The forms used to call
|
|
* window.handleScheduleResponse / handleDimScheduleResponse from an
|
|
* hx-on attribute; now one htmx:afterRequest listener on the page root
|
|
* does it, and the forms carry data-reports-result so app.js does not
|
|
* show the server's message a second time
|
|
* - keeps the "30%" label next to the dim brightness slider current
|
|
*
|
|
* The widget registry (LEDMatrixWidgets) and the schedule-picker widget are
|
|
* classic deferred scripts and normally load before this module runs; the
|
|
* short retry covers a page that arrives first anyway.
|
|
*
|
|
* The old globals are deprecated aliases made in core/boot.js; they forward
|
|
* to the exports of the same name at the bottom of this file.
|
|
*/
|
|
|
|
const DAYS = ['monday', 'tuesday', 'wednesday', 'thursday', 'friday', 'saturday', 'sunday'];
|
|
const RETRY_MS = 100;
|
|
const MAX_TRIES = 50;
|
|
|
|
const PICKERS = [
|
|
{
|
|
label: 'Schedule', container: 'schedule_picker_container', data: 'data-schedule-config',
|
|
fieldId: 'schedule', start: '07:00', end: '23:00',
|
|
// The display schedule fills in days only when it has no "days" at
|
|
// all; the dim schedule also when "days" is empty.
|
|
fillEmptyDays: false,
|
|
},
|
|
{
|
|
label: 'DimSchedule', container: 'dim_schedule_picker_container', data: 'data-dim-schedule-config',
|
|
fieldId: 'dim_schedule', start: '20:00', end: '07:00',
|
|
fillEmptyDays: true,
|
|
},
|
|
];
|
|
|
|
// Each plugin's picker (the Plugin Schedules card): the window in which the
|
|
// plugin may show. Unset, it offers the whole day.
|
|
const PLUGIN_PICKER = { label: 'PluginSchedule', start: '00:00', end: '23:59', fillEmptyDays: true };
|
|
|
|
const FORMS = {
|
|
schedule_form: { saved: 'Schedule settings saved', failed: 'Error saving schedule' },
|
|
dim_schedule_form: { saved: 'Dim schedule settings saved', failed: 'Error saving dim schedule' },
|
|
};
|
|
const PLUGIN_FORM = { saved: 'Plugin schedule saved', failed: 'Error saving plugin schedule' };
|
|
|
|
/** The notification labels for a form on this page, or null. */
|
|
function formLabels(form) {
|
|
if (FORMS[form.id]) return FORMS[form.id];
|
|
return form.classList.contains('plugin-schedule-form') ? PLUGIN_FORM : null;
|
|
}
|
|
|
|
const WIDGET_OPTIONS = { showModeToggle: true, showEnableToggle: true, compactMode: false };
|
|
const PLUGIN_WIDGET_OPTIONS = Object.assign({}, WIDGET_OPTIONS, {
|
|
enableDescription: 'When enabled, this plugin only shows during the hours below.',
|
|
});
|
|
|
|
// The mounted page, for the deprecated aliases (boot.js).
|
|
let active = null;
|
|
|
|
function readConfig(root, attribute) {
|
|
try {
|
|
const parsed = JSON.parse(root.getAttribute(attribute) || 'null');
|
|
return parsed && typeof parsed === 'object' ? parsed : {};
|
|
} catch {
|
|
return {};
|
|
}
|
|
}
|
|
|
|
/** The saved config, in the shape the schedule-picker widget takes. */
|
|
export function widgetValue(config, spec) {
|
|
config = config || {};
|
|
let mode = 'global';
|
|
if (config.mode) {
|
|
// Normalize mode value (handle both 'per_day' and 'per-day')
|
|
mode = config.mode.replace('-', '_');
|
|
} else if (config.days) {
|
|
mode = 'per_day';
|
|
}
|
|
const value = {
|
|
enabled: config.enabled || false,
|
|
mode: mode,
|
|
start_time: config.start_time || spec.start,
|
|
end_time: config.end_time || spec.end,
|
|
days: config.days || {},
|
|
};
|
|
const noDays = !config.days || (spec.fillEmptyDays && Object.keys(config.days).length === 0);
|
|
if (noDays) {
|
|
value.days = Object.fromEntries(DAYS.map(function(day) {
|
|
return [day, { enabled: true, start_time: spec.start, end_time: spec.end }];
|
|
}));
|
|
}
|
|
return value;
|
|
}
|
|
|
|
/** One notification for a schedule form's answer. */
|
|
function report(notify, xhr, labels) {
|
|
let response;
|
|
try {
|
|
response = JSON.parse(xhr.responseText);
|
|
} catch {
|
|
response = { status: 'error', message: 'Invalid response from server' };
|
|
}
|
|
if (!response || typeof response !== 'object') {
|
|
response = { status: 'error', message: 'Invalid response from server' };
|
|
}
|
|
const message = response.message || (response.status === 'success' ? labels.saved : labels.failed);
|
|
notify(message, response.status || 'info');
|
|
}
|
|
|
|
function drawPickers(root, ctx) {
|
|
const win = root.ownerDocument.defaultView;
|
|
let tries = 0;
|
|
function attempt() {
|
|
ctx.state.timer = null;
|
|
if (ctx.signal.aborted) return;
|
|
const registry = win.LEDMatrixWidgets;
|
|
const widget = registry && registry.get('schedule-picker');
|
|
if (!widget) {
|
|
if (++tries < MAX_TRIES) {
|
|
ctx.state.timer = win.setTimeout(attempt, RETRY_MS);
|
|
} else {
|
|
win.console.error(registry
|
|
? '[Schedule] schedule-picker widget not registered'
|
|
: '[Schedule] LEDMatrixWidgets registry not available');
|
|
}
|
|
return;
|
|
}
|
|
PICKERS.forEach(function(spec) {
|
|
const container = root.querySelector('#' + spec.container);
|
|
if (!container) {
|
|
win.console.error('[' + spec.label + '] Container not found');
|
|
return;
|
|
}
|
|
widget.render(container, { 'x-options': WIDGET_OPTIONS },
|
|
widgetValue(readConfig(root, spec.data), spec), { fieldId: spec.fieldId });
|
|
});
|
|
root.querySelectorAll('[data-plugin-schedule-config]').forEach(function(container) {
|
|
widget.render(container, { 'x-options': PLUGIN_WIDGET_OPTIONS },
|
|
widgetValue(readConfig(container, 'data-plugin-schedule-config'), PLUGIN_PICKER),
|
|
{ fieldId: container.id });
|
|
});
|
|
}
|
|
attempt();
|
|
}
|
|
|
|
export function init(root, ctx) {
|
|
const on = { signal: ctx.signal };
|
|
|
|
root.addEventListener('htmx:afterRequest', function(event) {
|
|
const form = event.target && event.target.closest ? event.target.closest('form') : null;
|
|
const labels = form && root.contains(form) ? formLabels(form) : null;
|
|
if (labels && event.detail && event.detail.xhr) report(ctx.notify, event.detail.xhr, labels);
|
|
}, on);
|
|
|
|
const slider = root.querySelector('#dim_brightness');
|
|
const label = root.querySelector('#dim_brightness_display');
|
|
if (slider && label) {
|
|
slider.addEventListener('input', function() { label.textContent = slider.value + '%'; }, on);
|
|
}
|
|
|
|
drawPickers(root, ctx);
|
|
active = ctx;
|
|
}
|
|
|
|
export function destroy(root, ctx) {
|
|
if (ctx.state.timer) {
|
|
root.ownerDocument.defaultView.clearTimeout(ctx.state.timer);
|
|
ctx.state.timer = null;
|
|
}
|
|
if (active === ctx) active = null;
|
|
}
|
|
|
|
// ── the old globals, kept as deprecated aliases (boot.js) ────────────────────
|
|
function notifier(event) {
|
|
if (active) return active.notify;
|
|
const win = event && event.target && event.target.ownerDocument
|
|
? event.target.ownerDocument.defaultView : globalThis;
|
|
return function(message, type) { return win.showNotification(message, type); };
|
|
}
|
|
|
|
/** window.handleScheduleResponse(event): an htmx:afterRequest event. */
|
|
export function handleScheduleResponse(event) {
|
|
report(notifier(event), event.detail.xhr, FORMS.schedule_form);
|
|
}
|
|
|
|
/** window.handleDimScheduleResponse(event): an htmx:afterRequest event. */
|
|
export function handleDimScheduleResponse(event) {
|
|
report(notifier(event), event.detail.xhr, FORMS.dim_schedule_form);
|
|
}
|