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 }