FileBrowser.tsx
⎇
Raw
1import "./FileBrowser.css";
2import {
3 type Accessor,
4 createEffect,
5 createResource,
6 createSignal,
7 For,
8 type InitializedResource,
9 on,
10 onMount,
11 type ResourceActions,
12 type Setter,
13 Show,
14 Suspense,
15} from "solid-js";
16import type { MediaFile, FileListing, Directory } from "music-server-shared/types";
17import type { AppOptions, PlaylistItem } from "../types";
18import { generateUUID, joinPath, LocalStorageValues, toast } from "../utils";
19import { ClosedFolderSVG, MusicSVG, RefreshSVG } from "./SVG";
20import { clearCoverCache as deleteCover, clearDirectoryCache, getCover, listFiles } from "../offline";
21import { DownloadButton } from "./DownloadButton";
22import { LoadingScreen } from "./LoadingScreen";
23
24interface FileBrowserProps {
25 breadcrumbs: Accessor<string[]>;
26 currentDir: Accessor<string>;
27 setCurrentDir: Setter<string>;
28 playTrack: (track: PlaylistItem) => void;
29 playlist: Accessor<PlaylistItem[]>;
30 setPlaylist: Setter<PlaylistItem[]>;
31 showFileBrowser: Accessor<boolean>;
32 setShowFileBrowser: Setter<boolean>;
33 options: Accessor<AppOptions>;
34 isOffline: Accessor<boolean>;
35 setOffline: Setter<boolean>;
36 files: InitializedResource<FileListing>;
37 fetchFiles: ResourceActions<FileListing>;
38}
39
40export default function FileBrowser(props: FileBrowserProps) {
41 const [loadingActions, setLoadingActions] = createSignal(0);
42 const [cover, fetchCover] = createResource<string | undefined, { dir: string; offline: boolean }>(
43 () => ({ dir: props.currentDir(), offline: props.isOffline() }),
44 async (args, info) => {
45 if (info.value) URL.revokeObjectURL(info.value as string);
46 return await getCover(args.dir, args.offline);
47 },
48 { initialValue: undefined },
49 );
50
51 let dialogElement!: HTMLDialogElement;
52 let addAnywayButton!: HTMLButtonElement;
53
54 function handleBreadcrumbClick(index: number) {
55 if (index === 0) {
56 props.setCurrentDir("");
57 } else {
58 props.setCurrentDir(joinPath(...props.breadcrumbs().slice(1, index + 1)));
59 }
60 }
61
62 function fileToPlaylistItem(path: string, item: MediaFile): PlaylistItem {
63 return {
64 id: generateUUID(),
65 metadata: item.metadata,
66 path: path,
67 };
68 }
69
70 // Recursively convert file listing to PlaylistItem array
71 function listingToPlaylistItems(basePath: string, files: FileListing): PlaylistItem[] {
72 let result: PlaylistItem[] = [];
73 for (const [fileName, file] of files) {
74 const fullPath = joinPath(basePath, fileName);
75 if ("metadata" in file) {
76 // It's an audio file
77 result.push(fileToPlaylistItem(fullPath, file));
78 } else if (file.files && file.files.size > 0) {
79 // It's a directory with files, recursively add its contents
80 result = [...result, ...listingToPlaylistItems(fullPath, file.files)];
81 }
82 }
83 return result.sort((a, b) => a.path.localeCompare(b.path, undefined, { numeric: true }));
84 }
85
86 function playFile(path: string, item: MediaFile) {
87 props.playTrack(fileToPlaylistItem(path, item));
88 }
89
90 function scrollPlaylistToBottom() {
91 const element = document.getElementById("playlist");
92 if (!element) return;
93 element.scrollTop = element.scrollHeight;
94 }
95
96 async function playFiles(fileName: string, recursive: boolean, queue: boolean, item?: MediaFile) {
97 if (recursive) {
98 setLoadingActions(loadingActions() + 1);
99 }
100 try {
101 const fullPath = joinPath(props.currentDir(), fileName);
102 let oldLength = 0;
103 let newPlaylist: PlaylistItem[];
104 const oldPlaylist = queue ? props.playlist() : [];
105 if (item) {
106 newPlaylist = [fileToPlaylistItem(fullPath, item)];
107 } else {
108 const files = await listFiles(fullPath, props.isOffline(), recursive);
109 newPlaylist = listingToPlaylistItems(fullPath, files);
110 if (queue) {
111 oldLength = oldPlaylist.length;
112 }
113 }
114 if (newPlaylist.length !== 0 && oldLength === 0) props.playTrack(newPlaylist[0]);
115 const addAction = () => {
116 props.setPlaylist([...oldPlaylist, ...newPlaylist]);
117 if (queue) scrollPlaylistToBottom();
118 };
119 if (
120 props.options().duplicateWarning &&
121 oldPlaylist.some((oldItem) => newPlaylist.some((newItem) => oldItem.path === newItem.path))
122 ) {
123 addAnywayButton.onclick = () => {
124 addAction();
125 dialogElement.close();
126 };
127 dialogElement.showModal();
128 } else {
129 addAction();
130 }
131 } finally {
132 if (recursive) {
133 setLoadingActions(loadingActions() - 1);
134 }
135 }
136 }
137
138 function reloadDirectory() {
139 clearDirectoryCache(props.currentDir());
140 //TODO: only clears the current dir, not recursively like clearDirectoryCache
141 deleteCover(props.currentDir());
142 props.fetchFiles.refetch();
143 fetchCover.refetch();
144 }
145
146 function fileSort(a: [string, Directory | MediaFile], b: [string, Directory | MediaFile]): number {
147 //sort directories first
148 if ("metadata" in b[1] && !("metadata" in a[1])) return -1;
149 if ("metadata" in a[1] && !("metadata" in b[1])) return 1;
150 return a[0].localeCompare(b[0], undefined, { numeric: true });
151 }
152
153 let filelistElement!: HTMLDivElement;
154 const scrollPositions: { part: string; pos: number }[] = [];
155 createEffect(
156 on(props.currentDir, () => {
157 //need the timeout, otherwise we could execute the scroll before rendering finishes
158 setTimeout(() => {
159 console.log(filelistElement.scrollTop);
160 let parts = `${props.currentDir()}`.split("/");
161 if (props.currentDir() !== "") parts = ["", ...parts];
162 scrollPositions.length = Math.min(parts.length, scrollPositions.length);
163 for (let index = 0; index < parts.length; index++) {
164 const part = parts[index];
165 if (scrollPositions.length - 1 < index) {
166 scrollPositions.push({ part, pos: 0 });
167 } else if (scrollPositions[index].part !== part) {
168 scrollPositions[index] = { part, pos: 0 };
169 }
170 }
171 filelistElement.scrollTop = scrollPositions[scrollPositions.length - 1].pos;
172 }, 0);
173 }),
174 );
175
176 onMount(() => {
177 try {
178 const savedOffline = localStorage.getItem(LocalStorageValues.offline);
179 props.setOffline(savedOffline === "true");
180 } catch (e) {
181 toast("loading values from localStorage failed", "error", e);
182 localStorage.clear();
183 }
184
185 filelistElement.addEventListener("scrollend", () => {
186 scrollPositions[scrollPositions.length - 1].pos = filelistElement.scrollTop;
187 });
188
189 createEffect(() => {
190 createEffect(() => localStorage.setItem(LocalStorageValues.offline, props.isOffline().toString()));
191 });
192 });
193
194 return (
195 <>
196 <dialog class="duplicate-warning-dialog" ref={dialogElement}>
197 <div>
198 Some of the items you're trying to add already exists in your current playlist
199 <div class="buttons">
200 <button ref={addAnywayButton}>Add anyway</button>
201 <button onClick={() => dialogElement.close()}>Cancel</button>
202 </div>
203 </div>
204 </dialog>
205 <div classList={{ "file-browser": true, hidden: !props.showFileBrowser() }}>
206 <div class="breadcrumb">
207 <For each={props.breadcrumbs()}>
208 {(crumb, index) => (
209 <>
210 <span onClick={() => handleBreadcrumbClick(index())}>{decodeURIComponent(crumb)}</span>
211 {index() < props.breadcrumbs().length - 1 && "/"}
212 </>
213 )}
214 </For>
215 </div>
216
217 <div class="browser-actions">
218 <div>
219 <button
220 title="Replace playlist with all files in current directory"
221 onClick={() => playFiles("", false, false)}
222 >
223 Play Directory
224 </button>
225 <button
226 title="Replace playlist with all files in current directory and all subdirectories"
227 onClick={() => playFiles("", true, false)}
228 >
229 Play All
230 </button>
231 <button title="Add all files in current directory to playlist" onClick={() => playFiles("", false, true)}>
232 Queue Directory
233 </button>
234 <button
235 title="Add all files in current directory and all subdirectories to playlist"
236 onClick={() => playFiles("", true, true)}
237 >
238 Queue All
239 </button>
240 </div>
241 <div class="spacer" />
242 <Show when={!props.isOffline()}>
243 <button class="refresh-button" title="Refresh current directory, bypassing cache" onClick={reloadDirectory}>
244 <RefreshSVG />
245 </button>
246 </Show>
247 <button
248 title="Switch between online and offline mode. In offline mode only locally cached files are listed"
249 class="offline-button"
250 style={{ background: props.isOffline() ? "lightgreen" : "indianred" }}
251 textContent={props.isOffline() ? "Go online" : "Go offline"}
252 onclick={() => props.setOffline(!props.isOffline())}
253 />
254 </div>
255
256 <div class="file-list-wrapper">
257 <Show when={props.files.loading}>
258 <LoadingScreen />
259 </Show>
260
261 <div class="file-list" ref={filelistElement}>
262 <For each={Array.from(props.files?.latest.entries()).sort(fileSort)}>
263 {([fileName, item]) => {
264 const isDirectory = !("metadata" in item);
265 const fullPath = joinPath(props.currentDir(), fileName);
266 return (
267 <>
268 <div
269 class={(isDirectory && "directory-item") || "file-item"}
270 onClick={() => {
271 (isDirectory && props.setCurrentDir(fullPath)) || playFile(fullPath, item as MediaFile);
272 }}
273 >
274 {(isDirectory && <ClosedFolderSVG forButton={false} />) || <MusicSVG />}
275 <span>{fileName}</span>
276 <div class="spacer" />
277 <DownloadButton
278 fileName={fileName}
279 item={item}
280 fetchFiles={props.fetchFiles}
281 isOffline={props.isOffline}
282 currentDir={props.currentDir}
283 options={props.options}
284 />
285 <button
286 title="Add to Playlist"
287 onClick={(event) => {
288 event.stopPropagation();
289 if (isDirectory) {
290 playFiles(fileName, true, true);
291 } else {
292 playFiles(fileName, false, true, item);
293 }
294 }}
295 >
296 Add
297 </button>
298 </div>
299 </>
300 );
301 }}
302 </For>
303 </div>
304 </div>
305 <Show when={cover.loading || cover()}>
306 <div class="cover">
307 <Suspense fallback={<LoadingScreen />}>
308 <img src={cover()} alt="" />
309 </Suspense>
310 </div>
311 </Show>
312 <Show when={loadingActions() > 0}>
313 <LoadingScreen text="Loading files from subdirectories, this might take a while.." />
314 </Show>
315 </div>
316 <div
317 class="file-browser-overlay"
318 style={{ display: props.showFileBrowser() ? "block" : "none" }}
319 onClick={() => props.setShowFileBrowser(false)}
320 />
321 </>
322 );
323}
324