mirror of
https://github.com/imgfloat/server.git
synced 2026-06-22 21:01:23 +00:00
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:
@@ -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;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
Reference in New Issue
Block a user