From e9145220ae1ef774d08d7ce284fdfe9357967c79 Mon Sep 17 00:00:00 2001 From: Vynull App Date: Sun, 19 Jul 2026 02:09:01 +0000 Subject: [PATCH] web: remove tracks from a playlist There was no way to take a track out of a playlist from the web UI: removePlaylistTrack existed but nothing called it, dragging a row only reorders, and the bulk DELETE removes tracks from the whole library. Add the two missing affordances, both scoped to regular playlist views (smart playlists are rule-driven, and Collection and History have no membership to edit): a per-row x button that drops that track from the playlist, and a bulk-bar "- PLAYLIST" button that drops the whole selection in one update. The per-row handler resolves the playlist index by track ID rather than row index, so an active search filter or column sort cannot misalign the removal. The bulk path deselects only the removed tracks, so a selection spanning other sources survives, and postPlaylistTrackIDs now reports success so the toast only fires when the update actually landed. Library rows, tags, cues, and other playlists are untouched by either path. Verified headlessly end to end: per-row removal under an active BPM sort takes out the right track, bulk removal empties the playlist and leaves the collection intact, and neither affordance renders in Collection or smart-playlist views; no console errors. --- api/web/index.html | 64 +++++++++++++++++++++++++++++++++++++++++----- 1 file changed, 58 insertions(+), 6 deletions(-) diff --git a/api/web/index.html b/api/web/index.html index 0322af7..0291e44 100644 --- a/api/web/index.html +++ b/api/web/index.html @@ -1377,6 +1377,22 @@ } .load-btn .arrow { color: var(--orange); margin-right: 4px; } .load-btn:hover .arrow { color: var(--black); } + /* Per-row "remove from playlist" × — only rendered when the active + source is a regular playlist (see renderLibrary's reorderMode). */ + .pl-rm-btn { + background: transparent; + border: 1px solid var(--wire); + color: var(--text3); + width: 22px; height: 22px; padding: 0; + margin-right: 6px; + vertical-align: middle; + font-family: inherit; + font-size: 12px; + line-height: 1; + cursor: pointer; + transition: all .12s ease; + } + .pl-rm-btn:hover { background: var(--red); color: var(--black); border-color: var(--red); } .load-menu { position: fixed; @@ -5902,6 +5918,7 @@ + ${activeRegularPlaylist() ? '' : ''} @@ -5913,6 +5930,8 @@ bar.querySelector('#bulk-tag-add').onclick = e => openBulkTagPicker(e.currentTarget, 'add'); bar.querySelector('#bulk-tag-remove').onclick = e => openBulkTagPicker(e.currentTarget, 'remove'); bar.querySelector('#bulk-playlist').onclick = e => openBulkPlaylistPicker(e.currentTarget); + const plRemove = bar.querySelector('#bulk-pl-remove'); + if (plRemove) plRemove.onclick = bulkRemoveFromPlaylist; bar.querySelector('#bulk-export').onclick = () => { const sel = Array.from(bulkSelectedIDs); openExportModal('selection:' + sel.join(','), `${sel.length} SELECTED TRACK${sel.length === 1 ? '' : 'S'}`); @@ -6279,6 +6298,9 @@ // row at once — now that the cap is gone every filtered row is rendered, // so this matches the full filtered total shown in the stats chip. const head2 = `` + head + ''; + // Source-aware row extras: when viewing a regular playlist, rows can be + // drag-reordered and get a per-row × that removes them from the playlist. + const reorderMode = activeRegularPlaylist(); const body = visible.map(t => { const cells = cols.map(c => `${c.cell(t)}`).join(''); const bulkSelected = bulkSelectedIDs.has(t.id); @@ -6286,6 +6308,7 @@ `` + cells + ` + ${reorderMode ? `` : ''} @@ -6306,6 +6329,16 @@ el.querySelectorAll('.load-btn').forEach(btn => { btn.addEventListener('click', e => handleLoadClick(e, btn)); }); + // Per-row × (playlist view only) → drop the track from this playlist. + // Looks the index up by ID so an active search/sort can't misalign it. + el.querySelectorAll('[data-pl-remove]').forEach(btn => { + btn.addEventListener('click', e => { + e.stopPropagation(); + const tid = parseInt(btn.getAttribute('data-pl-remove'), 10); + const idx = plSelectedTracks.findIndex(t => t.id === tid); + if (idx !== -1) removePlaylistTrack(idx); + }); + }); // Bulk-selection checkboxes (per-row + the header sel-all) — wires // toggleBulkSelection on change so the bottom bulk-action bar shows. wireBulkSelectionHandlers(); @@ -6374,11 +6407,6 @@ // playlist, dragging a row reorders within that playlist; otherwise // (Collection, or a smart playlist) the drag pops up the floating // playlist drop overlay so the user can add the track elsewhere. - const reorderMode = (typeof selectedSource === 'number') - && (() => { - const sel = playlistsCache.find(p => p.id === selectedSource); - return !!sel && !sel.is_folder && !sel.is_smart; - })(); el.querySelectorAll('tr[data-track-row]').forEach((row, rowIdx) => { const tid = parseInt(row.getAttribute('data-track-row'), 10); row.addEventListener('click', (e) => { @@ -11301,15 +11329,39 @@

TAGS ${drawerTrackTags.length} p.id === selectedSource); + return !!sel && !sel.is_folder && !sel.is_smart; +} + +// Bulk-bar "− PLAYLIST": drop every selected track from the current +// playlist. Library rows, tags, cues, and other playlists are untouched. +async function bulkRemoveFromPlaylist() { + if (plSelectedID == null) return; + const drop = new Set(plSelectedTracks.filter(t => bulkSelectedIDs.has(t.id)).map(t => t.id)); + if (drop.size === 0) { toast('SELECTION IS NOT IN THIS PLAYLIST', 'error'); return; } + const keep = plSelectedTracks.map(t => t.id).filter(id => !drop.has(id)); + const n = plSelectedTracks.length - keep.length; + if (!await postPlaylistTrackIDs(plSelectedID, keep)) return; + // Deselect just the removed tracks — a cross-source selection survives. + drop.forEach(id => bulkSelectedIDs.delete(id)); + updateBulkBar(); + toast(`REMOVED ${n} TRACK${n === 1 ? '' : 'S'} FROM PLAYLIST`, 'success'); +} + async function postPlaylistTrackIDs(playlistID, trackIDs) { const r = await fetch(`/api/playlists/${playlistID}/tracks`, { method: 'POST', headers: {'Content-Type':'application/json'}, body: JSON.stringify({ track_ids: trackIDs }), }); - if (!r.ok) { toast('UPDATE FAILED', 'error'); return; } + if (!r.ok) { toast('UPDATE FAILED', 'error'); return false; } await loadPlaylistTracks(playlistID); // Also refresh the cache so the track count in the tree updates. await loadPlaylists(true); + return true; } // Add-tracks picker — a modal that lists every library track, filterable