Skip to content

Commit af041fc

Browse files
fhirschmannclaude
andcommitted
feat: m3u playlist builder in the web interface
Adds a "Create playlist" button in the Files tab that opens a two-pane builder: browse the SD card on the left, an ordered (drag-to-reorder) track list on the right. Files and whole folders can be added, plus webradio URLs (LOCAL_M3U plays SD files and streams interchangeably). Saving POSTs to the new /playlist endpoint, which writes /Playlists/<name>.m3u to the SD card (#EXTM3U header + one entry per line, creating /Playlists if needed). The resulting file can be assigned to an RFID tag with play mode 11 like any other playlist. i18n de/en/fr. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
1 parent c91bba5 commit af041fc

6 files changed

Lines changed: 271 additions & 0 deletions

File tree

README.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -134,6 +134,7 @@ neon logo that doubles as the SVG favicon ([`7be5254`](../../commit/7be5254)):
134134
| **Status control-LEDs**: the optional control LEDs (`NUM_CONTROL_LEDS`, soldered to the end of the same NeoPixel data line — no extra GPIO) can now mirror a runtime state instead of just showing a static color. Each control LED gets a per-slot function in the LED settings — **Static** (legacy fixed color), **Key lock** (red while controls are locked), **Repeat mode** (green = track, blue = playlist, purple = both), **Bluetooth** (violet = waiting, blue = connected), **Battery level** (state of charge as a green→yellow→red gradient) or **Off**. A master switch mutes all control LEDs (e.g. at night), toggleable via the bindable command **121** (button/RFID modifier) and the MQTT command/state-topic `control_leds` (`ON`/`OFF`, + Home Assistant discovery switch) | [`d12a496`](../../commit/d12a496) |
135135
| **HTTP file sync**: pull audio files from a web server (e.g. nginx) onto the SD card. A new **Sync** settings sub-tab takes a manifest URL plus optional HTTP Basic Auth (user/password); the device fetches a JSON manifest (`{"files":[{"path","size"},…]}`) and downloads every listed file that is missing locally or differs in size, streaming each in small chunks straight to SD. Additive only (never deletes), runs in a background task, refuses to start while audio is playing, and shows live progress including the file currently being downloaded plus a stop button to cancel a running sync. The Sync tab documents the manifest format and an nginx setup inline | [`ac24bbc`](../../commit/ac24bbc) |
136136
| **Fully offline web interface**: all third-party libraries (jQuery, jQuery-UI, Bootstrap JS/CSS, jstree + themes, bootstrap-slider, Font Awesome + webfonts, i18next) and the Orbitron/Rajdhani/Share Tech Mono fonts are now vendored and served gzipped from the device instead of public CDNs. This fixes the ~2-minute first-load hang with empty (untranslated) text after a cold boot/flash, and makes the management UI work without any internet access (incl. AP mode). `processHtml.py` embeds the assets into flash (build size grows accordingly) and now derives unique header symbol names from the full asset path. The libs are concatenated into a single JS and a single CSS bundle (`tools/bundle_assets.py`) so the page makes only a handful of requests — many parallel requests to the ESP32's async web server made some browsers (Safari/Chrome) stall for minutes while Firefox coped | [`c618191`](../../commit/c618191) |
137+
| **M3U playlist builder**: a "Create playlist" button in the Files tab opens a two-pane builder — browse the SD card on the left, an ordered (drag-to-reorder) track list on the right — to assemble an `.m3u` from SD files, whole folders, and webradio URLs (which `LOCAL_M3U` plays interchangeably). Saving writes `/Playlists/<name>.m3u` via a new `POST /playlist` endpoint; the file can then be assigned to an RFID tag with play mode 11. i18n de/en/fr | [`COMMIT_HASH`](../../commit/COMMIT_HASH) |
137138

138139
#### Feature highlights
139140

html/locales/de.json

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -432,6 +432,21 @@
432432
"stopped": "Synchronisation gestoppt"
433433
}
434434
},
435+
"playlist": {
436+
"button": "Playlist erstellen (.m3u)",
437+
"title": "Playlist erstellen",
438+
"browse": "Dateien auswählen",
439+
"tracks": "Playlist",
440+
"addFolder": "Alle Titel dieses Ordners hinzufügen",
441+
"urlPlaceholder": "Webradio-URL (http://…)",
442+
"addUrl": "Hinzufügen",
443+
"name": "Dateiname",
444+
"namePlaceholder": "z.B. Lieblingslieder",
445+
"save": "Speichern",
446+
"saved": "Playlist gespeichert: {{path}}",
447+
"noName": "Bitte einen Dateinamen eingeben",
448+
"empty": "Die Playlist ist leer"
449+
},
435450
"bt": {
436451
"sink": {
437452
"title": "ESPuino als Bluetooth-Lautsprecher",

html/locales/en.json

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -433,6 +433,21 @@
433433
"stopped": "Synchronization stopped"
434434
}
435435
},
436+
"playlist": {
437+
"button": "Create playlist (.m3u)",
438+
"title": "Create playlist",
439+
"browse": "Pick files",
440+
"tracks": "Playlist",
441+
"addFolder": "Add all tracks in this folder",
442+
"urlPlaceholder": "Webradio URL (http://…)",
443+
"addUrl": "Add",
444+
"name": "File name",
445+
"namePlaceholder": "e.g. Favourites",
446+
"save": "Save",
447+
"saved": "Playlist saved: {{path}}",
448+
"noName": "Please enter a file name",
449+
"empty": "The playlist is empty"
450+
},
436451
"bt": {
437452
"sink": {
438453
"title": "ESPuino as Bluetooth speaker",

html/locales/fr.json

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -425,6 +425,21 @@
425425
"stopped": "Synchronisation arrêtée"
426426
}
427427
},
428+
"playlist": {
429+
"button": "Créer une playlist (.m3u)",
430+
"title": "Créer une playlist",
431+
"browse": "Choisir des fichiers",
432+
"tracks": "Playlist",
433+
"addFolder": "Ajouter tous les titres de ce dossier",
434+
"urlPlaceholder": "URL webradio (http://…)",
435+
"addUrl": "Ajouter",
436+
"name": "Nom du fichier",
437+
"namePlaceholder": "p.ex. Favoris",
438+
"save": "Enregistrer",
439+
"saved": "Playlist enregistrée : {{path}}",
440+
"noName": "Veuillez saisir un nom de fichier",
441+
"empty": "La playlist est vide"
442+
},
428443
"bt": {
429444
"sink": {
430445
"title": "ESPuino en tant que haut-parleur Bluetooth",

html/management.html

Lines changed: 177 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -842,6 +842,32 @@
842842
border-color: var(--cp-cyan);
843843
}
844844

845+
/* playlist builder: scrollable file-browser / track panes */
846+
.cp-pl-list {
847+
height: 320px;
848+
overflow-y: auto;
849+
}
850+
851+
.cp-pl-list .list-group-item {
852+
display: flex;
853+
align-items: center;
854+
gap: .5rem;
855+
padding: .35rem .6rem;
856+
cursor: pointer;
857+
}
858+
859+
.cp-pl-list .pl-grip {
860+
cursor: grab;
861+
opacity: .6;
862+
}
863+
864+
.cp-pl-list .pl-label {
865+
flex: 1 1 auto;
866+
overflow: hidden;
867+
text-overflow: ellipsis;
868+
white-space: nowrap;
869+
}
870+
845871
/* monospace code block used for inline documentation (e.g. the sync manifest example) */
846872
.cp-code-block {
847873
border: 1px solid var(--cp-border);
@@ -1671,6 +1697,49 @@ <h5 class="modal-title" data-i18n="files.clean.title"></h5>
16711697
</div>
16721698
</div>
16731699
</div>
1700+
<!-- Modal dialog: build an .m3u playlist from SD files / folders / webradio URLs -->
1701+
<div class="modal fade" data-bs-backdrop="static" id="playlistModal" role="dialog">
1702+
<div class="modal-dialog modal-xl" role="document">
1703+
<div class="modal-content">
1704+
<div class="modal-header">
1705+
<h5 class="modal-title" data-i18n="playlist.title"></h5>
1706+
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button>
1707+
</div>
1708+
<div class="modal-body">
1709+
<div class="row g-3">
1710+
<div class="col-md-6">
1711+
<label class="form-label fw-bold" data-i18n="playlist.browse"></label>
1712+
<div class="input-group input-group-sm mb-1">
1713+
<button class="btn btn-secondary" type="button" id="plUpBtn" onclick="plNavigateUp()"><span class="fas fa-arrow-up"></span></button>
1714+
<span class="input-group-text flex-grow-1 text-truncate" id="plCwd">/</span>
1715+
<button class="btn btn-secondary" type="button" onclick="plAddCurrentFolder()" data-i18n="[title]playlist.addFolder"><span class="fas fa-folder-plus"></span></button>
1716+
</div>
1717+
<ul id="plBrowser" class="list-group cp-pl-list"></ul>
1718+
</div>
1719+
<div class="col-md-6">
1720+
<label class="form-label fw-bold"><span data-i18n="playlist.tracks"></span> (<span id="plCount">0</span>)</label>
1721+
<ul id="plTracks" class="list-group cp-pl-list"></ul>
1722+
<div class="input-group input-group-sm mt-2">
1723+
<input type="url" class="form-control" id="plUrl" data-i18n="[placeholder]playlist.urlPlaceholder">
1724+
<button class="btn btn-secondary" type="button" onclick="plAddUrl()" data-i18n="playlist.addUrl"></button>
1725+
</div>
1726+
</div>
1727+
</div>
1728+
<hr>
1729+
<label class="form-label" data-i18n="playlist.name"></label>
1730+
<div class="input-group">
1731+
<span class="input-group-text">/Playlists/</span>
1732+
<input type="text" class="form-control" id="plName" data-i18n="[placeholder]playlist.namePlaceholder">
1733+
<span class="input-group-text">.m3u</span>
1734+
</div>
1735+
</div>
1736+
<div class="modal-footer">
1737+
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal" data-i18n="cancel"></button>
1738+
<button type="button" class="btn btn-primary" id="plSaveBtn" onclick="savePlaylist()" data-i18n="playlist.save"></button>
1739+
</div>
1740+
</div>
1741+
</div>
1742+
</div>
16741743
<!-- Modal dialog for delete NVS RFID assignments -->
16751744
<div class="modal fade" data-bs-backdrop="static" id="deleteNVSRFIDModal" role="dialog">
16761745
<div class="modal-dialog" role="document">
@@ -1856,6 +1925,9 @@ <h5 class="modal-title" data-i18n="tools.nvs.erase.title"></h5>
18561925
<button id="rfidPlayBtn" class="btn btn-primary" onclick="playSelectedNode()"
18571926
disabled data-i18n="[title]files.context.play"><span id="rfidPlayIcon"
18581927
class="fas fa-play"></span></button>
1928+
<button id="createPlaylistBtn" class="btn btn-primary" data-bs-toggle="modal"
1929+
data-bs-target="#playlistModal" onclick="openPlaylistBuilder()"
1930+
data-i18n="[title]playlist.button"><span class="fas fa-list-ol"></span></button>
18591931
<button id="cleansd" class="btn btn-warning" data-bs-toggle="modal"
18601932
data-bs-target="#cleanSdModal" data-i18n="[title]files.clean.button"><span
18611933
class="fas fa-broom"></span></button>
@@ -5719,6 +5791,111 @@ <h5 class="modal-title" data-i18n="tools.nvs.erase.title"></h5>
57195791
}
57205792
}
57215793

5794+
/* ---- .m3u playlist builder (modal) ---- */
5795+
let plCwd = "/";
5796+
function plJoin(dir, name) { return (dir === "/" ? "" : dir) + "/" + name; }
5797+
function plIsAudio(name) { return /\.(mp3|ogg|oga|wav|wma|acc|m4a|flac|opus)$/i.test(name); }
5798+
function plUpdateCount() { $("#plCount").text($("#plTracks > li").length); }
5799+
5800+
function openPlaylistBuilder() {
5801+
plCwd = "/";
5802+
$("#plTracks").empty();
5803+
plUpdateCount();
5804+
$("#plName").val("");
5805+
$("#plUrl").val("");
5806+
plRenderBrowser();
5807+
// ordered, drag-sortable track list (jQuery UI)
5808+
if (!$("#plTracks").data("ui-sortable")) {
5809+
$("#plTracks").sortable({ handle: ".pl-grip" });
5810+
}
5811+
}
5812+
5813+
function plRenderBrowser() {
5814+
$("#plCwd").text(plCwd);
5815+
$("#plUpBtn").prop("disabled", plCwd === "/");
5816+
const list = $("#plBrowser").empty();
5817+
list.append($('<li class="list-group-item text-muted">').text(i18next.t("files.loading")));
5818+
getData("http://" + host + "/explorer?path=" + encodeURIComponent(plCwd), function (data) {
5819+
list.empty();
5820+
(data || []).forEach(function (e) {
5821+
if (e.root) return; // skip SD volume-label entry at root
5822+
const item = $('<li class="list-group-item">');
5823+
if (e.dir) {
5824+
item.append('<span class="fas fa-folder"></span>');
5825+
item.append($('<span class="pl-label">').text(e.name));
5826+
item.on("click", function () { plCwd = plJoin(plCwd, e.name); plRenderBrowser(); });
5827+
} else if (plIsAudio(e.name)) {
5828+
const p = plJoin(plCwd, e.name);
5829+
item.append('<span class="fas fa-file-audio"></span>');
5830+
item.append($('<span class="pl-label">').text(e.name));
5831+
const add = $('<button class="btn btn-sm btn-primary"><span class="fas fa-plus"></span></button>');
5832+
add.on("click", function (ev) { ev.stopPropagation(); plAddTrack(p, e.name); });
5833+
item.append(add);
5834+
item.on("click", function () { plAddTrack(p, e.name); });
5835+
} else {
5836+
return; // hide non-audio files
5837+
}
5838+
list.append(item);
5839+
});
5840+
if (list.children().length === 0) {
5841+
list.append($('<li class="list-group-item text-muted">').text("—"));
5842+
}
5843+
});
5844+
}
5845+
5846+
function plNavigateUp() {
5847+
if (plCwd === "/") return;
5848+
plCwd = plCwd.substring(0, plCwd.lastIndexOf("/")) || "/";
5849+
plRenderBrowser();
5850+
}
5851+
5852+
function plAddCurrentFolder() {
5853+
getData("http://" + host + "/explorer?path=" + encodeURIComponent(plCwd), function (data) {
5854+
(data || []).filter(e => !e.root && !e.dir && plIsAudio(e.name))
5855+
.sort((a, b) => natcompare(a.name.toLowerCase(), b.name.toLowerCase()))
5856+
.forEach(e => plAddTrack(plJoin(plCwd, e.name), e.name));
5857+
});
5858+
}
5859+
5860+
function plAddUrl() {
5861+
const u = ($("#plUrl").val() || "").trim();
5862+
if (!u) return;
5863+
plAddTrack(u, u);
5864+
$("#plUrl").val("");
5865+
}
5866+
5867+
function plAddTrack(path, label) {
5868+
const item = $('<li class="list-group-item">').attr("data-path", path);
5869+
item.append('<span class="pl-grip fas fa-grip-vertical"></span>');
5870+
item.append($('<span class="pl-label">').text(label).attr("title", path));
5871+
const rm = $('<button class="btn btn-sm btn-danger"><span class="fas fa-times"></span></button>');
5872+
rm.on("click", function () { item.remove(); plUpdateCount(); });
5873+
item.append(rm);
5874+
$("#plTracks").append(item);
5875+
plUpdateCount();
5876+
}
5877+
5878+
function savePlaylist() {
5879+
let name = ($("#plName").val() || "").trim().replace(/[\/\\]/g, "").replace(/\.m3u$/i, "");
5880+
if (!name) { toaster.error(i18next.t("playlist.noName")); return; }
5881+
const tracks = $("#plTracks > li").map(function () { return $(this).attr("data-path"); }).get();
5882+
if (!tracks.length) { toaster.error(i18next.t("playlist.empty")); return; }
5883+
const path = "/Playlists/" + name + ".m3u";
5884+
fetch("http://" + host + "/playlist", {
5885+
method: "POST",
5886+
headers: { "Content-Type": "application/json" },
5887+
body: JSON.stringify({ path: path, tracks: tracks })
5888+
}).then(r => {
5889+
if (!r.ok) throw new Error("HTTP " + r.status);
5890+
toaster.info(i18next.t("playlist.saved", { path: path }));
5891+
const m = bootstrap.Modal.getInstance(document.getElementById("playlistModal"));
5892+
if (m) m.hide();
5893+
// refresh the file tree so the new playlist shows up
5894+
const ref = $('#explorerTree').jstree(true);
5895+
if (ref) { const root = ref.get_node('#').children[0]; if (root) refreshNode(root); }
5896+
}).catch(e => toaster.error(i18next.t("toast.error") + ": " + e.message));
5897+
}
5898+
57225899
async function refreshSecurityStatus() {
57235900
try {
57245901
const status = await (await fetch("http://" + host + "/security")).json();

src/Web.cpp

Lines changed: 48 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -94,6 +94,7 @@ static void handleBluetoothConnectRequest(AsyncWebServerRequest *request, JsonVa
9494
static void handleWiFiScanRequest(AsyncWebServerRequest *request);
9595
static void handleGetRFIDRequest(AsyncWebServerRequest *request);
9696
static void handlePostRFIDRequest(AsyncWebServerRequest *request, JsonVariant &json);
97+
static void handleCreatePlaylistRequest(AsyncWebServerRequest *request, JsonVariant &json);
9798
static void handleDeleteRFIDRequest(AsyncWebServerRequest *request);
9899
static void handleGetInfo(AsyncWebServerRequest *request);
99100
static void handleGetSettings(AsyncWebServerRequest *request);
@@ -986,6 +987,8 @@ void webserverStart(void) {
986987

987988
wServer.on("/exploreraudio", HTTP_POST, explorerHandleAudioRequest);
988989

990+
wServer.addHandler(new AsyncCallbackJsonWebHandler("/playlist", handleCreatePlaylistRequest));
991+
989992
wServer.on("/trackprogress", HTTP_GET, handleTrackProgressRequest);
990993

991994
wServer.on("/savedSSIDs", HTTP_GET, handleGetSavedSSIDs);
@@ -2755,6 +2758,51 @@ void explorerHandleCreateRequest(AsyncWebServerRequest *request) {
27552758
request->send(200);
27562759
}
27572760

2761+
// Writes an .m3u playlist to the SD card. Body: {"path":"/Playlists/x.m3u","tracks":["/dir/a.mp3","http://stream", ...]}
2762+
// Each track becomes one line; the player's LOCAL_M3U mode plays SD files and webradio URLs alike.
2763+
static void handleCreatePlaylistRequest(AsyncWebServerRequest *request, JsonVariant &json) {
2764+
JsonObject obj = json.as<JsonObject>();
2765+
String path = obj["path"] | "";
2766+
JsonArray tracks = obj["tracks"].as<JsonArray>();
2767+
if (path.length() == 0 || tracks.isNull()) {
2768+
request->send(400, "text/plain", "missing path or tracks");
2769+
return;
2770+
}
2771+
if (!path.startsWith("/")) {
2772+
path = "/" + path;
2773+
}
2774+
if (!path.endsWith(".m3u")) {
2775+
path += ".m3u";
2776+
}
2777+
System_UpdateActivityTimer();
2778+
// create the parent directory (one level, e.g. /Playlists) if it doesn't exist yet
2779+
const int slash = path.lastIndexOf('/');
2780+
if (slash > 0) {
2781+
const String dir = path.substring(0, slash);
2782+
if (!gFSystem.exists(dir)) {
2783+
gFSystem.mkdir(dir);
2784+
}
2785+
}
2786+
File file = gFSystem.open(path, "w", true);
2787+
if (!file) {
2788+
Log_Printf(LOGLEVEL_ERROR, "PLAYLIST: cannot create %s", path.c_str());
2789+
request->send(500, "text/plain", "cannot create file");
2790+
return;
2791+
}
2792+
file.print("#EXTM3U\n");
2793+
for (JsonVariant t : tracks) {
2794+
String line = t.as<String>();
2795+
line.trim();
2796+
if (line.length() > 0) {
2797+
file.print(line);
2798+
file.print("\n");
2799+
}
2800+
}
2801+
file.close();
2802+
Log_Printf(LOGLEVEL_NOTICE, "PLAYLIST: wrote %s (%u entries)", path.c_str(), (unsigned) tracks.size());
2803+
request->send(200, "application/json", "{\"ok\":true}");
2804+
}
2805+
27582806
// Handles rename request of a file or directory
27592807
// requires a GET parameter srcpath to the old file or directory name
27602808
// requires a GET parameter dstpath to the new file or directory name

0 commit comments

Comments
 (0)