Skip to content

Commit fda14a3

Browse files
fhirschmannclaude
andcommitted
feat: now-playing info dialog in Control tab + neutral 'unlocked' button
- New info icon next to 'Current track' opens a dialog with the full track detail: ID3 title/artist/album, embedded cover, file path + size, play mode, track x/y, position/duration and the RFID tag that started the playlist. - Backend: GET /currenttrack returns this on demand (guarded for the idle state), so the frequent trackinfo websocket stays lean. File size via gFSystem; RFID tag from gPlayProperties.playRfidTag; duration/position from the existing getters. - UI: #modalTrackInfo dialog + showTrackInfo() (cover thumbnail + key/value table, empty fields omitted). i18n de/en/fr (control.trackinfo.*). - Also: '.cp-lock-button.unlocked' now uses the neutral dimmed styling of the other inactive control buttons (only '.locked' stays magenta), fixing the inconsistency where the default unlocked state looked 'active'. Verified on the test device with a tagged MP3 (uploaded/played/removed): the dialog shows Title/Artist/Album, File, Size 12 KB, Play mode, Position 00:01 / 00:03; idle returns active:false; no console errors. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
1 parent ce3116c commit fda14a3

6 files changed

Lines changed: 147 additions & 8 deletions

File tree

README.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -143,6 +143,7 @@ neon logo that doubles as the SVG favicon ([`7be5254`](../../commit/7be5254)):
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. Two dynamic server scripts (no static manifest.json): [`server/manifest.php`](server/manifest.php) lists the files for the file sync, [`server/rfid.php`](server/rfid.php) is the stateful RFID-tag store — both are linked from the Sync settings tab. Peers are managed in a small **add-peer editor** (IP/hostname + password, with a reveal toggle) and stored as JSON; since ESPuinos are password-protected, the peer push authenticates with each peer's password via the `X-API-Key` header (falling back to this device's web password). The Sync settings tab is split into **File sync** and **RFID-tag sync** sections (each Save button disabled until its URL is filled), and both runners (file sync + RFID full sync) live in the **Tools** tab. The file browser badges a file/folder (after the name) when it has an RFID assignment (tag icon) or a per-path EQ rule (sliders icon), and the explorer context menu can assign/remove both. i18n de/en/fr | [`rfid-sync`](../../releases/tag/rfid-sync) |
144144
| **Configurable branding + RFID settings tidy-up**: the navbar **header** title and the **footer** brand are now configurable (General → Branding) with a **live preview** as you type — empty keeps the "Leo Industries" default, so the fork is easy to re-brand. The Sync settings tab is split into two clear sections (**File sync** and **RFID-tag sync**). 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) |
145145
| **Now-playing: ID3 artist + album**: the Control tab already showed the ID3 **title** and embedded **cover** (APIC); it now also parses the **artist** (`TPE1`/`ARTIST=`) and **album** (`TALB`/`ALBUM=`) tags and shows them as "Artist — Album" under the title. The fields are reset per track (so a tag-less track doesn't show stale metadata) and exposed in the `trackinfo` websocket payload (`artist`/`album`) | [`id3-meta`](../../releases/tag/id3-meta) |
146+
| **Now-playing info dialog**: an info icon next to "Current track" in the Control tab opens a dialog with the full track detail — ID3 title/artist/album, embedded cover, file path + size, play mode, track x/y, position/duration and the RFID tag that started the playlist. Served on demand via a new `GET /currenttrack` (so it doesn't bloat the frequent `trackinfo` websocket). Also: the **Unlocked** control button now uses the neutral (inactive) styling like the other toggles — only **Locked** keeps an accent colour. i18n de/en/fr | [`trackinfo-dialog`](../../releases/tag/trackinfo-dialog) |
146147
| **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) |
147148

148149
#### Feature highlights

html/locales/de.json

Lines changed: 16 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -198,7 +198,22 @@
198198
"sleepOff": "Timer Aus",
199199
"sleepEOT": "Nach Track",
200200
"sleepEOP": "Nach Playlist",
201-
"sleepDisable": "Timer beenden"
201+
"sleepDisable": "Timer beenden",
202+
"trackinfo": {
203+
"title": "Titel-Info",
204+
"none": "Es wird gerade nichts abgespielt.",
205+
"artist": "Interpret",
206+
"album": "Album",
207+
"path": "Datei",
208+
"size": "Größe",
209+
"track": "Track",
210+
"playmode": "Wiedergabemodus",
211+
"position": "Position",
212+
"rfid": "RFID-Karte",
213+
"type": "Typ",
214+
"webstream": "Webradio",
215+
"songTitle": "Titel"
216+
}
202217
},
203218
"files": {
204219
"title": "Dateien",

html/locales/en.json

Lines changed: 16 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -198,7 +198,22 @@
198198
"sleepOff": "Sleep Off",
199199
"sleepEOT": "After Track",
200200
"sleepEOP": "After Playlist",
201-
"sleepDisable": "Disable Timer"
201+
"sleepDisable": "Disable Timer",
202+
"trackinfo": {
203+
"title": "Track info",
204+
"none": "Nothing is playing right now.",
205+
"artist": "Artist",
206+
"album": "Album",
207+
"path": "File",
208+
"size": "Size",
209+
"track": "Track",
210+
"playmode": "Play mode",
211+
"position": "Position",
212+
"rfid": "RFID card",
213+
"type": "Type",
214+
"webstream": "Webradio",
215+
"songTitle": "Title"
216+
}
202217
},
203218
"files": {
204219
"title": "Files",

html/locales/fr.json

Lines changed: 16 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -191,7 +191,22 @@
191191
"sleepOff": "Minuterie Off",
192192
"sleepEOT": "Après Piste",
193193
"sleepEOP": "Après Playlist",
194-
"sleepDisable": "Désactiver minuterie"
194+
"sleepDisable": "Désactiver minuterie",
195+
"trackinfo": {
196+
"title": "Infos du titre",
197+
"none": "Rien n'est en cours de lecture.",
198+
"artist": "Artiste",
199+
"album": "Album",
200+
"path": "Fichier",
201+
"size": "Taille",
202+
"track": "Piste",
203+
"playmode": "Mode de lecture",
204+
"position": "Position",
205+
"rfid": "Carte RFID",
206+
"type": "Type",
207+
"webstream": "Webradio",
208+
"songTitle": "Titre"
209+
}
195210
},
196211
"files": {
197212
"title": "Fichiers",

html/management.html

Lines changed: 63 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -730,11 +730,13 @@
730730
box-sizing: border-box;
731731
}
732732

733+
/* Unlocked is the neutral/default state -> look like the other (inactive) control buttons,
734+
so it no longer stands out as if it were "active". Only "locked" gets an accent colour. */
733735
.cp-lock-button.unlocked {
734-
color: var(--cp-cyan);
735-
border: 1px solid rgba(0, 240, 255, .4);
736-
background: rgba(0, 240, 255, .05);
737-
box-shadow: 0 0 8px rgba(0, 240, 255, .1);
736+
color: var(--cp-dim);
737+
border: 1px solid var(--cp-border);
738+
background: rgba(22, 33, 60, .2);
739+
box-shadow: 0 0 6px rgba(0, 0, 0, .2);
738740
}
739741

740742
.cp-lock-button.unlocked:hover {
@@ -1553,6 +1555,20 @@
15531555
</div>
15541556
</div>
15551557
</nav>
1558+
<!-- Modal dialog: detailed info about the currently playing track (ID3, size, RFID, ...) -->
1559+
<div class="modal fade" data-bs-backdrop="static" id="modalTrackInfo" role="dialog">
1560+
<div class="modal-dialog modal-dialog-scrollable" role="document">
1561+
<div class="modal-content">
1562+
<div class="modal-header">
1563+
<h5 class="modal-title" data-i18n="control.trackinfo.title"></h5>
1564+
</div>
1565+
<div class="modal-body" id="modalTrackInfoContent"></div>
1566+
<div class="modal-footer">
1567+
<button type="button" class="btn btn-primary" data-bs-dismiss="modal" data-i18n="ok"></button>
1568+
</div>
1569+
</div>
1570+
</div>
1571+
</div>
15561572
<!-- Modal dialog for info / shutdown / restart-->
15571573
<div class="modal fade bd-info-modal-xl" data-bs-backdrop="static" id="modalInfo" role="dialog">
15581574
<div class="modal-dialog modal-xl modal-dialog-scrollable" role="document">
@@ -1934,7 +1950,13 @@ <h5 class="modal-title" data-i18n="tools.nvs.erase.title"></h5>
19341950
</div>
19351951
<br />
19361952
<div class="mb-3 col-md-12">
1937-
<legend data-i18n="control.current"></legend>
1953+
<legend class="d-flex align-items-center justify-content-between" style="border-bottom:none;">
1954+
<span data-i18n="control.current"></span>
1955+
<button type="button" class="btn btn-sm cp-control-button" id="btn-track-info"
1956+
onclick="showTrackInfo()" data-i18n="[title]control.trackinfo.title">
1957+
<span class="fas fa-circle-info"></span>
1958+
</button>
1959+
</legend>
19381960
<div id="track"></div>
19391961
<div id="trackmeta" class="text-secondary small mb-1"></div>
19401962
<div id="trackProgressDiv" class="progress progress-sm">
@@ -6433,6 +6455,42 @@ <h5 class="modal-title" data-i18n="tools.nvs.erase.title"></h5>
64336455
}
64346456
}
64356457

6458+
// Detailed info dialog for the currently playing track (ID3 tags, size, RFID tag, ...).
6459+
async function showTrackInfo() {
6460+
let d;
6461+
try { d = await (await fetch("http://" + host + "/currenttrack")).json(); } catch (e) { d = null; }
6462+
const el = document.getElementById('modalTrackInfoContent');
6463+
const t = (k) => i18next.t('control.trackinfo.' + k);
6464+
if (!d || !d.active) {
6465+
el.innerHTML = '<p class="text-secondary mb-0">' + escapeHtml(t('none')) + '</p>';
6466+
new bootstrap.Modal(document.getElementById('modalTrackInfo')).show();
6467+
return;
6468+
}
6469+
const fmtBytes = (n) => {
6470+
n = Number(n); if (!isFinite(n) || n <= 0) return null;
6471+
const u = ['B', 'KB', 'MB', 'GB']; let i = 0; while (n >= 1024 && i < u.length - 1) { n /= 1024; i++; }
6472+
return (i === 0 ? n : (n < 10 ? n.toFixed(1) : Math.round(n))) + ' ' + u[i];
6473+
};
6474+
const rows = [];
6475+
const add = (labelKey, value) => { if (value === undefined || value === null || value === '') return; rows.push('<tr><th scope="row">' + t(labelKey) + '</th><td class="text-break">' + escapeHtml(String(value)) + '</td></tr>'); };
6476+
add('songTitle', d.title);
6477+
add('artist', d.artist);
6478+
add('album', d.album);
6479+
if (d.isWebstream) add('type', t('webstream'));
6480+
add('path', d.path);
6481+
add('size', fmtBytes(d.size));
6482+
if (d.totalTracks > 1) add('track', d.trackNumber + ' / ' + d.totalTracks);
6483+
if (d.playMode) add('playmode', i18next.t('files.rfid.playmode.mode.' + d.playMode));
6484+
if (d.duration > 0) add('position', secondsToTime(d.elapsed || 0) + ' / ' + secondsToTime(d.duration));
6485+
if (d.rfidTag && !/^0+$/.test(d.rfidTag)) add('rfid', d.rfidTag);
6486+
let html = '';
6487+
// show the cover thumbnail when one is embedded
6488+
html += '<div class="text-center mb-3"><img src="http://' + host + '/cover?' + Date.now() + '" alt="" style="max-width:160px;max-height:160px;border-radius:6px" onerror="this.style.display=\'none\'"></div>';
6489+
html += '<table class="table table-sm table-borderless cp-info-table mb-0"><tbody>' + rows.join('') + '</tbody></table>';
6490+
el.innerHTML = html;
6491+
new bootstrap.Modal(document.getElementById('modalTrackInfo')).show();
6492+
}
6493+
64366494
async function fetchInfo() {
64376495
let info = await (await fetch("http://" + host + "/info")).json();
64386496
console.log(info);

src/Web.cpp

Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -988,6 +988,41 @@ void webserverStart(void) {
988988
request->send(200, "application/json", buf);
989989
});
990990

991+
// Detailed info about the currently playing track (for the Control-tab info dialog):
992+
// ID3 title/artist/album, file path + size, play mode, track x/y, position/duration and the
993+
// RFID tag that started the playlist. Computed on demand so it doesn't bloat the websocket.
994+
wServer.on("/currenttrack", HTTP_GET, [](AsyncWebServerRequest *request) {
995+
AsyncJsonResponse *response = new AsyncJsonResponse(false);
996+
JsonObject o = response->getRoot();
997+
const bool active = gPlayProperties.playlist && (gPlayProperties.playMode != NO_PLAYLIST) && (gPlayProperties.playMode != BUSY);
998+
o["active"] = active;
999+
o["title"] = gPlayProperties.title;
1000+
o["artist"] = gPlayProperties.artist;
1001+
o["album"] = gPlayProperties.album;
1002+
o["playMode"] = gPlayProperties.playMode;
1003+
o["isWebstream"] = gPlayProperties.isWebstream;
1004+
o["rfidTag"] = gPlayProperties.playRfidTag;
1005+
o["elapsed"] = AudioPlayer_GetCurrentTime();
1006+
o["duration"] = AudioPlayer_GetFileDuration();
1007+
if (active) {
1008+
o["trackNumber"] = gPlayProperties.currentTrackNumber + 1;
1009+
o["totalTracks"] = gPlayProperties.playlist->size();
1010+
const char *path = gPlayProperties.playlist->at(gPlayProperties.currentTrackNumber);
1011+
if (path) {
1012+
o["path"] = path;
1013+
if (!gPlayProperties.isWebstream && path[0] == '/') {
1014+
File f = gFSystem.open(path);
1015+
if (f) {
1016+
o["size"].set((uint64_t) f.size());
1017+
f.close();
1018+
}
1019+
}
1020+
}
1021+
}
1022+
response->setLength();
1023+
request->send(response);
1024+
});
1025+
9911026
// WiFi scan
9921027
wServer.on("/wifiscan", HTTP_GET, handleWiFiScanRequest);
9931028

0 commit comments

Comments
 (0)