mirror of
https://github.com/ChuckBuilds/LEDMatrix.git
synced 2026-08-01 08:48:05 +00:00
Cols already allowed up to 128; Rows was capped at 64, which rejects valid larger panel configurations (e.g. 128-row tile chains). No server-side schema enforces a rows max, so this was purely an overly-strict HTML input attribute.
785 lines
53 KiB
HTML
785 lines
53 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="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="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 inserted between each plugin block in the ticker (0–128 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>
|
||
|
||
<!-- 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="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 (2–8).\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 — 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;
|
||
}
|
||
}
|
||
});
|
||
};
|
||
}
|
||
|
||
// 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';
|
||
});
|
||
}
|
||
|
||
// 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>
|