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