Playlist.tsx
⎇
Raw
1import { type Accessor, For, type Setter, createSignal, onMount, onCleanup, createEffect } from "solid-js";
2import "./Playlist.css";
3import type { PlayerState, PlaylistItem } from "../types";
4import { formatTime } from "../utils";
5import { ClearSVG, DragSVG } from "./SVG";
6
7interface PlaylistProps {
8 playlist: Accessor<PlaylistItem[]>;
9 setPlaylist: Setter<PlaylistItem[]>;
10 playerState: Accessor<PlayerState>;
11 setPlayerState: Setter<PlayerState>;
12 playTrack: (item: PlaylistItem) => void;
13 audioElement: HTMLAudioElement;
14}
15
16export default function Playlist(props: PlaylistProps) {
17 const [draggingItem, setDraggingItem] = createSignal<PlaylistItem | null>(null);
18 const [dragStartIndex, setDragStartIndex] = createSignal<number | null>(null);
19 const [dragOverIndex, setDragOverIndex] = createSignal<number | null>(null);
20
21 let playlistRef!: HTMLDivElement;
22
23 //used for throttling drag updates
24 let rafId: number | null = null;
25 function scheduleUpdate(clientY: number) {
26 lastClientY = clientY;
27 if (rafId != null) return;
28 rafId = requestAnimationFrame(() => {
29 updateDragOverIndex(lastClientY);
30 rafId = null;
31 });
32 }
33
34 // For auto-scroll during drag
35 let lastClientY = 0;
36 let scrollInterval: undefined | number;
37
38 function startAutoScrollLoop() {
39 function step() {
40 const rect = playlistRef.getBoundingClientRect();
41 const edgeThreshold = 75;
42 // Scroll faster the closer to the edge
43 const scrollAmount = (distance: number) => Math.min(15, 1 + (distance / edgeThreshold) * 5);
44
45 if (lastClientY < rect.top + edgeThreshold) {
46 const distance = rect.top + edgeThreshold - lastClientY;
47 playlistRef.scrollTop -= scrollAmount(distance);
48 } else if (lastClientY > rect.bottom - edgeThreshold) {
49 const distance = lastClientY - (rect.bottom - edgeThreshold);
50 playlistRef.scrollTop += scrollAmount(distance);
51 }
52
53 scheduleUpdate(lastClientY);
54 }
55 scrollInterval = setInterval(step, 10);
56 }
57
58 function stopAutoScrollLoop() {
59 clearInterval(scrollInterval);
60 scrollInterval = undefined;
61 }
62
63 function removeFromPlaylist(id: string) {
64 const currentTrack = props.playerState().currentTrack;
65 const newPlaylist = props.playlist().filter((i) => i.id !== id);
66 if (currentTrack && currentTrack.id === id) {
67 const idx = props.playlist().findIndex((i) => i.id === id);
68 if (newPlaylist.length > 0) {
69 props.playTrack(idx < newPlaylist.length ? newPlaylist[idx] : newPlaylist[0]);
70 }
71 }
72 props.setPlaylist(newPlaylist);
73 }
74
75 function moveDraggedItem() {
76 const playlist = props.playlist();
77 const movedItem = draggingItem();
78 const startIdx = dragStartIndex();
79 const dropIdx = dragOverIndex();
80 if (movedItem && startIdx != null && dropIdx != null && startIdx !== dropIdx) {
81 const newItems = [...playlist];
82 newItems.splice(startIdx, 1);
83 const adjustedIndex = startIdx < dropIdx ? dropIdx - 1 : dropIdx;
84 newItems.splice(adjustedIndex, 0, movedItem);
85 props.setPlaylist(newItems);
86 }
87 }
88
89 function endDrag(move: boolean) {
90 if (rafId) cancelAnimationFrame(rafId);
91 rafId = null;
92 stopAutoScrollLoop();
93 if (move) moveDraggedItem();
94 setDraggingItem(null);
95 setDragStartIndex(null);
96 setDragOverIndex(null);
97 }
98
99 function updateDragOverIndex(clientY: number) {
100 const items = Array.from(playlistRef.querySelectorAll(".playlist-item")) as HTMLElement[];
101 for (let i = 0; i < items.length; i++) {
102 const rect = items[i].getBoundingClientRect();
103 if (clientY < rect.top + rect.height / 2) {
104 setDragOverIndex(i);
105 return;
106 }
107 }
108 setDragOverIndex(items.length);
109 }
110
111 function onDragStart(item: PlaylistItem, index: number, dragElement: HTMLElement, e: DragEvent) {
112 setDraggingItem(item);
113 setDragStartIndex(index);
114 if (!e.dataTransfer) return;
115 e.dataTransfer.effectAllowed = "move";
116 e.dataTransfer.setDragImage(dragElement, dragElement.offsetWidth / 20, dragElement.offsetHeight / 2);
117 }
118
119 function onDragOver(e: DragEvent) {
120 e.preventDefault();
121 scheduleUpdate(e.clientY);
122 if (!e.dataTransfer) return;
123 e.dataTransfer.dropEffect = "move";
124 }
125
126 function onDrop(e: DragEvent) {
127 e.preventDefault();
128 endDrag(true);
129 }
130
131 function onTouchStart(item: PlaylistItem, index: number, e: TouchEvent) {
132 setDraggingItem(item);
133 setDragStartIndex(index);
134 lastClientY = e.touches[0].clientY;
135 }
136
137 function onTouchMove(e: TouchEvent) {
138 if (!draggingItem()) return;
139 if (!scrollInterval) startAutoScrollLoop();
140 e.preventDefault();
141 scheduleUpdate(e.touches[0].clientY);
142 }
143
144 onMount(() => {
145 const cancelDrag = () => endDrag(false);
146 const doDrag = () => endDrag(true);
147 playlistRef.addEventListener("touchmove", onTouchMove, { passive: false });
148 playlistRef.addEventListener("touchend", doDrag);
149 playlistRef.addEventListener("touchcancel", cancelDrag);
150
151 onCleanup(() => {
152 playlistRef?.removeEventListener("touchmove", onTouchMove);
153 playlistRef?.removeEventListener("touchend", doDrag);
154 playlistRef?.removeEventListener("touchcancel", cancelDrag);
155 stopAutoScrollLoop();
156 });
157 });
158
159 return (
160 <div class="playlist-container">
161 <div class="playlist-header">
162 <span>Playlist ({props.playlist().length} tracks)</span>
163 <button title="Clear Playlist" onClick={() => props.setPlaylist([])}>
164 <ClearSVG />
165 </button>
166 </div>
167 <div class="playlist" ref={playlistRef} onDragOver={onDragOver} onDrop={onDrop} onDragEnd={() => endDrag(true)}>
168 <For each={props.playlist()}>
169 {(item, index) => {
170 const isActive = () => props.playerState().currentTrack?.id === item.id;
171 const isDragging = () => draggingItem()?.id === item.id;
172 const droppingBefore = () => dragOverIndex() === index() && dragStartIndex() !== index();
173 const droppingAfter = () => dragOverIndex() === index() + 1 && dragStartIndex() !== index();
174 return (
175 <div
176 classList={{
177 "playlist-item": true,
178 active: isActive(),
179 "drop-target-top-border": droppingBefore(),
180 "drop-target-bottom-border": droppingAfter(),
181 }}
182 style={{ opacity: isDragging() ? 0.5 : 1 }}
183 onClick={() => props.playTrack(item)}
184 >
185 <div
186 class="playlist-drag-btn"
187 draggable="true"
188 onDragStart={(e) => onDragStart(item, index(), e.currentTarget.parentElement as HTMLElement, e)}
189 onTouchStart={(e) => onTouchStart(item, index(), e)}
190 >
191 <DragSVG />
192 </div>
193 <span>{item.name}</span>
194 <div>
195 {formatTime(item.duration)}
196 <button
197 class="playlist-remove"
198 title={`Remove ${item.name}`}
199 onClick={(e) => {
200 e.stopPropagation();
201 removeFromPlaylist(item.id);
202 }}
203 >
204 <ClearSVG />
205 </button>
206 </div>
207 </div>
208 );
209 }}
210 </For>
211 <div class="drop-area-end" />
212 </div>
213 </div>
214 );
215}
216