/*
* 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.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('') + '
';
}
function songRow(s, opts) {
opts = opts || {};
const handle = opts.draggable
? 'β Ώ' : '';
const tuning = s.tuning_name
? '' + esc(s.tuning_name) + '' : '';
return '' +
handle +
'
' +
'' + esc(s.title) + tuning + '' +
'' + esc(s.artist) + '' +
'' +
'' +
'';
}
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.
if (typeof window.playSong === 'function') window.playSong(encodeURIComponent(fn));
});
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 =
'' +
'
' +
'' +
'
' +
(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.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;
root.innerHTML =
'' +
'
' +
'
' +
'
' + esc(pl.name) + '
' +
(isSystem ? '' :
'
' +
'' +
(pl.cover_url ? '' : '') +
'' +
'' +
'
') +
'
' +
(pl.songs.length
? '
' + pl.songs.map((s) => songRow(s, { draggable: !isSystem })).join('') + '
'
: '
Empty β add songs from the library.
') +
'
';
root.querySelector('#v3-pl-back')?.addEventListener('click', renderPlaylists);
const listEl = root.querySelector('#v3-pl-songs');
if (listEl) wireSongRows(listEl, pid, () => 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);
});
}
// ββ #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();
})();