mirror of
https://github.com/got-feedBack/feedBack.git
synced 2026-08-10 18:59:56 +00:00
Playlists could only ever be listed alphabetically (system playlists first). Users who group playlists by purpose had no way to put the ones they reach for daily at the front. Adds a nullable `position` column and orders by `(system_key IS NULL), (position IS NULL), position, name COLLATE NOCASE`, so manually-ordered playlists lead, unpositioned ones keep sorting alphabetically behind them, and system playlists stay pinned first. Drag-reorder mirrors the existing within-playlist song reorder, adapted for grid tiles (insert side decided on the horizontal midpoint since tiles flow left-to-right then wrap). System playlists are neither drag sources nor drop targets. `POST /api/playlists/reorder` requires an exact permutation of the current non-system ids, so a duplicate, omission, extra, unknown id, or a system id is rejected rather than silently producing duplicate positions; booleans are rejected explicitly because `sorted([True, 2]) == sorted([1, 2])` would otherwise slip through the permutation check. `POST /api/playlists/sort-alpha` clears the manual order again. Claude-Session: https://claude.ai/code/session_01SFDokqh2H6mEjk1Kgbi6JW Signed-off-by: ChrisBeWithYou <christian.a.cowan@gmail.com> Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
494 lines
32 KiB
JavaScript
494 lines
32 KiB
JavaScript
/*
|
||
* fee[dB]ack v0.3.0 — Playlists + Saved for Later screens.
|
||
*
|
||
* Vanilla JS (constitution P-II). Core REST (/api/playlists, /api/saved/*),
|
||
* no capability domain. Renders #v3-playlists (list + detail with drag-
|
||
* reorder) and #v3-saved (the reserved system playlist). Exposes
|
||
* window.v3Saved.toggle(filename) for the "Save for later" affordance on song
|
||
* cards/rows (used by the library/dashboard).
|
||
*/
|
||
(function () {
|
||
'use strict';
|
||
const sm = window.feedBack;
|
||
|
||
const esc = (s) => String(s == null ? '' : s).replace(/[&<>"']/g, (c) => (
|
||
{ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' }[c]));
|
||
|
||
// Return null (don't throw) on a network/connection failure so a fetch
|
||
// rejection can't abort rendering — matches the "degrades gracefully"
|
||
// contract and the other v3 modules' jget/jsend.
|
||
async function jget(u) { try { const r = await fetch(u); return r.ok ? r.json() : null; } catch (e) { return null; } }
|
||
async function jsend(method, u, body) {
|
||
try {
|
||
const r = await fetch(u, {
|
||
method, headers: { 'Content-Type': 'application/json' },
|
||
body: body == null ? undefined : JSON.stringify(body),
|
||
});
|
||
return r.ok ? r.json() : null;
|
||
} catch (e) { return null; }
|
||
}
|
||
|
||
// Content-dependent playlist cover: a custom uploaded cover wins; otherwise
|
||
// the playlist's own song art — the icon when empty, one cover for a few
|
||
// songs, a 2×2 mosaic at 4+. `art_urls` / `cover_url` come from /api/playlists.
|
||
function playlistCoverHtml(p) {
|
||
const box = 'w-full aspect-square rounded-lg overflow-hidden bg-fb-bg/50 mb-3';
|
||
const img = (u, cls) => '<img src="' + esc(u) + '" alt="" class="' + cls + '" onerror="this.style.visibility=\'hidden\'">';
|
||
if (p.cover_url) return '<div class="' + box + '">' + img(p.cover_url, 'w-full h-full object-cover') + '</div>';
|
||
const arts = Array.isArray(p.art_urls) ? p.art_urls : [];
|
||
if (!arts.length) {
|
||
return '<div class="' + box + ' flex items-center justify-center text-5xl text-fb-textDim">' + (p.kind === 'album' ? '💿' : p.system_key ? '🔖' : '🎵') + '</div>';
|
||
}
|
||
if (arts.length < 4) return '<div class="' + box + '">' + img(arts[0], 'w-full h-full object-cover') + '</div>';
|
||
return '<div class="' + box + ' grid grid-cols-2 grid-rows-2 gap-px">' +
|
||
arts.slice(0, 4).map((u) => img(u, 'w-full h-full object-cover')).join('') + '</div>';
|
||
}
|
||
|
||
// The slot's pinned-arrangement INDEX, resolved from the stored NAME
|
||
// against the slot's current chart (names survive rescans; an index
|
||
// wouldn't). null = no pin / the name isn't on this chart → full song.
|
||
function _slotArrIndex(s) {
|
||
if (!s.arrangement || !Array.isArray(s.arrangements)) return null;
|
||
const m = s.arrangements.find((a) => a && (a.smart_name === s.arrangement || a.name === s.arrangement));
|
||
return (m && m.index != null) ? m.index : null;
|
||
}
|
||
|
||
function songRow(s, opts) {
|
||
opts = opts || {};
|
||
const handle = opts.draggable
|
||
? '<span class="cursor-grab text-fb-textDim/60 px-1" title="Drag to reorder">⠿</span>' : '';
|
||
const tuning = s.tuning_name
|
||
? '<span class="ml-2 text-[0.625rem] bg-fb-mid text-black font-bold px-1.5 py-0.5 rounded-sm">' + esc(s.tuning_name) + '</span>' : '';
|
||
// ── Curated-album slot extras (P6) — mixes/saved emit none of this ──
|
||
// A slot plays its RESOLVED chart (data-play-fn: the pinned file, or
|
||
// the work's current keeper when the pinned file is gone) with its
|
||
// pinned arrangement (data-play-arr); `missing` = the whole work left
|
||
// the library, so the row dims and loses play (denominator stays
|
||
// honest). ▾ opens the slot editor (chart + arrangement pin).
|
||
const isAlbum = !!opts.album;
|
||
const missing = isAlbum && !!s.missing;
|
||
const playFn = s.resolved_filename || s.filename;
|
||
const arrIdx = isAlbum ? _slotArrIndex(s) : null;
|
||
const playAttrs = isAlbum && !missing
|
||
? ' data-play-fn="' + esc(playFn) + '"' + (arrIdx != null ? ' data-play-arr="' + arrIdx + '"' : '')
|
||
: '';
|
||
const acc = (isAlbum && typeof opts.acc === 'number')
|
||
? '<span class="text-xs font-bold shrink-0 ' + (opts.acc >= 0.9 ? 'text-fb-good' : opts.acc >= 0.5 ? 'text-fb-mid' : 'text-fb-low') + '">' + Math.floor(opts.acc * 100) + '%</span>'
|
||
: '';
|
||
const pin = (isAlbum && s.arrangement)
|
||
? '<span class="ml-2 text-[0.625rem] bg-fb-primary/20 text-fb-primary font-bold px-1.5 py-0.5 rounded-sm" title="Pinned arrangement">' + esc(s.arrangement) + '</span>' : '';
|
||
const orphan = (isAlbum && s.resolved_from_orphan)
|
||
? '<span class="ml-2 text-[0.625rem] text-fb-textDim" title="The pinned chart is gone — playing this song\'s current keeper instead">(auto)</span>' : '';
|
||
const slotBtn = (isAlbum && !missing)
|
||
? '<button data-slot aria-label="Choose chart / arrangement" title="Choose chart / arrangement" class="opacity-0 group-hover:opacity-100 text-fb-textDim hover:text-fb-text text-sm px-2">▾</button>' : '';
|
||
return '<li data-fn="' + esc(s.filename) + '"' + playAttrs + (opts.draggable ? ' draggable="true"' : '') +
|
||
' class="flex items-center gap-3 px-3 py-2 rounded-lg hover:bg-fb-card/50 group' + (missing ? ' opacity-50' : '') + '">' +
|
||
handle +
|
||
'<img src="' + esc(s.art_url) + '" alt="" class="w-10 h-10 rounded object-cover bg-fb-card" onerror="this.style.visibility=\'hidden\'">' +
|
||
'<span class="flex-1 min-w-0"><span class="block text-sm text-fb-text truncate">' + esc(s.title) + tuning + pin + orphan + '</span>' +
|
||
'<span class="block text-xs text-fb-textDim truncate">' + (missing ? 'Missing — no version of this song is in your library' : esc(s.artist)) + '</span></span>' +
|
||
acc +
|
||
(missing ? '' : '<button data-v3-play aria-label="Play" class="opacity-0 group-hover:opacity-100 text-fb-primary hover:text-fb-primaryHi text-sm px-2" title="Play">▶</button>') +
|
||
slotBtn +
|
||
'<button data-remove aria-label="Remove from playlist" class="opacity-0 group-hover:opacity-100 text-fb-textDim hover:text-fb-accent text-sm px-2" title="Remove">✕</button>' +
|
||
'</li>';
|
||
}
|
||
|
||
function wireSongRows(listEl, pid, onChange) {
|
||
listEl.querySelectorAll('li[data-fn]').forEach((li) => {
|
||
const fn = li.getAttribute('data-fn');
|
||
li.querySelector('[data-v3-play]')?.addEventListener('click', () => {
|
||
// playSong decodeURIComponent()s its arg for the highway WS, so
|
||
// pass an encoded filename (like the rest of v3) — a raw name
|
||
// with %/#/?/ in it would otherwise misroute or throw.
|
||
// Album slots override the play target (data-play-fn = the
|
||
// orphan-resolved chart) + pass the pinned arrangement index;
|
||
// mix/saved rows carry neither attribute and behave as before.
|
||
const pfn = li.getAttribute('data-play-fn') || fn;
|
||
const pa = li.getAttribute('data-play-arr');
|
||
if (typeof window.playSong === 'function') {
|
||
window.playSong(encodeURIComponent(pfn), pa == null ? undefined : Number(pa));
|
||
}
|
||
});
|
||
li.querySelector('[data-remove]')?.addEventListener('click', async () => {
|
||
await fetch('/api/playlists/' + pid + '/songs/' + encodeURIComponent(fn), { method: 'DELETE' });
|
||
onChange();
|
||
});
|
||
});
|
||
// Drag-reorder.
|
||
let dragEl = null;
|
||
listEl.querySelectorAll('li[draggable="true"]').forEach((li) => {
|
||
li.addEventListener('dragstart', () => { dragEl = li; li.classList.add('opacity-50'); });
|
||
li.addEventListener('dragend', () => { li.classList.remove('opacity-50'); });
|
||
li.addEventListener('dragover', (e) => {
|
||
e.preventDefault();
|
||
if (!dragEl || dragEl === li) return;
|
||
const rect = li.getBoundingClientRect();
|
||
const after = (e.clientY - rect.top) > rect.height / 2;
|
||
li.parentNode.insertBefore(dragEl, after ? li.nextSibling : li);
|
||
});
|
||
li.addEventListener('drop', async (e) => {
|
||
e.preventDefault();
|
||
const order = Array.from(listEl.querySelectorAll('li[data-fn]')).map((x) => x.getAttribute('data-fn'));
|
||
await jsend('POST', '/api/playlists/' + pid + '/reorder', { order });
|
||
// Re-sync from the server: if /reorder was rejected (concurrent
|
||
// change) or the request failed, the optimistic DOM order would
|
||
// otherwise diverge from what was actually persisted.
|
||
onChange();
|
||
});
|
||
});
|
||
}
|
||
|
||
// ── #v3-playlists ─────────────────────────────────────────────────────--
|
||
async function renderPlaylists() {
|
||
const root = document.getElementById('v3-playlists');
|
||
if (!root) return;
|
||
const lists = (await jget('/api/playlists')) || [];
|
||
// Drag-to-reorder is for user playlists only — system ones (Saved for
|
||
// Later) stay pinned first by the server ordering.
|
||
const userCount = lists.filter((p) => !p.system_key).length;
|
||
root.innerHTML =
|
||
'<div class="max-w-5xl mx-auto px-6 md:px-8 pb-8">' +
|
||
'<div class="flex items-center justify-end gap-2 mb-6">' +
|
||
// Sort A–Z: clears the manual (drag) order server-side. Only worth
|
||
// showing once there are two user playlists to order.
|
||
(userCount > 1
|
||
? '<button id="v3-pl-sort-az" title="Sort playlists alphabetically (clears manual order)" class="text-sm text-fb-textDim hover:text-fb-text px-2">Sort A–Z</button>' : '') +
|
||
// Curated album (P6): a hand-picked ORDERED set with a chosen chart
|
||
// per track — same machinery as a playlist, kind='album'.
|
||
'<button id="v3-pl-new-album" title="A hand-picked, ordered set of songs — your version of an album, with your chosen chart per track" class="bg-fb-card/80 hover:bg-fb-card border border-fb-border/60 text-fb-text px-4 py-2 rounded-md text-sm font-medium">💿 New album</button>' +
|
||
'<button id="v3-pl-new" class="bg-fb-primary hover:bg-fb-primaryHi text-white px-4 py-2 rounded-md text-sm font-medium shadow-lg shadow-fb-primary/20">New playlist</button>' +
|
||
'</div>' +
|
||
(lists.length
|
||
? '<div id="v3-pl-grid" class="grid grid-cols-2 sm:grid-cols-3 lg:grid-cols-4 gap-4">' + lists.map((p) =>
|
||
'<button data-pl="' + p.id + '"' + (p.system_key ? '' : ' draggable="true"') + ' class="text-left bg-fb-card/80 backdrop-blur rounded-xl p-4 border border-fb-border/50 hover:border-fb-primary/40 transition">' +
|
||
playlistCoverHtml(p) +
|
||
'<div class="flex items-center gap-1">' +
|
||
'<span class="flex-1 min-w-0 text-sm font-medium text-fb-text truncate">' + esc(p.name) + '</span>' +
|
||
(p.system_key ? '' : '<span class="cursor-grab text-fb-textDim/60 px-1" title="Drag to reorder">⠿</span>') +
|
||
'</div>' +
|
||
'<div class="text-xs text-fb-textDim">' + (p.kind === 'album' ? '💿 Album · ' : '') + p.count + ' song' + (p.count === 1 ? '' : 's') + '</div>' +
|
||
'</button>').join('') + '</div>'
|
||
: '<p class="text-fb-textDim">No playlists yet. Create one to group songs.</p>') +
|
||
'</div>';
|
||
root.querySelector('#v3-pl-new')?.addEventListener('click', async () => {
|
||
const name = ((await window.uiPrompt({ title: 'New Playlist', label: 'Playlist name', okLabel: 'Create', placeholder: 'My Playlist' })) || '').trim();
|
||
if (!name) return;
|
||
await jsend('POST', '/api/playlists', { name });
|
||
renderPlaylists();
|
||
});
|
||
root.querySelector('#v3-pl-new-album')?.addEventListener('click', async () => {
|
||
const name = ((await window.uiPrompt({ title: 'New Album', label: 'Album name', okLabel: 'Create', placeholder: 'My Album' })) || '').trim();
|
||
if (!name) return;
|
||
await jsend('POST', '/api/playlists', { name, kind: 'album' });
|
||
renderPlaylists();
|
||
});
|
||
root.querySelector('#v3-pl-sort-az')?.addEventListener('click', async () => {
|
||
await jsend('POST', '/api/playlists/sort-alpha');
|
||
renderPlaylists();
|
||
});
|
||
root.querySelectorAll('[data-pl]').forEach((b) =>
|
||
b.addEventListener('click', () => renderPlaylistDetail(parseInt(b.getAttribute('data-pl'), 10))));
|
||
// Drag-reorder of the playlist cards themselves (mirrors wireSongRows).
|
||
// Only user playlists carry draggable="true"; system cards are neither
|
||
// drag sources nor drop targets, so nothing can be inserted ahead of
|
||
// them (and the server pins them first regardless).
|
||
const grid = root.querySelector('#v3-pl-grid');
|
||
if (grid) {
|
||
let dragEl = null;
|
||
grid.querySelectorAll('button[data-pl][draggable="true"]').forEach((card) => {
|
||
card.addEventListener('dragstart', () => { dragEl = card; card.classList.add('opacity-50'); });
|
||
card.addEventListener('dragend', () => { card.classList.remove('opacity-50'); });
|
||
card.addEventListener('dragover', (e) => {
|
||
e.preventDefault();
|
||
if (!dragEl || dragEl === card) return;
|
||
// Grid tiles flow left→right then wrap, so the insert side
|
||
// is horizontal (the song rows' vertical-midpoint idiom,
|
||
// rotated); moving to another row targets that row's cards.
|
||
const rect = card.getBoundingClientRect();
|
||
const after = (e.clientX - rect.left) > rect.width / 2;
|
||
card.parentNode.insertBefore(dragEl, after ? card.nextSibling : card);
|
||
});
|
||
card.addEventListener('drop', async (e) => {
|
||
e.preventDefault();
|
||
const order = Array.from(grid.querySelectorAll('button[data-pl][draggable="true"]'))
|
||
.map((x) => parseInt(x.getAttribute('data-pl'), 10));
|
||
await jsend('POST', '/api/playlists/reorder', { order });
|
||
// Re-sync from the server: if /reorder was rejected
|
||
// (concurrent change) or the request failed, the optimistic
|
||
// DOM order would otherwise diverge from what persisted.
|
||
renderPlaylists();
|
||
});
|
||
});
|
||
}
|
||
}
|
||
|
||
async function renderPlaylistDetail(pid) {
|
||
const root = document.getElementById('v3-playlists');
|
||
if (!root) return;
|
||
const pl = await jget('/api/playlists/' + pid);
|
||
if (!pl) { renderPlaylists(); return; }
|
||
const isSystem = !!pl.system_key;
|
||
const isAlbum = pl.kind === 'album';
|
||
// Set-scoped repertoire (P6, §7.2): an album is a bounded practice SET
|
||
// with a denominator — "N of M mastered", per-track accuracy, never one
|
||
// album score. Same 0.9 threshold as the library meter/green badge.
|
||
let best = {};
|
||
if (isAlbum) best = (await jget('/api/stats/best')) || {};
|
||
const slotAcc = (s) => best[s.resolved_filename || s.filename];
|
||
let meter = '';
|
||
if (isAlbum && pl.songs.length) {
|
||
const tracks = pl.songs.filter((s) => !s.missing);
|
||
const mastered = tracks.filter((s) => (slotAcc(s) || 0) >= 0.9).length;
|
||
const started = tracks.filter((s) => { const b = slotAcc(s); return typeof b === 'number' && b > 0 && b < 0.9; }).length;
|
||
const pct = tracks.length ? Math.max(0, Math.min(100, Math.round((mastered / tracks.length) * 100))) : 0;
|
||
meter =
|
||
'<div class="mb-6">' +
|
||
'<div class="flex items-baseline justify-between gap-3 mb-1">' +
|
||
'<span class="text-sm font-semibold text-fb-text">Album repertoire</span>' +
|
||
'<span class="text-xs text-fb-textDim">' + mastered + ' of ' + tracks.length + ' mastered' +
|
||
(started ? ' · ' + started + ' in progress' : '') + '</span></div>' +
|
||
'<div class="v3-rep-track"><div class="v3-rep-fill" style="width:' + pct + '%"></div></div>' +
|
||
'</div>';
|
||
}
|
||
root.innerHTML =
|
||
'<div class="max-w-3xl mx-auto p-6 md:p-8">' +
|
||
'<button id="v3-pl-back" class="text-sm text-fb-textDim hover:text-fb-text mb-4">← Playlists</button>' +
|
||
'<div class="flex items-center justify-between mb-6 gap-3">' +
|
||
'<h2 class="text-3xl font-bold text-fb-text truncate">' + (isAlbum ? '💿 ' : '') + esc(pl.name) + '</h2>' +
|
||
'<div class="flex gap-2 shrink-0 items-center">' +
|
||
(pl.songs.length
|
||
? '<button id="v3-pl-shuffle" class="px-2 py-2 rounded-md" aria-pressed="false">' +
|
||
'<svg class="w-5 h-5" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" d="M16 3h5v5M4 20L21 3M21 16v5h-5M15 15l6 6M4 4l5 5"/></svg>' +
|
||
'</button>' +
|
||
'<button id="v3-pl-playall" class="bg-fb-primary hover:bg-fb-primaryHi text-white text-sm font-medium px-4 py-2 rounded-md">▶ Play ' + (isAlbum ? 'album' : 'all') + '</button>'
|
||
: '') +
|
||
(isSystem ? '' :
|
||
'<button id="v3-pl-cover" class="text-sm text-fb-textDim hover:text-fb-text px-2">Cover</button>' +
|
||
(pl.cover_url ? '<button id="v3-pl-cover-rm" class="text-sm text-fb-textDim hover:text-fb-accent px-2">Remove cover</button>' : '') +
|
||
'<button id="v3-pl-rename" class="text-sm text-fb-textDim hover:text-fb-text px-2">Rename</button>' +
|
||
'<button id="v3-pl-delete" class="text-sm text-fb-textDim hover:text-fb-accent px-2">Delete</button>' +
|
||
'<input type="file" id="v3-pl-cover-file" accept="image/*" class="hidden">') +
|
||
'</div>' +
|
||
'</div>' +
|
||
meter +
|
||
(pl.songs.length
|
||
? '<ul id="v3-pl-songs" class="space-y-1">' + pl.songs.map((s) => songRow(s, { draggable: !isSystem, album: isAlbum, acc: isAlbum ? slotAcc(s) : undefined })).join('') + '</ul>'
|
||
: '<p class="text-fb-textDim">Empty — add songs from the library' + (isAlbum ? ' (the ⋮ menu or the batch bar\'s "Add to playlist")' : '') + '.</p>') +
|
||
'</div>';
|
||
root.querySelector('#v3-pl-back')?.addEventListener('click', renderPlaylists);
|
||
// Shuffle toggle (crossing arrows, next to Play). Persisted globally —
|
||
// one preference, not per playlist. The queue is shuffled once when
|
||
// Play starts (playQueue.start's shuffle opt); the stored playlist
|
||
// order is never touched.
|
||
const shuffleBtn = root.querySelector('#v3-pl-shuffle');
|
||
const shuffleOn = () => { try { return localStorage.getItem('v3PlaylistShuffle') === '1'; } catch (_) { return false; } };
|
||
const paintShuffle = () => {
|
||
if (!shuffleBtn) return;
|
||
const on = shuffleOn();
|
||
shuffleBtn.className = on
|
||
? 'px-2 py-2 rounded-md border border-fb-primary bg-fb-primary hover:bg-fb-primaryHi text-white'
|
||
: 'px-2 py-2 rounded-md border border-fb-border text-fb-textDim hover:text-fb-text';
|
||
shuffleBtn.title = on ? 'Shuffle: on' : 'Shuffle: off';
|
||
shuffleBtn.setAttribute('aria-pressed', on ? 'true' : 'false');
|
||
};
|
||
paintShuffle();
|
||
shuffleBtn?.addEventListener('click', () => {
|
||
try { localStorage.setItem('v3PlaylistShuffle', shuffleOn() ? '0' : '1'); } catch (_) { /* private mode */ }
|
||
paintShuffle();
|
||
});
|
||
// Play all: start the play-queue with this playlist's songs (auto-advances
|
||
// track to track). Falls back to playing the first song on an older core
|
||
// without the queue, so the button always does something. An ALBUM plays
|
||
// each slot's resolved chart with its pinned arrangement (playQueue's
|
||
// per-index arrangements array, #685) and skips missing works.
|
||
root.querySelector('#v3-pl-playall')?.addEventListener('click', () => {
|
||
const files = [], arrs = [];
|
||
(pl.songs || []).forEach((s) => {
|
||
if (isAlbum && s.missing) return;
|
||
const fn = s.resolved_filename || s.filename;
|
||
if (!fn) return;
|
||
files.push(fn);
|
||
const idx = isAlbum ? _slotArrIndex(s) : null;
|
||
arrs.push(idx == null ? undefined : idx);
|
||
});
|
||
if (!files.length) return;
|
||
if (window.feedBack && window.feedBack.playQueue) {
|
||
window.feedBack.playQueue.start(files, isAlbum
|
||
? { source: pl.name, arrangements: arrs, shuffle: shuffleOn() }
|
||
: { source: pl.name, shuffle: shuffleOn() });
|
||
} else if (typeof window.playSong === 'function') window.playSong(encodeURIComponent(files[0]));
|
||
});
|
||
const listEl = root.querySelector('#v3-pl-songs');
|
||
if (listEl) wireSongRows(listEl, pid, () => renderPlaylistDetail(pid));
|
||
// Album slot editor (▾ per row): pick the slot's chart + arrangement.
|
||
if (listEl && isAlbum) {
|
||
listEl.querySelectorAll('li[data-fn]').forEach((li) => {
|
||
li.querySelector('[data-slot]')?.addEventListener('click', () => {
|
||
const fn = li.getAttribute('data-fn');
|
||
const slot = (pl.songs || []).find((x) => x.filename === fn);
|
||
if (slot) openSlotPicker(pid, slot, () => renderPlaylistDetail(pid));
|
||
});
|
||
});
|
||
}
|
||
root.querySelector('#v3-pl-rename')?.addEventListener('click', async () => {
|
||
const name = ((await window.uiPrompt({ title: 'Rename Playlist', label: 'Playlist name', value: pl.name, okLabel: 'Rename' })) || '').trim();
|
||
if (!name) return;
|
||
await jsend('PATCH', '/api/playlists/' + pid, { name });
|
||
renderPlaylistDetail(pid);
|
||
});
|
||
root.querySelector('#v3-pl-delete')?.addEventListener('click', async () => {
|
||
if (!window.confirm('Delete "' + pl.name + '"?')) return;
|
||
await fetch('/api/playlists/' + pid, { method: 'DELETE' });
|
||
renderPlaylists();
|
||
});
|
||
// Custom cover: pick an image → upload as a data URL → the playlist card
|
||
// shows it (overriding the song-art cover). Re-render the detail so the
|
||
// Remove-cover button appears; the grid picks up the new cover on return.
|
||
const coverFile = root.querySelector('#v3-pl-cover-file');
|
||
root.querySelector('#v3-pl-cover')?.addEventListener('click', () => coverFile && coverFile.click());
|
||
coverFile?.addEventListener('change', () => {
|
||
const f = coverFile.files && coverFile.files[0];
|
||
if (!f) return;
|
||
const reader = new FileReader();
|
||
reader.onload = async (e) => {
|
||
await jsend('POST', '/api/playlists/' + pid + '/cover', { image: e.target.result });
|
||
renderPlaylistDetail(pid);
|
||
};
|
||
reader.readAsDataURL(f);
|
||
});
|
||
root.querySelector('#v3-pl-cover-rm')?.addEventListener('click', async () => {
|
||
await fetch('/api/playlists/' + pid + '/cover', { method: 'DELETE' });
|
||
renderPlaylistDetail(pid);
|
||
});
|
||
}
|
||
|
||
// ── Curated-album slot editor (P6, §7.2) ─────────────────────────────────
|
||
// Pins THIS slot's chart + arrangement. The per-slot pick is deliberately
|
||
// independent of the work's global preferred — a rehearsed set must stay
|
||
// the same notes even if the global keeper is re-picked later. Charts come
|
||
// from the work-charts API; the arrangement pin is stored as a NAME (it
|
||
// survives rescans; the index is resolved at play). A compact overlay for
|
||
// now — unifying with the library's Charts drawer (slot-scoped mode) is a
|
||
// follow-up once the in-flight drawer changes land.
|
||
async function openSlotPicker(pid, slot, onChange) {
|
||
const curFn = slot.resolved_filename || slot.filename;
|
||
let wk = slot.work_key;
|
||
if (!wk) {
|
||
const w = await jget('/api/chart/' + encodeURIComponent(curFn) + '/work');
|
||
wk = w && w.work_key;
|
||
}
|
||
const charts = wk ? await jget('/api/work/' + encodeURIComponent(wk) + '/charts') : null;
|
||
const chartList = (charts && Array.isArray(charts.charts)) ? charts.charts : [];
|
||
const radio = (name, value, checked, label, sub) =>
|
||
'<label class="flex items-start gap-2 px-2 py-1.5 rounded hover:bg-fb-card/60 cursor-pointer">' +
|
||
'<input type="radio" name="' + name + '" value="' + esc(value) + '"' + (checked ? ' checked' : '') + ' class="accent-fb-primary mt-0.5">' +
|
||
'<span class="min-w-0 flex-1"><span class="block text-sm text-fb-text truncate">' + label + '</span>' +
|
||
(sub ? '<span class="block text-[0.625rem] text-fb-textDim truncate">' + sub + '</span>' : '') +
|
||
'</span></label>';
|
||
// Checked = the stored pin; an orphaned slot (stored file gone from the
|
||
// list) pre-checks the chart it currently resolves to, so Apply re-pins
|
||
// what's actually playing.
|
||
const slotInList = chartList.some((x) => x.filename === slot.filename);
|
||
const chartRows = chartList.map((c) => radio(
|
||
'slot-chart', c.filename,
|
||
c.filename === slot.filename || (!slotInList && c.filename === curFn),
|
||
esc(c.title) + (c.is_representative ? ' <span class="text-[0.625rem] text-fb-primary">● preferred</span>' : ''),
|
||
esc((c.tuning_name ? c.tuning_name + ' · ' : '') + c.filename))).join('');
|
||
const arrRows = [radio('slot-arr', '', !slot.arrangement, 'Full song <span class="text-[0.625rem] text-fb-textDim">(default)</span>', '')]
|
||
.concat((slot.arrangements || []).map((a) => {
|
||
const name = (a && (a.smart_name || a.name)) || '';
|
||
if (!name) return '';
|
||
return radio('slot-arr', name,
|
||
slot.arrangement === name || slot.arrangement === a.name,
|
||
esc(name), '');
|
||
})).join('');
|
||
const overlay = document.createElement('div');
|
||
overlay.className = 'fixed inset-0 z-[200] bg-black/60 backdrop-blur-sm flex items-center justify-center p-4';
|
||
overlay.innerHTML =
|
||
'<div class="bg-fb-card w-full max-w-md rounded-xl border border-fb-border/60 p-5 space-y-4 max-h-[80vh] overflow-y-auto v3-scroll">' +
|
||
'<div class="flex items-center justify-between gap-2">' +
|
||
'<h3 class="text-lg font-semibold text-fb-text truncate">' + esc(slot.title) + '</h3>' +
|
||
'<button type="button" data-x class="text-fb-textDim hover:text-fb-text text-xl leading-none" aria-label="Close">✕</button></div>' +
|
||
(chartRows
|
||
? '<div><div class="text-xs font-semibold uppercase tracking-wider text-fb-textDim mb-1">Chart for this slot</div>' + chartRows + '</div>'
|
||
: '') +
|
||
'<div><div class="text-xs font-semibold uppercase tracking-wider text-fb-textDim mb-1">Arrangement</div>' + arrRows + '</div>' +
|
||
'<div data-err class="hidden text-xs text-red-400"></div>' +
|
||
'<div class="flex justify-end gap-2">' +
|
||
'<button type="button" data-cancel class="text-sm text-fb-textDim hover:text-fb-text px-3 py-2">Cancel</button>' +
|
||
'<button type="button" data-apply class="bg-fb-primary hover:bg-fb-primaryHi text-white text-sm font-medium px-5 py-2 rounded-md">Apply</button>' +
|
||
'</div></div>';
|
||
const onKey = (e) => { if (e.key === 'Escape') { e.preventDefault(); close(); } };
|
||
function close() { overlay.remove(); document.removeEventListener('keydown', onKey); }
|
||
document.addEventListener('keydown', onKey);
|
||
overlay.addEventListener('click', (e) => { if (e.target === overlay) close(); });
|
||
overlay.querySelector('[data-x]').addEventListener('click', close);
|
||
overlay.querySelector('[data-cancel]').addEventListener('click', close);
|
||
overlay.querySelector('[data-apply]').addEventListener('click', async () => {
|
||
const chart = overlay.querySelector('input[name="slot-chart"]:checked');
|
||
const arr = overlay.querySelector('input[name="slot-arr"]:checked');
|
||
const body = {};
|
||
if (chart && chart.value && chart.value !== slot.filename) body.chart_filename = chart.value;
|
||
if (arr) {
|
||
const v = arr.value || null;
|
||
if (v !== (slot.arrangement || null)) body.arrangement = v;
|
||
}
|
||
if (Object.keys(body).length) {
|
||
// jsend → null on a non-2xx (e.g. swap-to-other-work rejected, or
|
||
// the resolved target duplicates another slot's pin). Surfacing it
|
||
// and keeping the picker open beats silently closing "as saved".
|
||
const res = await jsend('PATCH', '/api/playlists/' + pid + '/songs/' + encodeURIComponent(slot.filename), body);
|
||
if (!res) {
|
||
const err = overlay.querySelector('[data-err]');
|
||
if (err) { err.textContent = 'Could not update this slot.'; err.classList.remove('hidden'); }
|
||
return; // keep the picker open — not a success
|
||
}
|
||
}
|
||
close();
|
||
onChange();
|
||
});
|
||
document.body.appendChild(overlay);
|
||
}
|
||
|
||
// ── #v3-saved ─────────────────────────────────────────────────────────--
|
||
async function renderSaved() {
|
||
const root = document.getElementById('v3-saved');
|
||
if (!root) return;
|
||
const lists = (await jget('/api/playlists')) || [];
|
||
const saved = lists.find((p) => p.system_key === 'saved_for_later');
|
||
const pl = saved ? await jget('/api/playlists/' + saved.id) : null;
|
||
root.innerHTML =
|
||
'<div class="max-w-3xl mx-auto px-6 md:px-8 pb-8">' +
|
||
(pl && pl.songs.length
|
||
? '<ul id="v3-saved-songs" class="space-y-1">' + pl.songs.map((s) => songRow(s, {})).join('') + '</ul>'
|
||
: '<p class="text-fb-textDim">Nothing saved yet. Use “Save for later” on a song to add it here.</p>') +
|
||
'</div>';
|
||
const listEl = root.querySelector('#v3-saved-songs');
|
||
if (listEl && pl) wireSongRows(listEl, pl.id, renderSaved);
|
||
}
|
||
|
||
// ── Public: Save-for-later toggle for song cards/rows ─────────────────--
|
||
window.v3Saved = {
|
||
toggle: async function (filename) {
|
||
const res = await jsend('POST', '/api/saved/toggle', { filename });
|
||
return res ? res.saved : null;
|
||
},
|
||
};
|
||
window.v3Playlists = { refresh: renderPlaylists, refreshSaved: renderSaved };
|
||
|
||
// Lazy-render when these screens are shown (data can change between visits).
|
||
if (sm && typeof sm.on === 'function') {
|
||
sm.on('screen:changed', (e) => {
|
||
const id = e && e.detail && e.detail.id;
|
||
if (id === 'v3-playlists') renderPlaylists();
|
||
else if (id === 'v3-saved') renderSaved();
|
||
});
|
||
}
|
||
function boot() { renderPlaylists(); renderSaved(); }
|
||
// `defer` runs this at readyState 'interactive' — later scripts have not
|
||
// evaluated yet, so wait for DOMContentLoaded (see static/v3/index.html).
|
||
if (document.readyState !== 'complete') document.addEventListener('DOMContentLoaded', boot, { once: true });
|
||
else boot();
|
||
})();
|