refactor: flatten playlist panel UI

Remove the collapsible toggle and the separate detail card. The panel is
now always-visible at the bottom of the rail (max-height: 40vh, scrollable).
Expanding a playlist shows its controls/tracks inline directly under its
row — no nested card, no second title. Track list caps at 4 visible items
(max-height: 4 * 30px). Playback controls and now-playing label sit in a
single horizontal row alongside the track name.
This commit is contained in:
2026-05-01 11:14:34 +02:00
parent 5306c54c0b
commit 3d53c60c0d
3 changed files with 329 additions and 410 deletions
+70 -92
View File
@@ -1013,55 +1013,54 @@ button:disabled:hover {
} }
/* ── Playlist panel (admin rail) ─────────────────────────────── */ /* ── Playlist panel (admin rail) ─────────────────────────────── */
/*
* The panel sits at the bottom of the rail and reserves a fixed slice
* of vertical space. The list scrolls inside it; no toggle/collapse.
*/
.playlist-panel { .playlist-panel {
border-top: 1px solid var(--color-border); border-top: 1px solid var(--color-border);
flex-shrink: 0; flex-shrink: 0;
display: flex;
flex-direction: column;
max-height: 40vh;
} }
.playlist-panel-header { .playlist-panel-header {
display: flex; display: flex;
align-items: center; align-items: center;
justify-content: space-between; justify-content: space-between;
padding: 10px 12px; padding: 8px 12px 4px;
cursor: pointer; font-size: 12px;
user-select: none;
font-size: 13px;
font-weight: 600; font-weight: 600;
color: var(--color-text-2); color: var(--color-text-2);
transition: color 0.15s; text-transform: uppercase;
} letter-spacing: 0.4px;
flex-shrink: 0;
.playlist-panel-header:hover {
color: var(--color-text);
} }
.playlist-panel-title { .playlist-panel-title {
display: flex; display: flex;
align-items: center; align-items: center;
gap: 7px;
}
.playlist-panel-chevron {
font-size: 11px;
transition: transform 0.2s;
}
.playlist-panel-chevron.rotated {
transform: rotate(180deg);
}
.playlist-panel-body {
padding: 0 8px 10px;
display: flex;
flex-direction: column;
gap: 6px; gap: 6px;
} }
.playlist-panel-body {
overflow-y: auto;
padding: 0 8px 8px;
display: flex;
flex-direction: column;
gap: 4px;
min-height: 0;
}
/* new-playlist input row */
.playlist-create-row { .playlist-create-row {
display: flex; display: flex;
gap: 6px; gap: 6px;
align-items: center; align-items: center;
padding: 4px 0; padding: 2px 0 4px;
flex-shrink: 0;
} }
.playlist-create-row input { .playlist-create-row input {
@@ -1079,13 +1078,14 @@ button:disabled:hover {
border-color: var(--color-accent-border); border-color: var(--color-accent-border);
} }
/* list */
.playlist-list { .playlist-list {
list-style: none; list-style: none;
margin: 0; margin: 0;
padding: 0; padding: 0;
display: flex; display: flex;
flex-direction: column; flex-direction: column;
gap: 2px; gap: 1px;
} }
.playlist-list-empty { .playlist-list-empty {
@@ -1094,20 +1094,10 @@ button:disabled:hover {
padding: 4px 4px; padding: 4px 4px;
} }
/* each playlist row */
.playlist-list-item { .playlist-list-item {
display: flex;
align-items: center;
justify-content: space-between;
gap: 6px;
padding: 5px 6px;
border-radius: 8px; border-radius: 8px;
border: 1px solid transparent; border: 1px solid transparent;
transition: background 0.12s;
cursor: pointer;
}
.playlist-list-item:hover {
background: var(--color-surface-3);
} }
.playlist-list-item.active { .playlist-list-item.active {
@@ -1115,9 +1105,23 @@ button:disabled:hover {
background: var(--color-accent-subtle); background: var(--color-accent-subtle);
} }
.playlist-list-row {
display: flex;
align-items: center;
justify-content: space-between;
gap: 6px;
padding: 5px 6px;
border-radius: 8px;
cursor: pointer;
transition: background 0.12s;
}
.playlist-list-item:not(.active) .playlist-list-row:hover {
background: var(--color-surface-3);
}
.playlist-list-name { .playlist-list-name {
font-size: 13px; font-size: 13px;
cursor: pointer;
flex: 1; flex: 1;
overflow: hidden; overflow: hidden;
text-overflow: ellipsis; text-overflow: ellipsis;
@@ -1126,48 +1130,19 @@ button:disabled:hover {
.playlist-list-actions { .playlist-list-actions {
display: flex; display: flex;
gap: 4px; gap: 2px;
flex-shrink: 0; flex-shrink: 0;
} }
/* Playlist detail */ /* inline detail — appears directly under the row when expanded */
.playlist-detail { .playlist-inline-detail {
border: 1px solid var(--color-border); padding: 4px 6px 8px 6px;
border-radius: 10px;
padding: 10px;
display: flex; display: flex;
flex-direction: column; flex-direction: column;
gap: 8px;
background: var(--color-surface-1);
}
.playlist-detail-header {
display: flex;
flex-direction: column;
gap: 4px;
}
.playlist-detail-title-row {
display: flex;
align-items: center;
justify-content: space-between;
gap: 6px; gap: 6px;
} }
.playlist-detail-name { /* rename form */
font-size: 13px;
font-weight: 600;
flex: 1;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.playlist-detail-actions {
display: flex;
gap: 4px;
}
.playlist-rename-form { .playlist-rename-form {
display: flex; display: flex;
gap: 4px; gap: 4px;
@@ -1190,13 +1165,11 @@ button:disabled:hover {
border-color: var(--color-accent-border); border-color: var(--color-accent-border);
} }
/* Playback controls */ /* playback controls */
.playlist-controls { .playlist-controls {
display: flex; display: flex;
flex-direction: column; align-items: center;
gap: 4px; gap: 6px;
padding: 6px 0 2px;
border-top: 1px solid var(--color-border);
} }
.playlist-now-playing { .playlist-now-playing {
@@ -1205,16 +1178,18 @@ button:disabled:hover {
overflow: hidden; overflow: hidden;
text-overflow: ellipsis; text-overflow: ellipsis;
white-space: nowrap; white-space: nowrap;
min-height: 14px; flex: 1;
min-width: 0;
} }
.playlist-buttons { .playlist-buttons {
display: flex; display: flex;
gap: 4px; gap: 2px;
align-items: center; align-items: center;
flex-shrink: 0;
} }
/* Track list */ /* add-track row */
.playlist-add-track-row { .playlist-add-track-row {
display: flex; display: flex;
gap: 6px; gap: 6px;
@@ -1232,36 +1207,39 @@ button:disabled:hover {
color: var(--color-text); color: var(--color-text);
} }
/* track list — max 4 items visible */
.playlist-track-list { .playlist-track-list {
list-style: none; list-style: none;
margin: 0; margin: 0;
padding: 0; padding: 0;
display: flex; display: flex;
flex-direction: column; flex-direction: column;
gap: 2px; gap: 1px;
max-height: 160px; max-height: calc(4 * 30px);
overflow-y: auto; overflow-y: auto;
} }
.playlist-track-empty { .playlist-track-empty {
font-size: 12px; font-size: 12px;
color: var(--color-text-2); color: var(--color-text-2);
padding: 4px; padding: 2px 4px;
} }
.playlist-track-item { .playlist-track-item {
display: flex; display: flex;
align-items: center; align-items: center;
gap: 6px; gap: 6px;
padding: 5px 6px; padding: 4px 4px;
border-radius: 7px; border-radius: 6px;
border: 1px solid transparent; min-height: 30px;
background: var(--color-surface-2); transition: background 0.1s;
cursor: grab; }
.playlist-track-item:hover {
background: var(--color-surface-3);
} }
.playlist-track-item.playing { .playlist-track-item.playing {
border-color: var(--color-accent-border);
background: var(--color-accent-subtle); background: var(--color-accent-subtle);
} }
@@ -1270,13 +1248,13 @@ button:disabled:hover {
} }
.playlist-track-item.drag-over { .playlist-track-item.drag-over {
border-color: var(--color-accent-icon); background: var(--color-surface-4);
} }
.playlist-track-handle { .playlist-track-handle {
color: var(--color-text-2); color: var(--color-text-2);
font-size: 11px;
cursor: grab; cursor: grab;
font-size: 11px;
flex-shrink: 0; flex-shrink: 0;
} }
+254 -280
View File
@@ -23,33 +23,6 @@
}; };
let audioAssets = []; // [{id, name}] — populated from the DOM asset list let audioAssets = []; // [{id, name}] — populated from the DOM asset list
// ── DOM refs ──────────────────────────────────────────────────────────
const el = {
panel: () => document.getElementById("playlist-panel"),
toggle: () => document.getElementById("playlist-panel-toggle"),
body: () => document.getElementById("playlist-panel-body"),
chevron: () => document.querySelector(".playlist-panel-chevron"),
newNameInput: () => document.getElementById("new-playlist-name"),
createBtn: () => document.getElementById("create-playlist-btn"),
list: () => document.getElementById("playlist-list"),
detail: () => document.getElementById("playlist-detail"),
renameForm: () => document.getElementById("playlist-rename-form"),
renameInput: () => document.getElementById("playlist-rename-input"),
renameSave: () => document.getElementById("playlist-rename-save"),
renameCancel: () => document.getElementById("playlist-rename-cancel"),
controls: () => document.getElementById("playlist-controls"),
nowPlayingLabel: () => document.getElementById("playlist-now-playing-label"),
playPauseBtn: () => document.getElementById("playlist-play-pause-btn"),
prevBtn: () => document.getElementById("playlist-prev-btn"),
nextBtn: () => document.getElementById("playlist-next-btn"),
trackSelect: () => document.getElementById("playlist-track-select"),
addTrackBtn: () => document.getElementById("playlist-add-track-btn"),
trackList: () => document.getElementById("playlist-track-list"),
nowPlayingPill: () => document.getElementById("admin-now-playing-pill"),
nowPlayingText: () => document.getElementById("admin-now-playing-text"),
};
// ── API helpers ─────────────────────────────────────────────────────── // ── API helpers ───────────────────────────────────────────────────────
async function apiFetch(path, options = {}) { async function apiFetch(path, options = {}) {
@@ -67,17 +40,11 @@
// ── Initialisation ──────────────────────────────────────────────────── // ── Initialisation ────────────────────────────────────────────────────
async function init() { async function init() {
bindToggle();
bindCreate(); bindCreate();
bindDetailButtons();
bindPlaybackButtons();
bindAddTrack();
await loadPlaylists(); await loadPlaylists();
await loadActivePlaylist(); await loadActivePlaylist();
refreshAudioAssetOptions(); refreshAudioAssetOptions();
// Listen for live updates forwarded from admin.js
window.addEventListener("playlistEvent", (e) => handlePlaylistEvent(e.detail)); window.addEventListener("playlistEvent", (e) => handlePlaylistEvent(e.detail));
// Also watch asset list mutations to keep the audio asset picker fresh
const assetListEl = document.getElementById("asset-list"); const assetListEl = document.getElementById("asset-list");
if (assetListEl) { if (assetListEl) {
new MutationObserver(refreshAudioAssetOptions).observe(assetListEl, { childList: true, subtree: true }); new MutationObserver(refreshAudioAssetOptions).observe(assetListEl, { childList: true, subtree: true });
@@ -85,27 +52,23 @@
} }
function refreshAudioAssetOptions() { function refreshAudioAssetOptions() {
// Collect audio asset IDs and names from the admin asset list items (data attributes set by admin.js)
const items = document.querySelectorAll("#asset-list .asset-item[data-asset-type='AUDIO']"); const items = document.querySelectorAll("#asset-list .asset-item[data-asset-type='AUDIO']");
audioAssets = Array.from(items).map(item => ({ audioAssets = Array.from(items).map(item => ({
id: item.dataset.assetId, id: item.dataset.assetId,
name: item.dataset.assetName || item.querySelector(".asset-name")?.textContent?.trim() || item.dataset.assetId, name: item.dataset.assetName || item.dataset.assetId,
})).filter(a => a.id); })).filter(a => a.id);
renderTrackSelectOptions(); // Update any open track-select dropdowns
} document.querySelectorAll(".playlist-track-select").forEach(sel => {
const current = sel.value;
function renderTrackSelectOptions() { sel.innerHTML = '<option value="">Add audio asset…</option>';
const sel = el.trackSelect(); audioAssets.forEach(a => {
if (!sel) return; const opt = document.createElement("option");
const current = sel.value; opt.value = a.id;
sel.innerHTML = '<option value="">Add audio asset…</option>'; opt.textContent = a.name;
audioAssets.forEach(a => { sel.appendChild(opt);
const opt = document.createElement("option"); });
opt.value = a.id; if (current) sel.value = current;
opt.textContent = a.name;
sel.appendChild(opt);
}); });
if (current) sel.value = current;
} }
// ── Load ────────────────────────────────────────────────────────────── // ── Load ──────────────────────────────────────────────────────────────
@@ -124,43 +87,27 @@
const active = await fetch(`${apiBase()}/active`).then(r => r.ok ? r.json() : null).catch(() => null); const active = await fetch(`${apiBase()}/active`).then(r => r.ok ? r.json() : null).catch(() => null);
activePlaylistId = active?.id ?? null; activePlaylistId = active?.id ?? null;
renderPlaylistList(); renderPlaylistList();
if (expandedPlaylistId) renderDetail();
} catch { /* silently ignore */ } } catch { /* silently ignore */ }
} }
// ── Panel toggle ──────────────────────────────────────────────────────
function bindToggle() {
const toggle = el.toggle();
if (!toggle) return;
toggle.addEventListener("click", () => {
const body = el.body();
const open = !body.classList.contains("hidden");
body.classList.toggle("hidden", open);
toggle.setAttribute("aria-expanded", String(!open));
el.chevron()?.classList.toggle("rotated", !open);
});
toggle.addEventListener("keydown", e => {
if (e.key === "Enter" || e.key === " ") { e.preventDefault(); toggle.click(); }
});
}
// ── Create playlist ─────────────────────────────────────────────────── // ── Create playlist ───────────────────────────────────────────────────
function bindCreate() { function bindCreate() {
el.createBtn()?.addEventListener("click", createPlaylist); document.getElementById("create-playlist-btn")?.addEventListener("click", createPlaylist);
el.newNameInput()?.addEventListener("keydown", e => { if (e.key === "Enter") { e.preventDefault(); createPlaylist(); } }); document.getElementById("new-playlist-name")?.addEventListener("keydown", e => {
if (e.key === "Enter") { e.preventDefault(); createPlaylist(); }
});
} }
async function createPlaylist() { async function createPlaylist() {
const input = el.newNameInput(); const input = document.getElementById("new-playlist-name");
const name = input?.value?.trim(); const name = input?.value?.trim();
if (!name) { showToast("Enter a playlist name.", "info"); return; } if (!name) { showToast("Enter a playlist name.", "info"); return; }
try { try {
const view = await apiFetch("", { method: "POST", body: JSON.stringify({ name }) }); const view = await apiFetch("", { method: "POST", body: JSON.stringify({ name }) });
input.value = ""; input.value = "";
// Don't push locally — the PLAYLIST_CREATED STOMP event will add it. // Don't push locally — the PLAYLIST_CREATED STOMP event will add it.
// Just pre-set the expanded id so it opens as soon as the event renders it. // Pre-set expanded id so it opens as soon as the event renders it.
if (view?.id) expandedPlaylistId = view.id; if (view?.id) expandedPlaylistId = view.id;
} catch { } catch {
showToast("Could not create playlist.", "error"); showToast("Could not create playlist.", "error");
@@ -170,7 +117,7 @@
// ── Playlist list ───────────────────────────────────────────────────── // ── Playlist list ─────────────────────────────────────────────────────
function renderPlaylistList() { function renderPlaylistList() {
const list = el.list(); const list = document.getElementById("playlist-list");
if (!list) return; if (!list) return;
list.innerHTML = ""; list.innerHTML = "";
if (playlists.length === 0) { if (playlists.length === 0) {
@@ -181,10 +128,19 @@
return; return;
} }
playlists.forEach(p => { playlists.forEach(p => {
const isExpanded = p.id === expandedPlaylistId;
const isActive = p.id === activePlaylistId;
const li = document.createElement("li"); const li = document.createElement("li");
li.className = "playlist-list-item" + (p.id === expandedPlaylistId ? " expanded" : "") + (p.id === activePlaylistId ? " active" : ""); li.className = "playlist-list-item"
+ (isExpanded ? " expanded" : "")
+ (isActive ? " active" : "");
li.dataset.id = p.id; li.dataset.id = p.id;
li.addEventListener("click", () => toggleExpand(p.id));
// ── header row ───────────────────────────────────────────────
const row = document.createElement("div");
row.className = "playlist-list-row";
row.addEventListener("click", () => toggleExpand(p.id));
const nameSpan = document.createElement("span"); const nameSpan = document.createElement("span");
nameSpan.className = "playlist-list-name"; nameSpan.className = "playlist-list-name";
@@ -193,138 +149,259 @@
const actions = document.createElement("div"); const actions = document.createElement("div");
actions.className = "playlist-list-actions"; actions.className = "playlist-list-actions";
// select-for-playback button
const selectBtn = document.createElement("button"); const selectBtn = document.createElement("button");
selectBtn.type = "button"; selectBtn.type = "button";
selectBtn.className = "ghost small" + (p.id === activePlaylistId ? " active" : ""); selectBtn.className = "ghost small icon-button" + (isActive ? " active" : "");
selectBtn.title = p.id === activePlaylistId ? "Active — click to deselect" : "Select for playback"; selectBtn.title = isActive ? "Active — click to deselect" : "Select for playback";
selectBtn.innerHTML = p.id === activePlaylistId selectBtn.innerHTML = isActive
? '<i class="fa-solid fa-check" aria-hidden="true"></i>' ? '<i class="fa-solid fa-check" aria-hidden="true"></i>'
: '<i class="fa-solid fa-circle-play" aria-hidden="true"></i>'; : '<i class="fa-solid fa-circle-play" aria-hidden="true"></i>';
selectBtn.addEventListener("click", (e) => { e.stopPropagation(); toggleActivePlaylist(p.id); }); selectBtn.addEventListener("click", (e) => { e.stopPropagation(); toggleActivePlaylist(p.id); });
actions.appendChild(selectBtn); actions.appendChild(selectBtn);
if (p.id === expandedPlaylistId) { if (isExpanded) {
const renameBtn = document.createElement("button"); const renameBtn = document.createElement("button");
renameBtn.type = "button"; renameBtn.type = "button";
renameBtn.className = "ghost small icon-button"; renameBtn.className = "ghost small icon-button";
renameBtn.title = "Rename playlist"; renameBtn.title = "Rename";
renameBtn.innerHTML = '<i class="fa-solid fa-pencil" aria-hidden="true"></i>'; renameBtn.innerHTML = '<i class="fa-solid fa-pencil" aria-hidden="true"></i>';
renameBtn.addEventListener("click", (e) => { renameBtn.addEventListener("click", (e) => {
e.stopPropagation(); e.stopPropagation();
el.renameInput().value = p.name; const form = li.querySelector(".playlist-rename-form");
el.renameForm()?.classList.remove("hidden"); if (form) {
el.renameInput()?.focus(); form.classList.toggle("hidden");
if (!form.classList.contains("hidden")) {
form.querySelector("input")?.focus();
}
}
}); });
const deleteBtn = document.createElement("button"); const deleteBtn = document.createElement("button");
deleteBtn.type = "button"; deleteBtn.type = "button";
deleteBtn.className = "ghost small icon-button danger-icon"; deleteBtn.className = "ghost small icon-button danger-icon";
deleteBtn.title = "Delete playlist"; deleteBtn.title = "Delete";
deleteBtn.innerHTML = '<i class="fa-solid fa-trash" aria-hidden="true"></i>'; deleteBtn.innerHTML = '<i class="fa-solid fa-trash" aria-hidden="true"></i>';
deleteBtn.addEventListener("click", (e) => { e.stopPropagation(); deletePlaylist(); }); deleteBtn.addEventListener("click", (e) => { e.stopPropagation(); deletePlaylist(p); });
actions.appendChild(renameBtn); actions.appendChild(renameBtn);
actions.appendChild(deleteBtn); actions.appendChild(deleteBtn);
} }
li.appendChild(nameSpan);
li.appendChild(actions); row.appendChild(nameSpan);
row.appendChild(actions);
li.appendChild(row);
// ── inline detail (only when expanded) ───────────────────────
if (isExpanded) {
li.appendChild(buildInlineDetail(p));
}
list.appendChild(li); list.appendChild(li);
}); });
} }
function buildInlineDetail(p) {
const detail = document.createElement("div");
detail.className = "playlist-inline-detail";
// rename form (hidden by default)
const renameForm = document.createElement("div");
renameForm.className = "playlist-rename-form hidden";
const renameInput = document.createElement("input");
renameInput.type = "text";
renameInput.maxLength = 100;
renameInput.autocomplete = "off";
renameInput.value = p.name;
const renameSave = document.createElement("button");
renameSave.type = "button";
renameSave.className = "ghost small";
renameSave.textContent = "Save";
const renameCancel = document.createElement("button");
renameCancel.type = "button";
renameCancel.className = "ghost small";
renameCancel.textContent = "Cancel";
const doRename = async () => {
const name = renameInput.value.trim();
if (!name) return;
try {
const updated = await apiFetch(`/${p.id}`, { method: "PUT", body: JSON.stringify({ name }) });
const idx = playlists.findIndex(x => x.id === p.id);
if (idx >= 0) playlists[idx] = updated;
renameForm.classList.add("hidden");
renderPlaylistList();
} catch {
showToast("Could not rename playlist.", "error");
}
};
renameSave.addEventListener("click", doRename);
renameInput.addEventListener("keydown", e => { if (e.key === "Enter") doRename(); });
renameCancel.addEventListener("click", () => renameForm.classList.add("hidden"));
renameForm.appendChild(renameInput);
renameForm.appendChild(renameSave);
renameForm.appendChild(renameCancel);
detail.appendChild(renameForm);
// playback controls (only when this playlist is active)
if (p.id === activePlaylistId) {
const controls = document.createElement("div");
controls.className = "playlist-controls";
const nowPlaying = document.createElement("div");
nowPlaying.className = "playlist-now-playing";
if (playbackState.playing && playbackState.trackId) {
const track = p.tracks.find(t => t.id === playbackState.trackId);
nowPlaying.textContent = track ? `${track.assetName}` : "Playing…";
}
controls.appendChild(nowPlaying);
const buttons = document.createElement("div");
buttons.className = "playlist-buttons";
const prevBtn = document.createElement("button");
prevBtn.type = "button";
prevBtn.className = "ghost small icon-button";
prevBtn.title = "Previous / Restart";
prevBtn.innerHTML = '<i class="fa-solid fa-backward-step" aria-hidden="true"></i>';
prevBtn.addEventListener("click", () => commandPrev(p));
const playPauseBtn = document.createElement("button");
playPauseBtn.type = "button";
playPauseBtn.className = "ghost small icon-button";
playPauseBtn.title = "Play / Pause";
const ppIcon = playbackState.playing && !playbackState.paused ? "fa-pause" : "fa-play";
playPauseBtn.innerHTML = `<i class="fa-solid ${ppIcon}" aria-hidden="true"></i>`;
playPauseBtn.addEventListener("click", () => togglePlayPause(p));
const nextBtn = document.createElement("button");
nextBtn.type = "button";
nextBtn.className = "ghost small icon-button";
nextBtn.title = "Next track";
nextBtn.innerHTML = '<i class="fa-solid fa-forward-step" aria-hidden="true"></i>';
nextBtn.addEventListener("click", () => commandNext(p));
buttons.appendChild(prevBtn);
buttons.appendChild(playPauseBtn);
buttons.appendChild(nextBtn);
controls.appendChild(buttons);
detail.appendChild(controls);
}
// add-track row
const addRow = document.createElement("div");
addRow.className = "playlist-add-track-row";
const sel = document.createElement("select");
sel.className = "playlist-track-select";
sel.innerHTML = '<option value="">Add audio asset…</option>';
audioAssets.forEach(a => {
const opt = document.createElement("option");
opt.value = a.id;
opt.textContent = a.name;
sel.appendChild(opt);
});
const addBtn = document.createElement("button");
addBtn.type = "button";
addBtn.className = "ghost small";
addBtn.textContent = "Add";
addBtn.addEventListener("click", () => addTrack(p, sel.value));
addRow.appendChild(sel);
addRow.appendChild(addBtn);
detail.appendChild(addRow);
// track list
const trackList = document.createElement("ul");
trackList.className = "playlist-track-list";
if (!p.tracks?.length) {
const empty = document.createElement("li");
empty.className = "playlist-track-empty";
empty.textContent = "No tracks yet.";
trackList.appendChild(empty);
} else {
p.tracks.forEach(track => {
const tli = document.createElement("li");
tli.className = "playlist-track-item" + (track.id === playbackState.trackId ? " playing" : "");
tli.dataset.trackId = track.id;
tli.draggable = true;
const handle = document.createElement("span");
handle.className = "playlist-track-handle";
handle.innerHTML = '<i class="fa-solid fa-grip-vertical" aria-hidden="true"></i>';
const name = document.createElement("span");
name.className = "playlist-track-name";
name.textContent = track.assetName;
const removeBtn = document.createElement("button");
removeBtn.type = "button";
removeBtn.className = "ghost small icon-button danger-icon";
removeBtn.title = "Remove";
removeBtn.innerHTML = '<i class="fa-solid fa-xmark" aria-hidden="true"></i>';
removeBtn.addEventListener("click", () => removeTrack(p.id, track.id));
tli.appendChild(handle);
tli.appendChild(name);
tli.appendChild(removeBtn);
trackList.appendChild(tli);
});
bindDragReorder(trackList, p.id);
}
detail.appendChild(trackList);
return detail;
}
// ── Expand / collapse ─────────────────────────────────────────────────
function toggleExpand(playlistId) {
expandedPlaylistId = expandedPlaylistId === playlistId ? null : playlistId;
renderPlaylistList();
}
// ── Active playlist ───────────────────────────────────────────────────
async function toggleActivePlaylist(playlistId) { async function toggleActivePlaylist(playlistId) {
const newId = playlistId === activePlaylistId ? null : playlistId; const newId = playlistId === activePlaylistId ? null : playlistId;
try { try {
await apiFetch("/active", { method: "PUT", body: JSON.stringify({ playlistId: newId }) }); await apiFetch("/active", { method: "PUT", body: JSON.stringify({ playlistId: newId }) });
activePlaylistId = newId; activePlaylistId = newId;
renderPlaylistList(); renderPlaylistList();
renderDetail();
} catch { } catch {
showToast("Could not update active playlist.", "error"); showToast("Could not update active playlist.", "error");
} }
} }
function toggleExpand(playlistId) { // ── Track management ──────────────────────────────────────────────────
expandedPlaylistId = expandedPlaylistId === playlistId ? null : playlistId;
renderPlaylistList();
renderDetail();
}
function expandPlaylist(playlistId) { async function addTrack(p, audioAssetId) {
expandedPlaylistId = playlistId; if (!audioAssetId) { showToast("Select an audio asset to add.", "info"); return; }
renderPlaylistList();
renderDetail();
}
// ── Detail panel ──────────────────────────────────────────────────────
function bindDetailButtons() {
el.renameSave()?.addEventListener("click", saveRename);
el.renameInput()?.addEventListener("keydown", e => { if (e.key === "Enter") saveRename(); });
el.renameCancel()?.addEventListener("click", () => el.renameForm()?.classList.add("hidden"));
}
function renderDetail() {
const detail = el.detail();
if (!detail) return;
const p = getExpanded();
if (!p) { detail.classList.add("hidden"); el.renameForm()?.classList.add("hidden"); return; }
detail.classList.remove("hidden");
renderControls(p);
renderTrackList(p);
}
function renderControls(p) {
const controls = el.controls();
if (!controls) return;
const isActive = p.id === activePlaylistId;
controls.classList.toggle("hidden", !isActive);
if (!isActive) return;
const playPauseIcon = el.playPauseBtn()?.querySelector("i");
if (playPauseIcon) {
playPauseIcon.className = playbackState.playing && !playbackState.paused
? "fa-solid fa-pause"
: "fa-solid fa-play";
}
const label = el.nowPlayingLabel();
if (label) {
if (playbackState.playing && playbackState.trackId) {
const track = p.tracks.find(t => t.id === playbackState.trackId);
label.textContent = track ? `${track.assetName}` : "Playing…";
} else {
label.textContent = "";
}
}
}
function getExpanded() {
return playlists.find(p => p.id === expandedPlaylistId) ?? null;
}
async function saveRename() {
const input = el.renameInput();
const name = input?.value?.trim();
if (!name) return;
const p = getExpanded();
if (!p) return;
try { try {
const updated = await apiFetch(`/${p.id}`, { method: "PUT", body: JSON.stringify({ name }) }); const updated = await apiFetch(`/${p.id}/tracks`, { method: "POST", body: JSON.stringify({ audioAssetId }) });
const idx = playlists.findIndex(x => x.id === p.id); const idx = playlists.findIndex(x => x.id === p.id);
if (idx >= 0) playlists[idx] = updated; if (idx >= 0) playlists[idx] = updated;
el.renameForm()?.classList.add("hidden");
renderPlaylistList(); renderPlaylistList();
renderDetail();
} catch { } catch {
showToast("Could not rename playlist.", "error"); showToast("Could not add track.", "error");
} }
} }
async function deletePlaylist() { async function removeTrack(playlistId, trackId) {
const p = getExpanded(); try {
if (!p) return; const updated = await apiFetch(`/${playlistId}/tracks/${trackId}`, { method: "DELETE" });
const idx = playlists.findIndex(p => p.id === playlistId);
if (idx >= 0) playlists[idx] = updated;
renderPlaylistList();
} catch {
showToast("Could not remove track.", "error");
}
}
async function deletePlaylist(p) {
if (!confirm(`Delete playlist "${p.name}"?`)) return; if (!confirm(`Delete playlist "${p.name}"?`)) return;
try { try {
await apiFetch(`/${p.id}`, { method: "DELETE" }); await apiFetch(`/${p.id}`, { method: "DELETE" });
@@ -332,57 +409,13 @@
if (expandedPlaylistId === p.id) expandedPlaylistId = null; if (expandedPlaylistId === p.id) expandedPlaylistId = null;
if (activePlaylistId === p.id) activePlaylistId = null; if (activePlaylistId === p.id) activePlaylistId = null;
renderPlaylistList(); renderPlaylistList();
renderDetail();
} catch { } catch {
showToast("Could not delete playlist.", "error"); showToast("Could not delete playlist.", "error");
} }
} }
// ── Track list ────────────────────────────────────────────────────────
function renderTrackList(p) {
const list = el.trackList();
if (!list) return;
list.innerHTML = "";
if (!p.tracks?.length) {
const empty = document.createElement("li");
empty.className = "playlist-track-empty";
empty.textContent = "No tracks yet.";
list.appendChild(empty);
return;
}
p.tracks.forEach(track => {
const li = document.createElement("li");
li.className = "playlist-track-item" + (track.id === playbackState.trackId ? " playing" : "");
li.dataset.trackId = track.id;
li.draggable = true;
const handle = document.createElement("span");
handle.className = "playlist-track-handle";
handle.innerHTML = '<i class="fa-solid fa-grip-vertical" aria-hidden="true"></i>';
const name = document.createElement("span");
name.className = "playlist-track-name";
name.textContent = track.assetName;
const removeBtn = document.createElement("button");
removeBtn.type = "button";
removeBtn.className = "ghost small icon-button danger-icon";
removeBtn.title = "Remove from playlist";
removeBtn.innerHTML = '<i class="fa-solid fa-xmark" aria-hidden="true"></i>';
removeBtn.addEventListener("click", () => removeTrack(p.id, track.id));
li.appendChild(handle);
li.appendChild(name);
li.appendChild(removeBtn);
list.appendChild(li);
});
bindDragReorder(list, p.id);
}
function bindDragReorder(list, playlistId) { function bindDragReorder(list, playlistId) {
let dragging = null; let dragging = null;
list.querySelectorAll(".playlist-track-item").forEach(item => { list.querySelectorAll(".playlist-track-item").forEach(item => {
item.addEventListener("dragstart", (e) => { item.addEventListener("dragstart", (e) => {
dragging = item; dragging = item;
@@ -399,15 +432,11 @@
if (dragging && dragging !== item) { if (dragging && dragging !== item) {
list.querySelectorAll(".playlist-track-item").forEach(el => el.classList.remove("drag-over")); list.querySelectorAll(".playlist-track-item").forEach(el => el.classList.remove("drag-over"));
item.classList.add("drag-over"); item.classList.add("drag-over");
// Reorder in DOM
const items = [...list.querySelectorAll(".playlist-track-item")]; const items = [...list.querySelectorAll(".playlist-track-item")];
const fromIdx = items.indexOf(dragging); const fromIdx = items.indexOf(dragging);
const toIdx = items.indexOf(item); const toIdx = items.indexOf(item);
if (fromIdx < toIdx) { if (fromIdx < toIdx) list.insertBefore(dragging, item.nextSibling);
list.insertBefore(dragging, item.nextSibling); else list.insertBefore(dragging, item);
} else {
list.insertBefore(dragging, item);
}
} }
}); });
item.addEventListener("drop", async (e) => { item.addEventListener("drop", async (e) => {
@@ -421,70 +450,25 @@
}); });
const idx = playlists.findIndex(p => p.id === playlistId); const idx = playlists.findIndex(p => p.id === playlistId);
if (idx >= 0) playlists[idx] = updated; if (idx >= 0) playlists[idx] = updated;
// No full re-render needed; DOM is already in the right order
} catch { } catch {
showToast("Could not reorder tracks.", "error"); showToast("Could not reorder tracks.", "error");
await loadPlaylists(); await loadPlaylists();
renderDetail();
} }
}); });
}); });
} }
function bindAddTrack() {
el.addTrackBtn()?.addEventListener("click", addTrack);
}
async function addTrack() {
const p = getExpanded();
if (!p) return;
const audioAssetId = el.trackSelect()?.value;
if (!audioAssetId) { showToast("Select an audio asset to add.", "info"); return; }
try {
const updated = await apiFetch(`/${p.id}/tracks`, { method: "POST", body: JSON.stringify({ audioAssetId }) });
const idx = playlists.findIndex(x => x.id === p.id);
if (idx >= 0) playlists[idx] = updated;
el.trackSelect().value = "";
renderPlaylistList();
renderDetail();
} catch {
showToast("Could not add track.", "error");
}
}
async function removeTrack(playlistId, trackId) {
try {
const updated = await apiFetch(`/${playlistId}/tracks/${trackId}`, { method: "DELETE" });
const idx = playlists.findIndex(p => p.id === playlistId);
if (idx >= 0) playlists[idx] = updated;
renderPlaylistList();
renderDetail();
} catch {
showToast("Could not remove track.", "error");
}
}
// ── Playback controls ───────────────────────────────────────────────── // ── Playback controls ─────────────────────────────────────────────────
function bindPlaybackButtons() { async function togglePlayPause(p) {
el.playPauseBtn()?.addEventListener("click", togglePlayPause);
el.prevBtn()?.addEventListener("click", commandPrev);
el.nextBtn()?.addEventListener("click", commandNext);
}
async function togglePlayPause() {
const p = getExpanded();
if (!p || p.id !== activePlaylistId) return;
if (playbackState.playing && !playbackState.paused) { if (playbackState.playing && !playbackState.paused) {
await commandPause(); await commandPause(p);
} else { } else {
await commandPlay(playbackState.trackId || p.tracks[0]?.id || null); await commandPlay(p, playbackState.trackId || p.tracks[0]?.id || null);
} }
} }
async function commandPlay(trackId) { async function commandPlay(p, trackId) {
const p = getExpanded();
if (!p) return;
try { try {
await apiFetch(`/${p.id}/play`, { method: "POST", body: JSON.stringify({ trackId }) }); await apiFetch(`/${p.id}/play`, { method: "POST", body: JSON.stringify({ trackId }) });
} catch { } catch {
@@ -492,9 +476,7 @@
} }
} }
async function commandPause() { async function commandPause(p) {
const p = getExpanded();
if (!p) return;
try { try {
await apiFetch(`/${p.id}/pause`, { method: "POST" }); await apiFetch(`/${p.id}/pause`, { method: "POST" });
} catch { } catch {
@@ -502,9 +484,8 @@
} }
} }
async function commandNext() { async function commandNext(p) {
const p = getExpanded(); if (!playbackState.trackId) return;
if (!p || !playbackState.trackId) return;
try { try {
await apiFetch(`/${p.id}/next`, { method: "POST", body: JSON.stringify({ currentTrackId: playbackState.trackId }) }); await apiFetch(`/${p.id}/next`, { method: "POST", body: JSON.stringify({ currentTrackId: playbackState.trackId }) });
} catch { } catch {
@@ -512,9 +493,8 @@
} }
} }
async function commandPrev() { async function commandPrev(p) {
const p = getExpanded(); if (!playbackState.trackId) return;
if (!p || !playbackState.trackId) return;
try { try {
await apiFetch(`/${p.id}/prev`, { method: "POST", body: JSON.stringify({ currentTrackId: playbackState.trackId }) }); await apiFetch(`/${p.id}/prev`, { method: "POST", body: JSON.stringify({ currentTrackId: playbackState.trackId }) });
} catch { } catch {
@@ -541,7 +521,6 @@
if (idx >= 0) playlists[idx] = payload; if (idx >= 0) playlists[idx] = payload;
else playlists.push(payload); else playlists.push(payload);
renderPlaylistList(); renderPlaylistList();
if (expandedPlaylistId === payload.id) renderDetail();
} }
break; break;
case "PLAYLIST_DELETED": case "PLAYLIST_DELETED":
@@ -549,45 +528,40 @@
if (expandedPlaylistId === playlistId) expandedPlaylistId = null; if (expandedPlaylistId === playlistId) expandedPlaylistId = null;
if (activePlaylistId === playlistId) activePlaylistId = null; if (activePlaylistId === playlistId) activePlaylistId = null;
renderPlaylistList(); renderPlaylistList();
renderDetail();
break; break;
case "PLAYLIST_SELECTED": case "PLAYLIST_SELECTED":
activePlaylistId = payload?.id ?? null; activePlaylistId = payload?.id ?? null;
playbackState = { playing: false, paused: false, trackId: null }; playbackState = { playing: false, paused: false, trackId: null };
renderPlaylistList(); renderPlaylistList();
renderDetail();
break; break;
case "PLAYLIST_PLAY": case "PLAYLIST_PLAY":
playbackState = { playing: true, paused: false, trackId: trackId ?? null }; playbackState = { playing: true, paused: false, trackId: trackId ?? null };
renderPlaylistList();
updateNowPlayingPill(); updateNowPlayingPill();
renderDetail();
break; break;
case "PLAYLIST_PAUSE": case "PLAYLIST_PAUSE":
playbackState = { ...playbackState, paused: true }; playbackState = { ...playbackState, paused: true };
renderPlaylistList();
updateNowPlayingPill(); updateNowPlayingPill();
renderDetail();
break; break;
case "PLAYLIST_NEXT": case "PLAYLIST_NEXT":
case "PLAYLIST_PREV": case "PLAYLIST_PREV":
if (trackId) { if (trackId) playbackState = { playing: true, paused: false, trackId };
playbackState = { playing: true, paused: false, trackId }; renderPlaylistList();
}
updateNowPlayingPill(); updateNowPlayingPill();
renderDetail();
break; break;
case "PLAYLIST_ENDED": case "PLAYLIST_ENDED":
playbackState = { playing: false, paused: false, trackId: null }; playbackState = { playing: false, paused: false, trackId: null };
renderPlaylistList();
updateNowPlayingPill(); updateNowPlayingPill();
renderDetail();
break; break;
} }
} }
function updateNowPlayingPill() { function updateNowPlayingPill() {
const pill = el.nowPlayingPill(); const pill = document.getElementById("admin-now-playing-pill");
const textEl = el.nowPlayingText(); const textEl = document.getElementById("admin-now-playing-text");
if (!pill || !textEl) return; if (!pill || !textEl) return;
if (playbackState.playing && !playbackState.paused && playbackState.trackId) { if (playbackState.playing && !playbackState.paused && playbackState.trackId) {
const p = playlists.find(pl => pl.id === activePlaylistId); const p = playlists.find(pl => pl.id === activePlaylistId);
const track = p?.tracks?.find(t => t.id === playbackState.trackId); const track = p?.tracks?.find(t => t.id === playbackState.trackId);
+5 -38
View File
@@ -78,55 +78,22 @@
</div> </div>
<!-- Playlist panel --> <!-- Playlist panel -->
<div class="playlist-panel" id="playlist-panel"> <div class="playlist-panel" id="playlist-panel">
<div class="playlist-panel-header" id="playlist-panel-toggle" role="button" tabindex="0" aria-expanded="false"> <div class="playlist-panel-header">
<span class="playlist-panel-title"> <span class="playlist-panel-title">
<i class="fa-solid fa-music" aria-hidden="true"></i> <i class="fa-solid fa-music" aria-hidden="true"></i>
Playlists Playlists
</span> </span>
<i class="fa-solid fa-chevron-down playlist-panel-chevron" aria-hidden="true"></i>
</div> </div>
<div class="playlist-panel-body hidden" id="playlist-panel-body"> <div class="playlist-panel-body" id="playlist-panel-body">
<!-- New playlist form --> <!-- New playlist form -->
<div class="playlist-create-row"> <div class="playlist-create-row">
<input id="new-playlist-name" type="text" placeholder="Playlist name" maxlength="100" autocomplete="off" /> <input id="new-playlist-name" type="text" placeholder="New playlist" maxlength="100" autocomplete="off" />
<button type="button" id="create-playlist-btn" class="ghost small"> <button type="button" id="create-playlist-btn" class="ghost small" title="Create playlist">
<i class="fa-solid fa-plus" aria-hidden="true"></i> <i class="fa-solid fa-plus" aria-hidden="true"></i>
</button> </button>
</div> </div>
<!-- Playlist list --> <!-- Playlist list (items rendered by playlist.js) -->
<ul id="playlist-list" class="playlist-list"></ul> <ul id="playlist-list" class="playlist-list"></ul>
<!-- Playlist detail (shown when one is expanded) -->
<div id="playlist-detail" class="playlist-detail hidden">
<div id="playlist-rename-form" class="playlist-rename-form hidden">
<input id="playlist-rename-input" type="text" maxlength="100" autocomplete="off" />
<button type="button" id="playlist-rename-save" class="ghost small">Save</button>
<button type="button" id="playlist-rename-cancel" class="ghost small">Cancel</button>
</div>
<!-- Playback controls (only when this playlist is active) -->
<div id="playlist-controls" class="playlist-controls hidden">
<div class="playlist-now-playing" id="playlist-now-playing-label"></div>
<div class="playlist-buttons">
<button type="button" id="playlist-prev-btn" class="ghost small icon-button" title="Previous / Restart">
<i class="fa-solid fa-backward-step" aria-hidden="true"></i>
</button>
<button type="button" id="playlist-play-pause-btn" class="ghost small icon-button" title="Play / Pause">
<i class="fa-solid fa-play" aria-hidden="true"></i>
</button>
<button type="button" id="playlist-next-btn" class="ghost small icon-button" title="Next track">
<i class="fa-solid fa-forward-step" aria-hidden="true"></i>
</button>
</div>
</div>
<!-- Add track -->
<div class="playlist-add-track-row">
<select id="playlist-track-select" class="playlist-track-select">
<option value="">Add audio asset…</option>
</select>
<button type="button" id="playlist-add-track-btn" class="ghost small">Add</button>
</div>
<!-- Track list (drag to reorder) -->
<ul id="playlist-track-list" class="playlist-track-list"></ul>
</div>
</div> </div>
</div> </div>
</div> </div>