dev.js (23.5 KB)
1 /* Local-only reorder/rename code used when refining your mix via serve.py */ 2 3 // Drag to reorder 4 5 const REORDER_DRAG_THRESHOLD = 5; 6 const LONG_PRESS_MS = 400; 7 const LONG_PRESS_MOVE_TOLERANCE = 10; 8 let activeReorder = null; 9 let longPressTimer = null; 10 11 function attachReorderHandlers(item, index) { 12 item.addEventListener('mousedown', (e) => { 13 // Ignore non-primary buttons 14 if (e.button !== 0) return; 15 if (e.shiftKey) { 16 e.preventDefault(); 17 e.stopPropagation(); 18 item._suppressClick = true; 19 const t = tracks[index]; 20 if (!t) return; 21 if (!confirm(`Delete ${t.artist} – ${t.title}?`)) return; 22 deleteTrack(index); 23 return; 24 } 25 startReorder(item, index, e.clientX, e.clientY); 26 }); 27 28 item.addEventListener('touchstart', (e) => { 29 if (e.touches.length !== 1) return; 30 const t = e.touches[0]; 31 startLongPress(item, index, t.clientX, t.clientY); 32 }, { passive: true }); 33 } 34 35 function startLongPress(item, index, startX, startY) { 36 if (longPressTimer) clearTimeout(longPressTimer); 37 38 const candidate = { item, index, startX, startY }; 39 longPressTimer = setTimeout(() => { 40 longPressTimer = null; 41 cleanup(); 42 startReorder(candidate.item, candidate.index, candidate.startX, candidate.startY, /* isTouch */ true); 43 beginDrag(); 44 if (navigator.vibrate) navigator.vibrate(10); 45 }, LONG_PRESS_MS); 46 47 const onMove = (e) => { 48 const t = e.touches[0]; 49 if (!t) return; 50 const dx = t.clientX - startX; 51 const dy = t.clientY - startY; 52 if (Math.hypot(dx, dy) > LONG_PRESS_MOVE_TOLERANCE) cancel(); 53 }; 54 const onUp = () => cancel(); 55 56 function cleanup() { 57 document.removeEventListener('touchmove', onMove); 58 document.removeEventListener('touchend', onUp); 59 } 60 61 function cancel() { 62 if (longPressTimer) { 63 clearTimeout(longPressTimer); 64 longPressTimer = null; 65 } 66 cleanup(); 67 } 68 69 document.addEventListener('touchmove', onMove, { passive: true }); 70 document.addEventListener('touchend', onUp); 71 } 72 73 function startReorder(item, index, startX, startY, isTouch = false) { 74 activeReorder = { 75 item, 76 index, 77 startX, 78 startY, 79 started: false, 80 placeholder: null, 81 offsetY: 0, 82 itemHeight: 0, 83 autoScrollRaf: null, 84 autoScrollSpeed: 0, 85 lastClientY: startY, 86 isTouch, 87 }; 88 if (isTouch) { 89 document.addEventListener('touchmove', onReorderTouchMove, { passive: false }); 90 document.addEventListener('touchend', onReorderEnd); 91 document.addEventListener('touchcancel', onReorderEnd); 92 } else { 93 document.body.classList.add('reorder-pressed'); 94 document.addEventListener('mousemove', onReorderMove); 95 document.addEventListener('mouseup', onReorderEnd); 96 } 97 } 98 99 function beginDrag() { 100 const r = activeReorder; 101 const rect = r.item.getBoundingClientRect(); 102 const playlistRect = playlist.getBoundingClientRect(); 103 r.itemHeight = rect.height; 104 r.offsetY = r.startY - rect.top; 105 106 const styles = window.getComputedStyle(r.item); 107 const placeholder = document.createElement('div'); 108 placeholder.classList.add('playlist-item-placeholder'); 109 placeholder.style.height = `${rect.height}px`; 110 placeholder.style.marginTop = styles.marginTop; 111 placeholder.style.marginBottom = styles.marginBottom; 112 placeholder.style.paddingLeft = styles.paddingLeft; 113 placeholder.style.paddingRight = styles.paddingRight; 114 placeholder.style.paddingBottom = styles.paddingBottom; 115 r.item.parentNode.insertBefore(placeholder, r.item); 116 r.placeholder = placeholder; 117 118 r.item.classList.add('dragging'); 119 r.item.style.position = 'fixed'; 120 r.item.style.left = `${playlistRect.left}px`; 121 r.item.style.top = `${rect.top}px`; 122 r.item.style.width = `${playlistRect.width}px`; 123 r.item.style.height = `${rect.height}px`; 124 r.item.style.zIndex = '3'; 125 document.body.appendChild(r.item); 126 // Now we're actually dragging — disable pointer-events on the other items 127 document.body.classList.add('reordering'); 128 129 r.started = true; 130 r.item._suppressClick = true; 131 } 132 133 function onReorderMove(e) { 134 const r = activeReorder; 135 if (!r) return; 136 r.lastClientY = e.clientY; 137 138 if (!r.started) { 139 const dx = e.clientX - r.startX; 140 const dy = e.clientY - r.startY; 141 if (Math.hypot(dx, dy) < REORDER_DRAG_THRESHOLD) return; 142 beginDrag(); 143 } 144 145 positionDraggedItem(); 146 updateAutoScroll(); 147 } 148 149 function onReorderTouchMove(e) { 150 const r = activeReorder; 151 if (!r || !r.started) return; 152 e.preventDefault(); 153 const t = e.touches[0]; 154 if (!t) return; 155 r.lastClientY = t.clientY; 156 positionDraggedItem(); 157 updateAutoScroll(); 158 } 159 160 function positionDraggedItem() { 161 const r = activeReorder; 162 if (!r || !r.started) return; 163 164 // Clamp the dragged item between the bottom of the now-playing area 165 // (the wrapper's top) and the top of the navigation controls. 166 const wrapperRect = playlistWrapper.getBoundingClientRect(); 167 const controlsRect = controls.getBoundingClientRect(); 168 const desiredTop = r.lastClientY - r.offsetY; 169 const minTop = wrapperRect.top; 170 const maxTop = controlsRect.top - r.itemHeight / 2; 171 const clampedTop = Math.max(minTop, Math.min(maxTop, desiredTop)); 172 r.item.style.top = `${clampedTop}px`; 173 174 repositionPlaceholder(); 175 } 176 177 function layoutMidY(el) { 178 const rect = el.getBoundingClientRect(); 179 if (el._flipTargetTop !== undefined) { 180 return el._flipTargetTop + rect.height / 2; 181 } 182 return rect.top + rect.height / 2; 183 } 184 185 function repositionPlaceholder() { 186 const r = activeReorder; 187 if (!r || !r.started) return; 188 189 const draggedRect = r.item.getBoundingClientRect(); 190 const draggedMid = draggedRect.top + draggedRect.height / 2; 191 192 const prev = r.placeholder.previousElementSibling; 193 if (prev && prev.classList.contains('playlist-item')) { 194 if (draggedMid < layoutMidY(prev)) { 195 flipItem(prev, () => playlist.insertBefore(r.placeholder, prev)); 196 return; 197 } 198 } 199 200 const next = r.placeholder.nextElementSibling; 201 if (next && next.classList.contains('playlist-item')) { 202 if (draggedMid > layoutMidY(next)) { 203 flipItem(next, () => playlist.insertBefore(r.placeholder, next.nextSibling)); 204 return; 205 } 206 } 207 } 208 209 const AUTO_SCROLL_EDGE = 60; // px from edge that triggers scrolling 210 const AUTO_SCROLL_MAX_SPEED = 6; // px per frame 211 212 function updateAutoScroll() { 213 const r = activeReorder; 214 if (!r || !r.started) return; 215 216 const wrapperRect = playlistWrapper.getBoundingClientRect(); 217 const bottomBound = controls.getBoundingClientRect().top; 218 const y = r.lastClientY; 219 220 let speed = 0; 221 if (y < wrapperRect.top + AUTO_SCROLL_EDGE) { 222 const intensity = (wrapperRect.top + AUTO_SCROLL_EDGE - y) / AUTO_SCROLL_EDGE; 223 speed = -Math.min(1, intensity) * AUTO_SCROLL_MAX_SPEED; 224 } else if (y > bottomBound - AUTO_SCROLL_EDGE) { 225 const intensity = (y - (bottomBound - AUTO_SCROLL_EDGE)) / AUTO_SCROLL_EDGE; 226 speed = Math.min(1, intensity) * AUTO_SCROLL_MAX_SPEED; 227 } 228 229 r.autoScrollSpeed = speed; 230 if (speed !== 0 && !r.autoScrollRaf) { 231 const tick = () => { 232 const cur = activeReorder; 233 if (!cur || !cur.started || cur.autoScrollSpeed === 0) { 234 if (cur) cur.autoScrollRaf = null; 235 return; 236 } 237 const before = playlistWrapper.scrollTop; 238 playlistWrapper.scrollTop = Math.max(0, Math.min(playlistWrapper.scrollHeight - playlistWrapper.clientHeight, playlistWrapper.scrollTop + cur.autoScrollSpeed)); 239 const delta = playlistWrapper.scrollTop - before; 240 if (delta !== 0) { 241 positionDraggedItem(); 242 } 243 cur.autoScrollRaf = requestAnimationFrame(tick); 244 }; 245 r.autoScrollRaf = requestAnimationFrame(tick); 246 } 247 } 248 249 function flipItem(el, mutate) { 250 el.style.transition = 'none'; 251 el.style.transform = ''; 252 const oldTop = el.getBoundingClientRect().top; 253 254 mutate(); 255 256 const newTop = el.getBoundingClientRect().top; 257 const dy = oldTop - newTop; 258 if (!dy) return; 259 el.style.transform = `translateY(${dy}px)`; 260 void el.offsetHeight; 261 el.style.transition = 'transform 180ms ease'; 262 el.style.transform = ''; 263 264 el._flipTargetTop = newTop; 265 if (el._flipEndHandler) el.removeEventListener('transitionend', el._flipEndHandler); 266 el._flipEndHandler = (e) => { 267 if (e.propertyName !== 'transform') return; 268 delete el._flipTargetTop; 269 el.removeEventListener('transitionend', el._flipEndHandler); 270 el._flipEndHandler = null; 271 }; 272 el.addEventListener('transitionend', el._flipEndHandler); 273 } 274 275 function onReorderEnd() { 276 const r = activeReorder; 277 document.removeEventListener('mousemove', onReorderMove); 278 document.removeEventListener('mouseup', onReorderEnd); 279 document.removeEventListener('touchmove', onReorderTouchMove); 280 document.removeEventListener('touchend', onReorderEnd); 281 document.removeEventListener('touchcancel', onReorderEnd); 282 activeReorder = null; 283 document.body.classList.remove('reorder-pressed'); 284 document.body.classList.remove('reordering'); 285 if (r && r.autoScrollRaf) cancelAnimationFrame(r.autoScrollRaf); 286 if (!r) return; 287 288 if (!r.started) { 289 // No real drag — let the click handler fire normally 290 return; 291 } 292 293 // Compute the new order from placeholder position and rebuild tracks array 294 const fromIndex = r.index; 295 const itemsInDom = Array.from(playlist.querySelectorAll('.playlist-item, .playlist-item-placeholder')); 296 const toIndex = itemsInDom.indexOf(r.placeholder); 297 298 const droppedTop = r.item.getBoundingClientRect().top; 299 r.placeholder.remove(); 300 301 if (toIndex === -1 || toIndex === fromIndex) { 302 renderPlaylist(); 303 animateDroppedTrack(r.item, fromIndex, droppedTop); 304 return; 305 } 306 307 // Build the new tracks array directly from the DOM ordering. 308 const moved = tracks[fromIndex]; 309 const remaining = tracks.filter((_, i) => i !== fromIndex); 310 const newTracks = []; 311 let remainingIdx = 0; 312 for (let i = 0; i < itemsInDom.length; i++) { 313 if (itemsInDom[i] === r.placeholder) { 314 newTracks.push(moved); 315 } else { 316 newTracks.push(remaining[remainingIdx++]); 317 } 318 } 319 320 const playingTrack = tracks[currentTrackIndex]; 321 tracks.length = 0; 322 tracks.push(...newTracks); 323 const newCurrentIndex = tracks.indexOf(playingTrack); 324 if (newCurrentIndex !== -1) setCurrentTrackIndex(newCurrentIndex); 325 326 renderPlaylist(); 327 animateDroppedTrack(r.item, toIndex, droppedTop); 328 saveTrackOrder(); 329 } 330 331 function animateDroppedTrack(draggedEl, newIndex, oldTop) { 332 const items = playlist.querySelectorAll('.playlist-item'); 333 const target = items[newIndex]; 334 if (!target) { 335 draggedEl.remove(); 336 return; 337 } 338 339 // Hide the rebuilt item but keep it in layout so sizing/scroll are stable. 340 target.style.visibility = 'hidden'; 341 const newTop = target.getBoundingClientRect().top; 342 343 const cleanup = () => { 344 draggedEl.remove(); 345 target.style.visibility = ''; 346 }; 347 348 if (oldTop === newTop) { 349 cleanup(); 350 return; 351 } 352 353 const playlistRect = playlist.getBoundingClientRect(); 354 draggedEl.style.transition = 'none'; 355 draggedEl.style.top = `${oldTop}px`; 356 draggedEl.style.left = `${playlistRect.left}px`; 357 draggedEl.style.width = `${playlistRect.width}px`; 358 void draggedEl.offsetHeight; 359 draggedEl.style.transition = 'top 180ms ease'; 360 draggedEl.style.top = `${newTop}px`; 361 362 const finish = (e) => { 363 if (e.propertyName !== 'top') return; 364 draggedEl.removeEventListener('transitionend', finish); 365 cleanup(); 366 }; 367 draggedEl.addEventListener('transitionend', finish); 368 } 369 370 function saveTrackOrder() { 371 // Strip the runtime-only `looping` flag so we don't persist transient UI state 372 const persisted = tracks.map(({ looping, ...rest }) => rest); 373 fetch('/tracks', { 374 method: 'POST', 375 headers: { 'Content-Type': 'application/json' }, 376 body: JSON.stringify(persisted), 377 }).catch(err => console.error('Failed to save order:', err)); 378 } 379 380 // Shift+click delete 381 382 function deleteTrack(index) { 383 const removed = tracks[index]; 384 if (!removed) return; 385 386 // Snapshot old positions of every playlist row keyed by filename, so we 387 // can FLIP-animate the survivors after re-rendering. 388 const beforeItems = Array.from(playlist.querySelectorAll('.playlist-item')); 389 const oldTopByFilename = new Map(); 390 beforeItems.forEach((el, i) => { 391 const t = tracks[i]; 392 if (t) oldTopByFilename.set(t.filename, el.getBoundingClientRect().top); 393 }); 394 395 const wasPlaying = currentTrackIndex === index; 396 397 // Free any preloaded blob for the deleted track 398 if (preloadedAudio[removed.filename]) { 399 try { URL.revokeObjectURL(preloadedAudio[removed.filename].blobUrl); } catch (e) {} 400 delete preloadedAudio[removed.filename]; 401 } 402 403 tracks.splice(index, 1); 404 405 if (wasPlaying) { 406 try { audio.pause(); } catch (e) {} 407 audio.removeAttribute('src'); 408 audio.load(); 409 isPlaying = false; 410 resetProgressBar(); 411 if (tracks.length === 0) { 412 playerReady = false; 413 updateCurrentTrackDisplay('No tracks found'); 414 updatePlayPauseButton(); 415 } else { 416 const nextIndex = Math.min(index, tracks.length - 1); 417 setCurrentTrackIndex(nextIndex); 418 const next = tracks[nextIndex]; 419 updateCurrentTrackDisplay(`Ready to play: ${next.artist} – ${next.title}`); 420 updatePlayPauseButton(); 421 } 422 } else if (currentTrackIndex > index) { 423 setCurrentTrackIndex(currentTrackIndex - 1); 424 } 425 426 renderPlaylist(); 427 428 // FLIP: translate each surviving row from its old top to its new top. 429 const afterItems = playlist.querySelectorAll('.playlist-item'); 430 afterItems.forEach((el, i) => { 431 const t = tracks[i]; 432 if (!t) return; 433 const oldTop = oldTopByFilename.get(t.filename); 434 if (oldTop === undefined) return; 435 const newTop = el.getBoundingClientRect().top; 436 const dy = oldTop - newTop; 437 if (!dy) return; 438 el.style.transition = 'none'; 439 el.style.transform = `translateY(${dy}px)`; 440 void el.offsetHeight; 441 el.style.transition = 'transform 180ms ease'; 442 el.style.transform = ''; 443 const clear = (e) => { 444 if (e.propertyName !== 'transform') return; 445 el.style.transition = ''; 446 el.style.transform = ''; 447 el.removeEventListener('transitionend', clear); 448 }; 449 el.addEventListener('transitionend', clear); 450 }); 451 452 fetch('/tracks/' + encodeURIComponent(removed.filename), { method: 'DELETE' }) 453 .then(r => { if (!r.ok) throw new Error('delete failed: ' + r.status); }) 454 .catch(err => console.error('Failed to delete track:', err)); 455 } 456 457 // Live sync over SSE 458 459 // Swap the now-playing text without disturbing an in-flight marquee 460 function updateNowPlayingTextInPlace(text) { 461 const span = currentTrackDisplay.querySelector('span'); 462 if (!span) { 463 updateCurrentTrackDisplay(text); 464 return; 465 } 466 467 // If the marquee isn't currently animating, decide whether the new text needs animation 468 if (!marqueeAnimating) { 469 span.textContent = text; 470 const containerWidth = currentTrackDisplay.offsetWidth - 30; 471 if (span.scrollWidth > containerWidth) { 472 marqueeOriginalText = text; 473 setupMarquee({ preserveOffset: true }); 474 } else { 475 marqueeOriginalText = text; 476 } 477 return; 478 } 479 480 // Marquee is animating. Update the duplicated text in place, recompute 481 // the half-width (since the new text may differ in length), and wrap 482 // the current offset into the new range 483 marqueeOriginalText = text; 484 const spacing = ' '; 485 span.textContent = text + spacing + text + spacing; 486 487 const containerWidth = currentTrackDisplay.offsetWidth - 30; 488 if (span.scrollWidth / 2 <= containerWidth) { 489 // New text fits — stop the marquee cleanly. 490 setupMarquee({ preserveOffset: true }); 491 return; 492 } 493 494 marqueeHalfWidth = span.scrollWidth / 2; 495 marqueeOffset = marqueeOffset % marqueeHalfWidth; 496 span.style.transform = `translateX(-${marqueeOffset}px)`; 497 } 498 499 // Skip the very first SSE message so we don't double-render before the player has finished its 500 // initial setup. 501 let sseSeenInitial = false; 502 503 function tracksEqual(a, b) { 504 if (a.length !== b.length) return false; 505 for (let i = 0; i < a.length; i++) { 506 if (a[i].filename !== b[i].filename) return false; 507 if (a[i].title !== b[i].title) return false; 508 if (a[i].artist !== b[i].artist) return false; 509 } 510 return true; 511 } 512 513 function applyRenames(renames) { 514 for (const { from, to } of renames) { 515 if (from === to) continue; 516 517 if (preloadedAudio[from]) { 518 preloadedAudio[to] = preloadedAudio[from]; 519 delete preloadedAudio[from]; 520 } 521 522 if (cachedTracks.has(from)) { 523 cachedTracks.delete(from); 524 cachedTracks.add(to); 525 } 526 527 const oldUrl = `mix/${from}`; 528 if (audio.src && audio.src.endsWith(oldUrl)) { 529 const wasPlaying = !audio.paused; 530 const t = audio.currentTime; 531 audio.src = `mix/${to}`; 532 audio.load(); 533 audio.addEventListener('loadedmetadata', function resume() { 534 audio.removeEventListener('loadedmetadata', resume); 535 try { audio.currentTime = t; } catch (e) {} 536 if (wasPlaying) audio.play().catch(() => {}); 537 }, { once: true }); 538 } 539 540 const idx = tracks.findIndex(t => t.filename === from); 541 if (idx !== -1) tracks[idx].filename = to; 542 } 543 } 544 545 function reconcileTracks(incoming, renames) { 546 if (renames && renames.length) applyRenames(renames); 547 548 const incomingByName = new Map(incoming.map(t => [t.filename, t])); 549 const currentNames = new Set(tracks.map(t => t.filename)); 550 const incomingNames = new Set(incomingByName.keys()); 551 552 if (tracks.length > 0 && tracksEqual(tracks, incoming)) return; 553 554 const playingFilename = tracks[currentTrackIndex] && tracks[currentTrackIndex].filename; 555 const playingRemoved = playingFilename && !incomingNames.has(playingFilename); 556 557 // Free blob URLs for tracks that disappeared. 558 for (const name of currentNames) { 559 if (!incomingNames.has(name) && preloadedAudio[name]) { 560 try { URL.revokeObjectURL(preloadedAudio[name].blobUrl); } catch (e) {} 561 delete preloadedAudio[name]; 562 } 563 } 564 565 const loopingByName = new Map(tracks.map(t => [t.filename, t.looping || false])); 566 const newTracks = incoming.map(t => ({ ...t, looping: loopingByName.get(t.filename) || false })); 567 568 tracks.length = 0; 569 tracks.push(...newTracks); 570 571 if (tracks.length === 0) { 572 playerReady = false; 573 try { audio.pause(); } catch (e) {} 574 audio.removeAttribute('src'); 575 audio.load(); 576 isPlaying = false; 577 resetProgressBar(); 578 updatePlayPauseButton(); 579 updateCurrentTrackDisplay('No tracks found'); 580 playlist.innerHTML = ''; 581 return; 582 } 583 584 playerReady = true; 585 586 if (playingRemoved) { 587 // Playing track is gone: stop, advance to the next-best slot, and 588 // drop into "Ready to play". 589 try { audio.pause(); } catch (e) {} 590 audio.removeAttribute('src'); 591 audio.load(); 592 isPlaying = false; 593 resetProgressBar(); 594 const nextIndex = Math.min(currentTrackIndex, tracks.length - 1); 595 setCurrentTrackIndex(nextIndex); 596 const next = tracks[nextIndex]; 597 updateCurrentTrackDisplay(`Ready to play: ${next.artist} – ${next.title}`); 598 updatePlayPauseButton(); 599 } else if (playingFilename) { 600 // Playing track survived (possibly via a rename applied above): its 601 // index may have shifted because of reorders or adds/removes 602 // elsewhere in the list. 603 const newIndex = tracks.findIndex(t => t.filename === playingFilename); 604 if (newIndex !== -1) setCurrentTrackIndex(newIndex); 605 606 const cur = tracks[currentTrackIndex]; 607 if (cur) { 608 const isReady = currentTrackDisplay.textContent.includes('Ready to play:'); 609 const text = isReady 610 ? `Ready to play: ${cur.artist} – ${cur.title}` 611 : `${cur.artist} – ${cur.title}`; 612 updateNowPlayingTextInPlace(text); 613 } 614 } 615 616 if (batchUploadsInFlight === 0) { 617 renderPlaylist(); 618 flushPendingDropAnimation(); 619 } 620 621 // Pick up any tracks added since last preload pass. 622 if (typeof preloadNextTrack === 'function') { 623 currentPreloadIndex = 0; 624 preloadNextTrack(); 625 } 626 } 627 628 function startLiveSync() { 629 const source = new EventSource('/events'); 630 source.addEventListener('tracks', (e) => { 631 const payload = JSON.parse(e.data); 632 if (!sseSeenInitial) { 633 sseSeenInitial = true; 634 return; 635 } 636 reconcileTracks(payload.tracks, payload.renames); 637 }); 638 } 639 640 startLiveSync(); 641 642 // Drag-and-drop file upload 643 644 const SUPPORTED_DROP_EXTS = ['.mp3', '.m4a', '.ogg', '.flac', '.wav']; 645 646 // Snapshots of row positions captured at drop time, awaiting the SSE round-trip 647 // so we can FLIP-animate displaced rows once the new tracks land. 648 let pendingDropAnimations = []; 649 650 // While > 0, a drag-drop batch is uploading. SSE updates land in `tracks` 651 // but renderPlaylist is deferred so the user only sees the final layout. 652 // Avoids snap-flashing through each intermediate ordering. 653 let batchUploadsInFlight = 0; 654 655 function dragHasFiles(e) { 656 if (!e.dataTransfer) return false; 657 const types = e.dataTransfer.types; 658 if (!types) return false; 659 for (let i = 0; i < types.length; i++) { 660 if (types[i] === 'Files') return true; 661 } 662 return false; 663 } 664 665 document.addEventListener('dragover', (e) => { 666 if (!dragHasFiles(e)) return; 667 e.preventDefault(); 668 if (e.dataTransfer) e.dataTransfer.dropEffect = 'copy'; 669 }); 670 671 document.addEventListener('drop', (e) => { 672 if (!dragHasFiles(e)) return; 673 e.preventDefault(); 674 675 const files = Array.from(e.dataTransfer.files || []).filter(f => { 676 const lower = f.name.toLowerCase(); 677 return SUPPORTED_DROP_EXTS.some(ext => lower.endsWith(ext)); 678 }); 679 if (files.length === 0) return; 680 681 const insertAfter = computeInsertAfterIndex(e.clientY); 682 uploadFiles(files, insertAfter); 683 }); 684 685 function computeInsertAfterIndex(clientY) { 686 const items = Array.from(playlist.querySelectorAll('.playlist-item')); 687 if (items.length === 0) return -1; 688 let aboveCount = 0; 689 for (const item of items) { 690 const rect = item.getBoundingClientRect(); 691 if (rect.top + rect.height / 2 < clientY) aboveCount++; 692 else break; 693 } 694 return aboveCount - 1; 695 } 696 697 function uploadFiles(files, insertAfter) { 698 const snapshot = { positions: snapshotRowPositions(), ready: false }; 699 pendingDropAnimations.push(snapshot); 700 batchUploadsInFlight++; 701 702 const dropSnapshot = () => { 703 const idx = pendingDropAnimations.indexOf(snapshot); 704 if (idx !== -1) pendingDropAnimations.splice(idx, 1); 705 }; 706 707 let cursor = insertAfter; 708 const results = []; 709 const chain = files.reduce((p, file) => p.then(() => { 710 return uploadOneFile(file, cursor).then(r => { 711 results.push(r); 712 if (r && typeof r.final_index === 'number' && r.final_index >= 0) { 713 cursor = r.final_index; 714 } else { 715 cursor++; 716 } 717 }); 718 }), Promise.resolve()); 719 720 chain.then(() => { 721 batchUploadsInFlight--; 722 const noBroadcast = results.every(r => r && r.duplicate && !r.moved); 723 if (noBroadcast) { 724 dropSnapshot(); 725 return; 726 } 727 snapshot.ready = true; 728 // Render once against the final batch state, then FLIP-animate from 729 // the pre-drop snapshot. 730 if (batchUploadsInFlight === 0) { 731 renderPlaylist(); 732 flushPendingDropAnimation(); 733 } 734 }).catch(err => { 735 batchUploadsInFlight--; 736 console.error('Upload failed:', err); 737 dropSnapshot(); 738 if (batchUploadsInFlight === 0) renderPlaylist(); 739 }); 740 } 741 742 function uploadOneFile(file, insertAfter) { 743 return fetch('/upload/' + encodeURIComponent(file.name), { 744 method: 'PUT', 745 headers: { 746 'Content-Type': 'application/octet-stream', 747 'X-Insert-After': String(insertAfter), 748 }, 749 body: file, 750 }).then(r => { 751 if (!r.ok) throw new Error('upload failed: ' + r.status); 752 return r.json().catch(() => ({})); 753 }); 754 } 755 756 function snapshotRowPositions() { 757 const map = new Map(); 758 const items = playlist.querySelectorAll('.playlist-item'); 759 items.forEach((el, i) => { 760 const t = tracks[i]; 761 if (t) map.set(t.filename, el.getBoundingClientRect().top); 762 }); 763 return map; 764 } 765 766 function flushPendingDropAnimation() { 767 const ready = pendingDropAnimations.filter(s => s.ready); 768 if (ready.length === 0) return; 769 // Merge ready snapshots. For any filename, prefer the OLDEST recorded 770 // top so multi-file drops still animate from the original pre-drop 771 // position. 772 const merged = new Map(); 773 for (const snap of ready) { 774 for (const [name, top] of snap.positions) { 775 if (!merged.has(name)) merged.set(name, top); 776 } 777 } 778 pendingDropAnimations = pendingDropAnimations.filter(s => !s.ready); 779 780 const items = playlist.querySelectorAll('.playlist-item'); 781 items.forEach((el, i) => { 782 const t = tracks[i]; 783 if (!t) return; 784 const oldTop = merged.get(t.filename); 785 if (oldTop === undefined) return; 786 const newTop = el.getBoundingClientRect().top; 787 const dy = oldTop - newTop; 788 if (!dy) return; 789 el.style.transition = 'none'; 790 el.style.transform = `translateY(${dy}px)`; 791 void el.offsetHeight; 792 el.style.transition = 'transform 180ms ease'; 793 el.style.transform = ''; 794 const clear = (e) => { 795 if (e.propertyName !== 'transform') return; 796 el.style.transition = ''; 797 el.style.transform = ''; 798 el.removeEventListener('transitionend', clear); 799 }; 800 el.addEventListener('transitionend', clear); 801 }); 802 }