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