feat: drag-and-drop reordering for playlist tracks

Drag handle appears on hover, moves items in DOM, syncs order to
songs array, and saves to Navidrome via createPlaylist 800ms after
last drop (debounced to avoid rapid API calls).

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
HoneyandClaude Sonnet 4.6 committed 2026-04-28 21:15:48 +02:00
1 parent ac5cbfb3de
commit 8deeaa6475
2 files changed
+73

No files matched your search

+66
View File
@@ -542,10 +542,76 @@ export async function loadPlaylistDetail(pl) {
playQueue(shuffled, 0);
};
sl.innerHTML = '';
sl.classList.add('pl-draggable');
songs.forEach((s,i) => sl.appendChild(makeSongItem(s, i, songs, { noArtistLink: true, noAlbumLink: true })));
_initPlaylistDragDrop(sl, songs, S._currentPlaylist);
} catch(e) { sl.innerHTML = `<p style="color:var(--danger)">${e.message}</p>`; }
}
function _initPlaylistDragDrop(container, songs, pl) {
let dragSrc = null;
let saveTimer = null;
Array.from(container.children).forEach((item, i) => {
const handle = document.createElement('div');
handle.className = 'pl-drag-handle';
handle.innerHTML = '<svg width="10" height="14" viewBox="0 0 10 14" fill="currentColor"><circle cx="3" cy="2" r="1.3"/><circle cx="7" cy="2" r="1.3"/><circle cx="3" cy="7" r="1.3"/><circle cx="7" cy="7" r="1.3"/><circle cx="3" cy="12" r="1.3"/><circle cx="7" cy="12" r="1.3"/></svg>';
item.insertBefore(handle, item.firstChild);
item._songRef = songs[i];
item.addEventListener('dragstart', e => {
if (!e.target.closest('.pl-drag-handle')) { e.preventDefault(); return; }
dragSrc = item;
e.dataTransfer.effectAllowed = 'move';
setTimeout(() => item.classList.add('dragging'), 0);
});
item.addEventListener('dragend', () => {
item.classList.remove('dragging');
container.querySelectorAll('.drag-over').forEach(el => el.classList.remove('drag-over'));
});
item.addEventListener('dragover', e => {
e.preventDefault();
if (item === dragSrc) return;
container.querySelectorAll('.drag-over').forEach(el => el.classList.remove('drag-over'));
item.classList.add('drag-over');
});
item.addEventListener('dragleave', e => {
if (!item.contains(e.relatedTarget)) item.classList.remove('drag-over');
});
item.addEventListener('drop', e => {
e.preventDefault();
if (!dragSrc || item === dragSrc) return;
item.classList.remove('drag-over');
const allItems = Array.from(container.children);
const fromIdx = allItems.indexOf(dragSrc);
const toIdx = allItems.indexOf(item);
container.insertBefore(dragSrc, fromIdx < toIdx ? item.nextSibling : item);
// Sync songs array to new DOM order
const newOrder = Array.from(container.children).map(el => el._songRef);
songs.splice(0, songs.length, ...newOrder);
// Update track numbers
Array.from(container.children).forEach((el, idx) => {
const n = el.querySelector('.song-num');
if (n) n.textContent = idx + 1;
});
// Debounced save
clearTimeout(saveTimer);
saveTimer = setTimeout(() => _savePlaylistOrder(pl, songs), 800);
});
handle.addEventListener('mousedown', () => { item.draggable = true; });
item.addEventListener('dragend', () => { item.draggable = false; });
});
}
async function _savePlaylistOrder(pl, songs) {
try {
const songIds = songs.map(s => `&songId=${s.id}`).join('');
await api('createPlaylist', `&playlistId=${pl.id}&name=${encodeURIComponent(pl.name)}${songIds}`);
toast('✅ Reihenfolge gespeichert');
} catch(e) { toast('Fehler beim Speichern: ' + e.message); }
}
export async function quickPlayAlbum(a) {
try { const r = await api('getAlbum', `&id=${a.id}`); playQueue(r.album?.song || [], 0); }
catch(e) { toast('Fehler: ' + e.message); }
+7
View File
@@ -189,6 +189,13 @@ nav{flex:1;overflow-y:auto;padding:4px 10px}
.song-dur{font-size:11px;color:var(--text3);font-family:var(--mono)}
.song-actions{display:flex;gap:2px;opacity:0;transition:opacity .15s}
.song-item:hover .song-actions{opacity:1}
/* ── PLAYLIST DRAG & DROP ── */
.song-list.pl-draggable .song-item{grid-template-columns:18px 32px 44px 1fr auto}
.pl-drag-handle{display:flex;align-items:center;justify-content:center;color:var(--text3);opacity:0;cursor:grab;transition:opacity .15s;touch-action:none}
.song-list.pl-draggable .song-item:hover .pl-drag-handle{opacity:1}
.pl-drag-handle:active{cursor:grabbing}
.song-item.dragging{opacity:0.35}
.song-item.drag-over{box-shadow:0 -2px 0 var(--accent)}
/* ── ALBUM DETAIL ── */
.back-btn{background:none;border:none;color:var(--text2);cursor:pointer;display:flex;align-items:center;gap:6px;font-family:var(--font);font-size:12px;padding:6px 0;margin-bottom:16px;transition:color .15s}