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";
7import { savePlaylist } from "../offline";
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 try {
157 toast("Started playlist download", "info");
158 const playlist = props.playlist();
159 const filePaths = playlist.map((item) => {
160 return item.path;
161 });
162 const form = document.createElement("form");
163 form.method = "POST";
164 form.action = "/download-playlist";
165 const input = document.createElement("input");
166 input.type = "hidden";
167 input.name = "files";
168 //needed to support stream download with complex body in request
169 input.value = btoa(encodeURIComponent(JSON.stringify(filePaths)));
170 form.appendChild(input);
171 document.body.appendChild(form);
172 form.submit();
173 document.body.removeChild(form);
174 } catch (e) {
175 toast("Playlist items download failed", "error", e);
176 }
177 }
178
179 onMount(() => {
180 const cancelDrag = () => endDrag(false);
181 const doDrag = () => endDrag(true);
182 playlistRef.addEventListener("touchmove", onTouchMove, { passive: false });
183 playlistRef.addEventListener("touchend", doDrag);
184 playlistRef.addEventListener("touchcancel", cancelDrag);
185
186 onCleanup(() => {
187 playlistRef?.removeEventListener("touchmove", onTouchMove);
188 playlistRef?.removeEventListener("touchend", doDrag);
189 playlistRef?.removeEventListener("touchcancel", cancelDrag);
190 stopAutoScrollLoop();
191 });
192 });
193
194 let dialogElement!: HTMLDialogElement;
195 let playlistNameInput!: HTMLInputElement;
196 let allowOverwriteCheckbox!: HTMLInputElement;
197
198 async function save() {
199 try {
200 if (!playlistNameInput.reportValidity()) return;
201 await savePlaylist({ name: playlistNameInput.value, items: props.playlist() }, allowOverwriteCheckbox.checked);
202 closeDialog();
203 } catch (e) {
204 if (e instanceof Error && e.name === "ConstraintError") {
205 toast("Playlist with this name already exists", "error");
206 } else {
207 toast("Saving playlist failed", "error", e);
208 }
209 }
210 }
211
212 function closeDialog() {
213 dialogElement.close();
214 playlistNameInput.value = "";
215 allowOverwriteCheckbox.checked = false;
216 }
217
218 return (
219 <div class="playlist-container">
220 <dialog class="save-dialog" ref={dialogElement}>
221 <div>
222 <input ref={playlistNameInput} required type="text" placeholder="Playlist name" />
223 <div class="save-buttons">
224 <label for="overwrite-checkbox">allow overwrite</label>
225 <input ref={allowOverwriteCheckbox} id="overwrite-checkbox" type="checkbox" />
226 <button onClick={save}>Save</button>
227 <button onClick={closeDialog}>Cancel</button>
228 </div>
229 </div>
230 </dialog>
231
232 <div class="playlist-header">
233 <span>Playlist ({props.playlist().length} tracks)</span>
234 <div class="spacer" />
235 <button title="Save playlist" onClick={() => dialogElement.showModal()}>
236 <SaveSVG />
237 </button>
238 <button
239 title="Download all files in the playlist in their original format as a tar file. WARNING: Uses a lot of memory during downlad"
240 onClick={browserDownloadAll}
241 >
242 <DownloadSVG />
243 </button>
244 <button title="Clear Playlist" onClick={() => props.setPlaylist([])}>
245 <ClearSVG />
246 </button>
247 </div>
248 <div class="playlist" ref={playlistRef} onDragOver={onDragOver} onDrop={onDrop} onDragEnd={() => endDrag(true)}>
249 <For each={props.playlist()}>
250 {(item, index) => {
251 const isActive = () => props.playerState().currentTrack?.id === item.id;
252 const isDragging = () => draggingItem()?.id === item.id;
253 const droppingBefore = () => dragOverIndex() === index() && dragStartIndex() !== index();
254 const droppingAfter = () => dragOverIndex() === index() + 1 && dragStartIndex() !== index();
255 return (
256 <div
257 classList={{
258 "playlist-item": true,
259 active: isActive(),
260 "drop-target-top-border": droppingBefore(),
261 "drop-target-bottom-border": droppingAfter(),
262 }}
263 style={{ opacity: isDragging() ? 0.5 : 1 }}
264 onClick={() => props.playTrack(item)}
265 >
266 <div
267 class="playlist-drag-btn"
268 draggable="true"
269 onDragStart={(e) => onDragStart(item, index(), e.currentTarget.parentElement as HTMLElement, e)}
270 onTouchStart={(e) => onTouchStart(item, index(), e)}
271 >
272 <DragSVG />
273 </div>
274 <span>{item.name}</span>
275 <div class="spacer" />
276 {formatTime(item.duration)}
277 <button
278 title={"Download this file in its original format to your file system"}
279 onClick={(e) => {
280 e.stopPropagation();
281 browserDownload(item.name, item.path);
282 }}
283 >
284 <DownloadSVG />
285 </button>
286 <button
287 title={`Remove ${item.name}`}
288 onClick={(e) => {
289 e.stopPropagation();
290 removeFromPlaylist(item.id);
291 }}
292 >
293 <ClearSVG />
294 </button>
295 </div>
296 );
297 }}
298 </For>
299 <div class="drop-area-end" />
300 </div>
301 </div>
302 );
303}
304