Playlist.tsx
⎇
Raw
1import { createSelector, type Accessor, type Setter, createSignal, type ResourceActions, createEffect } from "solid-js";
2import "./Playlist.css";
3import { getItemName, type PlayerState, type PlaylistItem, type AppOptions } from "../types";
4import { formatTime, toast } from "../utils";
5import { ClearSVG, DownloadSVG, DragSVG, LinkSVG, SaveSVG, SyncSVG } from "./SVG";
6import { savePlaylist, isFileCached, download, downloadStatusMap } from "../offline";
7import { DraggableList } from "./DraggableList";
8import type { FileListing } from "music-server-shared/types";
9
10interface PlaylistProps {
11 currentDir: Accessor<string>;
12 playlist: Accessor<PlaylistItem[]>;
13 setPlaylist: Setter<PlaylistItem[]>;
14 playerState: Accessor<PlayerState>;
15 setPlayerState: Setter<PlayerState>;
16 playTrack: (item: PlaylistItem) => void;
17 audioElement: HTMLAudioElement;
18 options: Accessor<AppOptions>;
19 fetchFiles: ResourceActions<FileListing>;
20}
21
22export default function Playlist(props: PlaylistProps) {
23 function removeFromPlaylist(id: string) {
24 const currentTrack = props.playerState().currentTrack;
25 const newPlaylist = props.playlist().filter((i) => i.id !== id);
26 if (currentTrack && currentTrack.id === id) {
27 const idx = props.playlist().findIndex((i) => i.id === id);
28 if (newPlaylist.length > 0) {
29 props.playTrack(idx < newPlaylist.length ? newPlaylist[idx] : newPlaylist[0]);
30 }
31 }
32 props.setPlaylist(newPlaylist);
33 }
34
35 async function playlistDownload(download: boolean) {
36 try {
37 const playlist = props.playlist();
38 const filePaths = playlist.map((item) => item.path);
39 const response = await fetch("/prepare-playlist", { body: JSON.stringify(filePaths), method: "POST" });
40 if (!response.ok) throw new Error(`Generating playlist id not successful: ${await response.text()}`);
41 const id = await response.text();
42 if (download) {
43 toast("Started playlist download", "info");
44 const a = document.createElement("a");
45 a.href = `/download-playlist/${id}`;
46 document.body.appendChild(a);
47 a.click();
48 document.body.removeChild(a);
49 } else {
50 toast("Copied playlist link to clipboard", "info");
51 const urlPath = location.href.split(/\?|#/)[0];
52 navigator.clipboard.writeText(`${urlPath}${urlPath.endsWith("/") ? "" : "/"}download-playlist/${id}`);
53 }
54 } catch (e) {
55 toast("Playlist items download failed", "error", e);
56 }
57 }
58
59 const [syncing, setSyncing] = createSignal(false);
60 async function syncPlaylist() {
61 if (syncing()) return;
62 setSyncing(true);
63 const progressToast = toast("Syncing files in playlist...", "progress");
64 try {
65 const itemsToSync: PlaylistItem[] = [];
66 for (const item of props.playlist()) {
67 if (!(await isFileCached(item.path, downloadStatusMap[item.path]))) itemsToSync.push(item);
68 }
69 const syncPromises = itemsToSync.map((item) => {
70 const basePath = item.path.split("/").slice(0, -1).join("/");
71 const fileName = item.path.split("/").toReversed()[0];
72 return download(fileName, item, basePath, props.currentDir, props.options(), props.fetchFiles);
73 });
74 await Promise.allSettled(syncPromises);
75 progressToast.hideToast();
76 toast("Playlist sync completed", "success");
77 } catch (e) {
78 progressToast.hideToast();
79 toast("Playlist sync failed", "error", e);
80 } finally {
81 setSyncing(false);
82 }
83 }
84
85 let dialogElement!: HTMLDialogElement;
86 let playlistNameInput!: HTMLInputElement;
87 let allowOverwriteCheckbox!: HTMLInputElement;
88
89 async function save() {
90 try {
91 if (!playlistNameInput.reportValidity()) return;
92 await savePlaylist({ name: playlistNameInput.value, items: props.playlist() }, allowOverwriteCheckbox.checked);
93 closeDialog();
94 } catch (e) {
95 if (e instanceof Error && e.name === "ConstraintError") {
96 toast("Playlist with this name already exists", "error");
97 } else {
98 toast("Saving playlist failed", "error", e);
99 }
100 }
101 }
102
103 function closeDialog() {
104 dialogElement.close();
105 playlistNameInput.value = "";
106 allowOverwriteCheckbox.checked = false;
107 }
108
109 const [syncedCount, setSyncedCount] = createSignal(0);
110 createEffect(async () => {
111 const playlist = props.playlist();
112 let count = 0;
113 const promises = [];
114 for (const item of playlist) {
115 promises.push(
116 isFileCached(item.path, downloadStatusMap[item.path]).then((res) => {
117 if (res) count++;
118 }),
119 );
120 }
121 await Promise.all(promises);
122 setSyncedCount(count);
123 });
124 return (
125 <div class="playlist-container">
126 <dialog class="save-dialog" ref={dialogElement}>
127 <div>
128 <input ref={playlistNameInput} required type="text" placeholder="Playlist name" />
129 <div class="save-buttons">
130 <label for="overwrite-checkbox">allow overwrite</label>
131 <input ref={allowOverwriteCheckbox} id="overwrite-checkbox" type="checkbox" />
132 <button onClick={save}>Save</button>
133 <button onClick={closeDialog}>Cancel</button>
134 </div>
135 </div>
136 </dialog>
137
138 <div class="playlist-header">
139 <span>
140 Playlist ({props.playlist().length} tracks, {syncedCount()} synced)
141 </span>
142 <div class="spacer" />
143 <button title="Save playlist" onClick={() => dialogElement.showModal()}>
144 <SaveSVG />
145 </button>
146 <button title="Sync all unsynced files in playlist" onClick={syncPlaylist} disabled={syncing()}>
147 <SyncSVG />
148 </button>
149 <button
150 title="Copies and 1-hour valid link for the same action as the download button"
151 onClick={() => playlistDownload(false)}
152 >
153 <LinkSVG />
154 </button>
155 <button
156 title="Download all files in the playlist in their original format as a .tar file, or without archive if it contains just a single item"
157 onClick={() => playlistDownload(true)}
158 >
159 <DownloadSVG />
160 </button>
161 <button title="Clear Playlist" onClick={() => props.setPlaylist([])}>
162 <ClearSVG />
163 </button>
164 </div>
165
166 <DraggableList<PlaylistItem> items={props.playlist} setItems={props.setPlaylist} id="playlist">
167 {(item, _, { dragHandlers, isDragging, dropPosition }) => {
168 const isActive = createSelector(
169 () => props.playerState().currentTrack?.id,
170 (itemId, currentId) => currentId === itemId,
171 );
172 return (
173 <div
174 classList={{
175 "playlist-item": true,
176 "draggable-item": true,
177 active: isActive(item.id),
178 "drop-target-top-border": dropPosition() === "before",
179 "drop-target-bottom-border": dropPosition() === "after",
180 }}
181 style={{ opacity: isDragging() ? 0.5 : 1 }}
182 onClick={() => props.playTrack(item)}
183 >
184 <div class="playlist-drag-btn" {...dragHandlers}>
185 <DragSVG />
186 </div>
187 <span>{getItemName(item)}</span>
188 <div class="spacer" />
189 {formatTime(item.metadata.duration)}
190 <button
191 title={`Remove ${getItemName(item)}`}
192 onClick={(e) => {
193 e.stopPropagation();
194 removeFromPlaylist(item.id);
195 }}
196 >
197 <ClearSVG />
198 </button>
199 </div>
200 );
201 }}
202 </DraggableList>
203 </div>
204 );
205}
206