/* * 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.slopsmith; 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; } } 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 = (window.prompt('Playlist name?') || '').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.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 = (window.prompt('Rename playlist', pl.name) || '').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(); }); } // ── #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(); })();