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(
202 { name: playlistNameInput.value, items: props.playlist() },
203 allowOverwriteCheckbox.checked,
204 );
205 closeDialog();
206 } catch (e) {
207 if (e instanceof Error && e.name === "ConstraintError") {
208 toast("Playlist with this name already exists", "error");
209 } else {
210 toast("Saving playlist failed", "error", e);
211 }
212 }
213 }
214
215 function closeDialog() {
216 dialogElement.close();
217 playlistNameInput.value = "";
218 allowOverwriteCheckbox.checked = false;
219 }
220
221 return (
222 <div class="playlist-container">
223 <dialog class="save-dialog" ref={dialogElement}>
224 <form>
225 <input ref={playlistNameInput} required type="text" placeholder="Playlist name" />
226 <div class="save-buttons">
227 <label for="overwrite-checkbox">allow overwrite</label>
228 <input ref={allowOverwriteCheckbox} id="overwrite-checkbox" type="checkbox" />
229 <button onClick={save}>Save</button>
230 <button onClick={closeDialog}>Cancel</button>
231 </div>
232 </form>
233 </dialog>
234
235 <div class="playlist-header">
236 <span>Playlist ({props.playlist().length} tracks)</span>
237 <div class="spacer" />
238 <button title="Save playlist" onClick={() => dialogElement.showModal()}>
239 <SaveSVG />
240 </button>
241 <button
242 title="Download all files in the playlist in their original format as a tar file. WARNING: Uses a lot of memory during downlad"
243 onClick={browserDownloadAll}
244 >
245 <DownloadSVG />
246 </button>
247 <button title="Clear Playlist" onClick={() => props.setPlaylist([])}>
248 <ClearSVG />
249 </button>
250 </div>
251 <div class="playlist" ref={playlistRef} onDragOver={onDragOver} onDrop={onDrop} onDragEnd={() => endDrag(true)}>
252 <For each={props.playlist()}>
253 {(item, index) => {
254 const isActive = () => props.playerState().currentTrack?.id === item.id;
255 const isDragging = () => draggingItem()?.id === item.id;
256 const droppingBefore = () => dragOverIndex() === index() && dragStartIndex() !== index();
257 const droppingAfter = () => dragOverIndex() === index() + 1 && dragStartIndex() !== index();
258 return (
259 <div
260 classList={{
261 "playlist-item": true,
262 active: isActive(),
263 "drop-target-top-border": droppingBefore(),
264 "drop-target-bottom-border": droppingAfter(),
265 }}
266 style={{ opacity: isDragging() ? 0.5 : 1 }}
267 onClick={() => props.playTrack(item)}
268 >
269 <div
270 class="playlist-drag-btn"
271 draggable="true"
272 onDragStart={(e) => onDragStart(item, index(), e.currentTarget.parentElement as HTMLElement, e)}
273 onTouchStart={(e) => onTouchStart(item, index(), e)}
274 >
275 <DragSVG />
276 </div>
277 <span>{item.name}</span>
278 <div class="spacer" />
279 {formatTime(item.duration)}
280 <button
281 title={"Download this file in its original format to your file system"}
282 onClick={(e) => {
283 e.stopPropagation();
284 browserDownload(item.name, item.path);
285 }}
286 >
287 <DownloadSVG />
288 </button>
289 <button
290 title={`Remove ${item.name}`}
291 onClick={(e) => {
292 e.stopPropagation();
293 removeFromPlaylist(item.id);
294 }}
295 >
296 <ClearSVG />
297 </button>
298 </div>
299 );
300 }}
301 </For>
302 <div class="drop-area-end" />
303 </div>
304 </div>
305 );
306}
307