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, joinPath, toast } from "../utils";
5import { ClearSVG, DownloadSVG, DragSVG } from "./SVG";
6import { createTar } from "../tar";
7import Toastify from "toastify-js";
8
9interface PlaylistProps {
10 currentDir: Accessor<string>;
11 playlist: Accessor<PlaylistItem[]>;
12 setPlaylist: Setter<PlaylistItem[]>;
13 playerState: Accessor<PlayerState>;
14 setPlayerState: Setter<PlayerState>;
15 playTrack: (item: PlaylistItem) => void;
16 audioElement: HTMLAudioElement;
17}
18
19export default function Playlist(props: PlaylistProps) {
20 const [draggingItem, setDraggingItem] = createSignal<PlaylistItem | null>(null);
21 const [dragStartIndex, setDragStartIndex] = createSignal<number | null>(null);
22 const [dragOverIndex, setDragOverIndex] = createSignal<number | null>(null);
23
24 let playlistRef!: HTMLDivElement;
25
26 //used for throttling drag updates
27 let rafId: number | null = null;
28 function scheduleUpdate(clientY: number) {
29 lastClientY = clientY;
30 if (rafId != null) return;
31 rafId = requestAnimationFrame(() => {
32 updateDragOverIndex(lastClientY);
33 rafId = null;
34 });
35 }
36
37 // For auto-scroll during drag
38 let lastClientY = 0;
39 let scrollInterval: undefined | number;
40
41 function startAutoScrollLoop() {
42 function step() {
43 const rect = playlistRef.getBoundingClientRect();
44 const edgeThreshold = 75;
45 // Scroll faster the closer to the edge
46 const scrollAmount = (distance: number) => Math.min(15, 1 + (distance / edgeThreshold) * 5);
47
48 if (lastClientY < rect.top + edgeThreshold) {
49 const distance = rect.top + edgeThreshold - lastClientY;
50 playlistRef.scrollTop -= scrollAmount(distance);
51 } else if (lastClientY > rect.bottom - edgeThreshold) {
52 const distance = lastClientY - (rect.bottom - edgeThreshold);
53 playlistRef.scrollTop += scrollAmount(distance);
54 }
55
56 scheduleUpdate(lastClientY);
57 }
58 scrollInterval = setInterval(step, 10);
59 }
60
61 function stopAutoScrollLoop() {
62 clearInterval(scrollInterval);
63 scrollInterval = undefined;
64 }
65
66 function removeFromPlaylist(id: string) {
67 const currentTrack = props.playerState().currentTrack;
68 const newPlaylist = props.playlist().filter((i) => i.id !== id);
69 if (currentTrack && currentTrack.id === id) {
70 const idx = props.playlist().findIndex((i) => i.id === id);
71 if (newPlaylist.length > 0) {
72 props.playTrack(idx < newPlaylist.length ? newPlaylist[idx] : newPlaylist[0]);
73 }
74 }
75 props.setPlaylist(newPlaylist);
76 }
77
78 function moveDraggedItem() {
79 const playlist = props.playlist();
80 const movedItem = draggingItem();
81 const startIdx = dragStartIndex();
82 const dropIdx = dragOverIndex();
83 if (movedItem && startIdx != null && dropIdx != null && startIdx !== dropIdx) {
84 const newItems = [...playlist];
85 newItems.splice(startIdx, 1);
86 const adjustedIndex = startIdx < dropIdx ? dropIdx - 1 : dropIdx;
87 newItems.splice(adjustedIndex, 0, movedItem);
88 props.setPlaylist(newItems);
89 }
90 }
91
92 function endDrag(move: boolean) {
93 if (rafId) cancelAnimationFrame(rafId);
94 rafId = null;
95 stopAutoScrollLoop();
96 if (move) moveDraggedItem();
97 setDraggingItem(null);
98 setDragStartIndex(null);
99 setDragOverIndex(null);
100 }
101
102 function updateDragOverIndex(clientY: number) {
103 const items = Array.from(playlistRef.querySelectorAll(".playlist-item")) as HTMLElement[];
104 for (let i = 0; i < items.length; i++) {
105 const rect = items[i].getBoundingClientRect();
106 if (clientY < rect.top + rect.height / 2) {
107 setDragOverIndex(i);
108 return;
109 }
110 }
111 setDragOverIndex(items.length);
112 }
113
114 function onDragStart(item: PlaylistItem, index: number, dragElement: HTMLElement, e: DragEvent) {
115 setDraggingItem(item);
116 setDragStartIndex(index);
117 if (!e.dataTransfer) return;
118 e.dataTransfer.effectAllowed = "move";
119 e.dataTransfer.setDragImage(dragElement, dragElement.offsetWidth / 20, dragElement.offsetHeight / 2);
120 }
121
122 function onDragOver(e: DragEvent) {
123 e.preventDefault();
124 scheduleUpdate(e.clientY);
125 if (!e.dataTransfer) return;
126 e.dataTransfer.dropEffect = "move";
127 }
128
129 function onDrop(e: DragEvent) {
130 e.preventDefault();
131 endDrag(true);
132 }
133
134 function onTouchStart(item: PlaylistItem, index: number, e: TouchEvent) {
135 setDraggingItem(item);
136 setDragStartIndex(index);
137 lastClientY = e.touches[0].clientY;
138 }
139
140 function onTouchMove(e: TouchEvent) {
141 if (!draggingItem()) return;
142 if (!scrollInterval) startAutoScrollLoop();
143 e.preventDefault();
144 scheduleUpdate(e.touches[0].clientY);
145 }
146
147 function browserDownload(name: string, path: string) {
148 const a = document.createElement("a");
149 a.href = `/download/${path}`;
150 a.download = name;
151 document.body.appendChild(a);
152 a.click();
153 document.body.removeChild(a);
154 }
155 async function browserDownloadAll() {
156 const playlist = props.playlist();
157
158 const containerElement = document.createElement("div");
159 containerElement.style = "display:flex;flex-direction:column;";
160 containerElement.textContent = "Download playlist...";
161 const progressElement = document.createElement("progress");
162 progressElement.value = 0;
163 progressElement.max = playlist.length;
164 containerElement.appendChild(progressElement);
165
166 const progressToast = Toastify({
167 node: containerElement,
168 duration: -1,
169 close: false,
170 });
171
172 progressToast.showToast();
173 try {
174 const contentMap = new Map<string, Uint8Array>();
175 for (const item of playlist) {
176 const response = await fetch(`/download/${item.path}`);
177 if (!response.ok) throw new Error(`Download of ${item.name} was not successful`);
178 contentMap.set(item.name, new Uint8Array(await response.arrayBuffer()));
179 progressElement.value += 1;
180 }
181 const tarFile = createTar(contentMap);
182 const a = document.createElement("a");
183 a.href = URL.createObjectURL(new Blob([tarFile]));
184 a.download = "playlist.tar";
185 document.body.appendChild(a);
186 a.click();
187 document.body.removeChild(a);
188 } catch (e) {
189 toast("Playlist items download failed", "error", e);
190 } finally {
191 progressToast.hideToast();
192 }
193 }
194
195 onMount(() => {
196 const cancelDrag = () => endDrag(false);
197 const doDrag = () => endDrag(true);
198 playlistRef.addEventListener("touchmove", onTouchMove, { passive: false });
199 playlistRef.addEventListener("touchend", doDrag);
200 playlistRef.addEventListener("touchcancel", cancelDrag);
201
202 onCleanup(() => {
203 playlistRef?.removeEventListener("touchmove", onTouchMove);
204 playlistRef?.removeEventListener("touchend", doDrag);
205 playlistRef?.removeEventListener("touchcancel", cancelDrag);
206 stopAutoScrollLoop();
207 });
208 });
209
210 return (
211 <div class="playlist-container">
212 <div class="playlist-header">
213 <span>Playlist ({props.playlist().length} tracks)</span>
214 <div class="spacer" />
215 <button
216 title="Download all files in the playlist in their original format as a tar file"
217 onClick={browserDownloadAll}
218 >
219 <DownloadSVG />
220 </button>
221 <button title="Clear Playlist" onClick={() => props.setPlaylist([])}>
222 <ClearSVG />
223 </button>
224 </div>
225 <div class="playlist" ref={playlistRef} onDragOver={onDragOver} onDrop={onDrop} onDragEnd={() => endDrag(true)}>
226 <For each={props.playlist()}>
227 {(item, index) => {
228 const isActive = () => props.playerState().currentTrack?.id === item.id;
229 const isDragging = () => draggingItem()?.id === item.id;
230 const droppingBefore = () => dragOverIndex() === index() && dragStartIndex() !== index();
231 const droppingAfter = () => dragOverIndex() === index() + 1 && dragStartIndex() !== index();
232 return (
233 <div
234 classList={{
235 "playlist-item": true,
236 active: isActive(),
237 "drop-target-top-border": droppingBefore(),
238 "drop-target-bottom-border": droppingAfter(),
239 }}
240 style={{ opacity: isDragging() ? 0.5 : 1 }}
241 onClick={() => props.playTrack(item)}
242 >
243 <div
244 class="playlist-drag-btn"
245 draggable="true"
246 onDragStart={(e) => onDragStart(item, index(), e.currentTarget.parentElement as HTMLElement, e)}
247 onTouchStart={(e) => onTouchStart(item, index(), e)}
248 >
249 <DragSVG />
250 </div>
251 <span>{item.name}</span>
252 <div class="spacer" />
253 {formatTime(item.duration)}
254 <button
255 title={"Download this file in its original format to your file system"}
256 onClick={(e) => {
257 e.stopPropagation();
258 browserDownload(item.name, item.path);
259 }}
260 >
261 <DownloadSVG />
262 </button>
263 <button
264 title={`Remove ${item.name}`}
265 onClick={(e) => {
266 e.stopPropagation();
267 removeFromPlaylist(item.id);
268 }}
269 >
270 <ClearSVG />
271 </button>
272 </div>
273 );
274 }}
275 </For>
276 <div class="drop-area-end" />
277 </div>
278 </div>
279 );
280}
281