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 }