Playlist.tsx
⎇
Raw
1import { createSelector, type Accessor, type Setter, createSignal, type ResourceActions, createEffect } from "solid-js";
2import "./Playlist.css";
3import { getItemName, type PlayerState, type PlaylistItem, type AppOptions } from "../types";
4import { formatTime, toast } from "../utils";
5import { ClearSVG, DownloadSVG, DragSVG, LinkSVG, SaveSVG, SyncSVG } from "./SVG";
6import { savePlaylist, isFileSynced, download, downloadStatusMap } from "../offline";
7import { DraggableList } from "./DraggableList";
8import type { FileListing } from "music-server-shared/types";
9
10interface PlaylistProps {
11 currentDir: Accessor<string>;
12 playlist: Accessor<PlaylistItem[]>;
13 setPlaylist: Setter<PlaylistItem[]>;
14 playerState: PlayerState;
15 setPlayerState: Setter<PlayerState>;
16 playTrack: (item: PlaylistItem) => void;
17 playNext: () => void;
18 audioElement: HTMLAudioElement;
19 options: AppOptions;
20 fetchFiles: ResourceActions<FileListing>;
21}
22
23export default function Playlist(props: PlaylistProps) {
24 function removeFromPlaylist(id: string) {
25 props.playNext();
26 const newPlaylist = props.playlist().filter((i) => i.id !== id);
27 props.setPlaylist(newPlaylist);
28 }
29
30 async function playlistDownload(download: boolean) {
31 try {
32 const playlist = props.playlist();
33 const filePaths = playlist.map((item) => item.path);
34 const response = await fetch("/prepare-playlist", { body: JSON.stringify(filePaths), method: "POST" });
35 if (!response.ok) throw new Error(`Generating playlist id not successful: ${await response.text()}`);
36 const id = await response.text();
37 if (download) {
38 toast("Started playlist download", "info");
39 const a = document.createElement("a");
40 a.href = `/download-playlist/${id}`;
41 document.body.appendChild(a);
42 a.click();
43 document.body.removeChild(a);
44 } else {
45 toast("Copied playlist link to clipboard", "info");
46 const urlPath = location.href.split(/\?|#/)[0];
47 navigator.clipboard.writeText(`${urlPath}${urlPath.endsWith("/") ? "" : "/"}download-playlist/${id}`);
48 }
49 } catch (e) {
50 toast("Playlist items download failed", "error", e);
51 }
52 }
53
54 const [syncing, setSyncing] = createSignal(false);
55 async function syncPlaylist() {
56 if (syncing()) return;
57 setSyncing(true);
58 const progressToast = toast("Syncing files in playlist...", "progress");
59 try {
60 const itemsToSync: PlaylistItem[] = [];
61 for (const item of props.playlist()) {
62 if (!(await isFileSynced(item.path, downloadStatusMap[item.path]))) itemsToSync.push(item);
63 }
64 const syncPromises = itemsToSync.map((item) => {
65 const basePath = item.path.split("/").slice(0, -1).join("/");
66 const fileName = item.path.split("/").toReversed()[0];
67 return download(fileName, item, basePath, props.currentDir, props.options, props.fetchFiles);
68 });
69 await Promise.allSettled(syncPromises);
70 progressToast.hideToast();
71 toast("Playlist sync completed", "success");
72 } catch (e) {
73 progressToast.hideToast();
74 toast("Playlist sync failed", "error", e);
75 } finally {
76 setSyncing(false);
77 }
78 }
79
80 let dialogElement!: HTMLDialogElement;
81 let playlistNameInput!: HTMLInputElement;
82 let allowOverwriteCheckbox!: HTMLInputElement;
83
84 async function save() {
85 try {
86 if (!playlistNameInput.reportValidity()) return;
87 await savePlaylist({ name: playlistNameInput.value, items: props.playlist() }, allowOverwriteCheckbox.checked);
88 closeDialog();
89 } catch (e) {
90 if (e instanceof Error && e.name === "ConstraintError") {
91 toast("Playlist with this name already exists", "error");
92 } else {
93 toast("Saving playlist failed", "error", e);
94 }
95 }
96 }
97
98 function closeDialog() {
99 dialogElement.close();
100 playlistNameInput.value = "";
101 allowOverwriteCheckbox.checked = false;
102 }
103
104 const [syncedCount, setSyncedCount] = createSignal(0);
105 createEffect(async () => {
106 const playlist = props.playlist();
107 let count = 0;
108 const promises = [];
109 for (const item of playlist) {
110 promises.push(
111 isFileSynced(item.path, downloadStatusMap[item.path]).then((res) => {
112 if (res) count++;
113 }),
114 );
115 }
116 await Promise.all(promises);
117 setSyncedCount(count);
118 });
119 return (
120 <div class="playlist-container">
121 <dialog class="save-dialog" ref={dialogElement}>
122 <div>
123 <input ref={playlistNameInput} required type="text" placeholder="Playlist name" />
124 <div class="save-buttons">
125 <label for="overwrite-checkbox">allow overwrite</label>
126 <input ref={allowOverwriteCheckbox} id="overwrite-checkbox" type="checkbox" />
127 <button onClick={save}>Save</button>
128 <button onClick={closeDialog}>Cancel</button>
129 </div>
130 </div>
131 </dialog>
132
133 <div class="playlist-header">
134 <span>
135 Playlist ({props.playlist().length} tracks, {syncedCount()} synced)
136 </span>
137 <div class="spacer" />
138 <div class="button-container">
139 <button title="Save playlist" onClick={() => dialogElement.showModal()}>
140 <SaveSVG />
141 </button>
142 <button title="Sync all unsynced files in playlist" onClick={syncPlaylist} disabled={syncing()}>
143 <SyncSVG />
144 </button>
145 <button
146 title="Copies and 1-hour valid link for the same action as the download button"
147 onClick={() => playlistDownload(false)}
148 >
149 <LinkSVG />
150 </button>
151 <button
152 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"
153 onClick={() => playlistDownload(true)}
154 >
155 <DownloadSVG />
156 </button>
157 <button title="Clear Playlist" onClick={() => props.setPlaylist([])}>
158 <ClearSVG />
159 </button>
160 </div>
161 </div>
162
163 <DraggableList<PlaylistItem> items={props.playlist} setItems={props.setPlaylist} id="playlist">
164 {(item, _, { dragHandlers, isDragging, dropPosition }) => {
165 const isActive = createSelector(
166 () => props.playerState.currentTrack?.id,
167 (itemId, currentId) => currentId === itemId,
168 );
169 return (
170 <div
171 classList={{
172 "playlist-item": true,
173 "draggable-item": true,
174 active: isActive(item.id),
175 "drop-target-top-border": dropPosition() === "before",
176 "drop-target-bottom-border": dropPosition() === "after",
177 }}
178 style={{ opacity: isDragging() ? 0.5 : 1 }}
179 onClick={() => props.playTrack(item)}
180 >
181 <div class="playlist-drag-btn" {...dragHandlers}>
182 <DragSVG />
183 </div>
184 <span>{getItemName(item)}</span>
185 <div class="spacer" />
186 {formatTime(item.metadata.duration)}
187 <button
188 title={`Remove ${getItemName(item)}`}
189 onClick={(e) => {
190 e.stopPropagation();
191 removeFromPlaylist(item.id);
192 }}
193 >
194 <ClearSVG />
195 </button>
196 </div>
197 );
198 }}
199 </DraggableList>
200 </div>
201 );
202}
203