|
842 | 842 | border-color: var(--cp-cyan); |
843 | 843 | } |
844 | 844 |
|
| 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 | + |
845 | 871 | /* monospace code block used for inline documentation (e.g. the sync manifest example) */ |
846 | 872 | .cp-code-block { |
847 | 873 | border: 1px solid var(--cp-border); |
@@ -1671,6 +1697,49 @@ <h5 class="modal-title" data-i18n="files.clean.title"></h5> |
1671 | 1697 | </div> |
1672 | 1698 | </div> |
1673 | 1699 | </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> |
1674 | 1743 | <!-- Modal dialog for delete NVS RFID assignments --> |
1675 | 1744 | <div class="modal fade" data-bs-backdrop="static" id="deleteNVSRFIDModal" role="dialog"> |
1676 | 1745 | <div class="modal-dialog" role="document"> |
@@ -1856,6 +1925,9 @@ <h5 class="modal-title" data-i18n="tools.nvs.erase.title"></h5> |
1856 | 1925 | <button id="rfidPlayBtn" class="btn btn-primary" onclick="playSelectedNode()" |
1857 | 1926 | disabled data-i18n="[title]files.context.play"><span id="rfidPlayIcon" |
1858 | 1927 | 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> |
1859 | 1931 | <button id="cleansd" class="btn btn-warning" data-bs-toggle="modal" |
1860 | 1932 | data-bs-target="#cleanSdModal" data-i18n="[title]files.clean.button"><span |
1861 | 1933 | class="fas fa-broom"></span></button> |
@@ -5719,6 +5791,111 @@ <h5 class="modal-title" data-i18n="tools.nvs.erase.title"></h5> |
5719 | 5791 | } |
5720 | 5792 | } |
5721 | 5793 |
|
| 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 | + |
5722 | 5899 | async function refreshSecurityStatus() { |
5723 | 5900 | try { |
5724 | 5901 | const status = await (await fetch("http://" + host + "/security")).json(); |
|
0 commit comments