mirror of
https://github.com/imgfloat/server.git
synced 2026-06-22 21:01:23 +00:00
feat: add playlist admin panel UI, now-playing pill, CSS, and marketplace script
- Admin panel in rail with collapsible playlist section (create, rename,
delete, expand, drag-reorder tracks, playback controls, active selection)
- playlist.js IIFE wired to REST API with CSRF meta-tag injection
- Live events forwarded from console.js via window CustomEvent
- Admin-only now-playing pill overlaid on canvas surface
- playlist-now-playing marketplace script draws rounded pill on canvas
- Fix: add data-asset-type/id/name to asset list items so playlist.js can
populate the track-add dropdown
- Fix: renderer.js _onPlaylistTrackEnded reads XSRF-TOKEN cookie for CSRF
- Fix: playlist.js commandPause no longer passes headers:{} that stripped CSRF
- Fix: PLAYLIST_PREV restarts current track when currentTime >= 3 s
This commit is contained in:
@@ -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."
|
||||||
|
}
|
||||||
@@ -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();
|
||||||
|
};
|
||||||
@@ -1009,6 +1009,297 @@ button:disabled:hover {
|
|||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
gap: 12px;
|
gap: 12px;
|
||||||
height: 100%;
|
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 {
|
.header-actions.tight {
|
||||||
|
|||||||
@@ -727,6 +727,11 @@ export function createAdminConsole({
|
|||||||
applyCanvasSettings(event.payload);
|
applyCanvasSettings(event.payload);
|
||||||
return;
|
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;
|
const assetId = event.assetId || event?.patch?.id || event?.payload?.id;
|
||||||
if (event.type === "PREVIEW" && event.patch) {
|
if (event.type === "PREVIEW" && event.patch) {
|
||||||
applyPreviewPatch(assetId, event.patch);
|
applyPreviewPatch(assetId, event.patch);
|
||||||
@@ -1551,6 +1556,9 @@ export function createAdminConsole({
|
|||||||
li.classList.add("selected");
|
li.classList.add("selected");
|
||||||
}
|
}
|
||||||
li.classList.toggle("is-hidden", !!asset.hidden);
|
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");
|
const row = document.createElement("div");
|
||||||
row.className = "asset-row";
|
row.className = "asset-row";
|
||||||
|
|||||||
@@ -809,8 +809,15 @@ export class BroadcastRenderer {
|
|||||||
case "PLAYLIST_NEXT":
|
case "PLAYLIST_NEXT":
|
||||||
case "PLAYLIST_PREV": {
|
case "PLAYLIST_PREV": {
|
||||||
if (playlistId !== this.playlistState.playlistId) break;
|
if (playlistId !== this.playlistState.playlistId) break;
|
||||||
|
const isPrev = event.type === "PLAYLIST_PREV";
|
||||||
if (trackId) {
|
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 {
|
} else {
|
||||||
// PREV with no trackId means restart current track
|
// PREV with no trackId means restart current track
|
||||||
if (this.playlistCurrentElement) {
|
if (this.playlistCurrentElement) {
|
||||||
@@ -882,11 +889,15 @@ export class BroadcastRenderer {
|
|||||||
_onPlaylistTrackEnded() {
|
_onPlaylistTrackEnded() {
|
||||||
if (!this.playlistState.playlistId || !this.playlistState.trackId) return;
|
if (!this.playlistState.playlistId || !this.playlistState.trackId) return;
|
||||||
// POST to server so it emits the appropriate NEXT or ENDED event
|
// 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(
|
fetch(
|
||||||
`/api/channels/${encodeURIComponent(this.broadcaster)}/playlists/${encodeURIComponent(this.playlistState.playlistId)}/track-ended`,
|
`/api/channels/${encodeURIComponent(this.broadcaster)}/playlists/${encodeURIComponent(this.playlistState.playlistId)}/track-ended`,
|
||||||
{
|
{
|
||||||
method: "POST",
|
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 }),
|
body: JSON.stringify({ trackId: this.playlistState.trackId }),
|
||||||
}
|
}
|
||||||
).catch(() => {});
|
).catch(() => {});
|
||||||
|
|||||||
@@ -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 = '<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);
|
||||||
|
});
|
||||||
|
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
|
||||||
|
? '<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);
|
||||||
|
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 = '<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) {
|
||||||
|
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();
|
||||||
|
}
|
||||||
|
})();
|
||||||
@@ -76,6 +76,72 @@
|
|||||||
<div class="rail-scroll">
|
<div class="rail-scroll">
|
||||||
<ul id="asset-list" class="asset-list"></ul>
|
<ul id="asset-list" class="asset-list"></ul>
|
||||||
</div>
|
</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">
|
||||||
|
<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">
|
||||||
|
<!-- 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">
|
||||||
|
<i class="fa-solid fa-plus" aria-hidden="true"></i>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<!-- Playlist list -->
|
||||||
|
<ul id="playlist-list" class="playlist-list"></ul>
|
||||||
|
<!-- Playlist detail (shown when one is expanded) -->
|
||||||
|
<div id="playlist-detail" class="playlist-detail hidden">
|
||||||
|
<div class="playlist-detail-header">
|
||||||
|
<div class="playlist-detail-title-row">
|
||||||
|
<span id="playlist-detail-name" class="playlist-detail-name"></span>
|
||||||
|
<div class="playlist-detail-actions">
|
||||||
|
<button type="button" id="playlist-rename-btn" class="ghost small icon-button" title="Rename playlist">
|
||||||
|
<i class="fa-solid fa-pencil" aria-hidden="true"></i>
|
||||||
|
</button>
|
||||||
|
<button type="button" id="playlist-delete-btn" class="ghost small icon-button danger-icon" title="Delete playlist">
|
||||||
|
<i class="fa-solid fa-trash" aria-hidden="true"></i>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<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>
|
||||||
|
</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 id="asset-inspector" class="rail-inspector hidden">
|
<div id="asset-inspector" class="rail-inspector hidden">
|
||||||
@@ -374,6 +440,11 @@
|
|||||||
<div class="overlay canvas-boundary" id="admin-overlay">
|
<div class="overlay canvas-boundary" id="admin-overlay">
|
||||||
<div class="canvas-guides"></div>
|
<div class="canvas-guides"></div>
|
||||||
<canvas id="admin-canvas"></canvas>
|
<canvas id="admin-canvas"></canvas>
|
||||||
|
<!-- Now-playing pill: visible only in the admin view, not in the broadcast canvas -->
|
||||||
|
<div id="admin-now-playing-pill" class="admin-now-playing-pill hidden">
|
||||||
|
<i class="fa-solid fa-music" aria-hidden="true"></i>
|
||||||
|
<span id="admin-now-playing-text"></span>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="canvas-footnote">
|
<div class="canvas-footnote">
|
||||||
<p>Edges of the canvas are outlined to match the aspect ratio of the stream.</p>
|
<p>Edges of the canvas are outlined to match the aspect ratio of the stream.</p>
|
||||||
@@ -602,5 +673,6 @@
|
|||||||
<script src="/js/cookie-consent.js"></script>
|
<script src="/js/cookie-consent.js"></script>
|
||||||
<script src="/js/toast.js"></script>
|
<script src="/js/toast.js"></script>
|
||||||
<script type="module" src="/js/admin.js"></script>
|
<script type="module" src="/js/admin.js"></script>
|
||||||
|
<script src="/js/playlist.js"></script>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
|
|||||||
Reference in New Issue
Block a user