mirror of
https://github.com/ChuckBuilds/LEDMatrix.git
synced 2026-08-01 16:58:06 +00:00
On a wide panel Vegas mode spent much of its time showing black. At 50px/s on a 512px display, one display width of blank is 10.2 seconds, which makes several long-standing behaviours expensive: - ScrollHelper prepended a full display width of black as an "initial gap", charged once per cycle — 10.2s of black at the start of every rotation. - Plugins without get_vegas_content() are captured off a full-display canvas, so their blank margins entered the ticker too. Measured: of-the-day drew 35px of "No Data" on a 512px canvas (92% blank), youtube-stats 142px of content with 185px of black either side. Only the scroll_helper path had any trimming. - Cycle transitions deliberately pushed a blank frame and then recomposed synchronously: 84ms at best, 4.8s at worst, every millisecond of it black. - buffer_ahead doubled as the cycle size, so a 21-plugin install showed 3 plugins per cycle and took ~7 cycles to come around. - separator_width was applied between every image rather than at plugin boundaries, so a per-row ticker like the F1 scoreboard (116 images, which it renders 4px apart internally) got a 32px chasm between each row — and the width budget didn't count those gaps, so the plugin quietly occupied far more of the panel than intended. Changes: - src/vegas_mode/geometry.py: numpy column-ink primitives shared by the trimmer and the audit tool, so the number reported is the number acted on. A Python per-column loop over a 17,000px strip is far too slow for the render path. - PluginAdapter trims every content path, not just scroll_helper. Only outer edges are cropped: interior blank columns are the plugin's own layout (logo left, score right) and closing them would corrupt the design. A plugin on a non-black background is inherently unaffected. - ScrollHelper.create_scrolling_image takes an explicit lead_gap, still defaulting to display_width so the many standalone-ticker callers are unchanged. Vegas passes lead_in_width (default 0). - Cycle end holds the last rendered frame instead of blanking, turning the recompose into a brief freeze rather than the panel switching off. - plugins_per_cycle (default 6) is split from buffer_ahead, which goes back to being only a prefetch low-water mark. - max_plugin_width_ratio (default 3x display width) caps one plugin's share of a cycle. Overflow is deferred, not discarded: a rotation offset advances each fetch so later rows appear on subsequent cycles. Single oversized images are cropped at a blank column so the cut misses glyphs. - Composition groups images by plugin: rows are joined by intra_plugin_gap (default 8) and separator_width applies only between plugins. The width budget now counts those gaps. - Plugin data updates no longer run on the Vegas render path. All new settings are user-configurable in Display -> Vegas Scroll, including min/max cycle duration and dynamic duration, which previously existed in code but were reachable only by hand-editing config.json. Measured with scripts/dev/vegas_audit.py on a 512x64 panel: mean ink coverage 42.7% -> 69.4% fully blank 5.9% -> 0% reads as empty 13.6% -> 0% worst blank stretch 4.8s -> 0s full rotation 414s -> 123s plugins per cycle 3 -> 6 Note the metric choice: a "fully blank" scan (>=95% black viewport) reported only 0.4% and badly understated the problem, because two full-width segments with mid-canvas content never fully blank the viewport — they hold it at ~28%. window_coverage_stats grades every viewport position by how much ink it carries, which is what tracks perceived dead time. Known remaining: cycle transitions still freeze ~3.5s while the next cycle is fetched. Fixing that needs background prefetch, which is deferred because the fallback-capture path mutates the shared display_manager.image and racing it against the render loop risks torn frames. Co-Authored-By: Claude <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01KEZK1P1Q1fu5pcuVrkrCFZ
967 lines
66 KiB
HTML
967 lines
66 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: '' }"
|
||
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="#" @click.prevent="activeTab = '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="showDisplaySaveResult(event.detail.xhr)"
|
||
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="128"
|
||
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>
|
||
|
||
<!-- 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 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 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>
|
||
|
||
<!-- 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('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 → 1–2, Pi 4 → 2–4, Pi 5 (PIO) → 1–3. 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 — 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. 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 (1–11).\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 (0–4).\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 (50–500 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 (1–1000 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>
|
||
|
||
<!-- 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 (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).
|
||
(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;
|
||
}
|
||
out.textContent = (v.cols * v.chain_length) + ' × ' + (v.rows * v.parallel) + ' pixels';
|
||
}
|
||
for (const id of ids) {
|
||
const el = document.getElementById(id);
|
||
if (el) el.addEventListener('input', 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('Gap between rows contributed by the same plugin (0–128 px).\nDefault: 8. Multi-row plugins such as sports scoreboards, news feeds and the F1 standings return one image per row; this keeps those rows close together while Separator Width still marks the jump to the next plugin. Set 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>
|
||
|
||
<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="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_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: 3. A long ticker such as a news feed or leaderboard is trimmed to this and the remainder shown on later cycles, so one plugin cannot hold the display for minutes. Set 0 for no limit.', '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', 3.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>
|
||
|
||
<!-- 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;
|
||
});
|
||
|
||
// 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;
|
||
}
|
||
}
|
||
});
|
||
};
|
||
}
|
||
|
||
// Report the outcome of a display-settings save. XMLHttpRequest has no
|
||
// `responseJSON` (that's a jQuery property) — read `responseText` and the real
|
||
// status code, otherwise a failed save reports success.
|
||
window.showDisplaySaveResult = function(xhr) {
|
||
// Only 2xx counts as saved. A network failure reports status 0, which any
|
||
// `>= 400` test would wave through as success.
|
||
const httpSuccess = xhr.status >= 200 && xhr.status < 300;
|
||
let message = httpSuccess
|
||
? 'Display settings saved'
|
||
: 'Display settings were not saved. Check your connection and try again.';
|
||
let status = httpSuccess ? 'success' : 'error';
|
||
try {
|
||
const data = JSON.parse(xhr.responseText);
|
||
if (data.message) message = data.message;
|
||
// A body can refine a successful verdict but never overturn a failed one.
|
||
if (httpSuccess && data.status) status = data.status;
|
||
} catch {
|
||
// Non-JSON body — fall back to the status-code verdict above.
|
||
}
|
||
showNotification(message, status);
|
||
};
|
||
|
||
// 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, '"').replace(/'/g, ''');
|
||
}
|
||
|
||
// 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 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>
|