import { type Accessor, For, type Setter, createSignal, onMount, onCleanup, createEffect } from "solid-js"; import "./Playlist.css"; import type { PlayerState, PlaylistItem } from "../types"; import { formatTime, toast } from "../utils"; import { ClearSVG, DownloadSVG, DragSVG, SaveSVG } from "./SVG"; import Toastify from "toastify-js"; interface PlaylistProps { currentDir: Accessor; playlist: Accessor; setPlaylist: Setter; playerState: Accessor; setPlayerState: Setter; playTrack: (item: PlaylistItem) => void; audioElement: HTMLAudioElement; } export default function Playlist(props: PlaylistProps) { const [draggingItem, setDraggingItem] = createSignal(null); const [dragStartIndex, setDragStartIndex] = createSignal(null); const [dragOverIndex, setDragOverIndex] = createSignal(null); let playlistRef!: HTMLDivElement; //used for throttling drag updates let rafId: number | null = null; function scheduleUpdate(clientY: number) { lastClientY = clientY; if (rafId != null) return; rafId = requestAnimationFrame(() => { updateDragOverIndex(lastClientY); rafId = null; }); } // For auto-scroll during drag let lastClientY = 0; let scrollInterval: undefined | number; function startAutoScrollLoop() { function step() { const rect = playlistRef.getBoundingClientRect(); const edgeThreshold = 75; // Scroll faster the closer to the edge const scrollAmount = (distance: number) => Math.min(15, 1 + (distance / edgeThreshold) * 5); if (lastClientY < rect.top + edgeThreshold) { const distance = rect.top + edgeThreshold - lastClientY; playlistRef.scrollTop -= scrollAmount(distance); } else if (lastClientY > rect.bottom - edgeThreshold) { const distance = lastClientY - (rect.bottom - edgeThreshold); playlistRef.scrollTop += scrollAmount(distance); } scheduleUpdate(lastClientY); } scrollInterval = setInterval(step, 10); } function stopAutoScrollLoop() { clearInterval(scrollInterval); scrollInterval = undefined; } function removeFromPlaylist(id: string) { const currentTrack = props.playerState().currentTrack; const newPlaylist = props.playlist().filter((i) => i.id !== id); if (currentTrack && currentTrack.id === id) { const idx = props.playlist().findIndex((i) => i.id === id); if (newPlaylist.length > 0) { props.playTrack(idx < newPlaylist.length ? newPlaylist[idx] : newPlaylist[0]); } } props.setPlaylist(newPlaylist); } function moveDraggedItem() { const playlist = props.playlist(); const movedItem = draggingItem(); const startIdx = dragStartIndex(); const dropIdx = dragOverIndex(); if (movedItem && startIdx != null && dropIdx != null && startIdx !== dropIdx) { const newItems = [...playlist]; newItems.splice(startIdx, 1); const adjustedIndex = startIdx < dropIdx ? dropIdx - 1 : dropIdx; newItems.splice(adjustedIndex, 0, movedItem); props.setPlaylist(newItems); } } function endDrag(move: boolean) { if (rafId) cancelAnimationFrame(rafId); rafId = null; stopAutoScrollLoop(); if (move) moveDraggedItem(); setDraggingItem(null); setDragStartIndex(null); setDragOverIndex(null); } function updateDragOverIndex(clientY: number) { const items = Array.from(playlistRef.querySelectorAll(".playlist-item")) as HTMLElement[]; for (let i = 0; i < items.length; i++) { const rect = items[i].getBoundingClientRect(); if (clientY < rect.top + rect.height / 2) { setDragOverIndex(i); return; } } setDragOverIndex(items.length); } function onDragStart(item: PlaylistItem, index: number, dragElement: HTMLElement, e: DragEvent) { setDraggingItem(item); setDragStartIndex(index); if (!e.dataTransfer) return; e.dataTransfer.effectAllowed = "move"; e.dataTransfer.setDragImage(dragElement, dragElement.offsetWidth / 20, dragElement.offsetHeight / 2); } function onDragOver(e: DragEvent) { e.preventDefault(); scheduleUpdate(e.clientY); if (!e.dataTransfer) return; e.dataTransfer.dropEffect = "move"; } function onDrop(e: DragEvent) { e.preventDefault(); endDrag(true); } function onTouchStart(item: PlaylistItem, index: number, e: TouchEvent) { setDraggingItem(item); setDragStartIndex(index); lastClientY = e.touches[0].clientY; } function onTouchMove(e: TouchEvent) { if (!draggingItem()) return; if (!scrollInterval) startAutoScrollLoop(); e.preventDefault(); scheduleUpdate(e.touches[0].clientY); } function browserDownload(name: string, path: string) { const a = document.createElement("a"); a.href = `/download/${path}`; a.download = name; document.body.appendChild(a); a.click(); document.body.removeChild(a); } async function browserDownloadAll() { let progressToast = undefined; try { const playlist = props.playlist(); const containerElement = document.createElement("div"); containerElement.style = "display:flex;flex-direction:column;"; containerElement.textContent = "Download playlist..."; const progressElement = document.createElement("progress"); progressElement.value = 0; progressElement.max = playlist.length; containerElement.appendChild(progressElement); progressToast = Toastify({ node: containerElement, duration: -1, close: false, }); progressToast.showToast(); const filePaths = playlist.map((item) => { return item.path; }); const form = document.createElement("form"); form.method = "POST"; form.action = "/download-playlist"; const input = document.createElement("input"); input.type = "hidden"; input.name = "files"; //needed to support stream download with complex body in request input.value = btoa(encodeURIComponent(JSON.stringify(filePaths))); form.appendChild(input); document.body.appendChild(form); form.submit(); document.body.removeChild(form); } catch (e) { toast("Playlist items download failed", "error", e); } finally { progressToast?.hideToast(); } } onMount(() => { const cancelDrag = () => endDrag(false); const doDrag = () => endDrag(true); playlistRef.addEventListener("touchmove", onTouchMove, { passive: false }); playlistRef.addEventListener("touchend", doDrag); playlistRef.addEventListener("touchcancel", cancelDrag); onCleanup(() => { playlistRef?.removeEventListener("touchmove", onTouchMove); playlistRef?.removeEventListener("touchend", doDrag); playlistRef?.removeEventListener("touchcancel", cancelDrag); stopAutoScrollLoop(); }); }); return (
Playlist ({props.playlist().length} tracks)
endDrag(true)}> {(item, index) => { const isActive = () => props.playerState().currentTrack?.id === item.id; const isDragging = () => draggingItem()?.id === item.id; const droppingBefore = () => dragOverIndex() === index() && dragStartIndex() !== index(); const droppingAfter = () => dragOverIndex() === index() + 1 && dragStartIndex() !== index(); return (
props.playTrack(item)} >
onDragStart(item, index(), e.currentTarget.parentElement as HTMLElement, e)} onTouchStart={(e) => onTouchStart(item, index(), e)} >
{item.name}
{formatTime(item.duration)}
); }}
); }