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