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