reorder.js (10.7 KB)
1 const REORDER_DRAG_THRESHOLD = 5; 2 const LONG_PRESS_MS = 400; 3 const LONG_PRESS_MOVE_TOLERANCE = 10; 4 let activeReorder = null; 5 let longPressTimer = null; 6 7 function attachReorderHandlers(item, index) { 8 item.addEventListener('mousedown', (e) => { 9 // Ignore non-primary buttons 10 if (e.button !== 0) return; 11 startReorder(item, index, e.clientX, e.clientY); 12 }); 13 14 item.addEventListener('touchstart', (e) => { 15 if (e.touches.length !== 1) return; 16 const t = e.touches[0]; 17 startLongPress(item, index, t.clientX, t.clientY); 18 }, { passive: true }); 19 } 20 21 function startLongPress(item, index, startX, startY) { 22 if (longPressTimer) clearTimeout(longPressTimer); 23 24 const candidate = { item, index, startX, startY }; 25 longPressTimer = setTimeout(() => { 26 longPressTimer = null; 27 cleanup(); 28 startReorder(candidate.item, candidate.index, candidate.startX, candidate.startY, /* isTouch */ true); 29 beginDrag(); 30 if (navigator.vibrate) navigator.vibrate(10); 31 }, LONG_PRESS_MS); 32 33 const onMove = (e) => { 34 const t = e.touches[0]; 35 if (!t) return; 36 const dx = t.clientX - startX; 37 const dy = t.clientY - startY; 38 if (Math.hypot(dx, dy) > LONG_PRESS_MOVE_TOLERANCE) cancel(); 39 }; 40 const onUp = () => cancel(); 41 42 function cleanup() { 43 document.removeEventListener('touchmove', onMove); 44 document.removeEventListener('touchend', onUp); 45 } 46 47 function cancel() { 48 if (longPressTimer) { 49 clearTimeout(longPressTimer); 50 longPressTimer = null; 51 } 52 cleanup(); 53 } 54 55 document.addEventListener('touchmove', onMove, { passive: true }); 56 document.addEventListener('touchend', onUp); 57 } 58 59 function startReorder(item, index, startX, startY, isTouch = false) { 60 activeReorder = { 61 item, 62 index, 63 startX, 64 startY, 65 started: false, 66 placeholder: null, 67 offsetY: 0, 68 itemHeight: 0, 69 autoScrollRaf: null, 70 autoScrollSpeed: 0, 71 lastClientY: startY, 72 isTouch, 73 }; 74 if (isTouch) { 75 document.addEventListener('touchmove', onReorderTouchMove, { passive: false }); 76 document.addEventListener('touchend', onReorderEnd); 77 document.addEventListener('touchcancel', onReorderEnd); 78 } else { 79 document.body.classList.add('reorder-pressed'); 80 document.addEventListener('mousemove', onReorderMove); 81 document.addEventListener('mouseup', onReorderEnd); 82 } 83 } 84 85 function beginDrag() { 86 const r = activeReorder; 87 const rect = r.item.getBoundingClientRect(); 88 const playlistRect = playlist.getBoundingClientRect(); 89 r.itemHeight = rect.height; 90 r.offsetY = r.startY - rect.top; 91 92 const styles = window.getComputedStyle(r.item); 93 const placeholder = document.createElement('div'); 94 placeholder.classList.add('playlist-item-placeholder'); 95 placeholder.style.height = `${rect.height}px`; 96 placeholder.style.marginTop = styles.marginTop; 97 placeholder.style.marginBottom = styles.marginBottom; 98 placeholder.style.paddingLeft = styles.paddingLeft; 99 placeholder.style.paddingRight = styles.paddingRight; 100 placeholder.style.paddingBottom = styles.paddingBottom; 101 r.item.parentNode.insertBefore(placeholder, r.item); 102 r.placeholder = placeholder; 103 104 r.item.classList.add('dragging'); 105 r.item.style.position = 'fixed'; 106 r.item.style.left = `${playlistRect.left}px`; 107 r.item.style.top = `${rect.top}px`; 108 r.item.style.width = `${playlistRect.width}px`; 109 r.item.style.height = `${rect.height}px`; 110 r.item.style.zIndex = '10'; 111 document.body.appendChild(r.item); 112 // Now we're actually dragging — disable pointer-events on the other items 113 document.body.classList.add('reordering'); 114 115 r.started = true; 116 r.item._suppressClick = true; 117 } 118 119 function onReorderMove(e) { 120 const r = activeReorder; 121 if (!r) return; 122 r.lastClientY = e.clientY; 123 124 if (!r.started) { 125 const dx = e.clientX - r.startX; 126 const dy = e.clientY - r.startY; 127 if (Math.hypot(dx, dy) < REORDER_DRAG_THRESHOLD) return; 128 beginDrag(); 129 } 130 131 positionDraggedItem(); 132 updateAutoScroll(); 133 } 134 135 function onReorderTouchMove(e) { 136 const r = activeReorder; 137 if (!r || !r.started) return; 138 e.preventDefault(); 139 const t = e.touches[0]; 140 if (!t) return; 141 r.lastClientY = t.clientY; 142 positionDraggedItem(); 143 updateAutoScroll(); 144 } 145 146 function positionDraggedItem() { 147 const r = activeReorder; 148 if (!r || !r.started) return; 149 150 // Clamp the dragged item between the bottom of the now-playing area 151 // (the wrapper's top) and the top of the navigation controls. 152 const wrapperRect = playlistWrapper.getBoundingClientRect(); 153 const controlsRect = controls.getBoundingClientRect(); 154 const desiredTop = r.lastClientY - r.offsetY; 155 const minTop = wrapperRect.top; 156 const maxTop = controlsRect.top - r.itemHeight; 157 const clampedTop = Math.max(minTop, Math.min(maxTop, desiredTop)); 158 r.item.style.top = `${clampedTop}px`; 159 160 repositionPlaceholder(); 161 } 162 163 function layoutMidY(el) { 164 const rect = el.getBoundingClientRect(); 165 if (el._flipTargetTop !== undefined) { 166 return el._flipTargetTop + rect.height / 2; 167 } 168 return rect.top + rect.height / 2; 169 } 170 171 function repositionPlaceholder() { 172 const r = activeReorder; 173 if (!r || !r.started) return; 174 175 const draggedRect = r.item.getBoundingClientRect(); 176 const draggedMid = draggedRect.top + draggedRect.height / 2; 177 178 const prev = r.placeholder.previousElementSibling; 179 if (prev && prev.classList.contains('playlist-item')) { 180 if (draggedMid < layoutMidY(prev)) { 181 flipItem(prev, () => playlist.insertBefore(r.placeholder, prev)); 182 return; 183 } 184 } 185 186 const next = r.placeholder.nextElementSibling; 187 if (next && next.classList.contains('playlist-item')) { 188 if (draggedMid > layoutMidY(next)) { 189 flipItem(next, () => playlist.insertBefore(r.placeholder, next.nextSibling)); 190 return; 191 } 192 } 193 } 194 195 const AUTO_SCROLL_EDGE = 60; // px from edge that triggers scrolling 196 const AUTO_SCROLL_MAX_SPEED = 6; // px per frame 197 198 function updateAutoScroll() { 199 const r = activeReorder; 200 if (!r || !r.started) return; 201 202 const wrapperRect = playlistWrapper.getBoundingClientRect(); 203 const bottomBound = controls.getBoundingClientRect().top; 204 const y = r.lastClientY; 205 206 let speed = 0; 207 if (y < wrapperRect.top + AUTO_SCROLL_EDGE) { 208 const intensity = (wrapperRect.top + AUTO_SCROLL_EDGE - y) / AUTO_SCROLL_EDGE; 209 speed = -Math.min(1, intensity) * AUTO_SCROLL_MAX_SPEED; 210 } else if (y > bottomBound - AUTO_SCROLL_EDGE) { 211 const intensity = (y - (bottomBound - AUTO_SCROLL_EDGE)) / AUTO_SCROLL_EDGE; 212 speed = Math.min(1, intensity) * AUTO_SCROLL_MAX_SPEED; 213 } 214 215 r.autoScrollSpeed = speed; 216 if (speed !== 0 && !r.autoScrollRaf) { 217 const tick = () => { 218 const cur = activeReorder; 219 if (!cur || !cur.started || cur.autoScrollSpeed === 0) { 220 if (cur) cur.autoScrollRaf = null; 221 return; 222 } 223 const before = playlistWrapper.scrollTop; 224 playlistWrapper.scrollTop = Math.max(0, Math.min(playlistWrapper.scrollHeight - playlistWrapper.clientHeight, playlistWrapper.scrollTop + cur.autoScrollSpeed)); 225 const delta = playlistWrapper.scrollTop - before; 226 if (delta !== 0) { 227 positionDraggedItem(); 228 } 229 cur.autoScrollRaf = requestAnimationFrame(tick); 230 }; 231 r.autoScrollRaf = requestAnimationFrame(tick); 232 } 233 } 234 235 function flipItem(el, mutate) { 236 el.style.transition = 'none'; 237 el.style.transform = ''; 238 const oldTop = el.getBoundingClientRect().top; 239 240 mutate(); 241 242 const newTop = el.getBoundingClientRect().top; 243 const dy = oldTop - newTop; 244 if (!dy) return; 245 el.style.transform = `translateY(${dy}px)`; 246 void el.offsetHeight; 247 el.style.transition = 'transform 180ms ease'; 248 el.style.transform = ''; 249 250 el._flipTargetTop = newTop; 251 if (el._flipEndHandler) el.removeEventListener('transitionend', el._flipEndHandler); 252 el._flipEndHandler = (e) => { 253 if (e.propertyName !== 'transform') return; 254 delete el._flipTargetTop; 255 el.removeEventListener('transitionend', el._flipEndHandler); 256 el._flipEndHandler = null; 257 }; 258 el.addEventListener('transitionend', el._flipEndHandler); 259 } 260 261 function onReorderEnd() { 262 const r = activeReorder; 263 document.removeEventListener('mousemove', onReorderMove); 264 document.removeEventListener('mouseup', onReorderEnd); 265 document.removeEventListener('touchmove', onReorderTouchMove); 266 document.removeEventListener('touchend', onReorderEnd); 267 document.removeEventListener('touchcancel', onReorderEnd); 268 activeReorder = null; 269 document.body.classList.remove('reorder-pressed'); 270 document.body.classList.remove('reordering'); 271 if (r && r.autoScrollRaf) cancelAnimationFrame(r.autoScrollRaf); 272 if (!r) return; 273 274 if (!r.started) { 275 // No real drag — let the click handler fire normally 276 return; 277 } 278 279 // Compute the new order from placeholder position and rebuild tracks array 280 const fromIndex = r.index; 281 const itemsInDom = Array.from(playlist.querySelectorAll('.playlist-item, .playlist-item-placeholder')); 282 const toIndex = itemsInDom.indexOf(r.placeholder); 283 284 const droppedTop = r.item.getBoundingClientRect().top; 285 r.placeholder.remove(); 286 287 if (toIndex === -1 || toIndex === fromIndex) { 288 renderPlaylist(); 289 animateDroppedTrack(r.item, fromIndex, droppedTop); 290 return; 291 } 292 293 // Build the new tracks array directly from the DOM ordering. 294 const moved = tracks[fromIndex]; 295 const remaining = tracks.filter((_, i) => i !== fromIndex); 296 const newTracks = []; 297 let remainingIdx = 0; 298 for (let i = 0; i < itemsInDom.length; i++) { 299 if (itemsInDom[i] === r.placeholder) { 300 newTracks.push(moved); 301 } else { 302 newTracks.push(remaining[remainingIdx++]); 303 } 304 } 305 306 const playingTrack = tracks[currentTrackIndex]; 307 tracks.length = 0; 308 tracks.push(...newTracks); 309 const newCurrentIndex = tracks.indexOf(playingTrack); 310 if (newCurrentIndex !== -1) setCurrentTrackIndex(newCurrentIndex); 311 312 renderPlaylist(); 313 animateDroppedTrack(r.item, toIndex, droppedTop); 314 saveTrackOrder(); 315 } 316 317 function animateDroppedTrack(draggedEl, newIndex, oldTop) { 318 const items = playlist.querySelectorAll('.playlist-item'); 319 const target = items[newIndex]; 320 if (!target) { 321 draggedEl.remove(); 322 return; 323 } 324 325 // Hide the rebuilt item but keep it in layout so sizing/scroll are stable. 326 target.style.visibility = 'hidden'; 327 const newTop = target.getBoundingClientRect().top; 328 329 const cleanup = () => { 330 draggedEl.remove(); 331 target.style.visibility = ''; 332 }; 333 334 if (oldTop === newTop) { 335 cleanup(); 336 return; 337 } 338 339 const playlistRect = playlist.getBoundingClientRect(); 340 draggedEl.style.transition = 'none'; 341 draggedEl.style.top = `${oldTop}px`; 342 draggedEl.style.left = `${playlistRect.left}px`; 343 draggedEl.style.width = `${playlistRect.width}px`; 344 void draggedEl.offsetHeight; 345 draggedEl.style.transition = 'top 180ms ease'; 346 draggedEl.style.top = `${newTop}px`; 347 348 const finish = (e) => { 349 if (e.propertyName !== 'top') return; 350 draggedEl.removeEventListener('transitionend', finish); 351 cleanup(); 352 }; 353 draggedEl.addEventListener('transitionend', finish); 354 } 355 356 function saveTrackOrder() { 357 // Strip the runtime-only `looping` flag so we don't persist transient UI state 358 const persisted = tracks.map(({ looping, ...rest }) => rest); 359 fetch('/tracks', { 360 method: 'POST', 361 headers: { 'Content-Type': 'application/json' }, 362 body: JSON.stringify(persisted), 363 }).catch(err => console.error('Failed to save order:', err)); 364 }