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