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