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

    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 = '
    ' + '
    ' + '

    ' + esc(slot.title) + '

    ' + '
    ' + (chartRows ? '
    Chart for this slot
    ' + chartRows + '
    ' : '') + '
    Arrangement
    ' + arrRows + '
    ' + '' + '
    ' + '' + '' + '
    '; 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 ? '' : '

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