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