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