FileBrowser.tsx
⎇
Raw
1import "./FileBrowser.css";
2import type { Directory, FileListing, MediaFile } from "music-server-shared/types";
3import {
4 type Accessor,
5 createEffect,
6 createResource,
7 createSignal,
8 For,
9 type InitializedResource,
10 on,
11 onMount,
12 type ResourceActions,
13 type Setter,
14 Show,
15} from "solid-js";
16import { createStore, reconcile } from "solid-js/store";
17import type { FlatFileListing } from "../App";
18import { clearDirectoryCache, getCover, listFiles, storedFilesLive } from "../offline";
19import type { AppOptions, PlaylistItem } from "../types";
20import {
21 basename,
22 compareDirFirst,
23 formatFilename,
24 joinPath,
25 LocalStorageValues,
26 persistedToggle,
27 toast,
28} from "../utils";
29import { DownloadButton } from "./DownloadButton";
30import { LoadingScreen } from "./LoadingScreen";
31import { ClosedFolderSVG, DownSVG, FilterSVG, MusicSVG, RefreshSVG, UpSVG } from "./SVG";
32
33interface FileBrowserProps {
34 currentDir: Accessor<string>;
35 setCurrentDir: Setter<string>;
36 playTrack: (track: PlaylistItem) => void;
37 playlist: Accessor<PlaylistItem[]>;
38 setPlaylist: Setter<PlaylistItem[]>;
39 showFileBrowser: Accessor<boolean>;
40 setShowFileBrowser: Setter<boolean>;
41 options: AppOptions;
42 isOffline: Accessor<boolean>;
43 files: InitializedResource<FlatFileListing>;
44 fetchFiles: ResourceActions<FlatFileListing>;
45}
46
47export default function FileBrowser(props: FileBrowserProps) {
48 //"root" stands for the virtual top level that lists the user's roots
49 const breadcrumbs = () => ["root", ...props.currentDir().split("/").filter(Boolean)];
50 const [loadingActions, setLoadingActions] = createSignal(0);
51 const [showActionButtons, setShowActionButtons] = persistedToggle(LocalStorageValues.fileBrowserButtons, true);
52 const [showFilter, setShowFilter] = createSignal(false);
53 const [filter, setFilter] = createSignal("");
54 createEffect(
55 on(props.currentDir, () => {
56 setFilter("");
57 setShowFilter(false);
58 }),
59 );
60 createEffect(
61 on([showFilter, showActionButtons], ([showFilter, showActionButtons]) => {
62 if (!showActionButtons) setShowFilter(false);
63 if (!showFilter) setFilter("");
64 }),
65 );
66 const [cover, fetchCover] = createResource<string | undefined, { dir: string; offline: boolean }>(
67 () => ({ dir: props.currentDir(), offline: props.isOffline() }),
68 async (args, info) => {
69 if (info.value) URL.revokeObjectURL(info.value as string);
70 return await getCover(args.dir, props.options, false, args.offline);
71 },
72 { initialValue: undefined },
73 );
74
75 let dialogElement!: HTMLDialogElement;
76 let addAnywayButton!: HTMLButtonElement;
77
78 function handleBreadcrumbClick(index: number) {
79 if (index === 0) {
80 props.setCurrentDir("");
81 } else {
82 props.setCurrentDir(joinPath(...breadcrumbs().slice(1, index + 1)));
83 }
84 }
85
86 function fileToPlaylistItem(path: string, item: MediaFile): PlaylistItem {
87 return {
88 id: crypto.randomUUID(),
89 metadata: item.metadata,
90 path: path,
91 };
92 }
93
94 // Recursively convert file listing to PlaylistItem array
95 function listingToPlaylistItems(basePath: string, files: FileListing): PlaylistItem[] {
96 //keep sorting same as shown in the filebrowser: directories first (by name), then files (by display name)
97 const displayNameOf = (name: string, item: Directory | MediaFile) =>
98 "metadata" in item
99 ? formatFilename({ path: name, metadata: item.metadata }, props.options.filebrowserDisplay)
100 : name;
101 const sorted = Object.entries(files).sort(([aName, aItem], [bName, bItem]) =>
102 compareDirFirst(
103 displayNameOf(aName, aItem),
104 !("metadata" in aItem),
105 displayNameOf(bName, bItem),
106 !("metadata" in bItem),
107 ),
108 );
109 let result: PlaylistItem[] = [];
110 for (const [fileName, file] of sorted) {
111 const fullPath = joinPath(basePath, fileName);
112 if ("metadata" in file) {
113 // It's an audio file
114 result.push(fileToPlaylistItem(fullPath, file));
115 } else if (Object.keys(file.files).length > 0) {
116 // It's a directory with files, recursively add its contents
117 result = [...result, ...listingToPlaylistItems(fullPath, file.files)];
118 }
119 }
120
121 return result;
122 }
123
124 function playFile(path: string, item: MediaFile) {
125 props.playTrack(fileToPlaylistItem(path, item));
126 }
127
128 function scrollPlaylistToBottom() {
129 setTimeout(() => {
130 const element = document.getElementById("playlist");
131 if (!element) return;
132 element.scrollTop = element.scrollHeight;
133 }, 0);
134 }
135
136 const addedToast = toast("", "info");
137 addedToast.hideToast();
138
139 async function playFiles(fullPath: string, recursive: boolean, queue: boolean, item?: MediaFile) {
140 if (recursive) {
141 setLoadingActions(loadingActions() + 1);
142 }
143 try {
144 let oldLength = 0;
145 let newPlaylist: PlaylistItem[];
146 const oldPlaylist = queue ? props.playlist() : [];
147 if (item) {
148 newPlaylist = [fileToPlaylistItem(fullPath, item)];
149 } else {
150 const files = await listFiles(fullPath, props.isOffline(), recursive);
151 newPlaylist = listingToPlaylistItems(fullPath, files);
152 if (queue) {
153 oldLength = oldPlaylist.length;
154 }
155 }
156 const addAction = () => {
157 if (newPlaylist.length !== 0 && oldLength === 0) props.playTrack(newPlaylist[0]);
158 props.setPlaylist([...oldPlaylist, ...newPlaylist]);
159 addedToast.hideToast();
160 addedToast.options.text = `Added ${newPlaylist.length} tracks`;
161 addedToast.showToast();
162 if (queue) scrollPlaylistToBottom();
163 else props.setShowFileBrowser(false);
164 };
165 const newPaths = new Set(newPlaylist.map((item) => item.path));
166 if (props.options.duplicateWarning && oldPlaylist.some((oldItem) => newPaths.has(oldItem.path))) {
167 addAnywayButton.onclick = () => {
168 addAction();
169 dialogElement.close();
170 };
171 dialogElement.showModal();
172 } else {
173 addAction();
174 }
175 } finally {
176 if (recursive) {
177 setLoadingActions(loadingActions() - 1);
178 }
179 }
180 }
181
182 function reloadDirectory() {
183 clearDirectoryCache(props.currentDir()).then(() => props.fetchFiles.refetch());
184 //this call only exists to refresh the cache/db entry, the url it mints is unused
185 //(fetchCover.refetch creates the one that actually gets rendered), so revoke it
186 getCover(props.currentDir(), props.options, false, props.isOffline(), true).then((url) => {
187 if (url) URL.revokeObjectURL(url);
188 fetchCover.refetch();
189 });
190 }
191
192 let filelistElement!: HTMLDivElement;
193 const scrollPositions: { part: string; pos: number }[] = [];
194 let lastDir = props.currentDir();
195 createEffect(
196 //on props.files, because doing it on props.currentDir causes us to scroll up before the new content is being loaded
197 on(props.files, () => {
198 //don't update scroll pos if directory didn't change
199 //otherwise would cause jank when scrolling while in offline mode and synced items pop up
200 if (lastDir === props.currentDir()) return;
201 lastDir = props.currentDir();
202 //need the timeout, otherwise we could execute the scroll before rendering finishes
203 setTimeout(() => {
204 let parts = `${props.currentDir()}`.split("/");
205 if (props.currentDir() !== "") parts = ["", ...parts];
206 scrollPositions.length = Math.min(parts.length, scrollPositions.length);
207 for (let index = 0; index < parts.length; index++) {
208 const part = parts[index];
209 if (scrollPositions.length - 1 < index) {
210 scrollPositions.push({ part, pos: 0 });
211 } else if (scrollPositions[index].part !== part) {
212 scrollPositions[index] = { part, pos: 0 };
213 }
214 }
215 filelistElement.scrollTop = scrollPositions[scrollPositions.length - 1].pos;
216 }, 0);
217 }),
218 );
219
220 onMount(() => {
221 filelistElement.addEventListener("scrollend", () => {
222 if (scrollPositions.length > 0) scrollPositions[scrollPositions.length - 1].pos = filelistElement.scrollTop;
223 });
224 });
225
226 function getAbsolutePathFiles() {
227 return props.files.latest.map(([name, item]) => {
228 return [joinPath(props.currentDir(), name), item] as [string, Directory | MediaFile];
229 });
230 }
231
232 interface FileProxy {
233 key: string;
234 fullPath: string;
235 displayName: string;
236 file: MediaFile | Directory;
237 }
238
239 //this proxy store allows us to diff changes correctly when the filelisting changes, only rerendering the changed items
240 //absolute paths are required here to make sure it's really the same file in the same directory
241 const [fileStore, setFileStore] = createStore<FileProxy[]>([]);
242
243 function fileSort(a: FileProxy, b: FileProxy): number {
244 return compareDirFirst(a.displayName, !("metadata" in a.file), b.displayName, !("metadata" in b.file));
245 }
246
247 createEffect(() => {
248 setFileStore(
249 reconcile(
250 getAbsolutePathFiles()
251 .map((value) => {
252 const fullPath = value[0];
253 const displayName =
254 "metadata" in value[1]
255 ? formatFilename({ path: value[0], metadata: value[1].metadata }, props.options.filebrowserDisplay)
256 : basename(value[0]);
257 return {
258 key: fullPath + displayName,
259 fullPath,
260 displayName,
261 file: value[1],
262 };
263 })
264 .filter((item) => {
265 return item.displayName.toLowerCase().includes(filter());
266 })
267 .sort(fileSort),
268 { key: "key" },
269 ),
270 );
271 });
272
273 const [downloadedChildren, setDownloadedChildren] = createStore<Record<string, number>>({});
274 createEffect(() => {
275 const storedFiles = storedFilesLive();
276 const newChildren: Record<string, number> = {};
277
278 for (const storedFile of storedFiles) {
279 const pathParts = storedFile.toString().split("/");
280 // Update all parent directories in the path
281 for (let i = 1; i <= pathParts.length; i++) {
282 const parentPath = pathParts.slice(0, i).join("/");
283 if (parentPath) {
284 newChildren[parentPath] = (newChildren[parentPath] || 0) + 1;
285 }
286 }
287 }
288 setDownloadedChildren(reconcile(newChildren));
289 });
290
291 return (
292 <>
293 <dialog
294 class="duplicate-warning-dialog"
295 ref={dialogElement}
296 onclick={(e) => e.target.nodeName === "DIALOG" && dialogElement.close()}
297 >
298 <div>
299 Some of the items you're trying to add already exists in your current playlist
300 <div class="buttons">
301 <button ref={addAnywayButton}>Add anyway</button>
302 <button onClick={() => dialogElement.close()}>Cancel</button>
303 </div>
304 </div>
305 </dialog>
306 <div classList={{ "file-browser": true, hidden: !props.showFileBrowser() }}>
307 <div classList={{ breadcrumb: true, "breadcrumb-nowrap": !showActionButtons() }}>
308 <For each={breadcrumbs()}>
309 {(crumb, index) => (
310 <>
311 <span title={crumb} onClick={() => handleBreadcrumbClick(index())}>
312 {crumb}
313 </span>
314 {index() < breadcrumbs().length - 1 && "/"}
315 </>
316 )}
317 </For>
318 <button
319 class="toggle-browser-actions"
320 title="Toggle filebrowser actions"
321 onclick={() => setShowActionButtons(!showActionButtons())}
322 >
323 {showActionButtons() ? <UpSVG /> : <DownSVG />}
324 </button>
325 </div>
326
327 <Show when={showActionButtons()}>
328 <div class="browser-actions">
329 <div>
330 <button
331 title="Replace playlist with all files in current directory"
332 onClick={() => playFiles(props.currentDir(), false, false)}
333 >
334 Play Directory
335 </button>
336 <button
337 title="Replace playlist with all files in current directory and all subdirectories"
338 onClick={() => playFiles(props.currentDir(), true, false)}
339 >
340 Play All
341 </button>
342 <button
343 title="Add all files in current directory to playlist"
344 onClick={() => playFiles(props.currentDir(), false, true)}
345 >
346 Queue Directory
347 </button>
348 <button
349 title="Add all files in current directory and all subdirectories to playlist"
350 onClick={() => playFiles(props.currentDir(), true, true)}
351 >
352 Queue All
353 </button>
354 </div>
355 <div class="spacer" />
356 <button title="Filter current directory" onClick={() => setShowFilter(!showFilter())}>
357 <FilterSVG />
358 </button>
359 <Show when={!props.isOffline()}>
360 <button
361 class="refresh-button"
362 title="Refresh current directory listing and cover art, refreshing the cache"
363 onClick={reloadDirectory}
364 >
365 <RefreshSVG />
366 </button>
367 </Show>
368 </div>
369 <Show when={showFilter()}>
370 <input
371 oninput={(e) => setFilter(e.target.value)}
372 onkeydown={(e) => e.key === "Escape" && setShowFilter(false)}
373 class="filter-files"
374 type="text"
375 placeholder="Filter files..."
376 />
377 </Show>
378 </Show>
379
380 <div class="file-list-wrapper">
381 <Show when={props.files.loading}>
382 <LoadingScreen />
383 </Show>
384
385 <div class="file-list" ref={filelistElement}>
386 <For each={fileStore}>
387 {({ fullPath, displayName, file }) => {
388 const isDirectory = !("metadata" in file);
389 return (
390 <div
391 class={(isDirectory && "directory-item") || "file-item"}
392 onClick={() => {
393 (isDirectory && props.setCurrentDir(fullPath)) || playFile(fullPath, file as MediaFile);
394 }}
395 >
396 {(isDirectory && <ClosedFolderSVG forButton={false} />) || <MusicSVG />}
397 <span>{displayName}</span>
398 <div class="download-button-wrapper">
399 {isDirectory && !props.isOffline() && downloadedChildren[fullPath]}
400 <DownloadButton
401 fullPath={fullPath}
402 item={file}
403 fetchFiles={props.fetchFiles}
404 isOffline={props.isOffline}
405 options={props.options}
406 />
407 </div>
408 <button
409 title="Add to Playlist"
410 onClick={(event) => {
411 event.stopPropagation();
412 if (isDirectory) {
413 playFiles(fullPath, true, true);
414 } else {
415 playFiles(fullPath, false, true, file);
416 }
417 }}
418 >
419 Add
420 </button>
421 </div>
422 );
423 }}
424 </For>
425 </div>
426 </div>
427 <Show when={!props.options.hideCover && (cover.loading || cover())}>
428 <div class="cover">
429 <Show when={cover.loading}>
430 <LoadingScreen />
431 </Show>
432 <img src={cover()} alt="" />
433 </div>
434 </Show>
435 <Show when={loadingActions() > 0}>
436 <LoadingScreen text="Loading files from subdirectories, this might take a while.." />
437 </Show>
438 </div>
439 </>
440 );
441}
442