/* * 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 ? '' : '

    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 ? '' : '

    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(); })();