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