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) ─────────────────────────────── */
/*
* 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 {
border-top: 1px solid var(--color-border);
flex-shrink: 0;
display: flex;
flex-direction: column;
max-height: 40vh;
}
.playlist-panel-header {
display: flex;
align-items: center;
justify-content: space-between;
padding: 10px 12px;
cursor: pointer;
user-select: none;
font-size: 13px;
padding: 8px 12px 4px;
font-size: 12px;
font-weight: 600;
color: var(--color-text-2);
transition: color 0.15s;
}
.playlist-panel-header:hover {
color: var(--color-text);
text-transform: uppercase;
letter-spacing: 0.4px;
flex-shrink: 0;
}
.playlist-panel-title {
display: flex;
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;
}
.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 {
display: flex;
gap: 6px;
align-items: center;
padding: 4px 0;
padding: 2px 0 4px;
flex-shrink: 0;
}
.playlist-create-row input {
@@ -1079,13 +1078,14 @@ button:disabled:hover {
border-color: var(--color-accent-border);
}
/* list */
.playlist-list {
list-style: none;
margin: 0;
padding: 0;
display: flex;
flex-direction: column;
gap: 2px;
gap: 1px;
}
.playlist-list-empty {
@@ -1094,20 +1094,10 @@ button:disabled:hover {
padding: 4px 4px;
}
/* each playlist row */
.playlist-list-item {
display: flex;
align-items: center;
justify-content: space-between;
gap: 6px;
padding: 5px 6px;
border-radius: 8px;
border: 1px solid transparent;
transition: background 0.12s;
cursor: pointer;
}
.playlist-list-item:hover {
background: var(--color-surface-3);
}
.playlist-list-item.active {
@@ -1115,9 +1105,23 @@ button:disabled:hover {
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 {
font-size: 13px;
cursor: pointer;
flex: 1;
overflow: hidden;
text-overflow: ellipsis;
@@ -1126,48 +1130,19 @@ button:disabled:hover {
.playlist-list-actions {
display: flex;
gap: 4px;
gap: 2px;
flex-shrink: 0;
}
/* Playlist detail */
.playlist-detail {
border: 1px solid var(--color-border);
border-radius: 10px;
padding: 10px;
/* inline detail — appears directly under the row when expanded */
.playlist-inline-detail {
padding: 4px 6px 8px 6px;
display: flex;
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;
}
.playlist-detail-name {
font-size: 13px;
font-weight: 600;
flex: 1;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.playlist-detail-actions {
display: flex;
gap: 4px;
}
/* rename form */
.playlist-rename-form {
display: flex;
gap: 4px;
@@ -1190,13 +1165,11 @@ button:disabled:hover {
border-color: var(--color-accent-border);
}
/* Playback controls */
/* playback controls */
.playlist-controls {
display: flex;
flex-direction: column;
gap: 4px;
padding: 6px 0 2px;
border-top: 1px solid var(--color-border);
align-items: center;
gap: 6px;
}
.playlist-now-playing {
@@ -1205,16 +1178,18 @@ button:disabled:hover {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
min-height: 14px;
flex: 1;
min-width: 0;
}
.playlist-buttons {
display: flex;
gap: 4px;
gap: 2px;
align-items: center;
flex-shrink: 0;
}
/* Track list */
/* add-track row */
.playlist-add-track-row {
display: flex;
gap: 6px;
@@ -1232,36 +1207,39 @@ button:disabled:hover {
color: var(--color-text);
}
/* track list — max 4 items visible */
.playlist-track-list {
list-style: none;
margin: 0;
padding: 0;
display: flex;
flex-direction: column;
gap: 2px;
max-height: 160px;
gap: 1px;
max-height: calc(4 * 30px);
overflow-y: auto;
}
.playlist-track-empty {
font-size: 12px;
color: var(--color-text-2);
padding: 4px;
padding: 2px 4px;
}
.playlist-track-item {
display: flex;
align-items: center;
gap: 6px;
padding: 5px 6px;
border-radius: 7px;
border: 1px solid transparent;
background: var(--color-surface-2);
cursor: grab;
padding: 4px 4px;
border-radius: 6px;
min-height: 30px;
transition: background 0.1s;
}
.playlist-track-item:hover {
background: var(--color-surface-3);
}
.playlist-track-item.playing {
border-color: var(--color-accent-border);
background: var(--color-accent-subtle);
}
@@ -1270,13 +1248,13 @@ button:disabled:hover {
}
.playlist-track-item.drag-over {
border-color: var(--color-accent-icon);
background: var(--color-surface-4);
}
.playlist-track-handle {
color: var(--color-text-2);
font-size: 11px;
cursor: grab;
font-size: 11px;
flex-shrink: 0;
}
+240 -266
View File
@@ -23,33 +23,6 @@
};
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 ───────────────────────────────────────────────────────
async function apiFetch(path, options = {}) {
@@ -67,17 +40,11 @@
// ── Initialisation ────────────────────────────────────────────────────
async function init() {
bindToggle();
bindCreate();
bindDetailButtons();
bindPlaybackButtons();
bindAddTrack();
await loadPlaylists();
await loadActivePlaylist();
refreshAudioAssetOptions();
// Listen for live updates forwarded from admin.js
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");
if (assetListEl) {
new MutationObserver(refreshAudioAssetOptions).observe(assetListEl, { childList: true, subtree: true });
@@ -85,18 +52,13 @@
}
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']");
audioAssets = Array.from(items).map(item => ({
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);
renderTrackSelectOptions();
}
function renderTrackSelectOptions() {
const sel = el.trackSelect();
if (!sel) return;
// Update any open track-select dropdowns
document.querySelectorAll(".playlist-track-select").forEach(sel => {
const current = sel.value;
sel.innerHTML = '<option value="">Add audio asset…</option>';
audioAssets.forEach(a => {
@@ -106,6 +68,7 @@
sel.appendChild(opt);
});
if (current) sel.value = current;
});
}
// ── Load ──────────────────────────────────────────────────────────────
@@ -124,43 +87,27 @@
const active = await fetch(`${apiBase()}/active`).then(r => r.ok ? r.json() : null).catch(() => null);
activePlaylistId = active?.id ?? null;
renderPlaylistList();
if (expandedPlaylistId) renderDetail();
} 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 ───────────────────────────────────────────────────
function bindCreate() {
el.createBtn()?.addEventListener("click", createPlaylist);
el.newNameInput()?.addEventListener("keydown", e => { if (e.key === "Enter") { e.preventDefault(); createPlaylist(); } });
document.getElementById("create-playlist-btn")?.addEventListener("click", createPlaylist);
document.getElementById("new-playlist-name")?.addEventListener("keydown", e => {
if (e.key === "Enter") { e.preventDefault(); createPlaylist(); }
});
}
async function createPlaylist() {
const input = el.newNameInput();
const input = document.getElementById("new-playlist-name");
const name = input?.value?.trim();
if (!name) { showToast("Enter a playlist name.", "info"); return; }
try {
const view = await apiFetch("", { method: "POST", body: JSON.stringify({ name }) });
input.value = "";
// 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;
} catch {
showToast("Could not create playlist.", "error");
@@ -170,7 +117,7 @@
// ── Playlist list ─────────────────────────────────────────────────────
function renderPlaylistList() {
const list = el.list();
const list = document.getElementById("playlist-list");
if (!list) return;
list.innerHTML = "";
if (playlists.length === 0) {
@@ -181,10 +128,19 @@
return;
}
playlists.forEach(p => {
const isExpanded = p.id === expandedPlaylistId;
const isActive = p.id === activePlaylistId;
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.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");
nameSpan.className = "playlist-list-name";
@@ -193,169 +149,185 @@
const actions = document.createElement("div");
actions.className = "playlist-list-actions";
// select-for-playback button
const selectBtn = document.createElement("button");
selectBtn.type = "button";
selectBtn.className = "ghost small" + (p.id === activePlaylistId ? " active" : "");
selectBtn.title = p.id === activePlaylistId ? "Active — click to deselect" : "Select for playback";
selectBtn.innerHTML = p.id === activePlaylistId
selectBtn.className = "ghost small icon-button" + (isActive ? " active" : "");
selectBtn.title = isActive ? "Active — click to deselect" : "Select for playback";
selectBtn.innerHTML = isActive
? '<i class="fa-solid fa-check" aria-hidden="true"></i>'
: '<i class="fa-solid fa-circle-play" aria-hidden="true"></i>';
selectBtn.addEventListener("click", (e) => { e.stopPropagation(); toggleActivePlaylist(p.id); });
actions.appendChild(selectBtn);
if (p.id === expandedPlaylistId) {
if (isExpanded) {
const renameBtn = document.createElement("button");
renameBtn.type = "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.addEventListener("click", (e) => {
e.stopPropagation();
el.renameInput().value = p.name;
el.renameForm()?.classList.remove("hidden");
el.renameInput()?.focus();
const form = li.querySelector(".playlist-rename-form");
if (form) {
form.classList.toggle("hidden");
if (!form.classList.contains("hidden")) {
form.querySelector("input")?.focus();
}
}
});
const deleteBtn = document.createElement("button");
deleteBtn.type = "button";
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.addEventListener("click", (e) => { e.stopPropagation(); deletePlaylist(); });
deleteBtn.addEventListener("click", (e) => { e.stopPropagation(); deletePlaylist(p); });
actions.appendChild(renameBtn);
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);
});
}
async function toggleActivePlaylist(playlistId) {
const newId = playlistId === activePlaylistId ? null : playlistId;
try {
await apiFetch("/active", { method: "PUT", body: JSON.stringify({ playlistId: newId }) });
activePlaylistId = newId;
renderPlaylistList();
renderDetail();
} catch {
showToast("Could not update active playlist.", "error");
}
}
function buildInlineDetail(p) {
const detail = document.createElement("div");
detail.className = "playlist-inline-detail";
function toggleExpand(playlistId) {
expandedPlaylistId = expandedPlaylistId === playlistId ? null : playlistId;
renderPlaylistList();
renderDetail();
}
// 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";
function expandPlaylist(playlistId) {
expandedPlaylistId = playlistId;
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();
const doRename = async () => {
const name = renameInput.value.trim();
if (!name) return;
const p = getExpanded();
if (!p) 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;
el.renameForm()?.classList.add("hidden");
renameForm.classList.add("hidden");
renderPlaylistList();
renderDetail();
} 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);
}
async function deletePlaylist() {
const p = getExpanded();
if (!p) return;
if (!confirm(`Delete playlist "${p.name}"?`)) return;
try {
await apiFetch(`/${p.id}`, { method: "DELETE" });
playlists = playlists.filter(x => x.id !== p.id);
if (expandedPlaylistId === p.id) expandedPlaylistId = null;
if (activePlaylistId === p.id) activePlaylistId = null;
renderPlaylistList();
renderDetail();
} catch {
showToast("Could not delete playlist.", "error");
}
}
// add-track row
const addRow = document.createElement("div");
addRow.className = "playlist-add-track-row";
// ── Track list ────────────────────────────────────────────────────────
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";
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;
}
trackList.appendChild(empty);
} else {
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 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";
@@ -368,21 +340,82 @@
const removeBtn = document.createElement("button");
removeBtn.type = "button";
removeBtn.className = "ghost small icon-button danger-icon";
removeBtn.title = "Remove from playlist";
removeBtn.title = "Remove";
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);
tli.appendChild(handle);
tli.appendChild(name);
tli.appendChild(removeBtn);
trackList.appendChild(tli);
});
bindDragReorder(list, p.id);
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) {
const newId = playlistId === activePlaylistId ? null : playlistId;
try {
await apiFetch("/active", { method: "PUT", body: JSON.stringify({ playlistId: newId }) });
activePlaylistId = newId;
renderPlaylistList();
} catch {
showToast("Could not update active playlist.", "error");
}
}
// ── Track management ──────────────────────────────────────────────────
async function addTrack(p, audioAssetId) {
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;
renderPlaylistList();
} 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();
} catch {
showToast("Could not remove track.", "error");
}
}
async function deletePlaylist(p) {
if (!confirm(`Delete playlist "${p.name}"?`)) return;
try {
await apiFetch(`/${p.id}`, { method: "DELETE" });
playlists = playlists.filter(x => x.id !== p.id);
if (expandedPlaylistId === p.id) expandedPlaylistId = null;
if (activePlaylistId === p.id) activePlaylistId = null;
renderPlaylistList();
} catch {
showToast("Could not delete playlist.", "error");
}
}
function bindDragReorder(list, playlistId) {
let dragging = null;
list.querySelectorAll(".playlist-track-item").forEach(item => {
item.addEventListener("dragstart", (e) => {
dragging = item;
@@ -399,15 +432,11 @@
if (dragging && dragging !== item) {
list.querySelectorAll(".playlist-track-item").forEach(el => el.classList.remove("drag-over"));
item.classList.add("drag-over");
// Reorder in DOM
const items = [...list.querySelectorAll(".playlist-track-item")];
const fromIdx = items.indexOf(dragging);
const toIdx = items.indexOf(item);
if (fromIdx < toIdx) {
list.insertBefore(dragging, item.nextSibling);
} else {
list.insertBefore(dragging, item);
}
if (fromIdx < toIdx) list.insertBefore(dragging, item.nextSibling);
else list.insertBefore(dragging, item);
}
});
item.addEventListener("drop", async (e) => {
@@ -421,70 +450,25 @@
});
const idx = playlists.findIndex(p => p.id === playlistId);
if (idx >= 0) playlists[idx] = updated;
// No full re-render needed; DOM is already in the right order
} catch {
showToast("Could not reorder tracks.", "error");
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 ─────────────────────────────────────────────────
function bindPlaybackButtons() {
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;
async function togglePlayPause(p) {
if (playbackState.playing && !playbackState.paused) {
await commandPause();
await commandPause(p);
} else {
await commandPlay(playbackState.trackId || p.tracks[0]?.id || null);
await commandPlay(p, playbackState.trackId || p.tracks[0]?.id || null);
}
}
async function commandPlay(trackId) {
const p = getExpanded();
if (!p) return;
async function commandPlay(p, trackId) {
try {
await apiFetch(`/${p.id}/play`, { method: "POST", body: JSON.stringify({ trackId }) });
} catch {
@@ -492,9 +476,7 @@
}
}
async function commandPause() {
const p = getExpanded();
if (!p) return;
async function commandPause(p) {
try {
await apiFetch(`/${p.id}/pause`, { method: "POST" });
} catch {
@@ -502,9 +484,8 @@
}
}
async function commandNext() {
const p = getExpanded();
if (!p || !playbackState.trackId) return;
async function commandNext(p) {
if (!playbackState.trackId) return;
try {
await apiFetch(`/${p.id}/next`, { method: "POST", body: JSON.stringify({ currentTrackId: playbackState.trackId }) });
} catch {
@@ -512,9 +493,8 @@
}
}
async function commandPrev() {
const p = getExpanded();
if (!p || !playbackState.trackId) return;
async function commandPrev(p) {
if (!playbackState.trackId) return;
try {
await apiFetch(`/${p.id}/prev`, { method: "POST", body: JSON.stringify({ currentTrackId: playbackState.trackId }) });
} catch {
@@ -541,7 +521,6 @@
if (idx >= 0) playlists[idx] = payload;
else playlists.push(payload);
renderPlaylistList();
if (expandedPlaylistId === payload.id) renderDetail();
}
break;
case "PLAYLIST_DELETED":
@@ -549,45 +528,40 @@
if (expandedPlaylistId === playlistId) expandedPlaylistId = null;
if (activePlaylistId === playlistId) activePlaylistId = null;
renderPlaylistList();
renderDetail();
break;
case "PLAYLIST_SELECTED":
activePlaylistId = payload?.id ?? null;
playbackState = { playing: false, paused: false, trackId: null };
renderPlaylistList();
renderDetail();
break;
case "PLAYLIST_PLAY":
playbackState = { playing: true, paused: false, trackId: trackId ?? null };
renderPlaylistList();
updateNowPlayingPill();
renderDetail();
break;
case "PLAYLIST_PAUSE":
playbackState = { ...playbackState, paused: true };
renderPlaylistList();
updateNowPlayingPill();
renderDetail();
break;
case "PLAYLIST_NEXT":
case "PLAYLIST_PREV":
if (trackId) {
playbackState = { playing: true, paused: false, trackId };
}
if (trackId) playbackState = { playing: true, paused: false, trackId };
renderPlaylistList();
updateNowPlayingPill();
renderDetail();
break;
case "PLAYLIST_ENDED":
playbackState = { playing: false, paused: false, trackId: null };
renderPlaylistList();
updateNowPlayingPill();
renderDetail();
break;
}
}
function updateNowPlayingPill() {
const pill = el.nowPlayingPill();
const textEl = el.nowPlayingText();
const pill = document.getElementById("admin-now-playing-pill");
const textEl = document.getElementById("admin-now-playing-text");
if (!pill || !textEl) return;
if (playbackState.playing && !playbackState.paused && playbackState.trackId) {
const p = playlists.find(pl => pl.id === activePlaylistId);
const track = p?.tracks?.find(t => t.id === playbackState.trackId);
+5 -38
View File
@@ -78,55 +78,22 @@
</div>
<!-- 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">
<i class="fa-solid fa-music" aria-hidden="true"></i>
Playlists
</span>
<i class="fa-solid fa-chevron-down playlist-panel-chevron" aria-hidden="true"></i>
</div>
<div class="playlist-panel-body hidden" id="playlist-panel-body">
<div class="playlist-panel-body" id="playlist-panel-body">
<!-- New playlist form -->
<div class="playlist-create-row">
<input id="new-playlist-name" type="text" placeholder="Playlist name" maxlength="100" autocomplete="off" />
<button type="button" id="create-playlist-btn" class="ghost small">
<input id="new-playlist-name" type="text" placeholder="New playlist" maxlength="100" autocomplete="off" />
<button type="button" id="create-playlist-btn" class="ghost small" title="Create playlist">
<i class="fa-solid fa-plus" aria-hidden="true"></i>
</button>
</div>
<!-- Playlist list -->
<!-- Playlist list (items rendered by playlist.js) -->
<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>