From 6e410ece402ca3a6d740549aae4880c8a64c67ec Mon Sep 17 00:00:00 2001 From: ChrisBeWithYou Date: Wed, 1 Jul 2026 01:01:32 -0500 Subject: [PATCH] feat(v3): modern "Add to playlist" picker (replaces run-on prompt) The old add-to-playlist flow crammed every playlist into one uiPrompt label ("1. Foo 2. Bar 3. Baz ...") and asked the user to type a number -- unreadable past a couple of playlists, and reported as a bug. Replace it with openPlaylistPicker: a checkbox modal with membership pre-check (a song already in a playlist shows checked; a multi-song selection shows an indeterminate box when only some are in), an inline "+ New playlist" row, and a search box once the list is long. Toggling adds/removes via the existing REST (POST + DELETE .../songs/{filename}); only playlists the user actually touched change. One shared function still feeds the per-card menu, the batch bar, and the batch button, so the fix lands in all three. Escape / backdrop close; a bottom-right fbNotify toast confirms. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01QbexxfTt8q2tAn436MqGWF --- static/v3/songs.js | 137 +++++++++++++++++++++++++++++++++++++-------- 1 file changed, 113 insertions(+), 24 deletions(-) diff --git a/static/v3/songs.js b/static/v3/songs.js index 5b64135..2209c35 100644 --- a/static/v3/songs.js +++ b/static/v3/songs.js @@ -815,33 +815,122 @@ finishBatch(); } - // Prompt for a target playlist (pick a listed number, or type a new name to - // create it) and add the given song filenames to it. Shared by the - // select-mode batch bar and the per-card ⋮ menu's single-song add. Returns - // the playlist id (or null if cancelled). + // Modern "Add to playlist" picker - replaces the old run-on numbered prompt + // ("1. Foo 2. Bar ...", which didn't scale past a couple of playlists). Shows a + // checkbox list of playlists with membership PRE-CHECK (a song already in a + // playlist shows checked; a multi-song selection shows the indeterminate box + // when only some are in), an inline "New playlist" row, and a search box once + // the list is long. Toggling a row adds/removes the given songs via the + // existing REST; only rows the user actually TOUCHED are changed. Resolves + // true if any change was applied, else null (cancelled / no-op). Shared by the + // per-card more-menu and the select-mode batch bar. + function openPlaylistPicker(fns) { + return new Promise((resolve) => { (async () => { + const lists = ((await jget('/api/playlists')) || []).filter((p) => !p.system_key); + // Pre-check membership: fetch each playlist's songs once (playlists are + // few, and this is a one-off on open, not a hot path). ALL selected in + // -> checked; SOME -> indeterminate. + const counts = await Promise.all(lists.map(async (p) => { + const pl = await jget('/api/playlists/' + p.id); + const has = new Set(((pl && pl.songs) || []).map((s) => s.filename)); + return fns.reduce((n, fn) => n + (has.has(fn) ? 1 : 0), 0); + })); + const rows = lists.map((p, i) => ({ + id: p.id, name: p.name, count: p.count || 0, + initial: counts[i] === fns.length ? 'all' : (counts[i] > 0 ? 'some' : 'none'), + checked: counts[i] === fns.length, touched: false, + })); + + 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'; + let query = ''; + const onKey = (e) => { if (e.key === 'Escape') { e.preventDefault(); done(null); } }; + function done(applied) { overlay.remove(); document.removeEventListener('keydown', onKey); resolve(applied || null); } + document.addEventListener('keydown', onKey); + overlay.addEventListener('click', (e) => { if (e.target === overlay) done(null); }); + + const boxFor = (r) => r.touched ? (r.checked ? '☑' : '☐') + : (r.initial === 'all' ? '☑' : r.initial === 'some' ? '▣' : '☐'); + function rowHtml(r) { + return ''; + } + function listHtml() { + const q = query.trim().toLowerCase(); + const shown = q ? rows.filter((r) => r.name.toLowerCase().includes(q)) : rows; + if (!shown.length) return '

' + (rows.length ? 'No matches.' : 'No playlists yet - create one above.') + '

'; + return shown.map(rowHtml).join(''); + } + function bindRows() { + overlay.querySelectorAll('[data-pl]').forEach((b) => b.addEventListener('click', () => { + const r = rows.find((x) => String(x.id) === b.getAttribute('data-pl')); + if (!r) return; + r.touched = true; r.checked = !r.checked; + repaintList(); + })); + } + function repaintList() { const el = overlay.querySelector('[data-list]'); if (el) { el.innerHTML = listHtml(); bindRows(); } } + async function createNew() { + const inp = overlay.querySelector('[data-new]'); + const name = ((inp && inp.value) || '').trim(); + if (!name) return; + const created = await jsend('POST', '/api/playlists', { name }); + if (created && created.id) { + rows.unshift({ id: created.id, name: created.name || name, count: 0, initial: 'none', checked: true, touched: true }); + query = ''; paint(); + overlay.querySelector('[data-new]')?.focus(); + } + } + async function apply() { + // Act only where the final state differs from what's already stored. + const acts = rows.filter((r) => r.touched && ((r.checked && r.initial !== 'all') || (!r.checked && r.initial !== 'none'))); + for (const r of acts) { + for (const fn of fns) { + try { + if (r.checked) await fetch('/api/playlists/' + r.id + '/songs', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ filename: fn }) }); + else await fetch('/api/playlists/' + r.id + '/songs/' + encodeURIComponent(fn), { method: 'DELETE' }); + } catch (e) { /* */ } + } + } + if (window.v3Playlists) { try { window.v3Playlists.refresh(); } catch (e) { /* */ } } + if (acts.length && window.fbNotify) { + try { window.fbNotify.show({ title: 'Playlists updated', message: 'Updated ' + acts.length + ' playlist' + (acts.length === 1 ? '' : 's'), icon: '\U0001f3b5' }); } catch (e) { /* */ } + } + done(acts.length ? true : null); + } + function paint() { + overlay.innerHTML = + ''; + overlay.querySelector('[data-x]').addEventListener('click', () => done(null)); + overlay.querySelector('[data-done]').addEventListener('click', apply); + overlay.querySelector('[data-create]').addEventListener('click', createNew); + overlay.querySelector('[data-new]').addEventListener('keydown', (e) => { if (e.key === 'Enter') { e.preventDefault(); createNew(); } }); + const s = overlay.querySelector('[data-search]'); + if (s) s.addEventListener('input', (e) => { query = e.target.value; repaintList(); }); + bindRows(); + } + + document.body.appendChild(overlay); + paint(); + (overlay.querySelector('[data-search]') || overlay.querySelector('[data-new]'))?.focus(); + })(); }); + } + + // Open the picker for the given song filenames. Shared by the select-mode + // batch bar and the per-card more-menu. Resolves truthy if a change was applied. async function addFilenamesToPlaylist(filenames) { const fns = Array.from(filenames || []); if (!fns.length) return null; - const lists = (await jget('/api/playlists')) || []; - const choices = lists.filter((p) => !p.system_key); - const labels = choices.map((p, i) => (i + 1) + '. ' + p.name).join(' '); - const ans = ((await window.uiPrompt({ - title: 'Add ' + fns.length + ' song' + (fns.length === 1 ? '' : 's') + ' to a playlist', - label: (labels ? labels + ' ' : '') + 'Type a number above, or a new playlist name:', - okLabel: 'Add', - placeholder: 'Number or new playlist name', - })) || '').trim(); - if (!ans) return null; - let pid = null; - const num = parseInt(ans, 10); - if (!isNaN(num) && choices[num - 1]) pid = choices[num - 1].id; - else { const created = await jsend('POST', '/api/playlists', { name: ans }); pid = created && created.id; } - if (!pid) return null; - for (const fn of fns) { - try { await fetch('/api/playlists/' + pid + '/songs', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ filename: fn }) }); } catch (e) { /* */ } - } - if (window.v3Playlists) { try { window.v3Playlists.refresh(); } catch (e) { /* */ } } - return pid; + return openPlaylistPicker(fns); } async function batchAddToPlaylist() {