diff --git a/doc/marketplace-scripts/playlist-now-playing/metadata.json b/doc/marketplace-scripts/playlist-now-playing/metadata.json new file mode 100644 index 0000000..2ee372a --- /dev/null +++ b/doc/marketplace-scripts/playlist-now-playing/metadata.json @@ -0,0 +1,4 @@ +{ + "name": "Now Playing", + "description": "Shows a minimal pill in the bottom-right corner with the current playlist track name. Fades in when playing and out when paused or stopped." +} diff --git a/doc/marketplace-scripts/playlist-now-playing/source.js b/doc/marketplace-scripts/playlist-now-playing/source.js new file mode 100644 index 0000000..a32a6a7 --- /dev/null +++ b/doc/marketplace-scripts/playlist-now-playing/source.js @@ -0,0 +1,75 @@ +/** + * Now Playing — minimal playlist pill overlay. + * + * Displays a pill in the bottom-right corner of the broadcast canvas + * showing the current track name when a playlist is active and playing. + * Fades in when playback starts and fades out when paused or stopped. + * + * Context used: context.playlist, context.width, context.height + */ + +exports.init = function (context, state) { + state.opacity = 0; + state.targetOpacity = 0; + state.lastTrackName = null; +}; + +exports.tick = function (context, state) { + const { ctx, width, height, deltaMs, playlist } = context; + + const isPlaying = playlist && playlist.active && !playlist.paused && playlist.trackName; + state.targetOpacity = isPlaying ? 1 : 0; + + // Smooth fade + const speed = (deltaMs / 1000) * 3; // ~333ms transition + if (state.opacity < state.targetOpacity) { + state.opacity = Math.min(state.targetOpacity, state.opacity + speed); + } else if (state.opacity > state.targetOpacity) { + state.opacity = Math.max(state.targetOpacity, state.opacity - speed); + } + + if (state.opacity <= 0.01) { + return; + } + + const trackName = playlist?.trackName ?? ""; + + ctx.save(); + ctx.globalAlpha = state.opacity; + + const fontSize = Math.round(height * 0.022); + ctx.font = `600 ${fontSize}px system-ui, sans-serif`; + + const iconGlyph = "\u266A"; // ♪ + const text = `${iconGlyph} ${trackName}`; + const paddingH = fontSize * 0.9; + const paddingV = fontSize * 0.55; + const pillWidth = Math.min(ctx.measureText(text).width + paddingH * 2, width * 0.38); + const pillHeight = fontSize + paddingV * 2; + const margin = Math.round(height * 0.025); + const x = width - pillWidth - margin; + const y = height - pillHeight - margin; + const radius = pillHeight / 2; + + // Pill background + ctx.fillStyle = "rgba(0, 0, 0, 0.60)"; + ctx.beginPath(); + ctx.roundRect(x, y, pillWidth, pillHeight, radius); + ctx.fill(); + + // Clip text to pill + ctx.beginPath(); + ctx.roundRect(x + paddingH, y, pillWidth - paddingH * 2, pillHeight, 0); + ctx.clip(); + + // Draw icon in accent colour + ctx.fillStyle = "#a78bfa"; // purple accent + ctx.fillText(iconGlyph, x + paddingH, y + paddingV + fontSize * 0.82); + + // Draw track name in white + const iconWidth = ctx.measureText(iconGlyph + " ").width; + ctx.fillStyle = "#ffffff"; + ctx.fillText(trackName, x + paddingH + iconWidth, y + paddingV + fontSize * 0.82); + + ctx.restore(); +}; diff --git a/src/main/resources/static/css/styles.css b/src/main/resources/static/css/styles.css index 7be4df3..8f607a4 100644 --- a/src/main/resources/static/css/styles.css +++ b/src/main/resources/static/css/styles.css @@ -1009,6 +1009,297 @@ button:disabled:hover { flex-direction: column; gap: 12px; height: 100%; + position: relative; +} + +/* ── Playlist panel (admin rail) ─────────────────────────────── */ +.playlist-panel { + border-top: 1px solid var(--color-border); + flex-shrink: 0; +} + +.playlist-panel-header { + display: flex; + align-items: center; + justify-content: space-between; + padding: 10px 12px; + cursor: pointer; + user-select: none; + font-size: 13px; + font-weight: 600; + color: var(--color-text-2); + transition: color 0.15s; +} + +.playlist-panel-header:hover { + color: var(--color-text); +} + +.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-create-row { + display: flex; + gap: 6px; + align-items: center; + padding: 4px 0; +} + +.playlist-create-row input { + flex: 1; + font-size: 12px; + padding: 5px 8px; + min-width: 0; +} + +.playlist-list { + list-style: none; + margin: 0; + padding: 0; + display: flex; + flex-direction: column; + gap: 2px; +} + +.playlist-list-empty { + font-size: 12px; + color: var(--color-text-2); + padding: 4px 4px; +} + +.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; +} + +.playlist-list-item:hover { + background: var(--color-surface-3); +} + +.playlist-list-item.active { + border-color: var(--color-accent-border); + background: var(--color-accent-subtle); +} + +.playlist-list-name { + font-size: 13px; + cursor: pointer; + flex: 1; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.playlist-list-actions { + display: flex; + gap: 4px; + flex-shrink: 0; +} + +/* Playlist detail */ +.playlist-detail { + border: 1px solid var(--color-border); + border-radius: 10px; + padding: 10px; + 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; +} + +.playlist-rename-form { + display: flex; + gap: 4px; + align-items: center; + flex-wrap: wrap; +} + +.playlist-rename-form input { + flex: 1; + font-size: 12px; + padding: 4px 7px; + min-width: 0; +} + +/* Playback controls */ +.playlist-controls { + display: flex; + flex-direction: column; + gap: 4px; + padding: 6px 0 2px; + border-top: 1px solid var(--color-border); +} + +.playlist-now-playing { + font-size: 11px; + color: var(--color-text-2); + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + min-height: 14px; +} + +.playlist-buttons { + display: flex; + gap: 4px; + align-items: center; +} + +/* Track list */ +.playlist-add-track-row { + display: flex; + gap: 6px; + align-items: center; +} + +.playlist-track-select { + flex: 1; + font-size: 12px; + padding: 4px 6px; + min-width: 0; + background: var(--color-surface-2); + border: 1px solid var(--color-border); + border-radius: 6px; + color: var(--color-text); +} + +.playlist-track-list { + list-style: none; + margin: 0; + padding: 0; + display: flex; + flex-direction: column; + gap: 2px; + max-height: 160px; + overflow-y: auto; +} + +.playlist-track-empty { + font-size: 12px; + color: var(--color-text-2); + padding: 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; +} + +.playlist-track-item.playing { + border-color: var(--color-accent-border); + background: var(--color-accent-subtle); +} + +.playlist-track-item.dragging { + opacity: 0.4; +} + +.playlist-track-item.drag-over { + border-color: var(--color-accent-icon); +} + +.playlist-track-handle { + color: var(--color-text-2); + font-size: 11px; + cursor: grab; + flex-shrink: 0; +} + +.playlist-track-name { + font-size: 12px; + flex: 1; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +/* ── Admin now-playing pill (canvas overlay, admin-only) ─────── */ +.admin-now-playing-pill { + position: absolute; + bottom: 12px; + right: 12px; + display: flex; + align-items: center; + gap: 6px; + padding: 5px 10px; + border-radius: 999px; + background: rgba(0, 0, 0, 0.65); + backdrop-filter: blur(4px); + color: #fff; + font-size: 12px; + font-weight: 500; + pointer-events: none; + z-index: 10; + max-width: 220px; + overflow: hidden; +} + +.admin-now-playing-pill i { + color: var(--color-accent-icon); + flex-shrink: 0; +} + +/* danger-icon helper for ghost icon buttons */ +.danger-icon { + color: var(--color-danger, #ef4444) !important; } .header-actions.tight { diff --git a/src/main/resources/static/js/admin/console.js b/src/main/resources/static/js/admin/console.js index 67b7b58..2660a42 100644 --- a/src/main/resources/static/js/admin/console.js +++ b/src/main/resources/static/js/admin/console.js @@ -727,6 +727,11 @@ export function createAdminConsole({ applyCanvasSettings(event.payload); return; } + // Forward playlist events to playlist.js via a DOM event + if (event.type && event.type.startsWith("PLAYLIST_")) { + window.dispatchEvent(new CustomEvent("playlistEvent", { detail: event })); + return; + } const assetId = event.assetId || event?.patch?.id || event?.payload?.id; if (event.type === "PREVIEW" && event.patch) { applyPreviewPatch(assetId, event.patch); @@ -1551,6 +1556,9 @@ export function createAdminConsole({ li.classList.add("selected"); } li.classList.toggle("is-hidden", !!asset.hidden); + li.dataset.assetId = asset.id; + li.dataset.assetName = asset.name || ""; + li.dataset.assetType = asset.assetType || ""; const row = document.createElement("div"); row.className = "asset-row"; diff --git a/src/main/resources/static/js/broadcast/renderer.js b/src/main/resources/static/js/broadcast/renderer.js index ea04aee..d910cc6 100644 --- a/src/main/resources/static/js/broadcast/renderer.js +++ b/src/main/resources/static/js/broadcast/renderer.js @@ -809,8 +809,15 @@ export class BroadcastRenderer { case "PLAYLIST_NEXT": case "PLAYLIST_PREV": { if (playlistId !== this.playlistState.playlistId) break; + const isPrev = event.type === "PLAYLIST_PREV"; if (trackId) { - this._playTrack(trackId); + // For PREV: if current track has played >= 3 s, restart it instead of going back + if (isPrev && this.playlistCurrentElement && this.playlistCurrentElement.currentTime >= 3) { + this.playlistCurrentElement.currentTime = 0; + this.playlistCurrentElement.play().catch(() => {}); + } else { + this._playTrack(trackId); + } } else { // PREV with no trackId means restart current track if (this.playlistCurrentElement) { @@ -882,11 +889,15 @@ export class BroadcastRenderer { _onPlaylistTrackEnded() { if (!this.playlistState.playlistId || !this.playlistState.trackId) return; // POST to server so it emits the appropriate NEXT or ENDED event + const xsrfToken = document.cookie.split("; ").find(c => c.startsWith("XSRF-TOKEN="))?.split("=")[1]; fetch( `/api/channels/${encodeURIComponent(this.broadcaster)}/playlists/${encodeURIComponent(this.playlistState.playlistId)}/track-ended`, { method: "POST", - headers: { "Content-Type": "application/json" }, + headers: { + "Content-Type": "application/json", + ...(xsrfToken ? { "X-XSRF-TOKEN": decodeURIComponent(xsrfToken) } : {}), + }, body: JSON.stringify({ trackId: this.playlistState.trackId }), } ).catch(() => {}); diff --git a/src/main/resources/static/js/playlist.js b/src/main/resources/static/js/playlist.js new file mode 100644 index 0000000..78bdea2 --- /dev/null +++ b/src/main/resources/static/js/playlist.js @@ -0,0 +1,597 @@ +/** + * Playlist management panel for the admin console. + * + * Depends on `broadcaster` being defined in the page scope (set by admin.html inline script). + * Depends on `showToast` from toast.js. + * Communicates with the server via REST; live updates arrive via the shared STOMP topic and are + * forwarded here from admin.js via window.dispatchEvent("playlistEvent"). + */ +(function () { + "use strict"; + + const apiBase = () => `/api/channels/${encodeURIComponent(broadcaster)}/playlists`; + + // ── State ───────────────────────────────────────────────────────────── + + let playlists = []; // PlaylistView[] + let expandedPlaylistId = null; + let activePlaylistId = null; // the channel's persisted active playlist + let playbackState = { // mirrors the renderer's playlistState + playing: false, + paused: false, + trackId: null, + }; + 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"), + detailName: () => document.getElementById("playlist-detail-name"), + renameBtn: () => document.getElementById("playlist-rename-btn"), + deleteBtn: () => document.getElementById("playlist-delete-btn"), + 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 = {}) { + const csrfToken = document.querySelector("meta[name='_csrf']")?.content; + const csrfHeader = document.querySelector("meta[name='_csrf_header']")?.content; + const headers = { "Content-Type": "application/json", ...(options.headers || {}) }; + if (csrfToken && csrfHeader) headers[csrfHeader] = csrfToken; + const response = await fetch(`${apiBase()}${path}`, { ...options, headers }); + if (!response.ok) throw new Error(`Request failed: ${response.status}`); + if (response.status === 204) return null; + return response.json(); + } + + // ── 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 }); + } + } + + 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, + })).filter(a => a.id); + renderTrackSelectOptions(); + } + + function renderTrackSelectOptions() { + const sel = el.trackSelect(); + if (!sel) return; + const current = sel.value; + sel.innerHTML = ''; + audioAssets.forEach(a => { + const opt = document.createElement("option"); + opt.value = a.id; + opt.textContent = a.name; + sel.appendChild(opt); + }); + if (current) sel.value = current; + } + + // ── Load ────────────────────────────────────────────────────────────── + + async function loadPlaylists() { + try { + playlists = await apiFetch("") || []; + renderPlaylistList(); + } catch { + showToast("Unable to load playlists.", "error"); + } + } + + async function loadActivePlaylist() { + try { + 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(); } }); + } + + async function createPlaylist() { + const input = el.newNameInput(); + 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 }) }); + playlists.push(view); + input.value = ""; + renderPlaylistList(); + expandPlaylist(view.id); + } catch { + showToast("Could not create playlist.", "error"); + } + } + + // ── Playlist list ───────────────────────────────────────────────────── + + function renderPlaylistList() { + const list = el.list(); + if (!list) return; + list.innerHTML = ""; + if (playlists.length === 0) { + const empty = document.createElement("li"); + empty.className = "playlist-list-empty"; + empty.textContent = "No playlists yet."; + list.appendChild(empty); + return; + } + playlists.forEach(p => { + const li = document.createElement("li"); + li.className = "playlist-list-item" + (p.id === expandedPlaylistId ? " expanded" : "") + (p.id === activePlaylistId ? " active" : ""); + li.dataset.id = p.id; + + const nameSpan = document.createElement("span"); + nameSpan.className = "playlist-list-name"; + nameSpan.textContent = p.name; + nameSpan.addEventListener("click", () => toggleExpand(p.id)); + + const actions = document.createElement("div"); + actions.className = "playlist-list-actions"; + + 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.addEventListener("click", (e) => { e.stopPropagation(); toggleActivePlaylist(p.id); }); + + actions.appendChild(selectBtn); + li.appendChild(nameSpan); + li.appendChild(actions); + 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 toggleExpand(playlistId) { + expandedPlaylistId = expandedPlaylistId === playlistId ? null : playlistId; + renderPlaylistList(); + renderDetail(); + } + + function expandPlaylist(playlistId) { + expandedPlaylistId = playlistId; + renderPlaylistList(); + renderDetail(); + } + + // ── Detail panel ────────────────────────────────────────────────────── + + function bindDetailButtons() { + el.renameBtn()?.addEventListener("click", () => { + const p = getExpanded(); + if (!p) return; + el.renameInput().value = p.name; + el.renameForm()?.classList.remove("hidden"); + }); + el.renameSave()?.addEventListener("click", saveRename); + el.renameInput()?.addEventListener("keydown", e => { if (e.key === "Enter") saveRename(); }); + el.renameCancel()?.addEventListener("click", () => el.renameForm()?.classList.add("hidden")); + el.deleteBtn()?.addEventListener("click", deletePlaylist); + } + + function renderDetail() { + const detail = el.detail(); + if (!detail) return; + const p = getExpanded(); + if (!p) { detail.classList.add("hidden"); return; } + detail.classList.remove("hidden"); + el.detailName().textContent = p.name; + el.renameForm()?.classList.add("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 { + 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"); + renderPlaylistList(); + renderDetail(); + } catch { + showToast("Could not rename playlist.", "error"); + } + } + + 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"); + } + } + + // ── 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 = ''; + + 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 = ''; + 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) { + let dragging = null; + + list.querySelectorAll(".playlist-track-item").forEach(item => { + item.addEventListener("dragstart", (e) => { + dragging = item; + item.classList.add("dragging"); + e.dataTransfer.effectAllowed = "move"; + }); + item.addEventListener("dragend", () => { + item.classList.remove("dragging"); + dragging = null; + list.querySelectorAll(".playlist-track-item").forEach(el => el.classList.remove("drag-over")); + }); + item.addEventListener("dragover", (e) => { + e.preventDefault(); + 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); + } + } + }); + item.addEventListener("drop", async (e) => { + e.preventDefault(); + item.classList.remove("drag-over"); + const newOrder = [...list.querySelectorAll(".playlist-track-item")].map(li => li.dataset.trackId); + try { + const updated = await apiFetch(`/${playlistId}/tracks/order`, { + method: "PUT", + body: JSON.stringify({ trackIds: newOrder }), + }); + 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; + if (playbackState.playing && !playbackState.paused) { + await commandPause(); + } else { + await commandPlay(playbackState.trackId || p.tracks[0]?.id || null); + } + } + + async function commandPlay(trackId) { + const p = getExpanded(); + if (!p) return; + try { + await apiFetch(`/${p.id}/play`, { method: "POST", body: JSON.stringify({ trackId }) }); + } catch { + showToast("Could not start playback.", "error"); + } + } + + async function commandPause() { + const p = getExpanded(); + if (!p) return; + try { + await apiFetch(`/${p.id}/pause`, { method: "POST" }); + } catch { + showToast("Could not pause.", "error"); + } + } + + async function commandNext() { + const p = getExpanded(); + if (!p || !playbackState.trackId) return; + try { + await apiFetch(`/${p.id}/next`, { method: "POST", body: JSON.stringify({ currentTrackId: playbackState.trackId }) }); + } catch { + showToast("Could not skip to next.", "error"); + } + } + + async function commandPrev() { + const p = getExpanded(); + if (!p || !playbackState.trackId) return; + try { + await apiFetch(`/${p.id}/prev`, { method: "POST", body: JSON.stringify({ currentTrackId: playbackState.trackId }) }); + } catch { + showToast("Could not go back.", "error"); + } + } + + // ── Live event handler ──────────────────────────────────────────────── + + function handlePlaylistEvent(event) { + if (!event?.type) return; + const { type, playlistId, trackId, payload } = event; + + switch (type) { + case "PLAYLIST_CREATED": + if (payload && !playlists.find(p => p.id === payload.id)) { + playlists.push(payload); + renderPlaylistList(); + } + break; + case "PLAYLIST_UPDATED": + if (payload) { + const idx = playlists.findIndex(p => p.id === payload.id); + if (idx >= 0) playlists[idx] = payload; + else playlists.push(payload); + renderPlaylistList(); + if (expandedPlaylistId === payload.id) renderDetail(); + } + break; + case "PLAYLIST_DELETED": + playlists = playlists.filter(p => p.id !== playlistId); + 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 }; + updateNowPlayingPill(); + renderDetail(); + break; + case "PLAYLIST_PAUSE": + playbackState = { ...playbackState, paused: true }; + updateNowPlayingPill(); + renderDetail(); + break; + case "PLAYLIST_NEXT": + case "PLAYLIST_PREV": + if (trackId) { + playbackState = { playing: true, paused: false, trackId }; + } + updateNowPlayingPill(); + renderDetail(); + break; + case "PLAYLIST_ENDED": + playbackState = { playing: false, paused: false, trackId: null }; + updateNowPlayingPill(); + renderDetail(); + break; + } + } + + function updateNowPlayingPill() { + const pill = el.nowPlayingPill(); + const textEl = el.nowPlayingText(); + 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); + if (track) { + textEl.textContent = track.assetName; + pill.classList.remove("hidden"); + return; + } + } + pill.classList.add("hidden"); + } + + // ── Bootstrap ───────────────────────────────────────────────────────── + + if (document.readyState === "loading") { + document.addEventListener("DOMContentLoaded", init); + } else { + init(); + } +})(); diff --git a/src/main/resources/templates/admin.html b/src/main/resources/templates/admin.html index 13a5dd1..758e967 100644 --- a/src/main/resources/templates/admin.html +++ b/src/main/resources/templates/admin.html @@ -76,6 +76,72 @@
+ +
+ + +