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