diff --git a/index.html b/index.html index 5a8f1a7..c4cad8b 100644 --- a/index.html +++ b/index.html @@ -260,6 +260,7 @@ + @@ -267,6 +268,13 @@ +
diff --git a/src/main.js b/src/main.js index 7716599..bb9a18b 100644 --- a/src/main.js +++ b/src/main.js @@ -16,6 +16,7 @@ import { onGlobalSearch, s_goArtist, s_goAlbum, playerBarGoArtist, playerBarGoAlbum, searchSimilarArtist, searchAndPreview, loadItunesAlbum, expandArtistBio, loadAlbums, loadStarred, loadRandom, filterStarred, filterPlaylist, + plReorderEnter, plReorderSave, plReorderCancel, } from './views.js'; import { loadSpotifyImporter, resolveSpotifyLink, clearSpotifyPreview, spotifyDlAll } from './spotify.js'; import { @@ -282,7 +283,9 @@ Object.assign(window, { openSettings, closeSettings, saveSettings, confirmDisconnect, checkBackend, openSidebar, closeSidebar, switchNavMode, showCtxMenu, // Views - loadAlbums, loadStarred, loadRandom, filterStarred, filterPlaylist, loadSpotifyImporter, resolveSpotifyLink, clearSpotifyPreview, spotifyDlAll, + loadAlbums, loadStarred, loadRandom, filterStarred, filterPlaylist, + plReorderEnter, plReorderSave, plReorderCancel, + loadSpotifyImporter, resolveSpotifyLink, clearSpotifyPreview, spotifyDlAll, executeSpotifyDownload, openPlModal, closePlModal, addSongsToPlaylist, createAndAddPlaylist, createPlaylistPrompt, showSongInfo, closeInfoModal, diff --git a/src/views.js b/src/views.js index 4b2cae8..e83f3d6 100644 --- a/src/views.js +++ b/src/views.js @@ -557,11 +557,10 @@ export async function loadPlaylistDetail(pl) { playQueue(shuffled, 0); }; sl.innerHTML = ''; - sl.classList.add('pl-draggable'); const plSearch = document.getElementById('playlist-search'); if (plSearch) plSearch.value = ''; songs.forEach((s,i) => sl.appendChild(makeSongItem(s, i, songs, { noArtistLink: true, noAlbumLink: true }))); - _initPlaylistDragDrop(sl, songs, S._currentPlaylist); + _setupReorderMode(sl, songs, S._currentPlaylist); } catch(e) { sl.innerHTML = `${e.message}
`; } } @@ -574,9 +573,28 @@ export function filterPlaylist(val) { }); } -function _initPlaylistDragDrop(container, songs, pl) { - let dragSrc = null; - let saveTimer = null; +// ─── PLAYLIST REORDER MODE ─────────────────────────────────────────────── +let _reorderCtx = null; + +function _setupReorderMode(container, songs, pl) { + // Exit any previous reorder mode silently when loading a new playlist. + if (_reorderCtx) _exitReorderMode(false); + _reorderCtx = { container, songs, pl }; + const btn = document.getElementById('btn-reorder-playlist'); + if (btn) btn.onclick = plReorderEnter; + const bar = document.getElementById('pl-reorder-bar'); + if (bar) bar.style.display = 'none'; +} + +export function plReorderEnter() { + if (!_reorderCtx) return; + const { container, songs } = _reorderCtx; + container.classList.add('pl-draggable'); + // Block context-menu (long-press) on all items while reordering. + container.addEventListener('contextmenu', _blockCtxMenu, true); + + let dragSrc = null; + let touchDragSrc = null; Array.from(container.children).forEach((item, i) => { const handle = document.createElement('div'); @@ -585,54 +603,93 @@ function _initPlaylistDragDrop(container, songs, pl) { item.insertBefore(handle, item.firstChild); item._songRef = songs[i]; - // Track whether drag started from the handle (e.target in dragstart is always the item, not the handle) + // ── Desktop drag ── let fromHandle = false; handle.addEventListener('mousedown', () => { fromHandle = true; item.draggable = true; }); - item.addEventListener('dragstart', e => { if (!fromHandle) { e.preventDefault(); item.draggable = false; return; } - fromHandle = false; - dragSrc = item; + fromHandle = false; dragSrc = item; e.dataTransfer.effectAllowed = 'move'; - e.dataTransfer.setData('text/plain', ''); // required by Firefox + e.dataTransfer.setData('text/plain', ''); setTimeout(() => item.classList.add('dragging'), 0); }); item.addEventListener('dragend', () => { - fromHandle = false; - item.draggable = false; - dragSrc = null; + fromHandle = false; item.draggable = false; dragSrc = null; item.classList.remove('dragging'); container.querySelectorAll('.drag-over').forEach(el => el.classList.remove('drag-over')); }); item.addEventListener('dragover', e => { - e.preventDefault(); - e.dataTransfer.dropEffect = 'move'; + e.preventDefault(); e.dataTransfer.dropEffect = 'move'; 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('dragleave', e => { if (!item.contains(e.relatedTarget)) item.classList.remove('drag-over'); }); item.addEventListener('drop', e => { - e.preventDefault(); - e.stopPropagation(); + e.preventDefault(); e.stopPropagation(); 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); - const newOrder = Array.from(container.children).map(el => el._songRef); - songs.splice(0, songs.length, ...newOrder); - Array.from(container.children).forEach((el, idx) => { - const n = el.querySelector('.song-num'); - if (n) n.textContent = idx + 1; - }); - clearTimeout(saveTimer); - saveTimer = setTimeout(() => _savePlaylistOrder(pl, songs), 800); + _doReorder(container, songs, dragSrc, item); }); + + // ── Touch drag ── + handle.addEventListener('touchstart', e => { + e.preventDefault(); e.stopPropagation(); + touchDragSrc = item; item.classList.add('dragging'); + }, { passive: false }); + handle.addEventListener('touchmove', e => { + if (!touchDragSrc) return; + e.preventDefault(); + const t = e.touches[0]; + container.querySelectorAll('.drag-over').forEach(el => el.classList.remove('drag-over')); + const over = _itemAtPoint(container, t.clientX, t.clientY, touchDragSrc); + if (over) over.classList.add('drag-over'); + }, { passive: false }); + handle.addEventListener('touchend', e => { + if (!touchDragSrc) return; + const t = e.changedTouches[0]; + touchDragSrc.classList.remove('dragging'); + container.querySelectorAll('.drag-over').forEach(el => el.classList.remove('drag-over')); + const over = _itemAtPoint(container, t.clientX, t.clientY, touchDragSrc); + if (over) _doReorder(container, songs, touchDragSrc, over); + touchDragSrc = null; + }, { passive: false }); }); + + const bar = document.getElementById('pl-reorder-bar'); + if (bar) bar.style.display = 'flex'; +} + +function _blockCtxMenu(e) { e.preventDefault(); e.stopPropagation(); } + +function _itemAtPoint(container, x, y, exclude) { + return (document.elementsFromPoint(x, y) || []) + .find(el => el.classList?.contains('song-item') && el !== exclude && container.contains(el)) || null; +} + +function _doReorder(container, songs, src, target) { + const all = Array.from(container.children); + const from = all.indexOf(src), to = all.indexOf(target); + container.insertBefore(src, from < to ? target.nextSibling : target); + const newOrder = Array.from(container.children).map(el => el._songRef); + songs.splice(0, songs.length, ...newOrder); + Array.from(container.children).forEach((el, i) => { + const n = el.querySelector('.song-num'); if (n) n.textContent = i + 1; + }); +} + +export function plReorderSave() { _exitReorderMode(true); } +export function plReorderCancel() { _exitReorderMode(false); } + +function _exitReorderMode(save) { + if (!_reorderCtx) return; + const { container, songs, pl } = _reorderCtx; + container.classList.remove('pl-draggable'); + container.removeEventListener('contextmenu', _blockCtxMenu, true); + container.querySelectorAll('.pl-drag-handle').forEach(h => h.remove()); + const bar = document.getElementById('pl-reorder-bar'); + if (bar) bar.style.display = 'none'; + if (save) _savePlaylistOrder(pl, songs); } async function _savePlaylistOrder(pl, songs) { diff --git a/styles.css b/styles.css index 7ee05e2..169a9c1 100644 --- a/styles.css +++ b/styles.css @@ -165,6 +165,8 @@ nav{flex:1;overflow-y:auto;padding:4px 10px} .gtrack-actions{display:flex;align-items:center;gap:6px;opacity:0;transition:opacity .15s} .gtrack-item:hover .gtrack-actions{opacity:1} +button:focus{outline:none} + /* ── ICON BTN ── */ .icon-btn{background:none;border:none;color:var(--text2);cursor:pointer;padding:6px;border-radius:6px;display:flex;align-items:center;transition:color .15s,background .15s} .icon-btn:hover{color:var(--accent);background:var(--accent-dim)} @@ -193,8 +195,9 @@ nav{flex:1;overflow-y:auto;padding:4px 10px} /* ── 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} +.song-list.pl-draggable .pl-drag-handle{opacity:1} .pl-drag-handle:active{cursor:grabbing} +.pl-reorder-bar{display:flex;align-items:center;justify-content:space-between;padding:8px 12px;background:var(--surface);border-radius:10px;margin-bottom:8px;font-size:13px;color:var(--text2);gap:10px} .song-item.dragging{opacity:0.35} .song-item.drag-over{box-shadow:0 -2px 0 var(--accent)} @@ -389,7 +392,7 @@ nav{flex:1;overflow-y:auto;padding:4px 10px} .pl-btns button{flex:1;padding:10px;border-radius:8px;font-family:var(--font);font-size:13px;font-weight:600;cursor:pointer;border:none} /* ── EDIT PLAYLIST MODAL ── */ -.edit-pl-overlay{position:fixed;inset:0;background:rgba(0,0,0,.72);backdrop-filter:blur(5px);z-index:500;display:flex;align-items:center;justify-content:center;opacity:0;pointer-events:none;transition:opacity .2s} +.edit-pl-overlay{position:fixed;inset:0;background:rgba(0,0,0,.8);z-index:500;display:flex;align-items:center;justify-content:center;opacity:0;pointer-events:none;transition:opacity .2s} .edit-pl-overlay.open{opacity:1;pointer-events:all} .edit-pl-box{width:min(580px,calc(100vw - 24px));background:var(--surface);border:1px solid var(--border2);border-radius:18px;box-shadow:0 40px 80px rgba(0,0,0,.8);overflow:hidden} .edit-pl-header{display:flex;align-items:center;justify-content:space-between;padding:20px 24px 16px;font-size:18px;font-weight:700;border-bottom:1px solid var(--border)} @@ -422,7 +425,12 @@ nav{flex:1;overflow-y:auto;padding:4px 10px} .confirm-title{font-size:17px;font-weight:700;margin-bottom:10px} .confirm-msg{font-size:14px;color:var(--text2);line-height:1.5;margin-bottom:24px} .confirm-footer{display:flex;justify-content:flex-end;gap:10px} -@media(max-width:480px){.edit-pl-body{flex-direction:column;align-items:center}.edit-pl-cover-wrap{width:140px;height:140px}} +@media(max-width:480px){ + .edit-pl-body{flex-direction:column;align-items:center} + .edit-pl-cover-wrap{width:140px;height:140px} + .edit-pl-footer{flex-wrap:wrap;padding:12px 16px;gap:8px} + .edit-pl-footer>div{margin-left:auto} +} /* ── SONG INFO MODAL ── */ .info-modal{position:fixed;inset:0;background:rgba(0,0,0,.65);backdrop-filter:blur(4px);z-index:400;display:flex;align-items:center;justify-content:center;opacity:0;pointer-events:none;transition:opacity .2s}