mirror of
https://github.com/ChuckBuilds/LEDMatrix.git
synced 2026-10-04 14:25:08 +00:00
* fix(web): keep Cache and Logs helpers out of each other's way Both partials declared top-level showError and escapeHtml. Their scripts run at global scope after every HTMX swap, so whichever tab was opened last owned window.showError, and a Cache failure after visiting Logs rendered into the Logs panel (and the other way round). Each script is now an IIFE; Cache still exports deleteCacheFile for its row buttons. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * fix(web): make the HTMX-failure fallbacks for tab panels actually run - The "HTMX never loaded" fallback read appElement.__x.$data, which is Alpine 2. The page ships Alpine 3, so the check was always false and the Overview never loaded without HTMX. It now reads Alpine.$data(). - The Overview and WiFi panels used hx-on::htmx:response-error, which htmx expands to "htmx:htmx:response-error", an event that never fires. - loadTabContent sent requests with <body> as the source, so htmx fired its events on <body> and no panel's hx-on handler ran at all. The panel is now the source. htmx also resolves its promise on a 4xx/5xx, and the panel was stamped data-loaded anyway, leaving a skeleton that never retried; it is now stamped only when no responseError fired. loadPluginsDirect, loadOverviewDirect and loadWifiDirect are merged into one window.loadPartialDirect(id, url), which also runs the partial's inline scripts before Alpine sees the markup (as htmx-config.js does on htmx:afterSwap). The ~10 s "htmx never arrived" path in loadTabContent uses it for every tab instead of four hard-coded ones. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * fix(web): store and registry failures no longer wipe the Plugin Manager showError replaced the whole #plugins-content with an error message, so one failed store search, custom-registry install or saved-repository call took the installed list, the store and every control with it, with no way back short of reloading the tab. Those failures are now error notifications. The full-panel message is kept only for a first load of the installed list that failed (nothing to show yet); a failed refresh of an already-rendered list is a notification too. showSuccess's fallback branch, which wrote the message into innerHTML unescaped, is gone: showNotification always exists. The "Please try refreshing your browser" hint tested for the text "Failed to Fetch", which no browser produces (Chrome says "Failed to fetch", Firefox "NetworkError..."), so it never appeared. It now keys on the failure itself: a TypeError from fetch(), or PluginAPI's NETWORK_ERROR wrapper around one. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * fix(web): escape plugin action and install output on every path executePluginAction escaped data.message and data.output when an action failed but put data.message straight into innerHTML when it succeeded, and set the OAuth step-2 button's innerHTML from the manifest's step2_button_text. Plugin actions run plugin code, so that is plugin- or server-controlled markup in the page. Both paths now escape, and the button label is set with textContent. The same pattern sat in the install-from-GitHub-URL status lines (plugin_id, the server's message, and error.message, which can echo a repository URL) and the custom-registry load error; those are escaped too. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * fix(web): file-upload widget owns the image list and schedule editor plugins_manager.js loads after the widget bundle, so its older copies of deleteUploadedFile, updateImageList, hideUploadProgress, formatDate, openImageSchedule, toggleImageScheduleEnabled, updateImageSchedule{Mode, Time,Day} and updateCheckboxGroupData replaced the widget's. They are deleted; the widget files are the only definitions. Before switching over, the two sets were diffed and fixed so nothing regresses: - The old copy labelled the schedule/delete buttons for screen readers and lazy-loaded thumbnails; the widget now does both. - The schedule button did nothing on a card rendered by plugin_config.html whenever the image id is a UUID (every upload): the template turns "-" into "_" in the editor's id, and neither JS copy did. Both now use the template's rule. - The widget's "keep the open editor open" copied the editor's innerHTML into the new list. That dropped its event listeners and showed the old values, so after the first change the editor looked live but ignored input. A schedule edit now saves to the hidden input and updates the card's summary in place without re-rendering the list; a list re-render (upload, delete) rebuilds an open editor from the data. Editor controls are routed by one delegated change listener, so there are no per-element listeners to lose. - The old deleteUploadedFile had a JSON branch that removed a #file_<id> element and skipped the re-render. No template or script renders such an element, and JSON uploads are listed through updateImageList like images, so re-rendering (the widget's behaviour) is the consistent one; the branch was not carried over. - The template always renders the summary line (".image-schedule-summary", "Always shown" when unscheduled) so an edit has a line to update. The inline-handler test evaluated plugins_manager.js's updateImageList; test_file_upload_widget.js now covers the widget's list and editor. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * refactor(web): delete the unused handleCredentialsUpload Its last caller went when plugin_config.html switched credential uploads to the file-upload widget's handleSingleFileSelect. Nothing in the web UI, the tests or the plugin monorepo references it. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * refactor(web): delete dead and shadowed front-end code Nothing calls any of these (checked across web_interface/, test/ and the ledmatrix-plugins monorepo, including hx-*/x-*/onclick attributes): - app-shell.js: the Alpine methods refreshPlugins (it called a nonexistent this.searchPluginStore), loadPluginConfig, savePluginConfig, getSchemaPropertyType, escapeCssSelector, formatCommitInfo and formatDateInfo, and the top-level copies of savePluginConfig, getSchemaPropertyType, escapeCssSelector, formatCommitInfo, formatDateInfo and togglePluginFromTab. Plugin config forms save through hx-post in plugin_config.html. - window.reconnectSSE (app-shell.js); window.updateArrayTableAddButtonState (array-table.js). - toggleNestedSection, defined twice (app-shell.js and plugins_manager.js) and called from nowhere. - plugins_manager.js: the window.initializePlugins wrapper around an IIFE-local origInit that was always undefined, and __pluginDomReady, which was written but never read. - display.html's fixInvalidNumberInputs fallback: app-shell.js defines it before any partial loads. - base.html's window.loadCodeMirror and the two CodeMirror stylesheet preloads, and the .CodeMirror rules in plugins.html. The raw JSON editor is a plain textarea. Also deleted: app-shell.js definitions that a later script always replaced, so they never ran: executePluginAction (plugins_manager.js assigns its own), uninstallPlugin and its pollUninstallOperation (plugins_manager.js), and updateAllPlugins (install_manager.js). vendor/codemirror stays: test/test_web_smoke.py still requests codemirror.min.js as a sample static asset. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * refactor(web): call showNotification without checking it exists app-shell.js defines window.showNotification (a stand-in that queues until the notification widget loads) and base.html runs it, deferred, before every other script that notifies: app.js, the utilities, the widget bundle, plugins_manager.js, and all partials, which HTMX loads after the page. The 81 `typeof showNotification === 'function'` / `!== 'undefined'` checks, the `window.showNotification || console.log` and `|| alert` fallbacks, and their else branches (alert(), console output, and schedule.html's own hand-built toast) could never take the fallback path. They are removed, as is fonts.html's second copy of the queueing stand-in. The stand-in in app-shell.js keeps its guard (it must not replace the widget's implementation if load order ever changes), and BaseWidget's public notify()/getNotificationFunction() keep their shape for widgets that plugins ship. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * refactor(web): one HTML escaper, window.LEDEscape About 30 files each carried their own escapeHtml / escapeAttr / escHtml / _esc / escapeJs. They disagreed: several (notification.js, display.html's escapeHtml, operation_history.html, the app() stub) did not escape quotes, google-calendar-picker.js and tools.html's escHtml left ' alone, and some turned 0 into ''. Most were fine only because the quote-safe widget copies were preferred at runtime. window.LEDEscape now lives at the top of app-early.js, a blocking script in <head>, so it exists before any other script runs: html(v) & < > " ' as entities, null/undefined as '' attr(v) the same, for call sites that want to say "attribute" jsStringAttr(v) a JS string literal safe inside an inline handler Every former copy is now a one-line name for it (kept so call sites do not change), widgets included, with no fallback. plugins_manager.js loses its four escapeJs wrappers (callers use jsStringAttr), the duplicate escapeAttr and escapeHtml inside renderInstalledCards and renderCustomRegistryPlugins, and the window.escapeHtml / window.escapeAttribute exports, which nothing read. addArrayObjectItem's fallback markup (with a sixth hand-written escape chain) is gone too: window.renderArrayObjectItem is defined earlier in the same file, so the fallback could not run. The unused escapeHtml methods on the Alpine app (app-early.js stub and app-shell.js) are deleted. test_html_escaping.js now runs LEDEscape and every remaining name for it, and fails if a hand-rolled escaper reappears anywhere in web_interface/. Suites that evaluate slices of plugins_manager.js or widget files load LEDEscape from app-early.js through test/js/led_escape.js. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * fix(web): stop htmx re-running partial scripts after every tab load htmx-config.js runs each swapped-in <script> itself on htmx:afterSwap and meant to turn htmx's own script handling off with htmx.config.allowScriptTags = false. It did that once, while setting up, but base.html loads htmx with a dynamic <script>, so htmx was not defined yet and the setting never applied. On every tab load htmx then tried to run each script again in its settle phase, found it already replaced (no parent node) and threw "Cannot read properties of null (reading 'insertBefore')" into the console, which also skipped the rest of that swap's settle tasks. The setting is now applied in the afterSwap handler, which always runs after htmx exists and before htmx settles the same swap. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * fix(web): show "--" for a system stat the server could not read The stats stream and /system/status now send null for a metric they cannot read (cpu_temp off a Pi, for one) instead of 0. updateSystemStats built the header and Overview text as value + unit, so a null showed as "null°C". CPU, memory and temperature, in the header and on the Overview, now render "--" plus the unit for null or a missing field -- the same placeholder the page starts with, and what tools.html already shows. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * refactor(web): one Alpine accessor and one plugin-list signal window.getApp() (app-early.js) returns the root <body x-data="app()"> component through Alpine's public Alpine.$data, or null before Alpine has initialised it. It replaces the private el._x_dataStack[0] reads in app.js, app-early.js, app-shell.js, settings-search.js, overview.html and plugins_manager.js, the three local getAppComponent/appData/getAppData copies, and the Alpine 2 el.__x.$data fallbacks, which Alpine 3 never provides. Publishing the installed-plugin list: one load set window.installedPlugins and dispatched pluginsUpdated twice (loadInstalledPlugins, then renderInstalledPlugins), then wrote into the Alpine component through _x_dataStack[0] and called its updatePluginTabs() directly, and app-early.js's global listener set window.installedPlugins a third time and called updatePluginTabs() again. Now renderInstalledPlugins is the one publisher: it sets window.installedPlugins and dispatches pluginsUpdated once, and the full app()'s listener (app-shell.js) is the receiver. The app-early.js listener only builds the tab row while the app is not the full implementation yet. The "grid not loaded yet" case is a normal state (Plugin Manager tab not opened), so it logs through pluginLog instead of console.warn. updatePluginTabs had a "Debounce" comment and clearTimeout over a timer nothing ever set, and two identical branches; it now just calls _doUpdatePluginTabs (app-early.js detects the full implementation by that name in its source, which the new comment says). app()'s unused baseComponent lookup is removed. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * fix(web): reload the plugin list after installs and failed toggles Several callers refreshed the installed list with if (typeof loadInstalledPlugins === 'function') loadInstalledPlugins(); else if (typeof window.loadInstalledPlugins === 'function') ... but loadInstalledPlugins is local to the plugin-manager IIFE and window.loadInstalledPlugins is never defined, so from outside that IIFE both tests were false and nothing reloaded: - A failed plugin toggle left the switch drawn in the new state while the data said the old one. It now re-renders from the reverted data. The optimistic in-place edit also has to forget the grid's last-rendered markup, or setGridHtmlIfChanged sees identical HTML and skips the revert. A successful toggle still keeps the switch (and focus) as drawn. - Installing from a GitHub URL (the early handleGitHubPluginInstall), installing or uploading a Starlark app, and toggling a Starlark app on its config tab never refreshed the list, so the new app had no tab or Installed badge until the page was reloaded. They now force a reload through window.pluginManager.loadInstalledPlugins(true), and the Starlark grid redraws when that finishes instead of after a fixed 500 ms. - The Starlark uninstall inside the IIFE reloaded from the 3 s cache, which could still hold the app; it now forces a reload. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * refactor(web): route debug output through debugLog base.html defines window.debugLog, gated on localStorage.pluginDebug. plugins_manager.js read the same key twice more into its own flags (_PLUGIN_DEBUG_EARLY, and PLUGIN_DEBUG behind a pluginLog() wrapper), and api_client.js's RequestThrottler had a separate `debug` property with a setDebug() that nothing called. All of it now goes through debugLog. The "functions defined" dumps with their ✓ lines, and two per-plugin "enabled=" loops that ran on every render, are dropped; "[PLUGINS STUB]" labels on code that has not been a stub for a long time read "[PLUGINS]". Ungated console.log calls that announced normal events on every page load or action (settings search and tooltips registering, every toast repeated to the console, the schedule pickers initialising, widget registry unregister/clear) go through debugLog too. What remains on console.log is the widget registry's on-demand LEDMatrixWidgets.debug() dump and BaseWidget.notify's no-notifier fallback. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * refactor(web): drop waits and guards that could never fire - handlePluginAction polled up to 10 x 50 ms for window.togglePlugin, configurePlugin, updatePlugin and uninstallPlugin before calling them. All four are defined when the scripts load, before any card can be clicked, so the poll always succeeded at once; it now calls them. The long thinking-aloud comment over the toggle state is replaced by two lines on why the stored state, not the checkbox, decides. - initializePlugins checked typeof on setupGitHubInstallHandlers and applyStoreFiltersAndSort, function declarations in the same IIFE, and wrapped window.checkGitHubAuthStatus(), which returns a promise with its own .catch, in try/catch. - searchPluginStore wrapped each "#store-count" update (a getElementById and an innerHTML assignment) in try/catch four times; one setStoreCount() helper does it. The store's post-render re-attach of the GitHub token handler dropped its try/catch and existence checks for the same reason. - The load-time fallback outside the IIFE tested typeof initializePluginPageWhenReady, which is IIFE-local and so always undefined there; it calls window.initPluginsPage directly. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * refactor(web): delete two unused plugin-manager helpers stopOnDemand (IIFE-local; the page's stop button calls window.stopOnDemand from app-shell.js) and debounce had no callers. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * refactor(web): document the plugin-config handlers templates call validatePluginConfigForm, handleConfigSave, handleToggleResponse, handlePluginUpdate and refreshPluginConfig each get a JSDoc naming the attribute in partials/plugin_config.html that calls it and what the return value means (only validatePluginConfigForm's matters: false cancels the submit). - The `if (!window.__pluginConfigHandlersInitialized)` wrapper is gone: app-shell.js runs once per page, so it was never false. The block is dedented one level; `git diff -w` shows the real change. - The three handlers read xhr.responseJSON first. XMLHttpRequest has no such property (it is jQuery's), so that branch never ran; one xhrJson(xhr) helper parses responseText for all of them, with the same fallbacks as before. - runPluginOnDemand and stopOnDemand checked that plugins_manager.js's openOnDemandModal/requestOnDemandStop exist; plugins_manager.js is on every page, so they call them. - fixInvalidNumberInputs had a stray "Notification helper function" comment on top of its own; a leftover "section toggle ... duplicate definition removed" note is gone. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * fix(web): one toast per save, and a failed durations save says so app.js's global htmx:afterRequest listener showed the server's message for every htmx request, and every form and button that posts through htmx (plugin config save/toggle/update, Display, Durations, General, Schedule, Dim schedule, the Overview actions) also reports its own result from hx-on after-request. Each save showed two toasts. The global listener now stays quiet for a request whose element, or its form, has its own after-request handler. That exposed the Rotation & Durations form's handler, which read xhr.responseJSON: XMLHttpRequest has no such property, so it always said "Durations saved" in green, even when the save failed (the global toast had been the only place the error showed). display.html already had a correct version (2xx only counts as saved; the server's message wins; its status may refine success but never overturn failure). That is now window.showSaveResult(xhr, savedText, failedText) in app.js, used by the Display, Durations and General forms; General's inline copy of the same logic is gone. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * docs(web): file headers and comments that say what the code does now - plugins_manager.js, app-shell.js, app.js and app-early.js open with a header: what the file owns, how base.html loads it and in what order relative to the others, and the globals it defines. app-early.js's app() stub also says why it exists and that, with app-shell.js now loaded before Alpine, it does not run in practice. - base.html's note on plugins_manager.js said it must load last to win over same-named functions in app.js/app-shell.js; there are none left, so it now gives the real reason (it uses everything loaded before it). - Change-narration and "already defined at the top, no need to redefine" notes are gone or rewritten as present-tense reasons; comments that were wrong are fixed ("Toggle password visibility" over the function that opens the token panel, "Insert before the closing </nav>" over an appendChild, "(from v2)", the export note that still listed escapeHtml). About forty comments that restated the line below them are removed, and a second window.currentPluginConfig = null outside the IIFE is dropped (the IIFE sets it). - The file-upload, checkbox-group and custom-feeds widgets' render() stubs say plainly that the widget is rendered server-side, instead of "for now" / "placeholder for future client-side rendering". test_plugin_action_delegation.js sliced the source up to one of the removed notes; it now ends the slice at the next section header. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * fix(web): keep the escapeHtml/escapeAttribute globals for plugin pages 6da77363 removed window.escapeHtml and window.escapeAttribute because nothing in core or the plugin monorepo read them. Plugin web UIs served through serve_plugin_web_ui and third-party plugin pages may still call them, so they come back as aliases of window.LEDEscape.html and .attr, defined in app-early.js before any other script runs. test_html_escaping.js checks the aliases exist. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * docs(changelog): web-frontend Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * fix(web): encode the image thumbnail path; match script tags case-insensitively CodeQL flagged the upload widget building an <img> src from a stored path, and the escaper test extracting inline scripts with a case-sensitive regex. Each path segment is now URL-encoded (still a same-origin path, and correct for names with spaces or * fix(web): clear Codacy findings in the escaper, app shell and upload widget - LEDEscape looks entities up in a Map instead of indexing an object. - showNotification is declared as a global for app-shell.js. - openImageSchedule checks the index is a non-negative integer and reads the image with Array.prototype.at. - The schedule editor calls escapeHtml directly and documents why its innerHTML template is safe: every value is escaped or constrained. The remaining rule hits are suppressed on that line with the reason. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * fix(web): build the image schedule editor with DOM calls Codacy does not honour inline suppressions, and the editor's innerHTML template kept tripping its XSS rules even though every value was escaped. The editor is now built with a small element helper (createElement and setAttribute), so no value is ever parsed as HTML, and the file's own escapeHtml goes away. Also for Codacy: - LEDEscape.attr is its own function rather than a second name for html. - The tab loader records a failed load on the panel (data-load-failed) from a named handler, instead of a closure over a local flag. The fake DOM in test_file_upload_widget.js gains append/replaceChildren, its hostile-id check now asserts the id arrives as attribute data with no innerHTML anywhere in the editor, and test_html_escaping.js drops the file-upload.js escaper it no longer has. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * refactor(web): schedule editor helpers as plain functions Codacy's lint flags arrow functions held in local constants and a forEach callback that returns a value. The editor's pieces are now named function declarations (displayStyle, scheduleModeOption, scheduleRangeTime, scheduleDayTime, scheduleDayRow) taking what they need as arguments, and the element helper loops with for...of. htmx is declared as a global in app-shell.js. Output is unchanged; test_file_upload_widget.js passes. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
1061 lines
80 KiB
HTML
1061 lines
80 KiB
HTML
{% import 'v3/partials/_macros.html' as ui %}
|
||
<div class="bg-white rounded-lg shadow p-6">
|
||
<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>
|
||
</div>
|
||
|
||
{{ ui.settings_filter('Filter display settings…') }}
|
||
|
||
<!-- Hardware status banner: shown when display service is in fallback/simulation mode -->
|
||
<div x-data="{ show: false, errorMsg: '', cause: null }"
|
||
x-init="fetch('/api/v3/hardware/status').then(r => r.json()).then(d => {
|
||
const hw = (d && d.data) || {};
|
||
if (hw.ok === false) { show = true; errorMsg = hw.error || 'Unknown error'; cause = hw.cause || null; }
|
||
}).catch(() => {})"
|
||
x-show="show"
|
||
style="display:none"
|
||
class="bg-yellow-50 border border-yellow-300 rounded-lg p-4 mb-6">
|
||
<p class="font-semibold text-yellow-800"><i class="fas fa-exclamation-triangle mr-2"></i>LED matrix running in simulation mode</p>
|
||
<p class="text-sm text-yellow-700 mt-1">Hardware initialization failed: <span x-text="errorMsg" class="font-mono text-xs break-all"></span></p>
|
||
<p class="text-sm text-yellow-700 mt-2" x-show="cause === 'settings'">
|
||
The rgbmatrix library can't start with the settings named above, so LEDMatrix didn't try.
|
||
Change them below, save, then restart the display service from the Overview tab.
|
||
</p>
|
||
<p class="text-sm text-yellow-700 mt-2" x-show="cause !== 'settings'">
|
||
{% if is_pi5 %}If the <a href="#" @click.prevent="activeTab = 'logs'" class="underline font-medium">Logs tab</a> shows an
|
||
<code class="bg-yellow-100 px-1 rounded">mmap</code> error, the library was built without Raspberry Pi 5 support: rebuild it with
|
||
<code class="bg-yellow-100 px-1 rounded">sudo RPI_RGB_FORCE_REBUILD=1 ./first_time_install.sh</code>.
|
||
Otherwise the{% else %}The{% endif %}
|
||
<a href="#" @click.prevent="activeTab = 'logs'" class="underline font-medium">Logs tab</a> has the full error.
|
||
</p>
|
||
</div>
|
||
|
||
<form hx-post="/api/v3/config/main"
|
||
hx-ext="json-enc"
|
||
hx-headers='{"Content-Type": "application/json"}'
|
||
hx-swap="none"
|
||
hx-on:htmx:after-request="showSaveResult(event.detail.xhr, 'Display settings saved', 'Display settings were not saved. Check your connection and try again.')"
|
||
class="space-y-6"
|
||
novalidate
|
||
onsubmit="fixInvalidNumberInputs(this); return true;">
|
||
{# Marks this post as the whole form, so an unchecked box saves as
|
||
false. Without it the save endpoint treats absent keys as unchanged
|
||
(FORM_SECTION_FIELD in api_v3/config.py). #}
|
||
<input type="hidden" name="__form_section" value="display">
|
||
|
||
<!-- Hardware Settings -->
|
||
<div class="bg-gray-50 rounded-lg p-4">
|
||
<h3 class="text-md font-medium text-gray-900 mb-4">Hardware Configuration</h3>
|
||
|
||
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 xl:grid-cols-4 2xl:grid-cols-4 gap-4 mb-4">
|
||
<div class="form-group" id="setting-display-rows" data-setting-key="display.hardware.rows">
|
||
<label for="rows" class="block text-sm font-medium text-gray-700">Rows{{ ui.help_tip('Number of LED rows on a single panel.\nCommon: 16, 32, 48 or 64. Default: 32. Must match your panel and be an even number from 8 to 64, the most the rgbmatrix library drives per panel.', 'Rows') }}</label>
|
||
<input type="number"
|
||
id="rows"
|
||
name="rows"
|
||
value="{{ main_config.display.hardware.rows or 32 }}"
|
||
min="8"
|
||
step="2"
|
||
class="form-control">
|
||
</div>
|
||
|
||
<div class="form-group" id="setting-display-cols" data-setting-key="display.hardware.cols">
|
||
<label for="cols" class="block text-sm font-medium text-gray-700">Columns{{ ui.help_tip('Number of LED columns on a single panel.\nCommon: 32, 64, 96 or 128. Default: 64. Must match your panel. At least 16, with no upper limit.', 'Columns') }}</label>
|
||
<input type="number"
|
||
id="cols"
|
||
name="cols"
|
||
value="{{ main_config.display.hardware.cols or 64 }}"
|
||
min="16"
|
||
class="form-control">
|
||
</div>
|
||
|
||
<div class="form-group" id="setting-display-chain_length" data-setting-key="display.hardware.chain_length">
|
||
<label for="chain_length" class="block text-sm font-medium text-gray-700">Chain Length{{ ui.help_tip('How many panels are wired end-to-end in one chain.\nDefault: 2. Example: two 64×32 panels chained make a 128×32 display. 1 to 255; longer chains lower the refresh rate.', 'Chain Length') }}</label>
|
||
<input type="number"
|
||
id="chain_length"
|
||
name="chain_length"
|
||
value="{{ main_config.display.hardware.chain_length or 2 }}"
|
||
min="1"
|
||
class="form-control">
|
||
</div>
|
||
|
||
<div class="form-group" id="setting-display-parallel" data-setting-key="display.hardware.parallel">
|
||
<label for="parallel" class="block text-sm font-medium text-gray-700">Parallel{{ ui.help_tip('Number of separate chains driven in parallel from the HAT.\nDefault: 1. Up to 3, and the hardware mapping needs that many outputs: Regular and Classic have 3 (e.g. the Adafruit Triple LED Matrix Bonnet); Adafruit HAT, Adafruit HAT PWM and the Pi1 mappings have 1.', 'Parallel') }}</label>
|
||
<input type="number"
|
||
id="parallel"
|
||
name="parallel"
|
||
value="{{ main_config.display.hardware.parallel or 1 }}"
|
||
min="1"
|
||
max="3"
|
||
class="form-control">
|
||
</div>
|
||
</div>
|
||
|
||
<!-- Live total-resolution readout: width = cols x chain_length, height = rows x parallel -->
|
||
<p id="display-resolution-readout" class="text-sm text-gray-600 mb-4" aria-live="polite">
|
||
<i class="fas fa-expand-arrows-alt mr-1 text-gray-400"></i>
|
||
Your display: <strong id="display-resolution-value">—</strong>
|
||
<span class="text-gray-400">(columns × chain length wide, rows × parallel tall)</span>
|
||
</p>
|
||
|
||
<div class="grid grid-cols-1 md:grid-cols-3 gap-4">
|
||
<div class="form-group" id="setting-display-brightness" data-setting-key="display.hardware.brightness">
|
||
<label for="brightness" class="block text-sm font-medium text-gray-700">Brightness{{ ui.help_tip('Overall LED brightness (1–100%).\nLower is dimmer, higher is brighter. Recommended: 70–90 indoors, 90–100 in bright rooms.', 'Brightness') }}</label>
|
||
<div class="flex items-center space-x-2">
|
||
<input type="range"
|
||
id="brightness"
|
||
name="brightness"
|
||
value="{{ main_config.display.hardware.brightness or 95 }}"
|
||
min="1"
|
||
max="100"
|
||
class="flex-1">
|
||
<span id="brightness-value" class="text-sm font-medium w-12">{{ main_config.display.hardware.brightness or 95 }}</span>
|
||
</div>
|
||
</div>
|
||
|
||
<div class="form-group" id="setting-display-hardware_mapping" data-setting-key="display.hardware.hardware_mapping">
|
||
<label for="hardware_mapping" class="block text-sm font-medium text-gray-700">Hardware Mapping{{ ui.help_tip('How the LED panel is wired to the Pi.\nUse "Adafruit HAT PWM" for an Adafruit RGB Matrix HAT/Bonnet with the PWM solder mod; "Adafruit HAT" without it (no hardware pulsing, so expect a little more flicker); "Regular" for direct GPIO wiring and for the Adafruit Triple LED Matrix Bonnet.', 'Hardware Mapping') }}</label>
|
||
{#- Saving posts what is selected, so a stored value must render selected
|
||
even when it isn't one of the usual choices. The library matches
|
||
names case-insensitively and reads an empty name as "regular". -#}
|
||
{% set stored_mapping = main_config.display.hardware.get('hardware_mapping', 'adafruit-hat-pwm') %}
|
||
{% set mapping_key = (stored_mapping or 'regular')|lower if stored_mapping is string else stored_mapping|string %}
|
||
{% set mapping_labels = {'adafruit-hat-pwm': 'Adafruit HAT PWM', 'adafruit-hat': 'Adafruit HAT', 'regular': 'Regular', 'regular-pi1': 'Regular Pi1', 'classic': 'Classic (legacy wiring)', 'classic-pi1': 'Classic Pi1 (legacy wiring)'} %}
|
||
{% set mapping_values = ['adafruit-hat-pwm', 'adafruit-hat', 'regular', 'regular-pi1', 'classic'] if is_pi5 else ['adafruit-hat-pwm', 'adafruit-hat', 'regular', 'regular-pi1', 'classic', 'classic-pi1'] %}
|
||
<select id="hardware_mapping" name="hardware_mapping" class="form-control">
|
||
{% for value in mapping_values %}
|
||
<option value="{{ value }}" {% if mapping_key == value %}selected{% endif %}>{{ mapping_labels[value] }}</option>
|
||
{% endfor %}
|
||
{% if mapping_key not in mapping_values %}
|
||
<option value="{{ stored_mapping }}" selected>{{ stored_mapping }} (saved, can't be used)</option>
|
||
{% endif %}
|
||
</select>
|
||
{% if mapping_key not in mapping_values %}
|
||
<p class="text-sm text-red-600 mt-1">Your saved hardware mapping ("{{ stored_mapping }}") isn't one the installed rgbmatrix library {% if is_pi5 and mapping_key in mapping_labels %}can use on a Raspberry Pi 5{% else %}has{% endif %}, so the display won't start with it. Choose your board's mapping before saving.</p>
|
||
{% endif %}
|
||
</div>
|
||
|
||
<div class="form-group" id="setting-display-orientation" data-setting-key="display.hardware.orientation">
|
||
<label for="orientation" class="block text-sm font-medium text-gray-700">Panel Orientation{{ ui.help_tip('Rotates the rendered image to match how the panel is physically mounted.\nUse "Upside Down" if you flipped the panel 180° to move the Raspberry Pi / wiring to a more convenient side. 90° and 270° are for a panel mounted on its side, and swap the display width and height.', 'Panel Orientation') }}</label>
|
||
<select id="orientation" name="orientation" class="form-control">
|
||
<option value="normal" {% if main_config.display.hardware.get('orientation', 'normal') == "normal" %}selected{% endif %}>Normal</option>
|
||
<option value="90" {% if main_config.display.hardware.get('orientation', 'normal') == "90" %}selected{% endif %}>Rotated 90°</option>
|
||
<option value="180" {% if main_config.display.hardware.get('orientation', 'normal') == "180" %}selected{% endif %}>Upside Down (180°)</option>
|
||
<option value="270" {% if main_config.display.hardware.get('orientation', 'normal') == "270" %}selected{% endif %}>Rotated 270°</option>
|
||
</select>
|
||
</div>
|
||
|
||
<div class="form-group" id="setting-display-led_rgb_sequence" data-setting-key="display.hardware.led_rgb_sequence">
|
||
<label for="led_rgb_sequence" class="block text-sm font-medium text-gray-700">LED RGB Sequence{{ ui.help_tip('Order the panel expects color channels in.\nChange this only if reds/greens/blues look swapped (red shows as blue: try BGR). Default: RGB. The Waveshare 96x48 V2 needs BGR.', 'LED RGB Sequence') }}</label>
|
||
<select id="led_rgb_sequence" name="led_rgb_sequence" class="form-control">
|
||
<option value="RGB" {% if main_config.display.hardware.get('led_rgb_sequence', 'RGB') == "RGB" %}selected{% endif %}>RGB</option>
|
||
<option value="RBG" {% if main_config.display.hardware.get('led_rgb_sequence', 'RGB') == "RBG" %}selected{% endif %}>RBG</option>
|
||
<option value="GRB" {% if main_config.display.hardware.get('led_rgb_sequence', 'RGB') == "GRB" %}selected{% endif %}>GRB</option>
|
||
<option value="GBR" {% if main_config.display.hardware.get('led_rgb_sequence', 'RGB') == "GBR" %}selected{% endif %}>GBR</option>
|
||
<option value="BRG" {% if main_config.display.hardware.get('led_rgb_sequence', 'RGB') == "BRG" %}selected{% endif %}>BRG</option>
|
||
<option value="BGR" {% if main_config.display.hardware.get('led_rgb_sequence', 'RGB') == "BGR" %}selected{% endif %}>BGR</option>
|
||
</select>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- Advanced hardware settings: niche-panel and deep tuning fields.
|
||
Collapsed by default; reuses the same nested-section shell as
|
||
plugin config forms, so toggleSection() and the settings
|
||
search's auto-expand both work unchanged. -->
|
||
<div class="nested-section border border-gray-300 rounded-lg mt-4">
|
||
<button type="button"
|
||
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')">
|
||
<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)
|
||
</h4>
|
||
<p class="text-sm text-gray-600 mt-1">Multiplexing, panel variants, PWM tuning, and display options — the defaults work for standard HUB75 panels.</p>
|
||
</div>
|
||
<i id="display-section-advanced-hardware-icon" class="fas fa-chevron-right text-gray-500 transition-transform"></i>
|
||
</button>
|
||
<div id="display-section-advanced-hardware" class="nested-content bg-gray-50 px-4 py-4 space-y-4 hidden" style="display: none;">
|
||
|
||
<div class="grid grid-cols-1 md:grid-cols-3 gap-4">
|
||
<div class="form-group" id="setting-display-multiplexing" data-setting-key="display.hardware.multiplexing">
|
||
<label for="multiplexing" class="block text-sm font-medium text-gray-700">Multiplexing{{ ui.help_tip('How the pixels are wired on outdoor/specialty panels (P10, P8, P4 and P3 outdoor modules and similar), whose LEDs are not laid out in straight rows.\nLeave at 0 (Direct) for most indoor panels. If the image is scrambled in a repeating pattern, try the value named after your panel first, then the others.', 'Multiplexing') }}</label>
|
||
<select id="multiplexing" name="multiplexing" class="form-control">
|
||
<option value="0" {% if main_config.display.hardware.get('multiplexing', 0)|int == 0 %}selected{% endif %}>0 - Direct</option>
|
||
<option value="1" {% if main_config.display.hardware.get('multiplexing', 0)|int == 1 %}selected{% endif %}>1 - Stripe</option>
|
||
<option value="2" {% if main_config.display.hardware.get('multiplexing', 0)|int == 2 %}selected{% endif %}>2 - Checkered</option>
|
||
<option value="3" {% if main_config.display.hardware.get('multiplexing', 0)|int == 3 %}selected{% endif %}>3 - Spiral</option>
|
||
<option value="4" {% if main_config.display.hardware.get('multiplexing', 0)|int == 4 %}selected{% endif %}>4 - ZStripe</option>
|
||
<option value="5" {% if main_config.display.hardware.get('multiplexing', 0)|int == 5 %}selected{% endif %}>5 - ZnMirrorZStripe</option>
|
||
<option value="6" {% if main_config.display.hardware.get('multiplexing', 0)|int == 6 %}selected{% endif %}>6 - Coreman</option>
|
||
<option value="7" {% if main_config.display.hardware.get('multiplexing', 0)|int == 7 %}selected{% endif %}>7 - Kaler2Scan</option>
|
||
<option value="8" {% if main_config.display.hardware.get('multiplexing', 0)|int == 8 %}selected{% endif %}>8 - ZStripeUneven</option>
|
||
<option value="9" {% if main_config.display.hardware.get('multiplexing', 0)|int == 9 %}selected{% endif %}>9 - P10-128x4-Z</option>
|
||
<option value="10" {% if main_config.display.hardware.get('multiplexing', 0)|int == 10 %}selected{% endif %}>10 - QiangLiQ8</option>
|
||
<option value="11" {% if main_config.display.hardware.get('multiplexing', 0)|int == 11 %}selected{% endif %}>11 - InversedZStripe</option>
|
||
<option value="12" {% if main_config.display.hardware.get('multiplexing', 0)|int == 12 %}selected{% endif %}>12 - P10Outdoor1R1G1B v1</option>
|
||
<option value="13" {% if main_config.display.hardware.get('multiplexing', 0)|int == 13 %}selected{% endif %}>13 - P10Outdoor1R1G1B v2</option>
|
||
<option value="14" {% if main_config.display.hardware.get('multiplexing', 0)|int == 14 %}selected{% endif %}>14 - P10Outdoor1R1G1B v3</option>
|
||
<option value="15" {% if main_config.display.hardware.get('multiplexing', 0)|int == 15 %}selected{% endif %}>15 - P10CoremanMapper</option>
|
||
<option value="16" {% if main_config.display.hardware.get('multiplexing', 0)|int == 16 %}selected{% endif %}>16 - P8Outdoor1R1G1B</option>
|
||
<option value="17" {% if main_config.display.hardware.get('multiplexing', 0)|int == 17 %}selected{% endif %}>17 - FlippedStripe</option>
|
||
<option value="18" {% if main_config.display.hardware.get('multiplexing', 0)|int == 18 %}selected{% endif %}>18 - P10-32x16-HalfScan</option>
|
||
<option value="19" {% if main_config.display.hardware.get('multiplexing', 0)|int == 19 %}selected{% endif %}>19 - P10-32x16-QuarterScan</option>
|
||
<option value="20" {% if main_config.display.hardware.get('multiplexing', 0)|int == 20 %}selected{% endif %}>20 - P3Outdoor-64x64</option>
|
||
<option value="21" {% if main_config.display.hardware.get('multiplexing', 0)|int == 21 %}selected{% endif %}>21 - DoubleZMultiplex</option>
|
||
<option value="22" {% if main_config.display.hardware.get('multiplexing', 0)|int == 22 %}selected{% endif %}>22 - P4Outdoor-80x40</option>
|
||
</select>
|
||
</div>
|
||
|
||
<div class="form-group" id="setting-display-panel_type" data-setting-key="display.hardware.panel_type">
|
||
<label for="panel_type" class="block text-sm font-medium text-gray-700">Panel Type{{ ui.help_tip('Special initialization for panels with a specific driver chip (e.g. FM6126A, FM6127).\nLeave on Standard unless your panel stays blank or shows only the first pixel. FM6124 / FM6124D / FM6124DJ panels need no initialization: use Standard.', 'Panel Type') }}</label>
|
||
<select id="panel_type" name="panel_type" class="form-control">
|
||
<option value="" {% if not main_config.display.hardware.get('panel_type', '') %}selected{% endif %}>Standard</option>
|
||
<option value="FM6126A" {% if main_config.display.hardware.get('panel_type', '') == "FM6126A" %}selected{% endif %}>FM6126A</option>
|
||
<option value="FM6127" {% if main_config.display.hardware.get('panel_type', '') == "FM6127" %}selected{% endif %}>FM6127</option>
|
||
</select>
|
||
</div>
|
||
|
||
<div class="form-group" id="setting-display-row_address_type" data-setting-key="display.hardware.row_address_type">
|
||
<label for="row_address_type" class="block text-sm font-medium text-gray-700">Row Address Type{{ ui.help_tip('Row addressing scheme used by the panel.\nLeave at 0 (Default) unless your panel needs AB/ABC addressing — a wrong value shows a garbled or shifted image.\nABC panels (no E line, common on 128x64 FM6124 boards): try 3. Panels with SM5368 row drivers, such as the Waveshare 96x48 V2 (back silkscreen 24S-A1), need 5 with RGB sequence BGR. If rows then jump up and down, or the bottom row shows a copy of other rows, raise GPIO Slowdown (6–8 on a Pi 4). On a Raspberry Pi 5 the rgbmatrix library supports only 0 and 2, so only those are offered there.', 'Row Address Type') }}</label>
|
||
{% set stored_row_address_type = main_config.display.hardware.get('row_address_type', 0)|int %}
|
||
<select id="row_address_type" name="row_address_type" class="form-control">
|
||
<option value="0" {% if stored_row_address_type == 0 %}selected{% endif %}>0 - Default</option>
|
||
{% if not is_pi5 %}
|
||
<option value="1" {% if stored_row_address_type == 1 %}selected{% endif %}>1 - AB-addressed panels</option>
|
||
{% endif %}
|
||
<option value="2" {% if stored_row_address_type == 2 %}selected{% endif %}>2 - Row direct</option>
|
||
{% if not is_pi5 %}
|
||
<option value="3" {% if stored_row_address_type == 3 %}selected{% endif %}>3 - ABC-addressed panels</option>
|
||
<option value="4" {% if stored_row_address_type == 4 %}selected{% endif %}>4 - ABC Shift + DE direct</option>
|
||
<option value="5" {% if stored_row_address_type == 5 %}selected{% endif %}>5 - SM5368 / B707 row shift register</option>
|
||
{% endif %}
|
||
</select>
|
||
{% if is_pi5 and stored_row_address_type not in (0, 2) %}
|
||
<p class="text-sm text-red-600 mt-1">Your saved row address type ({{ stored_row_address_type }}) can't be used on this Raspberry Pi 5, so the display won't start with it. Saving this form sets it to 0.</p>
|
||
{% endif %}
|
||
</div>
|
||
</div>
|
||
|
||
<div class="grid grid-cols-1 md:grid-cols-3 gap-4">
|
||
<div class="form-group" id="setting-display-gpio_slowdown" data-setting-key="display.runtime.gpio_slowdown">
|
||
<label for="gpio_slowdown" class="block text-sm font-medium text-gray-700">GPIO Slowdown{{ ui.help_tip('Slows GPIO writes so the panel electronics keep up (0–10); higher is more reliable but lowers the refresh rate.\nStarting points: Pi Zero / Pi 1 → 0–1, Pi 2 / Pi 3 → 1–3, Pi 4 → 2–4, Pi 5 (PIO) → 1–3, starting at 1 (0 acts as 1 there). Panels on Row Address Type 5 (SM5368 row drivers) can need 6–8 on a Pi 4. Raise it if the display shows garbage, jumping rows or flicker; in RIO mode higher values may improve performance.', 'GPIO Slowdown') }}</label>
|
||
<input type="number"
|
||
id="gpio_slowdown"
|
||
name="gpio_slowdown"
|
||
value="{{ main_config.display.get('runtime', {}).get('gpio_slowdown', 3) }}"
|
||
min="0"
|
||
max="10"
|
||
class="form-control">
|
||
</div>
|
||
|
||
<div class="form-group" id="setting-display-rp1_rio" data-setting-key="display.runtime.rp1_rio">
|
||
<label for="rp1_rio" class="block text-sm font-medium text-gray-700">
|
||
RP1 Backend <span class="text-xs text-gray-400 font-normal">(Pi 5 only)</span>{{ ui.help_tip('Pi 5 RP1 coprocessor driver mode.\nPIO (0) is the default and uses less CPU. RIO (1) can push a higher refresh rate but inverts the GPIO Slowdown behavior. Ignored on every other Pi model.', 'RP1 Backend') }}
|
||
</label>
|
||
<select id="rp1_rio" name="rp1_rio" class="form-control">
|
||
<option value="0" {% if main_config.display.get('runtime', {}).get('rp1_rio', 0)|int == 0 %}selected{% endif %}>0 — PIO (default, low CPU)</option>
|
||
<option value="1" {% if main_config.display.get('runtime', {}).get('rp1_rio', 0)|int == 1 %}selected{% endif %}>1 — RIO (higher throughput; slowdown inverted)</option>
|
||
</select>
|
||
</div>
|
||
|
||
<div class="form-group" id="setting-display-scan_mode" data-setting-key="display.hardware.scan_mode">
|
||
<label for="scan_mode" class="block text-sm font-medium text-gray-700">Scan Mode{{ ui.help_tip('Order rows are refreshed in.\n0 = progressive (default), 1 = interlaced. Interlaced can look a little smoother when the refresh rate is very low, but usually shows a comb effect on anything moving. Leave at 0 unless you are tuning a slow setup.', 'Scan Mode') }}</label>
|
||
<input type="number"
|
||
id="scan_mode"
|
||
name="scan_mode"
|
||
value="{{ main_config.display.hardware.scan_mode or 0 }}"
|
||
min="0"
|
||
max="1"
|
||
class="form-control">
|
||
</div>
|
||
</div>
|
||
|
||
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||
<div class="form-group" id="setting-display-pwm_bits" data-setting-key="display.hardware.pwm_bits">
|
||
<label for="pwm_bits" class="block text-sm font-medium text-gray-700">PWM Bits{{ ui.help_tip('Color depth per channel (1–11).\nHigher means smoother color but a lower refresh rate; lower drops the subtlest shades for a faster refresh (1 = 8 colors). Default: 9.', 'PWM Bits') }}</label>
|
||
<input type="number"
|
||
id="pwm_bits"
|
||
name="pwm_bits"
|
||
value="{{ main_config.display.hardware.pwm_bits or 9 }}"
|
||
min="1"
|
||
max="11"
|
||
class="form-control">
|
||
</div>
|
||
|
||
<div class="form-group" id="setting-display-pwm_dither_bits" data-setting-key="display.hardware.pwm_dither_bits">
|
||
<label for="pwm_dither_bits" class="block text-sm font-medium text-gray-700">PWM Dither Bits{{ ui.help_tip('Time-dithers the lowest color bits (0–2): their brightness comes from showing them on only some frames, which raises the refresh rate.\nDefault: 1. 0 gives the steadiest dim colors; 2 is fastest but dark shades can shimmer. The rgbmatrix library accepts only 0–2.', 'PWM Dither Bits') }}</label>
|
||
<input type="number"
|
||
id="pwm_dither_bits"
|
||
name="pwm_dither_bits"
|
||
value="{{ main_config.display.hardware.get('pwm_dither_bits', 1) }}"
|
||
min="0"
|
||
max="2"
|
||
class="form-control">
|
||
</div>
|
||
</div>
|
||
|
||
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||
<div class="form-group" id="setting-display-pwm_lsb_nanoseconds" data-setting-key="display.hardware.pwm_lsb_nanoseconds">
|
||
<label for="pwm_lsb_nanoseconds" class="block text-sm font-medium text-gray-700">PWM LSB Nanoseconds{{ ui.help_tip('On-time of the least-significant color bit (50–3000 ns); each higher bit doubles it.\nDefault: 130. Lower allows a higher refresh rate but can cost color accuracy or add ghosting; raise it if bright text on black leaves faint trails.', 'PWM LSB Nanoseconds') }}</label>
|
||
<input type="number"
|
||
id="pwm_lsb_nanoseconds"
|
||
name="pwm_lsb_nanoseconds"
|
||
value="{{ main_config.display.hardware.pwm_lsb_nanoseconds or 130 }}"
|
||
min="50"
|
||
max="3000"
|
||
class="form-control">
|
||
</div>
|
||
|
||
<div class="form-group" id="setting-display-limit_refresh_rate_hz" data-setting-key="display.hardware.limit_refresh_rate_hz">
|
||
<label for="limit_refresh_rate_hz" class="block text-sm font-medium text-gray-700">Limit Refresh Rate (Hz){{ ui.help_tip('Caps the panel refresh rate (0–1000 Hz; 0 = no cap).\nDefault: 100. A steady cap reduces flicker from other activity on the Pi and in camera recordings. Scroll speeds are worked out against this value (against 100 Hz when it is 0), so a cap the panel can actually hold keeps scrolling even.', 'Limit Refresh Rate') }}</label>
|
||
<input type="number"
|
||
id="limit_refresh_rate_hz"
|
||
name="limit_refresh_rate_hz"
|
||
value="{{ main_config.display.hardware.get('limit_refresh_rate_hz', 100) }}"
|
||
min="0"
|
||
max="1000"
|
||
class="form-control">
|
||
</div>
|
||
</div>
|
||
|
||
<!-- Display Options -->
|
||
<div class="bg-gray-50 rounded-lg p-4">
|
||
<h3 class="text-md font-medium text-gray-900 mb-4">Display Options</h3>
|
||
|
||
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||
<div class="form-group" id="setting-display-disable_hardware_pulsing" data-setting-key="display.hardware.disable_hardware_pulsing">
|
||
<label class="flex items-center">
|
||
<input type="checkbox"
|
||
name="disable_hardware_pulsing"
|
||
value="true"
|
||
{% if main_config.display.hardware.disable_hardware_pulsing %}checked{% endif %}
|
||
class="form-control h-4 w-4 text-blue-600 focus:ring-blue-500 border-gray-300 rounded">
|
||
<span class="ml-2 text-sm font-medium text-gray-900">Disable Hardware Pulsing</span>
|
||
{{ ui.help_tip('Time the brightness pulses in software instead of with the Pi hardware PWM.\nLeave unchecked where possible: software timing is less exact, so a row or the whole panel can briefly flash brighter. Hardware pulsing needs the panel OE line on GPIO 18 (Adafruit HAT PWM, Regular, Triple Bonnet) and the Pi onboard sound driver disabled; check this if you need Pi audio. Without GPIO 18 the library uses software timing anyway.', 'Disable Hardware Pulsing') }}
|
||
</label>
|
||
</div>
|
||
|
||
<div class="form-group" id="setting-display-inverse_colors" data-setting-key="display.hardware.inverse_colors">
|
||
<label class="flex items-center">
|
||
<input type="checkbox"
|
||
name="inverse_colors"
|
||
value="true"
|
||
{% if main_config.display.hardware.inverse_colors %}checked{% endif %}
|
||
class="form-control h-4 w-4 text-blue-600 focus:ring-blue-500 border-gray-300 rounded">
|
||
<span class="ml-2 text-sm font-medium text-gray-900">Inverse Colors</span>
|
||
{{ ui.help_tip('Invert every color the panel shows.\nDefault: off. Only needed for panels wired with inverted color logic.', 'Inverse Colors') }}
|
||
</label>
|
||
</div>
|
||
|
||
<div class="form-group" id="setting-display-show_refresh_rate" data-setting-key="display.hardware.show_refresh_rate">
|
||
<label class="flex items-center">
|
||
<input type="checkbox"
|
||
name="show_refresh_rate"
|
||
value="true"
|
||
{% if main_config.display.hardware.show_refresh_rate %}checked{% endif %}
|
||
class="form-control h-4 w-4 text-blue-600 focus:ring-blue-500 border-gray-300 rounded">
|
||
<span class="ml-2 text-sm font-medium text-gray-900">Show Refresh Rate</span>
|
||
{{ ui.help_tip('Print the live panel refresh rate to the console; nothing is drawn on the panel.\nReadable when you stop the service and run the display in a terminal; under the service the output is buffered. Turn off for normal use.', 'Show Refresh Rate') }}
|
||
</label>
|
||
</div>
|
||
|
||
<div class="form-group" id="setting-display-use_short_date_format" data-setting-key="display.use_short_date_format">
|
||
<label class="flex items-center">
|
||
<input type="checkbox"
|
||
name="use_short_date_format"
|
||
value="true"
|
||
{% if main_config.display.use_short_date_format %}checked{% endif %}
|
||
class="form-control h-4 w-4 text-blue-600 focus:ring-blue-500 border-gray-300 rounded">
|
||
<span class="ml-2 text-sm font-medium text-gray-900">Use Short Date Format</span>
|
||
{{ ui.help_tip('Show dates in a compact form (e.g. 7/8 instead of July 8).\nHandy on narrow displays where space is tight.', 'Use Short Date Format') }}
|
||
</label>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- Dynamic Duration Settings -->
|
||
<div class="mt-6 pt-4 border-t border-gray-300">
|
||
<h4 class="text-sm font-medium text-gray-900 mb-3">Dynamic Duration</h4>
|
||
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||
<div class="form-group" id="setting-display-max_dynamic_duration_seconds" data-setting-key="display.dynamic_duration.max_duration_seconds">
|
||
<label for="max_dynamic_duration_seconds" class="block text-sm font-medium text-gray-700">Max Dynamic Duration (seconds){{ ui.help_tip('Ceiling on how long a plugin may extend its own on-screen time (30–1800s).\nDefault: 180. Plugins with live content (e.g. a game in progress) can request extra time up to this limit.', 'Max Dynamic Duration') }}</label>
|
||
<input type="number"
|
||
id="max_dynamic_duration_seconds"
|
||
name="max_dynamic_duration_seconds"
|
||
value="{{ main_config.display.get('dynamic_duration', {}).get('max_duration_seconds', 180) }}"
|
||
min="30"
|
||
max="1800"
|
||
class="form-control">
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</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 -->
|
||
<div class="bg-gray-50 rounded-lg p-4 mt-6">
|
||
<div class="flex items-center justify-between mb-4">
|
||
<div>
|
||
<h3 id="vegas_scroll_label" class="text-md font-medium text-gray-900">
|
||
<i class="fas fa-scroll mr-2"></i>Vegas Scroll Mode
|
||
</h3>
|
||
<p class="mt-1 text-sm text-gray-600">Combine all plugin content into one continuous scrolling ticker display.</p>
|
||
</div>
|
||
<label class="flex items-center cursor-pointer">
|
||
<input type="checkbox"
|
||
id="vegas_scroll_enabled"
|
||
name="vegas_scroll_enabled"
|
||
value="true"
|
||
aria-label="Enable Vegas Scroll Mode"
|
||
{% if main_config.display.get('vegas_scroll', {}).get('enabled', false) %}checked{% endif %}
|
||
class="h-5 w-5 text-blue-600 focus:ring-blue-500 border-gray-300 rounded">
|
||
<span class="ml-2 text-sm font-medium text-gray-700">Enable</span>
|
||
</label>
|
||
</div>
|
||
|
||
<!-- Vegas Settings (shown when enabled) -->
|
||
<div id="vegas_scroll_settings" class="space-y-4" style="{% if not main_config.display.get('vegas_scroll', {}).get('enabled', false) %}display: none;{% endif %}">
|
||
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||
<div class="form-group" id="setting-display-vegas_scroll_speed" data-setting-key="display.vegas_scroll.scroll_speed">
|
||
<label for="vegas_scroll_speed" class="block text-sm font-medium text-gray-700">Scroll Speed (pixels/second){{ ui.help_tip('How fast the Vegas ticker scrolls (10–200 px/s).\nDefault: 50. Higher is faster but harder to read.', 'Scroll Speed') }}</label>
|
||
<div class="flex items-center space-x-2">
|
||
<input type="range"
|
||
id="vegas_scroll_speed"
|
||
name="vegas_scroll_speed"
|
||
value="{{ main_config.display.get('vegas_scroll', {}).get('scroll_speed', 50) }}"
|
||
min="10"
|
||
max="200"
|
||
step="5"
|
||
class="flex-1">
|
||
<span id="vegas_scroll_speed_value" class="text-sm font-medium w-12">{{ main_config.display.get('vegas_scroll', {}).get('scroll_speed', 50) }}</span>
|
||
</div>
|
||
</div>
|
||
|
||
<div class="form-group" id="setting-display-vegas_separator_width" data-setting-key="display.vegas_scroll.separator_width">
|
||
<label for="vegas_separator_width" class="block text-sm font-medium text-gray-700">Separator Width (pixels){{ ui.help_tip('Blank gap where one plugin hands off to the next (0–128 px).\nDefault: 32. Larger values make the boundary between plugins clearer. This does not apply between rows of the same plugin — see Row Gap for that.', 'Separator Width') }}</label>
|
||
<input type="number"
|
||
id="vegas_separator_width"
|
||
name="vegas_separator_width"
|
||
value="{{ main_config.display.get('vegas_scroll', {}).get('separator_width', 32) }}"
|
||
min="0"
|
||
max="128"
|
||
class="form-control">
|
||
</div>
|
||
</div>
|
||
|
||
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||
<div class="form-group" id="setting-display-vegas_intra_plugin_gap" data-setting-key="display.vegas_scroll.intra_plugin_gap">
|
||
<label for="vegas_intra_plugin_gap" class="block text-sm font-medium text-gray-700">Row Gap (pixels){{ ui.help_tip('Extra gap always added between rows contributed by the same plugin (0–128 px).\nDefault: 8. This is a floor on top of Row Separation below, which does most of the work. Set both to 0 to butt rows directly together.', 'Row Gap') }}</label>
|
||
<input type="number"
|
||
id="vegas_intra_plugin_gap"
|
||
name="vegas_intra_plugin_gap"
|
||
value="{{ main_config.display.get('vegas_scroll', {}).get('intra_plugin_gap', 8) }}"
|
||
min="0"
|
||
max="128"
|
||
class="form-control">
|
||
</div>
|
||
|
||
<div class="form-group" id="setting-display-vegas_min_content_separation" data-setting-key="display.vegas_scroll.min_content_separation">
|
||
<label for="vegas_min_content_separation" class="block text-sm font-medium text-gray-700">Row Separation (pixels){{ ui.help_tip('Blank space guaranteed between rows of the same plugin, measured from the actual content rather than added blindly (0–256 px).\nDefault: 24. Rows already carrying wide margins get nothing added; rows drawn right up to their own edges — sports score cards, for instance — get the full amount, so they no longer look like they are touching. Raise it if items still feel cramped.', 'Row Separation') }}</label>
|
||
<input type="number"
|
||
id="vegas_min_content_separation"
|
||
name="vegas_min_content_separation"
|
||
value="{{ main_config.display.get('vegas_scroll', {}).get('min_content_separation', 24) }}"
|
||
min="0"
|
||
max="256"
|
||
class="form-control">
|
||
</div>
|
||
</div>
|
||
|
||
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||
<div class="form-group" id="setting-display-vegas_render_width_pct" data-setting-key="display.vegas_scroll.render_width_pct">
|
||
<label for="vegas_render_width_pct" class="block text-sm font-medium text-gray-700">Plugin Render Width (%){{ ui.help_tip('How much of the screen width each plugin is told it has while drawing for the ticker (10–100%).\nDefault: 100 (unchanged). Lowering it makes plugins choose a tighter layout rather than being cropped — a weather forecast becomes narrow cards instead of five columns spread across the panel. Useful on wide displays. Override per plugin with the vegas_width_pct setting in that plugin\'s own configuration.', 'Plugin Render Width') }}</label>
|
||
<input type="number"
|
||
id="vegas_render_width_pct"
|
||
name="vegas_render_width_pct"
|
||
value="{{ main_config.display.get('vegas_scroll', {}).get('render_width_pct', 100) }}"
|
||
min="10"
|
||
max="100"
|
||
step="5"
|
||
class="form-control">
|
||
</div>
|
||
</div>
|
||
|
||
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||
<div class="form-group" id="setting-display-vegas_target_fps" data-setting-key="display.vegas_scroll.target_fps">
|
||
<label for="vegas_target_fps" class="block text-sm font-medium text-gray-700">Target FPS{{ ui.help_tip('Frames per second the Vegas ticker aims to render.\nHigher = smoother scrolling but more CPU. Default: 125 (smoothest). Drop to 60/90 if the Pi runs hot.', 'Target FPS') }}</label>
|
||
<select id="vegas_target_fps" name="vegas_target_fps" class="form-control">
|
||
<option value="60" {% if main_config.display.get('vegas_scroll', {}).get('target_fps', 125) == 60 %}selected{% endif %}>60 FPS (Lower CPU)</option>
|
||
<option value="90" {% if main_config.display.get('vegas_scroll', {}).get('target_fps', 125) == 90 %}selected{% endif %}>90 FPS (Balanced)</option>
|
||
<option value="125" {% if main_config.display.get('vegas_scroll', {}).get('target_fps', 125) == 125 %}selected{% endif %}>125 FPS (Smoothest)</option>
|
||
</select>
|
||
</div>
|
||
|
||
<div class="form-group" id="setting-display-vegas_buffer_ahead" data-setting-key="display.vegas_scroll.buffer_ahead">
|
||
<label for="vegas_buffer_ahead" class="block text-sm font-medium text-gray-700">Buffer Ahead{{ ui.help_tip('How many upcoming plugins to pre-render so the scroll never stalls.\nDefault: 2 (recommended). More uses extra memory; less saves memory but risks hitches.', 'Buffer Ahead') }}</label>
|
||
<select id="vegas_buffer_ahead" name="vegas_buffer_ahead" class="form-control">
|
||
<option value="1" {% if main_config.display.get('vegas_scroll', {}).get('buffer_ahead', 2) == 1 %}selected{% endif %}>1 Plugin (Less memory)</option>
|
||
<option value="2" {% if main_config.display.get('vegas_scroll', {}).get('buffer_ahead', 2) == 2 %}selected{% endif %}>2 Plugins (Recommended)</option>
|
||
<option value="3" {% if main_config.display.get('vegas_scroll', {}).get('buffer_ahead', 2) == 3 %}selected{% endif %}>3 Plugins (More buffer)</option>
|
||
</select>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- Cycle Pacing -->
|
||
<div class="mt-4 pt-4 border-t border-gray-200">
|
||
<h4 class="text-sm font-medium text-gray-900 mb-3">Cycle Pacing</h4>
|
||
<p class="text-sm text-gray-600 mb-3">How long one pass through the ticker lasts, and how many plugins it covers.</p>
|
||
|
||
<div class="form-group mb-4" id="setting-display-vegas_continuous_scroll" data-setting-key="display.vegas_scroll.continuous_scroll">
|
||
<label class="flex items-center">
|
||
<input type="checkbox"
|
||
id="vegas_continuous_scroll"
|
||
name="vegas_continuous_scroll"
|
||
{% if main_config.display.get('vegas_scroll', {}).get('continuous_scroll', True) %}checked{% endif %}
|
||
class="form-checkbox">
|
||
<span class="ml-2 text-sm text-gray-700">Scroll continuously between groups{{ ui.help_tip('Keep one endless strip, extending it with the next group of plugins as the scroll approaches the end, so they simply arrive from the right. Default: on.\nWith this off the ticker builds a fresh strip and swaps it in, which stops the motion, replaces everything at once and restarts with the screen already full — a freeze, a flash and a jump.', 'Continuous Scroll') }}</span>
|
||
</label>
|
||
</div>
|
||
|
||
<div class="form-group mb-4" id="setting-display-vegas_smooth_scroll" data-setting-key="display.vegas_scroll.smooth_scroll">
|
||
<label class="flex items-center">
|
||
<input type="checkbox"
|
||
id="vegas_smooth_scroll"
|
||
name="vegas_smooth_scroll"
|
||
{% if main_config.display.get('vegas_scroll', {}).get('smooth_scroll', True) %}checked{% endif %}
|
||
class="form-checkbox">
|
||
<span class="ml-2 text-sm text-gray-700">Smooth sub-pixel motion{{ ui.help_tip('Blend between neighbouring pixel positions so the ticker moves once per rendered frame instead of once per pixel. Default: on.\nWithout it, motion happens only as often as the scroll speed in pixels per second — at 50 px/s that is 50 steps a second however fast the display renders, which reads as a slight judder. The trade is that text softens very slightly horizontally, since each frame blends two positions. Turn it off if you prefer maximum crispness.', 'Smooth Scrolling') }}</span>
|
||
</label>
|
||
</div>
|
||
|
||
<div class="grid grid-cols-1 md:grid-cols-2 gap-4 mb-4">
|
||
<div class="form-group" id="setting-display-vegas_extend_threshold_screens" data-setting-key="display.vegas_scroll.extend_threshold_screens">
|
||
<label for="vegas_extend_threshold_screens" class="block text-sm font-medium text-gray-700">Extend When (screens left){{ ui.help_tip('How much unscrolled content triggers loading the next group, measured in screen widths (1.0–10.0).\nDefault: 2. Higher loads earlier and leaves more slack, at the cost of holding more content in memory. Only applies when Continuous Scroll is on.', 'Extend Threshold') }}</label>
|
||
<input type="number"
|
||
id="vegas_extend_threshold_screens"
|
||
name="vegas_extend_threshold_screens"
|
||
value="{{ main_config.display.get('vegas_scroll', {}).get('extend_threshold_screens', 2.0) }}"
|
||
min="1"
|
||
max="10"
|
||
step="0.5"
|
||
class="form-control">
|
||
</div>
|
||
</div>
|
||
|
||
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||
<div class="form-group" id="setting-display-vegas_plugins_per_cycle" data-setting-key="display.vegas_scroll.plugins_per_cycle">
|
||
<label for="vegas_plugins_per_cycle" class="block text-sm font-medium text-gray-700">Plugins Per Cycle{{ ui.help_tip('How many plugins are composed into one pass of the ticker (1–50).\nDefault: 6. Higher means more variety before the ticker restarts, and fewer recompose pauses. Lower means each plugin comes around sooner.', 'Plugins Per Cycle') }}</label>
|
||
<input type="number"
|
||
id="vegas_plugins_per_cycle"
|
||
name="vegas_plugins_per_cycle"
|
||
value="{{ main_config.display.get('vegas_scroll', {}).get('plugins_per_cycle', 6) }}"
|
||
min="1"
|
||
max="50"
|
||
class="form-control">
|
||
</div>
|
||
|
||
<div class="form-group" id="setting-display-vegas_overflow_mode" data-setting-key="display.vegas_scroll.overflow_mode">
|
||
<label for="vegas_overflow_mode" class="block text-sm font-medium text-gray-700">When A Plugin Is Too Wide{{ ui.help_tip('What to do when a plugin has more content than its width allowance.\nRotate through it: show a different slice each time round, so everything is seen eventually. Right for interchangeable items like news headlines, odds or stock prices.\nShow the start only: always display from the beginning and drop the rest. Right for ordered content — a league table that shows ranks 1-6 and then resumes at 7 two rotations later reads as out of order.\nDefault: rotate. Override for one plugin with vegas_overflow in its own settings.', 'Overflow Handling') }}</label>
|
||
<select id="vegas_overflow_mode" name="vegas_overflow_mode" class="form-control">
|
||
<option value="rotate" {% if main_config.display.get('vegas_scroll', {}).get('overflow_mode', 'rotate') == 'rotate' %}selected{% endif %}>Rotate through it (default)</option>
|
||
<option value="truncate" {% if main_config.display.get('vegas_scroll', {}).get('overflow_mode', 'rotate') == 'truncate' %}selected{% endif %}>Show the start only</option>
|
||
</select>
|
||
</div>
|
||
|
||
<div class="form-group" id="setting-display-vegas_max_plugin_width_ratio" data-setting-key="display.vegas_scroll.max_plugin_width_ratio">
|
||
<label for="vegas_max_plugin_width_ratio" class="block text-sm font-medium text-gray-700">Max Plugin Width (screens){{ ui.help_tip('Caps how much of one cycle a single plugin may occupy, measured in screen widths (0–20).\nDefault: 0 (no limit) — every plugin shows all of its content and always starts at the beginning.\nSet a limit to stop one long ticker holding the display for minutes: it is cut to this width and the remainder shown on later cycles. The trade-off is that such a plugin then resumes mid-content on each appearance instead of starting fresh.', 'Max Plugin Width') }}</label>
|
||
<input type="number"
|
||
id="vegas_max_plugin_width_ratio"
|
||
name="vegas_max_plugin_width_ratio"
|
||
value="{{ main_config.display.get('vegas_scroll', {}).get('max_plugin_width_ratio', 0.0) }}"
|
||
min="0"
|
||
max="20"
|
||
step="0.5"
|
||
class="form-control">
|
||
</div>
|
||
</div>
|
||
|
||
<div class="form-group mt-4" id="setting-display-vegas_dynamic_duration_enabled" data-setting-key="display.vegas_scroll.dynamic_duration_enabled">
|
||
<label class="flex items-center">
|
||
<input type="checkbox"
|
||
id="vegas_dynamic_duration_enabled"
|
||
name="vegas_dynamic_duration_enabled"
|
||
{% if main_config.display.get('vegas_scroll', {}).get('dynamic_duration_enabled', True) %}checked{% endif %}
|
||
class="form-checkbox">
|
||
<span class="ml-2 text-sm text-gray-700">Size cycle time to the content{{ ui.help_tip('When on, each cycle runs just long enough to scroll all its content past, clamped to the min and max below.\nWhen off, the max is always used. Default: on.', 'Dynamic Cycle Duration') }}</span>
|
||
</label>
|
||
</div>
|
||
|
||
<div class="grid grid-cols-1 md:grid-cols-2 gap-4 mt-2">
|
||
<div class="form-group" id="setting-display-vegas_min_cycle_duration" data-setting-key="display.vegas_scroll.min_cycle_duration">
|
||
<label for="vegas_min_cycle_duration" class="block text-sm font-medium text-gray-700">Min Cycle Time (seconds){{ ui.help_tip('Shortest a single ticker pass may last (5–3600 s).\nDefault: 60.', 'Min Cycle Time') }}</label>
|
||
<input type="number"
|
||
id="vegas_min_cycle_duration"
|
||
name="vegas_min_cycle_duration"
|
||
value="{{ main_config.display.get('vegas_scroll', {}).get('min_cycle_duration', 60) }}"
|
||
min="5"
|
||
max="3600"
|
||
class="form-control">
|
||
</div>
|
||
|
||
<div class="form-group" id="setting-display-vegas_max_cycle_duration" data-setting-key="display.vegas_scroll.max_cycle_duration">
|
||
<label for="vegas_max_cycle_duration" class="block text-sm font-medium text-gray-700">Max Cycle Time (seconds){{ ui.help_tip('Longest a single ticker pass may last before it restarts with fresh content (10–3600 s).\nThis is the setting that caps total Vegas scroll time. Default: 240. Lower it if the ticker feels like it takes too long to come back around.', 'Max Cycle Time') }}</label>
|
||
<input type="number"
|
||
id="vegas_max_cycle_duration"
|
||
name="vegas_max_cycle_duration"
|
||
value="{{ main_config.display.get('vegas_scroll', {}).get('max_cycle_duration', 240) }}"
|
||
min="10"
|
||
max="3600"
|
||
class="form-control">
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- Dead Space -->
|
||
<div class="mt-4 pt-4 border-t border-gray-200">
|
||
<h4 class="text-sm font-medium text-gray-900 mb-3">Dead Space</h4>
|
||
<p class="text-sm text-gray-600 mb-3">Plugins that draw onto a full-screen canvas contribute all the empty space around their content. Trimming reclaims it so the ticker stays full.</p>
|
||
|
||
<div class="form-group mb-4" id="setting-display-vegas_auto_trim" data-setting-key="display.vegas_scroll.auto_trim">
|
||
<label class="flex items-center">
|
||
<input type="checkbox"
|
||
id="vegas_auto_trim"
|
||
name="vegas_auto_trim"
|
||
{% if main_config.display.get('vegas_scroll', {}).get('auto_trim', True) %}checked{% endif %}
|
||
class="form-checkbox">
|
||
<span class="ml-2 text-sm text-gray-700">Trim empty edges from plugin content{{ ui.help_tip('Crops blank columns from the left and right of each plugin block before it enters the ticker. Space between two pieces of content inside a block is left alone, so layouts are not altered. Default: on.', 'Auto Trim') }}</span>
|
||
</label>
|
||
</div>
|
||
|
||
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||
<div class="form-group" id="setting-display-vegas_content_padding" data-setting-key="display.vegas_scroll.content_padding">
|
||
<label for="vegas_content_padding" class="block text-sm font-medium text-gray-700">Content Padding (pixels){{ ui.help_tip('Blank columns kept either side of trimmed content, so it does not butt against the separator (0–128 px).\nDefault: 8.', 'Content Padding') }}</label>
|
||
<input type="number"
|
||
id="vegas_content_padding"
|
||
name="vegas_content_padding"
|
||
value="{{ main_config.display.get('vegas_scroll', {}).get('content_padding', 8) }}"
|
||
min="0"
|
||
max="128"
|
||
class="form-control">
|
||
</div>
|
||
|
||
<div class="form-group" id="setting-display-vegas_lead_in_width" data-setting-key="display.vegas_scroll.lead_in_width">
|
||
<label for="vegas_lead_in_width" class="block text-sm font-medium text-gray-700">Lead-In Gap (pixels){{ ui.help_tip('Blank space before the first plugin of each cycle (0–2048 px).\nDefault: 0. Anything approaching your screen width reads as the display switching off at the start of every cycle.', 'Lead-In Gap') }}</label>
|
||
<input type="number"
|
||
id="vegas_lead_in_width"
|
||
name="vegas_lead_in_width"
|
||
value="{{ main_config.display.get('vegas_scroll', {}).get('lead_in_width', 0) }}"
|
||
min="0"
|
||
max="2048"
|
||
class="form-control">
|
||
</div>
|
||
</div>
|
||
|
||
<div class="grid grid-cols-1 md:grid-cols-2 gap-4 mt-4">
|
||
<div class="form-group" id="setting-display-vegas_trim_threshold" data-setting-key="display.vegas_scroll.trim_threshold">
|
||
<label for="vegas_trim_threshold" class="block text-sm font-medium text-gray-700">Trim Threshold{{ ui.help_tip('How bright a pixel must be to count as content rather than empty space (0–254).\nDefault: 10, which ignores the near-black noise left by image compression. Raise it if very dark artwork is being kept; lower it if dark detail is being cropped.', 'Trim Threshold') }}</label>
|
||
<input type="number"
|
||
id="vegas_trim_threshold"
|
||
name="vegas_trim_threshold"
|
||
value="{{ main_config.display.get('vegas_scroll', {}).get('trim_threshold', 10) }}"
|
||
min="0"
|
||
max="254"
|
||
class="form-control">
|
||
</div>
|
||
|
||
<div class="form-group" id="setting-display-vegas_min_plugin_width" data-setting-key="display.vegas_scroll.min_plugin_width">
|
||
<label for="vegas_min_plugin_width" class="block text-sm font-medium text-gray-700">Min Plugin Width (pixels){{ ui.help_tip('Plugin blocks narrower than this after trimming are skipped for that cycle (0–512 px).\nDefault: 8. Raise it to hide plugins showing only a tiny placeholder such as "No Data" until they have real content.', 'Min Plugin Width') }}</label>
|
||
<input type="number"
|
||
id="vegas_min_plugin_width"
|
||
name="vegas_min_plugin_width"
|
||
value="{{ main_config.display.get('vegas_scroll', {}).get('min_plugin_width', 8) }}"
|
||
min="0"
|
||
max="512"
|
||
class="form-control">
|
||
</div>
|
||
</div>
|
||
|
||
<div class="grid grid-cols-1 md:grid-cols-2 gap-4 mt-4">
|
||
<div class="form-group" id="setting-display-vegas_min_cut_gap" data-setting-key="display.vegas_scroll.min_cut_gap">
|
||
<label for="vegas_min_cut_gap" class="block text-sm font-medium text-gray-700">Min Cut Gap (pixels){{ ui.help_tip('When a plugin is too wide for its share of a cycle and has to be narrowed, the cut is only made where there is at least this much blank space (1–128 px).\nDefault: 6. The gaps between letters are about 1px wide, so a smaller value lets a cut land inside a word and orphan its last letter into the next cycle. Raise it if cuts still land awkwardly. Continuous images such as maps have no gaps and are cut to fit regardless.', 'Min Cut Gap') }}</label>
|
||
<input type="number"
|
||
id="vegas_min_cut_gap"
|
||
name="vegas_min_cut_gap"
|
||
value="{{ main_config.display.get('vegas_scroll', {}).get('min_cut_gap', 6) }}"
|
||
min="1"
|
||
max="128"
|
||
class="form-control">
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- Plugin Order Section -->
|
||
<div class="mt-4 pt-4 border-t border-gray-200">
|
||
<h4 class="text-sm font-medium text-gray-900 mb-3">Plugin Order</h4>
|
||
<p class="text-sm text-gray-600 mb-3">Drag to reorder plugins. Uncheck to exclude from Vegas scroll.</p>
|
||
<div id="vegas_plugin_order" class="space-y-2 bg-white rounded-lg p-3 border border-gray-200">
|
||
<!-- Plugin order list will be populated by JavaScript -->
|
||
<p class="text-sm text-gray-500 italic">Loading plugins...</p>
|
||
</div>
|
||
<input type="hidden" id="vegas_plugin_order_value" name="vegas_plugin_order" value='{{ main_config.display.get("vegas_scroll", {}).get("plugin_order", [])|tojson }}'>
|
||
<input type="hidden" id="vegas_excluded_plugins_value" name="vegas_excluded_plugins" value='{{ main_config.display.get("vegas_scroll", {}).get("excluded_plugins", [])|tojson }}'>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- Double-Sided Display -->
|
||
<div class="bg-gray-50 rounded-lg p-4">
|
||
<h3 class="text-md font-medium text-gray-900 mb-1">Double-Sided Display</h3>
|
||
<p class="text-sm text-gray-600 mb-4">Show the same content on every panel in the chain — e.g. two 64×32 panels mirrored, or four panels as two identical screens. Rendered once and duplicated, so it adds no extra CPU. Takes effect after a display restart.</p>
|
||
|
||
<div class="form-group mb-4" id="setting-display-double_sided_enabled" data-setting-key="display.double_sided.enabled">
|
||
<label class="flex items-center gap-2">
|
||
<input type="checkbox"
|
||
id="double_sided_enabled"
|
||
name="double_sided_enabled"
|
||
value="true"
|
||
{% if main_config.display.get('double_sided', {}).get('enabled') %}checked{% endif %}
|
||
class="form-control h-4 w-4 text-blue-600 focus:ring-blue-500 border-gray-300 rounded">
|
||
<span class="text-sm font-medium text-gray-700">Enabled</span>
|
||
{{ ui.help_tip('Show the same content mirrored across every panel in the chain.\nRendered once and duplicated, so it adds no extra CPU. Takes effect after a display restart.', 'Double-Sided Enabled') }}
|
||
</label>
|
||
</div>
|
||
|
||
<!-- Hidden (not disabled) when the feature is off, so the values are
|
||
still submitted and round-trip through a save. -->
|
||
<div id="double_sided_settings"
|
||
class="grid grid-cols-1 md:grid-cols-2 gap-4"
|
||
{% if not main_config.display.get('double_sided', {}).get('enabled') %}style="display: none;"{% endif %}>
|
||
<div class="form-group" id="setting-display-double_sided_copies" data-setting-key="display.double_sided.copies">
|
||
<label for="double_sided_copies" class="block text-sm font-medium text-gray-700">Copies{{ ui.help_tip('How many identical screens to split the panel area into (2–8).\nWhen enabled, this must divide the panel evenly — e.g. 2 for a two-sided cube.', 'Copies') }}</label>
|
||
<input type="number"
|
||
id="double_sided_copies"
|
||
name="double_sided_copies"
|
||
value="{{ main_config.display.get('double_sided', {}).get('copies', 2) }}"
|
||
min="2"
|
||
max="8"
|
||
class="form-control">
|
||
</div>
|
||
|
||
<div class="form-group" id="setting-display-double_sided_axis" data-setting-key="display.double_sided.axis">
|
||
<label for="double_sided_axis" class="block text-sm font-medium text-gray-700">Split Axis{{ ui.help_tip('Direction the display is divided into copies.\nHorizontal splits along the chained panels (side by side); Vertical splits along parallel chains (stacked).', 'Split Axis') }}</label>
|
||
<select id="double_sided_axis" name="double_sided_axis" class="form-control">
|
||
<option value="horizontal" {% if main_config.display.get('double_sided', {}).get('axis', 'horizontal') == 'horizontal' %}selected{% endif %}>Horizontal — chained panels (side by side)</option>
|
||
<option value="vertical" {% if main_config.display.get('double_sided', {}).get('axis', 'horizontal') == 'vertical' %}selected{% endif %}>Vertical — parallel chains (stacked)</option>
|
||
</select>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
|
||
|
||
<!-- Multi-Display Sync Settings -->
|
||
<div class="bg-gray-50 rounded-lg p-4 mt-6">
|
||
<div class="flex items-center justify-between mb-4">
|
||
<div>
|
||
<h3 class="text-md font-medium text-gray-900">
|
||
<i class="fas fa-clone mr-2"></i>Multi-Display Sync
|
||
</h3>
|
||
<p class="mt-1 text-sm text-gray-600">
|
||
Extend scrolling content across two LED matrix display units over WiFi.
|
||
Both displays must have identical rows and cols. Chain length may differ.
|
||
</p>
|
||
</div>
|
||
</div>
|
||
|
||
<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()">
|
||
<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>
|
||
</select>
|
||
</div>
|
||
|
||
<div class="form-group" id="setting-display-sync_port" data-setting-key="sync.port">
|
||
<label for="sync_port" class="block text-sm font-medium text-gray-700">UDP Port{{ ui.help_tip('UDP port the two displays use to exchange frames (1024–65535).\nDefault: 5765. Must match on both Pis. If the ufw firewall is active, allow it with: sudo ufw allow ' ~ main_config.get('sync', {}).get('port', 5765) ~ '/udp', 'Sync UDP Port') }}</label>
|
||
<input type="number"
|
||
id="sync_port"
|
||
name="sync_port"
|
||
value="{{ main_config.get('sync', {}).get('port', 5765) }}"
|
||
min="1024"
|
||
max="65535"
|
||
class="form-control">
|
||
</div>
|
||
|
||
<div class="form-group" id="setting-display-sync_follower_position" data-setting-key="sync.follower_position" style="display:none">
|
||
<label for="sync_follower_position" class="block text-sm font-medium text-gray-700">Position{{ ui.help_tip('Which side of the leader this follower display sits on.\nSets whether this unit shows the left or right half of the extended scroll.', 'Follower Position') }}</label>
|
||
<select id="sync_follower_position" name="sync_follower_position" class="form-control">
|
||
<option value="left" {% if main_config.get('sync', {}).get('follower_position', 'left') == 'left' %}selected{% endif %}>Left of leader</option>
|
||
<option value="right" {% if main_config.get('sync', {}).get('follower_position', 'left') == 'right' %}selected{% endif %}>Right of leader</option>
|
||
</select>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- Live status indicator (populated by JS) -->
|
||
<div id="sync_status_bar" class="mt-4 hidden">
|
||
<div id="sync_status_content" class="flex items-start space-x-2 p-3 rounded-lg border text-sm"></div>
|
||
</div>
|
||
|
||
<!-- Incompatibility detail (shown when error) -->
|
||
<div id="sync_error_detail" class="mt-2 hidden">
|
||
<p class="text-xs text-yellow-700 bg-yellow-50 border border-yellow-200 rounded p-2" id="sync_error_text"></p>
|
||
<p class="text-xs text-gray-500 mt-1">rows and cols must match between displays. chain_length may differ.</p>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- Submit Button -->
|
||
<div class="flex justify-end">
|
||
<button type="submit"
|
||
class="btn bg-blue-600 hover:bg-blue-700 text-white px-4 py-2 rounded-md">
|
||
<i class="fas fa-save mr-2"></i>
|
||
Save Display Settings
|
||
</button>
|
||
</div>
|
||
</form>
|
||
</div>
|
||
|
||
<script>
|
||
// Update brightness display
|
||
document.getElementById('brightness').addEventListener('input', function() {
|
||
document.getElementById('brightness-value').textContent = this.value;
|
||
document.getElementById('brightness-display').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';
|
||
});
|
||
}
|
||
|
||
// 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;
|
||
});
|
||
}
|
||
|
||
// 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();
|
||
if (window.LEDVisibility) {
|
||
window.LEDVisibility.onActive('display', startSyncTimer, stopSyncTimer, 'display-sync-status');
|
||
} else if (!window.syncStatusInterval) {
|
||
window.syncStatusInterval = setInterval(pollSyncStatus, 5000);
|
||
}
|
||
}
|
||
|
||
if (document.readyState === 'loading') {
|
||
document.addEventListener('DOMContentLoaded', startSyncPolling);
|
||
} else {
|
||
startSyncPolling();
|
||
}
|
||
})();
|
||
</script>
|