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