/*
* 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) => '
';
if (p.cover_url) return '
' + img(p.cover_url, 'w-full h-full object-cover') + '
';
const arts = Array.isArray(p.art_urls) ? p.art_urls : [];
if (!arts.length) {
return '' + (p.kind === 'album' ? '๐ฟ' : p.system_key ? '๐' : '๐ต') + '
';
}
if (arts.length < 4) return '' + img(arts[0], 'w-full h-full object-cover') + '
';
return '' +
arts.slice(0, 4).map((u) => img(u, 'w-full h-full object-cover')).join('') + '
';
}
// 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
? 'โ ฟ' : '';
const tuning = s.tuning_name
? '' + esc(s.tuning_name) + '' : '';
// โโ 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')
? '' + Math.round(opts.acc * 100) + '%'
: '';
const pin = (isAlbum && s.arrangement)
? '' + esc(s.arrangement) + '' : '';
const orphan = (isAlbum && s.resolved_from_orphan)
? '(auto)' : '';
const slotBtn = (isAlbum && !missing)
? '' : '';
return '' +
handle +
'
' +
'' + esc(s.title) + tuning + pin + orphan + '' +
'' + (missing ? 'Missing โ no version of this song is in your library' : esc(s.artist)) + '' +
acc +
(missing ? '' : '') +
slotBtn +
'' +
'';
}
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')) || [];
root.innerHTML =
'' +
'
' +
// Curated album (P6): a hand-picked ORDERED set with a chosen chart
// per track โ same machinery as a playlist, kind='album'.
'' +
'' +
'
' +
(lists.length
? '
' + lists.map((p) =>
'
').join('') + '
'
: '
No playlists yet. Create one to group songs.
') +
'
';
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.querySelectorAll('[data-pl]').forEach((b) =>
b.addEventListener('click', () => renderPlaylistDetail(parseInt(b.getAttribute('data-pl'), 10))));
}
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 =
'' +
'
' +
'Album repertoire' +
'' + mastered + ' of ' + tracks.length + ' mastered' +
(started ? ' · ' + started + ' in progress' : '') + '
' +
'
' +
'
';
}
root.innerHTML =
'' +
'
' +
'
' +
'
' + (isAlbum ? '๐ฟ ' : '') + esc(pl.name) + '
' +
'
' +
(pl.songs.length ? '' : '') +
(isSystem ? '' :
'' +
(pl.cover_url ? '' : '') +
'' +
'' +
'') +
'
' +
'
' +
meter +
(pl.songs.length
? '
' + pl.songs.map((s) => songRow(s, { draggable: !isSystem, album: isAlbum, acc: isAlbum ? slotAcc(s) : undefined })).join('') + '
'
: '
Empty โ add songs from the library' + (isAlbum ? ' (the โฎ menu or the batch bar\'s "Add to playlist")' : '') + '.
') +
'
';
root.querySelector('#v3-pl-back')?.addEventListener('click', renderPlaylists);
// 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 }
: { source: pl.name });
} 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) =>
'';
// 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 ? ' โ preferred' : ''),
esc((c.tuning_name ? c.tuning_name + ' ยท ' : '') + c.filename))).join('');
const arrRows = [radio('slot-arr', '', !slot.arrangement, 'Full song (default)', '')]
.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 =
'';
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 =
'' +
(pl && pl.songs.length
? '
' + pl.songs.map((s) => songRow(s, {})).join('') + '
'
: '
Nothing saved yet. Use โSave for laterโ on a song to add it here.
') +
'
';
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(); }
if (document.readyState === 'loading') document.addEventListener('DOMContentLoaded', boot, { once: true });
else boot();
})();