Skip to content

Commit fd57fcb

Browse files
fhirschmannclaude
andcommitted
feat: configurable header/footer branding + move RFID reader settings to RFID tab
- Branding (General -> Branding): custom navbar header title and footer brand text, stored in NVS (uiHeader/uiFooter), applied live in fillSettings via applyBranding(). Empty keeps the 'Leo Industries' default, so the fork is easy to re-brand. - Moved the RFID reader settings (reader type, MFRC522 gain, PN5180 LPCD, SLIX2 password) out of the General tab into the RFID settings sub-tab, next to the card-learning UI. Pure DOM relocation (saved/filled by element id as before); the auto-save delegated handler now also covers #settings-rfid (the learn-action dropdown is excluded via data-no-autosave). i18n de/en/fr. Verified on the test device via Playwright: reader fields now live in the RFID tab and are gone from General; branding fields present; main file tab reads Files/Dateien and the settings sub-tab reads RFID; applyBranding updates navbar + footer live; branding persists via settings round-trip (reset afterwards); no console errors. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
1 parent 99ffb5b commit fd57fcb

6 files changed

Lines changed: 111 additions & 42 deletions

File tree

README.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -141,6 +141,7 @@ neon logo that doubles as the SVG favicon ([`7be5254`](../../commit/7be5254)):
141141
| **RFID settings sub-tab + learned-cards list**: the old inline RFID-assignment form (id field + play-mode + submit) is gone — music cards are learned from the file-browser context menu (scan popup). A new **RFID** settings sub-tab learns **modification cards** (pick a modification action → **Learn card** → scan popup) and lists **all learned cards** (music = path + play mode, modification = action) with a delete button each. i18n de/en/fr | [`rfid-settings-tab`](../../releases/tag/rfid-settings-tab) |
142142
| **Listening-time statistics**: tracks how long was actually listened per local calendar day in a 365-day ring buffer (persisted in NVS, ~1.5 KB). The info dialog (ℹ️) shows **today / yesterday / last 7 days / last 30 days** next to the existing total playtime. Seconds are accumulated by the audio loop while playing (clock from NTP/RTC; ignored until the clock is valid), flushed to NVS at most once a minute and on shutdown. New `/info` audio fields `playToday`/`playYesterday`/`play7d`/`play30d` (seconds). i18n de/en/fr | [`listening-stats`](../../releases/tag/listening-stats) |
143143
| **RFID-tag syncing (server + peer-to-peer)**: keeps RFID-tag assignments in sync across a central PHP server *and* other ESPuinos at the same time. On **learn** a freshly assigned card is pushed to the server and to every configured peer; a **full sync** (button in the Sync settings tab, bindable command **187**, or MQTT `rfid_sync`) pulls the server list, merges newest-wins per tag id (by timestamp) and pushes every local tag back to server + peers. A **catch-up** sync runs automatically once after the device comes online, so a player used **offline/outdoors** (no WiFi, no server) keeps working — cards learned offline are stored locally and pushed up on reconnect (assignments made while the clock was never set are healed to "now" on first sync so they win). Conflict resolution is **newest-wins** by timestamp. Server endpoint is the dynamic [`server/manifest.php`](server/manifest.php) (same script that serves the file-sync manifest; `?type=rfid` returns just the tag list); peers are a comma-separated list of other ESPuino hosts in the Sync settings tab. i18n de/en/fr | [`rfid-sync`](../../releases/tag/rfid-sync) |
144+
| **Configurable branding + RFID settings tidy-up**: the navbar **header** title and the **footer** brand are now configurable (General → Branding) — empty keeps the "Leo Industries" default, so the fork is easy to re-brand. The RFID **reader** settings (reader type, MFRC522 gain, PN5180 LPCD, SLIX2 password) moved out of the General tab into the **RFID** settings sub-tab where they belong, next to the card-learning UI. Both are covered by the General/RFID-tab auto-save. i18n de/en/fr | [`ui-branding`](../../releases/tag/ui-branding) |
144145
| **OpenAPI/Swagger spec updated**: the bundled `REST_API.yaml` (served at `/swagger.html`) now documents all fork-added endpoints (`/sync`, `/syncstop`, `/playlist`, `/githubupdate`, `/version`, `/login`, `/logout`, `/security`, `/eqrule(s)`, `/activeequalizer`, `/bluetooth*`, `/sdclean`) and declares the security schemes (`X-API-Key` header / `apikey` query param, or the `ESPUINO_SESSION` login cookie) so the Swagger "Authorize" button works. The web-interface footer links to the Swagger UI (`/swagger.html`) | [`f8298d9`](../../commit/f8298d9) |
145146

146147
#### Feature highlights

html/locales/de.json

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -618,6 +618,14 @@
618618
"strnatcmp": "Natürlich: Groß- und Kleinschreibung beachten",
619619
"strnatcasecmp": "Natürlich: Groß- und Kleinschreibung ignorieren",
620620
"recDepth": "Maximale Rekursionstiefe beim Generieren der Rekursiv-Playlists"
621+
},
622+
"branding": {
623+
"title": "Branding",
624+
"header": "Kopfzeile (Header-Titel)",
625+
"headerPlaceholder": "z.B. Mein Player",
626+
"footer": "Fußzeile (Marke)",
627+
"footerPlaceholder": "z.B. Mein Player",
628+
"hint": "Leer lassen für den Standard. Ersetzt „Leo Industries“ in Kopf- und Fußzeile."
621629
}
622630
},
623631
"tools": {

html/locales/en.json

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -618,6 +618,14 @@
618618
"strnatcmp": "Natural: case-sensitive",
619619
"strnatcasecmp": "Natural: case-insensitive",
620620
"recDepth": "Maximum recursion depth at generation of recursive playlists"
621+
},
622+
"branding": {
623+
"title": "Branding",
624+
"header": "Header title",
625+
"headerPlaceholder": "e.g. My Player",
626+
"footer": "Footer brand",
627+
"footerPlaceholder": "e.g. My Player",
628+
"hint": "Leave empty for the default. Replaces \"Leo Industries\" in the header and footer."
621629
}
622630
},
623631
"tools": {

html/locales/fr.json

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -607,6 +607,14 @@
607607
"strnatcmp": "Naturel: sensible à la casse",
608608
"strnatcasecmp": "Naturel: insensible à la casse",
609609
"recDepth": "Profondeur de récursion maximale lors de la génération de playlists récursives"
610+
},
611+
"branding": {
612+
"title": "Branding",
613+
"header": "Titre d'en-tête",
614+
"headerPlaceholder": "p.ex. Mon lecteur",
615+
"footer": "Marque du pied de page",
616+
"footerPlaceholder": "p.ex. Mon lecteur",
617+
"hint": "Laisser vide pour la valeur par défaut. Remplace « Leo Industries » dans l'en-tête et le pied de page."
610618
}
611619
},
612620
"tools": {

html/management.html

Lines changed: 78 additions & 42 deletions
Original file line numberDiff line numberDiff line change
@@ -2172,48 +2172,19 @@ <h5 class="modal-title" data-i18n="tools.nvs.erase.title"></h5>
21722172
</div>
21732173
</fieldset>
21742174
</div>
2175-
<div class="col-md-12 mb-4" id="rfidConfig">
2175+
<div class="col-md-12 mb-4" id="brandingConfig">
21762176
<fieldset>
2177-
<legend class="w-auto" data-i18n="general.rfid.title"></legend><br>
2177+
<legend class="w-auto" data-i18n="general.branding.title"></legend>
21782178
<div class="mb-3">
2179-
<label for="rfidReaderType" data-i18n="general.rfid.readerType.title"></label>
2180-
<select class="form-control form-select" id="rfidReaderType" name="rfidReaderType">
2181-
<option value="0" data-i18n="general.rfid.readerType.auto">Auto-detect</option>
2182-
<option value="1" data-i18n="general.rfid.readerType.mfrc522_spi">MFRC522 (SPI)</option>
2183-
<option value="2" data-i18n="general.rfid.readerType.mfrc522_i2c">MFRC522 (I2C)</option>
2184-
<option value="3" data-i18n="general.rfid.readerType.pn5180">PN5180</option>
2185-
</select>
2186-
<small class="form-text text-muted" data-i18n="general.rfid.description"></small>
2179+
<label for="headerText" data-i18n="general.branding.header"></label>
2180+
<input type="text" class="form-control" id="headerText" name="headerText" maxlength="40"
2181+
data-i18n="[placeholder]general.branding.headerPlaceholder">
21872182
</div>
21882183
<div class="mb-3">
2189-
<label for="mfrc522Gain" data-i18n="general.rfid.mfrc522Gain"></label>
2190-
<div class="input-group">
2191-
<input type="number" class="form-control" id="mfrc522Gain" name="mfrc522Gain" min="0" max="7">
2192-
<span class="input-group-text bg-transparent border-0">
2193-
<a href="#" class="link-secondary" data-bs-toggle="popover"
2194-
data-i18n="[data-bs-content]general.rfid.mfrc522GainExp" tabindex="0">
2195-
<i class="fas fa-circle-question"></i></a>
2196-
</span>
2197-
</div>
2198-
</div>
2199-
<div class="d-flex gap-2">
2200-
<input type="checkbox" id="pn5180Lpcd" name="pn5180Lpcd" value="false">
2201-
<label for="pn5180Lpcd" data-i18n="general.rfid.pn5180Lpcd"></label>
2202-
<a href="#" class="link-secondary" data-bs-toggle="popover"
2203-
data-i18n="[data-bs-content]general.rfid.pn5180LpcdExp" tabindex="0"><i
2204-
class="fas fa-circle-question"></i></a>
2205-
</div><br>
2206-
<div class="mb-3">
2207-
<label for="slix2Password" data-i18n="general.rfid.slix2Password.title"></label>
2208-
<div class="input-group">
2209-
<input type="text" class="form-control" id="slix2Password" name="slix2Password"
2210-
maxlength="8" placeholder="e.g. 01020304">
2211-
<span class="input-group-text bg-transparent border-0">
2212-
<a href="#" class="link-secondary" data-bs-toggle="popover"
2213-
data-i18n="[data-bs-content]general.rfid.slix2Password.description" tabindex="0">
2214-
<i class="fas fa-circle-question"></i></a>
2215-
</span>
2216-
</div>
2184+
<label for="footerText" data-i18n="general.branding.footer"></label>
2185+
<input type="text" class="form-control" id="footerText" name="footerText" maxlength="40"
2186+
data-i18n="[placeholder]general.branding.footerPlaceholder">
2187+
<small class="form-text text-muted" data-i18n="general.branding.hint"></small>
22172188
</div>
22182189
</fieldset>
22192190
</div>
@@ -2711,13 +2682,56 @@ <h5 class="modal-title" data-i18n="tools.nvs.erase.title"></h5>
27112682
</div>
27122683
<div class="tab-pane fade" id="settings-rfid" role="tabpanel" aria-labelledby="settings-rfid-subtab">
27132684
<div class="container">
2685+
<fieldset id="rfidConfig">
2686+
<legend class="w-auto" data-i18n="general.rfid.title"></legend>
2687+
<div class="mb-3">
2688+
<label for="rfidReaderType" data-i18n="general.rfid.readerType.title"></label>
2689+
<select class="form-control form-select" id="rfidReaderType" name="rfidReaderType">
2690+
<option value="0" data-i18n="general.rfid.readerType.auto">Auto-detect</option>
2691+
<option value="1" data-i18n="general.rfid.readerType.mfrc522_spi">MFRC522 (SPI)</option>
2692+
<option value="2" data-i18n="general.rfid.readerType.mfrc522_i2c">MFRC522 (I2C)</option>
2693+
<option value="3" data-i18n="general.rfid.readerType.pn5180">PN5180</option>
2694+
</select>
2695+
<small class="form-text text-muted" data-i18n="general.rfid.description"></small>
2696+
</div>
2697+
<div class="mb-3">
2698+
<label for="mfrc522Gain" data-i18n="general.rfid.mfrc522Gain"></label>
2699+
<div class="input-group">
2700+
<input type="number" class="form-control" id="mfrc522Gain" name="mfrc522Gain" min="0" max="7">
2701+
<span class="input-group-text bg-transparent border-0">
2702+
<a href="#" class="link-secondary" data-bs-toggle="popover"
2703+
data-i18n="[data-bs-content]general.rfid.mfrc522GainExp" tabindex="0">
2704+
<i class="fas fa-circle-question"></i></a>
2705+
</span>
2706+
</div>
2707+
</div>
2708+
<div class="d-flex gap-2">
2709+
<input type="checkbox" id="pn5180Lpcd" name="pn5180Lpcd" value="false">
2710+
<label for="pn5180Lpcd" data-i18n="general.rfid.pn5180Lpcd"></label>
2711+
<a href="#" class="link-secondary" data-bs-toggle="popover"
2712+
data-i18n="[data-bs-content]general.rfid.pn5180LpcdExp" tabindex="0"><i
2713+
class="fas fa-circle-question"></i></a>
2714+
</div><br>
2715+
<div class="mb-3">
2716+
<label for="slix2Password" data-i18n="general.rfid.slix2Password.title"></label>
2717+
<div class="input-group">
2718+
<input type="text" class="form-control" id="slix2Password" name="slix2Password"
2719+
maxlength="8" placeholder="e.g. 01020304">
2720+
<span class="input-group-text bg-transparent border-0">
2721+
<a href="#" class="link-secondary" data-bs-toggle="popover"
2722+
data-i18n="[data-bs-content]general.rfid.slix2Password.description" tabindex="0">
2723+
<i class="fas fa-circle-question"></i></a>
2724+
</span>
2725+
</div>
2726+
</div>
2727+
</fieldset>
27142728
<fieldset>
27152729
<legend class="w-auto" data-i18n="settingsex.rfid.modTitle"></legend>
27162730
<p class="small text-secondary" data-i18n="settingsex.rfid.modDesc"></p>
27172731
<div class="row gx-2 align-items-end mb-2">
27182732
<div class="col-12 col-md-8 mb-2">
27192733
<label class="form-label small mb-0" for="modIdLearn" data-i18n="files.rfid.mod.title"></label>
2720-
<select id="modIdLearn" class="form-select form-select-sm"></select>
2734+
<select id="modIdLearn" class="form-select form-select-sm" data-no-autosave></select>
27212735
</div>
27222736
<div class="col-12 col-md-4 mb-2">
27232737
<button type="button" class="btn btn-primary w-100" onclick="learnModCard()"><i class="fas fa-id-card"></i> <span data-i18n="settingsex.rfid.learnBtn"></span></button>
@@ -3195,7 +3209,7 @@ <h5 class="modal-title" data-i18n="tools.nvs.erase.title"></h5>
31953209
</div>
31963210
<footer id="cp-footer">
31973211
<div class="cp-footer-inner">
3198-
<span class="cp-footer-brand">LEO&nbsp;INDUSTRIES</span>
3212+
<span class="cp-footer-brand" id="cp-footer-brand">LEO&nbsp;INDUSTRIES</span>
31993213
<span class="cp-footer-sep">//</span>
32003214
<span class="cp-footer-div">DIVISION&nbsp;:&nbsp;AUDIO</span>
32013215
<span class="cp-footer-sep">//</span>
@@ -3207,7 +3221,7 @@ <h5 class="modal-title" data-i18n="tools.nvs.erase.title"></h5>
32073221
<a class="cp-footer-link" href="/swagger.html" target="_blank"
32083222
rel="noopener"><i class="fas fa-code"></i>&nbsp;API</a>
32093223
</div>
3210-
<div class="cp-footer-copy">&copy; 2026 LEO INDUSTRIES &middot; ALL SIGNALS RESERVED &middot; NEON DREAMS RUN ON ESP32</div>
3224+
<div class="cp-footer-copy" id="cp-footer-copy">&copy; 2026 LEO INDUSTRIES &middot; ALL SIGNALS RESERVED &middot; NEON DREAMS RUN ON ESP32</div>
32113225
</footer>
32123226
<script type="text/javascript">
32133227
var lastIdclicked = '';
@@ -4663,6 +4677,13 @@ <h5 class="modal-title" data-i18n="tools.nvs.erase.title"></h5>
46634677
if (genSettings.poweredVoltage !== undefined) {
46644678
$('#poweredVoltage').val(genSettings.poweredVoltage);
46654679
}
4680+
if (genSettings.headerText !== undefined) {
4681+
document.getElementById('headerText').value = genSettings.headerText;
4682+
}
4683+
if (genSettings.footerText !== undefined) {
4684+
document.getElementById('footerText').value = genSettings.footerText;
4685+
}
4686+
applyBranding(genSettings.headerText, genSettings.footerText);
46664687
$('#volumeCurve').prop('checked', genSettings.volumeCurve > 0);
46674688
if (genSettings.rfidReaderType !== undefined) {
46684689
$('#rfidReaderType').val(genSettings.rfidReaderType);
@@ -4965,7 +4986,7 @@ <h5 class="modal-title" data-i18n="tools.nvs.erase.title"></h5>
49654986
if (c) c.addEventListener("shown.bs.collapse", rebuildEqRulesList);
49664987
// Auto-save the General + Buttons tabs on any change (delegated, so the dynamically
49674988
// populated button dropdowns are covered too). No submit button on these tabs.
4968-
$('#settings-general, #settings-buttons').on('change slideStop', 'input, select, textarea', autoSaveGeneralSettings);
4989+
$('#settings-general, #settings-buttons, #settings-rfid').on('change slideStop', 'input:not([data-no-autosave]), select:not([data-no-autosave]), textarea', autoSaveGeneralSettings);
49694990
// Refresh the learned-cards list whenever the RFID settings sub-tab is shown.
49704991
var rfidSubtab = document.getElementById('settings-rfid-subtab');
49714992
if (rfidSubtab) { rfidSubtab.addEventListener('shown.bs.tab', rebuildLearnedList); }
@@ -5140,6 +5161,19 @@ <h5 class="modal-title" data-i18n="tools.nvs.erase.title"></h5>
51405161
return parseInt(input.replace(/^#([\da-f])([\da-f])([\da-f])$/i, '#$1$1$2$2$3$3').substring(1), 16);
51415162
};
51425163

5164+
// Apply a custom header (navbar brand) and footer brand. Empty values keep the defaults.
5165+
function applyBranding(header, footer) {
5166+
if (header !== undefined && header !== null && header.trim() !== "") {
5167+
document.getElementById('navbar-brand-co').textContent = header;
5168+
}
5169+
if (footer !== undefined && footer !== null && footer.trim() !== "") {
5170+
var brand = document.getElementById('cp-footer-brand');
5171+
if (brand) { brand.textContent = footer; }
5172+
var copy = document.getElementById('cp-footer-copy');
5173+
if (copy) { copy.innerHTML = '© ' + new Date().getFullYear() + ' ' + escapeHtml(footer); }
5174+
}
5175+
}
5176+
51435177
async function genSettings(clickedId) {
51445178
lastIdclicked = clickedId;
51455179
var myObj = {
@@ -5158,6 +5192,8 @@ <h5 class="modal-title" data-i18n="tools.nvs.erase.title"></h5>
51585192
recoverVolBoot: $('#recoverVolBoot').prop('checked'),
51595193
noSleepWhenPowered: $('#noSleepWhenPowered').prop('checked'),
51605194
poweredVoltage: Number($('#poweredVoltage').val()),
5195+
headerText: document.getElementById('headerText').value,
5196+
footerText: document.getElementById('footerText').value,
51615197
volumeCurve: $("#volumeCurve").prop('checked') ? 1 : 0,
51625198
rfidReaderType: Number($('#rfidReaderType').val()),
51635199
pn5180Lpcd: $('#pn5180Lpcd').prop('checked'),

src/Web.cpp

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1139,6 +1139,12 @@ WebsocketCodeType JSONToSettings(JsonObject doc) {
11391139
gPrefsSettings.putFloat("pwrSleepVolt", generalObj["poweredVoltage"].as<float>());
11401140
}
11411141
System_ReloadSleepSettings(); // apply the "no sleep while powered" change without a reboot
1142+
if (generalObj["headerText"].is<const char *>()) {
1143+
gPrefsSettings.putString("uiHeader", generalObj["headerText"].as<const char *>());
1144+
}
1145+
if (generalObj["footerText"].is<const char *>()) {
1146+
gPrefsSettings.putString("uiFooter", generalObj["footerText"].as<const char *>());
1147+
}
11421148
if (generalObj["readyPath"].is<const char *>()) {
11431149
const char *path = generalObj["readyPath"].as<const char *>();
11441150
size_t written = gPrefsSettings.putString("readyPath", path);
@@ -1560,6 +1566,8 @@ static void settingsToJSON(JsonObject obj, const String section) {
15601566
generalObj["readyPath"].set(gPrefsSettings.getString("readyPath", "/ready.mp3")); // READY_PATH
15611567
generalObj["noSleepWhenPowered"].set(gPrefsSettings.getBool("noSleepPwr", false)); // stay awake on external power
15621568
generalObj["poweredVoltage"].set(gPrefsSettings.getFloat("pwrSleepVolt", 3.5f)); // voltage threshold for "powered"
1569+
generalObj["headerText"].set(gPrefsSettings.getString("uiHeader", "")); // custom navbar brand (empty = default)
1570+
generalObj["footerText"].set(gPrefsSettings.getString("uiFooter", "")); // custom footer brand (empty = default)
15631571
}
15641572
if ((section == "") || (section == "equalizer")) {
15651573
// equalizer settings

0 commit comments

Comments
 (0)