Files
978a03b42d Add settings tooltips and search to the web UI (#387)
* Add settings tooltips and search to the web UI

Help users quickly find settings and understand how each one works.

Tooltips: a new delegated controller (static/v3/js/tooltips.js) drives an
accessible (i) info tooltip that appears on hover, keyboard focus, and tap.
A shared `help_tip` Jinja macro (partials/_macros.html) emits the trigger;
the plugin config macro and the core settings partials now surface help
text through it. Per the design, the always-visible field help paragraphs
are folded into the tooltip to declutter the forms, and the hardware/display
settings carry authored detail (default, range, recommendation).

Search: a global header search box finds settings across every settings tab
— even ones not yet opened — via a lazy client-side index built by scanning
the same field markup (static/v3/js/settings-search.js). Selecting a result
switches tabs, waits for the field to load, then scrolls to and flashes it.
A per-tab filter box hides non-matching fields on the current tab.

Plugin settings get tooltips for free by reusing each field's schema
`description`; every settings field also gets a stable `setting-<tab>-<key>`
anchor id for search navigation.

Styling uses the existing --color-* theme vars so light/dark mode both work,
and honors prefers-reduced-motion. Adds Flask render smoke tests that assert
each settings partial ships tooltips, anchors, and a filter box.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014gZxznuxw8L92FUMBN3Nqz

* Address Codacy static-analysis findings in settings JS

Refactor the two new modules to clear the flagged patterns without any
behavior change:

- Build the search dropdown with DOM nodes + textContent instead of
  innerHTML string concatenation, removing the XSS sinks and the manual
  escapeHtml helper it needed.
- Replace numeric index access (index[i], terms[j], opts[idx],
  currentResults[i]) with array iteration methods, NodeList.item(), and
  Array.prototype.at() to clear detect-object-injection.
- Use === via a shared termsMatch() helper, optional-catch binding, and
  drop a useless initial assignment.

Verified with ESLint (eslint:recommended + eslint-plugin-security) at zero
findings and re-ran the headless-Chromium behavior test (tooltip, per-tab
filter, global search navigation) — all green.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014gZxznuxw8L92FUMBN3Nqz

* Reduce complexity of revealAncestors in settings search

Extract isNodeHidden() and revealNode() helpers so revealAncestors drops
below the cyclomatic-complexity threshold. No behavior change; verified with
the headless-Chromium test.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014gZxznuxw8L92FUMBN3Nqz

* Resolve remaining Codacy findings in settings search

- Validate plugin ids against a strict allowlist (mirroring the server's
  _SAFE_PLUGIN_ID_RE) before they can appear in a fetch path, so the request
  URL is never built from unvalidated input (Codacy: user-controlled URL).
- Document that the fetched HTML is parsed into an inert document (scripts
  never run, never inserted into the live DOM) purely to read field text for
  the search index.
- Declare block-scoped locals with const instead of var where they were
  nested inside conditionals (Codacy: var not at function root).

No behavior change; re-verified with ESLint and the headless-Chromium test.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014gZxznuxw8L92FUMBN3Nqz

* Serve the settings search index from the server as JSON

Move index building off the client so there is no client-side HTML fetching
or DOM parsing (resolves Codacy's variable-fetch and DOMParser flags on the
read-only, same-origin index build).

- Add GET /v3/settings/search-index (pages_v3.py): renders the settings
  partials server-side and extracts each field's anchor id, key, label,
  tooltip, and section with a small stdlib HTMLParser, then caches the result
  keyed on the installed-plugin set. Parsing the rendered HTML keeps anchor
  ids identical to the live DOM, so the index cannot drift.
- settings-search.js: buildIndex() now does a single fetch of the literal
  endpoint + .json(); removed the per-partial fetch loop, DOMParser, scanDoc,
  CORE_TABS, and the plugin-id allowlist. Search, keyboard nav, navigation,
  and the per-tab filter are unchanged.

Net: fewer requests and no client-side HTML parsing. Verified with a new
endpoint test in test_web_settings_ui.py (12 pass) and the headless-Chromium
test (tooltip, filter, search navigate + flash all green).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014gZxznuxw8L92FUMBN3Nqz

* Address CodeRabbit review on settings search/tooltips

- pages_v3: include Durations tab in the search index so
  setting-durations-* fields are actually indexed
- test_web_settings_ui: assert setting-durations-clock is present in
  the search-index endpoint response
- settings-search.js: on index fetch failure, reset buildPromise
  instead of caching an empty (truthy) index so search can retry
- settings-search.js: filterScope returns null (not document) when no
  tab container matches, and the caller guards, so the per-tab filter
  can't hide fields across unrelated tabs
- settings-search.js: refresh the stale header comment to describe the
  server-side JSON index flow
- app.css: cap #settings-search-results height with overflow-y so the
  dropdown scrolls instead of overflowing small screens

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014gZxznuxw8L92FUMBN3Nqz

* Fix stuck search dropdown; add plugin-tab nested-settings filter

Global search:
- Close the results dropdown on input blur (guarded, with a short delay)
  so it reliably dismisses when focus leaves — previously it could linger
  because the only outside-close was a document click that Alpine/HTMX
  handlers can swallow.
- Clear the query text after navigating to a result so refocusing the box
  doesn't re-open stale results.
- Also dismiss on htmx:afterSwap (tab changes / navigation).

Per-tab filter (now on plugin tabs too):
- Render the shared settings_filter box in the plugin Configuration panel.
  It auto-wires: the delegated input handler and filterScope already target
  .plugin-config-tab.
- Teach applyTabFilter to reveal matches inside collapsed nested sections
  (render_nested_section defaults them shut), hide nested-section wrappers
  with no matches, and restore the original collapsed layout when cleared
  (only re-collapsing sections the filter itself opened).
- Count a visible nested-section as content for its parent heading so the
  heading isn't hidden while a subsection below still has matches.

Adds a plugin-config render test (filter box + nested anchors + tooltips).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014gZxznuxw8L92FUMBN3Nqz

* Close search dropdown via capture-phase outside-click

The dropdown could stay open after clicking away because the only
outside-click close was a bubble-phase document listener. The v3 UI is
one Alpine app() component full of HTMX/Alpine/widget click handlers;
when a click lands inside an element that calls stopPropagation(), the
event never bubbles to document and the close never runs.

- Replace the bubble-phase document 'click' close with a capture-phase
  'pointerdown' listener scoped to #settings-search-wrap. Capture runs
  before any bubbling stopPropagation can swallow the event, so it always
  fires; pointerdown also covers touch on the Pi screen. Clicking a result
  stays inside the wrap, so selection is unaffected.
- Guard the debounced input handler so a delayed render can't re-open the
  box after focus has left (type-then-click-away race).

Keeps the existing blur / Escape / htmx:afterSwap closes as secondary paths.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014gZxznuxw8L92FUMBN3Nqz

* Fix settings search dropdown not visually closing

.hidden has no effect in this app: app.css is a hand-picked utility
subset (no Tailwind build step) and never defines .hidden { display:
none }. openResults()/closeResults() only toggled the class, so the
dropdown stayed rendered (display: block) even once closeResults()
ran - confirmed via computed style in a headless browser. Set
style.display directly, matching the fallback already used by
revealNode()/collapseNode() elsewhere in this file.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>

---------

Co-authored-by: Claude <noreply@anthropic.com>
2026-07-09 09:22:24 -04:00

878 lines
56 KiB
HTML
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
{% 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: '' }"
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'; }
}).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">
On Raspberry Pi 5: ensure the library was rebuilt from the latest submodule
(<code class="bg-yellow-100 px-1 rounded">first_time_install.sh</code>)
and try adjusting <strong>GPIO Slowdown</strong> (start at 3, reduce if the display looks dim or choppy).
Check the <a href="/v3/logs" class="underline font-medium">Logs tab</a> for 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="showNotification(event.detail.xhr.responseJSON?.message || 'Display settings saved', event.detail.xhr.responseJSON?.status || 'success')"
class="space-y-6"
novalidate
onsubmit="fixInvalidNumberInputs(this); return true;">
<!-- 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, or 64. Default: 32. Must match your panel.', 'Rows') }}</label>
<input type="number"
id="rows"
name="rows"
value="{{ main_config.display.hardware.rows or 32 }}"
min="1"
max="64"
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 or 64. Default: 64. Must match your panel.', 'Columns') }}</label>
<input type="number"
id="cols"
name="cols"
value="{{ main_config.display.hardware.cols or 64 }}"
min="1"
max="128"
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.', 'Chain Length') }}</label>
<input type="number"
id="chain_length"
name="chain_length"
value="{{ main_config.display.hardware.chain_length or 2 }}"
min="1"
max="24"
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. The Raspberry Pi supports up to 3 (some HATs allow more).', 'Parallel') }}</label>
<input type="number"
id="parallel"
name="parallel"
value="{{ main_config.display.hardware.parallel or 1 }}"
min="1"
max="4"
class="form-control">
</div>
</div>
<div class="grid grid-cols-1 md:grid-cols-2 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 (1100%).\nLower is dimmer, higher is brighter. Recommended: 7090 indoors, 90100 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 HAT/Bonnet with the PWM solder mod; "Adafruit HAT" without it; "Regular" for direct GPIO wiring.', 'Hardware Mapping') }}</label>
<select id="hardware_mapping" name="hardware_mapping" class="form-control">
<option value="adafruit-hat-pwm" {% if main_config.display.hardware.hardware_mapping == "adafruit-hat-pwm" %}selected{% endif %}>Adafruit HAT PWM</option>
<option value="adafruit-hat" {% if main_config.display.hardware.hardware_mapping == "adafruit-hat" %}selected{% endif %}>Adafruit HAT</option>
<option value="regular" {% if main_config.display.hardware.hardware_mapping == "regular" %}selected{% endif %}>Regular</option>
<option value="regular-pi1" {% if main_config.display.hardware.hardware_mapping == "regular-pi1" %}selected{% endif %}>Regular Pi1</option>
</select>
</div>
</div>
<div class="grid grid-cols-1 md:grid-cols-3 gap-4">
<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. Default: RGB.', '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 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('Pixel-mapping scheme used by outdoor/specialty panels.\nLeave at 0 (Direct) for most indoor panels. Only change if the image is scrambled — try values until it looks right.', '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.', '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.', 'Row Address Type') }}</label>
<select id="row_address_type" name="row_address_type" class="form-control">
<option value="0" {% if main_config.display.hardware.get('row_address_type', 0)|int == 0 %}selected{% endif %}>0 - Default</option>
<option value="1" {% if main_config.display.hardware.get('row_address_type', 0)|int == 1 %}selected{% endif %}>1 - AB-addressed panels</option>
<option value="2" {% if main_config.display.hardware.get('row_address_type', 0)|int == 2 %}selected{% endif %}>2 - Row direct</option>
<option value="3" {% if main_config.display.hardware.get('row_address_type', 0)|int == 3 %}selected{% endif %}>3 - ABC-addressed panels</option>
<option value="4" {% if main_config.display.hardware.get('row_address_type', 0)|int == 4 %}selected{% endif %}>4 - ABC Shift + DE direct</option>
</select>
</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 the GPIO signal so the panel keeps up.\nGuide: Pi 3 → 12, Pi 4 → 24, Pi 5 (PIO) → 13. Increase if the display shows garbage 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.runtime.gpio_slowdown or 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 Pi 3/4.', '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 &mdash; PIO (default, low CPU)</option>
<option value="1" {% if main_config.display.get('runtime', {}).get('rp1_rio', 0)|int == 1 %}selected{% endif %}>1 &mdash; 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. Change only if you see banding or flicker on certain panels.', '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 (111).\nHigher means smoother color but a lower refresh rate; lower means faster refresh with more banding. Default: 11 (this build defaults to 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-dithering to gain apparent color depth (04).\nDefault: 0. Raising it can smooth gradients at the cost of a slightly lower refresh rate.', 'PWM Dither Bits') }}</label>
<input type="number"
id="pwm_dither_bits"
name="pwm_dither_bits"
value="{{ main_config.display.hardware.pwm_dither_bits or 1 }}"
min="0"
max="4"
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('Base time for the least-significant color bit (50500 ns).\nDefault: 130. Raising it can reduce flicker on some panels but lowers the maximum refresh rate.', '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="500"
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 (11000 Hz).\nDefault: 120. A steady cap reduces flicker in camera recordings and keeps timing consistent. Set higher or to the max your panel supports for the smoothest motion.', 'Limit Refresh Rate') }}</label>
<input type="number"
id="limit_refresh_rate_hz"
name="limit_refresh_rate_hz"
value="{{ main_config.display.hardware.limit_refresh_rate_hz or 120 }}"
min="1"
max="1000"
class="form-control">
</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 &mdash; e.g. two 64&times;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="grid grid-cols-1 md:grid-cols-3 gap-4">
<div class="form-group" 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>
<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 (28).\nMust 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 &mdash; chained panels (side by side)</option>
<option value="vertical" {% if main_config.display.get('double_sided', {}).get('axis', 'horizontal') == 'vertical' %}selected{% endif %}>Vertical &mdash; parallel chains (stacked)</option>
</select>
</div>
</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('Turn off hardware PWM pulsing.\nEnable this if the Pi audio is in use or you hear buzzing / see instability. Slightly increases CPU usage.', '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('Overlay the live panel refresh rate on the display.\nUseful for tuning GPIO Slowdown and PWM settings; 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 (301800s).\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>
<!-- 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 (10200 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 inserted between each plugin block in the ticker (0128 px).\nDefault: 32. Larger values make the boundary between plugins clearer.', '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_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>
<!-- 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>
<!-- 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 (102465535).\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;
});
// Fix invalid number inputs function (if not already defined globally)
if (typeof window.fixInvalidNumberInputs !== 'function') {
window.fixInvalidNumberInputs = function(form) {
if (!form) return;
const allInputs = form.querySelectorAll('input[type="number"]');
allInputs.forEach(input => {
const min = parseFloat(input.getAttribute('min'));
const max = parseFloat(input.getAttribute('max'));
const value = parseFloat(input.value);
if (!isNaN(value)) {
if (!isNaN(min) && value < min) {
input.value = min;
} else if (!isNaN(max) && value > max) {
input.value = max;
}
}
});
};
}
// Vegas Scroll Mode Settings
(function() {
// Escape HTML to prevent XSS
function escapeHtml(text) {
const div = document.createElement('div');
div.textContent = String(text || '');
return div.innerHTML;
}
// Escape for use in HTML attributes
function escapeAttr(text) {
return escapeHtml(text).replace(/"/g, '&quot;').replace(/'/g, '&#39;');
}
// 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';
});
}
// 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
function initPluginOrderList() {
const container = document.getElementById('vegas_plugin_order');
if (!container) return;
// Fetch available plugins
fetch('/api/v3/plugins/installed')
.then(response => response.json())
.then(data => {
// Handle both {data: {plugins: []}} and {plugins: []} response formats
const allPlugins = data.data?.plugins || data.plugins || [];
if (!allPlugins || allPlugins.length === 0) {
container.innerHTML = '<p class="text-sm text-gray-500 italic">No plugins available</p>';
return;
}
// Get current order and exclusions
const orderInput = document.getElementById('vegas_plugin_order_value');
const excludedInput = document.getElementById('vegas_excluded_plugins_value');
let currentOrder = [];
let excluded = [];
try {
currentOrder = JSON.parse(orderInput.value || '[]');
excluded = JSON.parse(excludedInput.value || '[]');
} catch (e) {
console.error('Error parsing vegas config:', e);
}
// Build ordered plugin list (only enabled plugins)
const plugins = allPlugins.filter(p => p.enabled);
const orderedPlugins = [];
// First add plugins in current order
currentOrder.forEach(id => {
const plugin = plugins.find(p => p.id === id);
if (plugin) orderedPlugins.push(plugin);
});
// Then add remaining plugins
plugins.forEach(plugin => {
if (!orderedPlugins.find(p => p.id === plugin.id)) {
orderedPlugins.push(plugin);
}
});
// Build HTML with display mode indicators
let html = '';
orderedPlugins.forEach((plugin, index) => {
const isExcluded = excluded.includes(plugin.id);
// Determine display mode (from plugin config or default)
const vegasMode = plugin.vegas_mode || plugin.vegas_content_type || 'fixed';
const modeLabels = {
'scroll': { label: 'Scroll', icon: 'fa-scroll', color: 'text-blue-600' },
'fixed': { label: 'Fixed', icon: 'fa-square', color: 'text-green-600' },
'static': { label: 'Static', icon: 'fa-pause', color: 'text-orange-600' }
};
const modeInfo = modeLabels[vegasMode] || modeLabels['fixed'];
// Escape plugin metadata to prevent XSS
const safePluginId = escapeAttr(plugin.id);
const safePluginName = escapeHtml(plugin.name || plugin.id);
html += `
<div class="flex items-center p-2 bg-gray-50 rounded border border-gray-200 cursor-move vegas-plugin-item"
data-plugin-id="${safePluginId}" draggable="true">
<i class="fas fa-grip-vertical text-gray-400 mr-3"></i>
<label class="flex items-center flex-1">
<input type="checkbox"
class="vegas-plugin-include h-4 w-4 text-blue-600 focus:ring-blue-500 border-gray-300 rounded mr-2"
${!isExcluded ? 'checked' : ''}>
<span class="text-sm font-medium text-gray-700">${safePluginName}</span>
</label>
<span class="text-xs ${modeInfo.color} ml-2" title="Vegas display mode: ${modeInfo.label}">
<i class="fas ${modeInfo.icon} mr-1"></i>${modeInfo.label}
</span>
</div>
`;
});
container.innerHTML = html || '<p class="text-sm text-gray-500 italic">No enabled plugins</p>';
// Setup drag and drop
setupDragAndDrop(container);
// Setup checkbox handlers
container.querySelectorAll('.vegas-plugin-include').forEach(checkbox => {
checkbox.addEventListener('change', updatePluginConfig);
});
// Initialize hidden inputs with current state
updatePluginConfig();
})
.catch(error => {
console.error('Error fetching plugins:', error);
container.innerHTML = '<p class="text-sm text-red-500">Error loading plugins</p>';
});
}
function setupDragAndDrop(container) {
let draggedItem = null;
container.querySelectorAll('.vegas-plugin-item').forEach(item => {
item.addEventListener('dragstart', function(e) {
draggedItem = this;
this.style.opacity = '0.5';
e.dataTransfer.effectAllowed = 'move';
});
item.addEventListener('dragend', function() {
this.style.opacity = '1';
draggedItem = null;
updatePluginConfig();
});
item.addEventListener('dragover', function(e) {
e.preventDefault();
e.dataTransfer.dropEffect = 'move';
const rect = this.getBoundingClientRect();
const midY = rect.top + rect.height / 2;
if (e.clientY < midY) {
this.style.borderTop = '2px solid #3b82f6';
this.style.borderBottom = '';
} else {
this.style.borderBottom = '2px solid #3b82f6';
this.style.borderTop = '';
}
});
item.addEventListener('dragleave', function() {
this.style.borderTop = '';
this.style.borderBottom = '';
});
item.addEventListener('drop', function(e) {
e.preventDefault();
this.style.borderTop = '';
this.style.borderBottom = '';
if (draggedItem && draggedItem !== this) {
const rect = this.getBoundingClientRect();
const midY = rect.top + rect.height / 2;
if (e.clientY < midY) {
container.insertBefore(draggedItem, this);
} else {
container.insertBefore(draggedItem, this.nextSibling);
}
}
});
});
}
function updatePluginConfig() {
const container = document.getElementById('vegas_plugin_order');
const orderInput = document.getElementById('vegas_plugin_order_value');
const excludedInput = document.getElementById('vegas_excluded_plugins_value');
if (!container || !orderInput || !excludedInput) return;
const order = [];
const excluded = [];
container.querySelectorAll('.vegas-plugin-item').forEach(item => {
const pluginId = item.dataset.pluginId;
const checkbox = item.querySelector('.vegas-plugin-include');
order.push(pluginId);
if (checkbox && !checkbox.checked) {
excluded.push(pluginId);
}
});
orderInput.value = JSON.stringify(order);
excludedInput.value = JSON.stringify(excluded);
}
// 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 and polling — guard against duplicate intervals on re-run
function startSyncPolling() {
updateSyncUI();
if (!window.syncStatusInterval) {
window.syncStatusInterval = setInterval(pollSyncStatus, 5000);
}
}
if (document.readyState === 'loading') {
document.addEventListener('DOMContentLoaded', startSyncPolling);
} else {
startSyncPolling();
}
})();
</script>