From 8deeaa64750b68143b1754415011fec8a49ef67a Mon Sep 17 00:00:00 2001 From: Honey Date: Tue, 28 Apr 2026 21:15:48 +0200 Subject: [PATCH] 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 --- src/views.js | 66 ++++++++++++++++++++++++++++++++++++++++++++++++++++ styles.css | 7 ++++++ 2 files changed, 73 insertions(+) diff --git a/src/views.js b/src/views.js index 5e10ec3..250cfa4 100644 --- a/src/views.js +++ b/src/views.js @@ -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 = `

${e.message}

`; } } +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 = ''; + 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); } diff --git a/styles.css b/styles.css index 9095c53..2c21c02 100644 --- a/styles.css +++ b/styles.css @@ -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}