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