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} from "solid-js";
15import type { MediaFile, FileListing, Directory } from "music-server-shared/types";
16import type { AppOptions, PlaylistItem } from "../types";
17import { formatFilename, generateUUID, joinPath, LocalStorageValues, toast } from "../utils";
18import { ClosedFolderSVG, DownSVG, FilterSVG, MusicSVG, RefreshSVG, UpSVG } from "./SVG";
19import { clearDirectoryCache, getCover, listFiles, storedFilesLive } from "../offline";
20import { DownloadButton } from "./DownloadButton";
21import { LoadingScreen } from "./LoadingScreen";
22import type { FlatFileListing } from "../App";
23import { createStore, reconcile } from "solid-js/store";
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 if (
160 props.options.duplicateWarning &&
161 oldPlaylist.some((oldItem) => newPlaylist.some((newItem) => oldItem.path === newItem.path))
162 ) {
163 addAnywayButton.onclick = () => {
164 addAction();
165 dialogElement.close();
166 };
167 dialogElement.showModal();
168 } else {
169 addAction();
170 }
171 } finally {
172 if (recursive) {
173 setLoadingActions(loadingActions() - 1);
174 }
175 }
176 }
177
178 function reloadDirectory() {
179 clearDirectoryCache(props.currentDir()).then(() => props.fetchFiles.refetch());
180 getCover(props.currentDir(), props.options, false, props.isOffline(), true).then(() => fetchCover.refetch());
181 }
182
183 let filelistElement!: HTMLDivElement;
184 const scrollPositions: { part: string; pos: number }[] = [];
185 let lastDir = props.currentDir();
186 createEffect(
187 //on props.files, because doing it on props.currentDir causes us to scroll up before the new content is being loaded
188 on(props.files, () => {
189 //don't update scroll pos if directory didn't change
190 //otherwise would cause jank when scrolling while in offline mode and synced items pop up
191 if (lastDir === props.currentDir()) return;
192 lastDir = props.currentDir();
193 //need the timeout, otherwise we could execute the scroll before rendering finishes
194 setTimeout(() => {
195 let parts = `${props.currentDir()}`.split("/");
196 if (props.currentDir() !== "") parts = ["", ...parts];
197 scrollPositions.length = Math.min(parts.length, scrollPositions.length);
198 for (let index = 0; index < parts.length; index++) {
199 const part = parts[index];
200 if (scrollPositions.length - 1 < index) {
201 scrollPositions.push({ part, pos: 0 });
202 } else if (scrollPositions[index].part !== part) {
203 scrollPositions[index] = { part, pos: 0 };
204 }
205 }
206 filelistElement.scrollTop = scrollPositions[scrollPositions.length - 1].pos;
207 }, 0);
208 }),
209 );
210
211 onMount(() => {
212 const savedFileBrowserButtons = localStorage.getItem(LocalStorageValues.fileBrowserButtons);
213 if (savedFileBrowserButtons !== null) setShowActionButtons(savedFileBrowserButtons === "true");
214 createEffect(() => localStorage.setItem(LocalStorageValues.fileBrowserButtons, showActionButtons().toString()));
215
216 filelistElement.addEventListener("scrollend", () => {
217 scrollPositions[scrollPositions.length - 1].pos = filelistElement.scrollTop;
218 });
219 });
220
221 function getAbsolutePathFiles() {
222 return props.files.latest.map(([name, item]) => {
223 return [joinPath(props.currentDir(), name), item] as [string, Directory | MediaFile];
224 });
225 }
226
227 interface FileProxy {
228 key: string;
229 fullPath: string;
230 displayName: string;
231 file: MediaFile | Directory;
232 }
233
234 //this proxy store allows us to diff changes correctly when the filelisting changes, only rerendering the changed items
235 //absolute paths are required here to make sure it's really the same file in the same directory
236 const [fileStore, setFileStore] = createStore<FileProxy[]>([]);
237
238 function fileSort(a: FileProxy, b: FileProxy): number {
239 //sort directories first
240 if ("metadata" in b.file && !("metadata" in a.file)) return -1;
241 if ("metadata" in a.file && !("metadata" in b.file)) return 1;
242 return a.displayName.localeCompare(b.displayName, undefined, { numeric: true });
243 }
244
245 createEffect(() => {
246 setFileStore(
247 reconcile(
248 getAbsolutePathFiles()
249 .map((value) => {
250 const fullPath = value[0];
251 const displayName =
252 "metadata" in value[1]
253 ? formatFilename({ path: value[0], metadata: value[1].metadata }, props.options.filebrowserDisplay)
254 : value[0].split("/").toReversed()[0];
255 return {
256 key: fullPath + displayName,
257 fullPath,
258 displayName,
259 file: value[1],
260 };
261 })
262 .filter((item) => {
263 return item.displayName.toLowerCase().includes(filter());
264 })
265 .sort(fileSort),
266 { key: "key" },
267 ),
268 );
269 });
270
271 const [downloadedChildren, setDownloadedChildren] = createStore<Record<string, number>>({});
272 createEffect(() => {
273 const storedFiles = storedFilesLive();
274 const newChildren: Record<string, number> = {};
275
276 for (const storedFile of storedFiles) {
277 const pathParts = storedFile.toString().split("/");
278 // Update all parent directories in the path
279 for (let i = 1; i <= pathParts.length; i++) {
280 const parentPath = pathParts.slice(0, i).join("/");
281 if (parentPath) {
282 newChildren[parentPath] = (newChildren[parentPath] || 0) + 1;
283 }
284 }
285 }
286 setDownloadedChildren(reconcile(newChildren));
287 });
288
289 return (
290 <>
291 <dialog
292 class="duplicate-warning-dialog"
293 ref={dialogElement}
294 onclick={(e) => e.target.nodeName === "DIALOG" && dialogElement.close()}
295 >
296 <div>
297 Some of the items you're trying to add already exists in your current playlist
298 <div class="buttons">
299 <button ref={addAnywayButton}>Add anyway</button>
300 <button onClick={() => dialogElement.close()}>Cancel</button>
301 </div>
302 </div>
303 </dialog>
304 <div classList={{ "file-browser": true, hidden: !props.showFileBrowser() }}>
305 <div classList={{ breadcrumb: true, "breadcrumb-nowrap": !showActionButtons() }}>
306 <For each={props.breadcrumbs()}>
307 {(crumb, index) => (
308 <>
309 <span title={crumb} onClick={() => handleBreadcrumbClick(index())}>
310 {crumb}
311 </span>
312 {index() < props.breadcrumbs().length - 1 && "/"}
313 </>
314 )}
315 </For>
316 <button
317 class="toggle-browser-actions"
318 title="Toggle filebrowser actions"
319 onclick={() => setShowActionButtons(!showActionButtons())}
320 >
321 {showActionButtons() ? <UpSVG /> : <DownSVG />}
322 </button>
323 </div>
324
325 <Show when={showActionButtons()}>
326 <div class="browser-actions">
327 <div>
328 <button
329 title="Replace playlist with all files in current directory"
330 onClick={() => playFiles(props.currentDir(), false, false)}
331 >
332 Play Directory
333 </button>
334 <button
335 title="Replace playlist with all files in current directory and all subdirectories"
336 onClick={() => playFiles(props.currentDir(), true, false)}
337 >
338 Play All
339 </button>
340 <button
341 title="Add all files in current directory to playlist"
342 onClick={() => playFiles(props.currentDir(), false, true)}
343 >
344 Queue Directory
345 </button>
346 <button
347 title="Add all files in current directory and all subdirectories to playlist"
348 onClick={() => playFiles(props.currentDir(), true, true)}
349 >
350 Queue All
351 </button>
352 </div>
353 <div class="spacer" />
354 <button title="Filter current directory" onClick={() => setShowFilter(!showFilter())}>
355 <FilterSVG />
356 </button>
357 <Show when={!props.isOffline()}>
358 <button
359 class="refresh-button"
360 title="Refresh current directory listing and cover art, refreshing the cache"
361 onClick={reloadDirectory}
362 >
363 <RefreshSVG />
364 </button>
365 </Show>
366 </div>
367 <Show when={showFilter()}>
368 <input
369 oninput={(e) => setFilter(e.target.value)}
370 onkeydown={(e) => e.key === "Escape" && setShowFilter(false)}
371 class="filter-files"
372 type="text"
373 placeholder="Filter files..."
374 />
375 </Show>
376 </Show>
377
378 <div class="file-list-wrapper">
379 <Show when={props.files.loading}>
380 <LoadingScreen />
381 </Show>
382
383 <div class="file-list" ref={filelistElement}>
384 <For each={fileStore}>
385 {({ fullPath, displayName, file }) => {
386 const isDirectory = !("metadata" in file);
387 return (
388 <>
389 <div
390 class={(isDirectory && "directory-item") || "file-item"}
391 onClick={() => {
392 (isDirectory && props.setCurrentDir(fullPath)) || playFile(fullPath, file as MediaFile);
393 }}
394 >
395 {(isDirectory && <ClosedFolderSVG forButton={false} />) || <MusicSVG />}
396 <span>{displayName}</span>
397 <div class="download-button-wrapper">
398 {isDirectory && !props.isOffline() && downloadedChildren[fullPath]}
399 <DownloadButton
400 fullPath={fullPath}
401 item={file}
402 fetchFiles={props.fetchFiles}
403 isOffline={props.isOffline}
404 currentDir={props.currentDir}
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 }}
425 </For>
426 </div>
427 </div>
428 <Show when={!props.options.hideCover && (cover.loading || cover())}>
429 <div class="cover">
430 <Show when={cover.loading}>
431 <LoadingScreen />
432 </Show>
433 <img src={cover()} alt="" />
434 </div>
435 </Show>
436 <Show when={loadingActions() > 0}>
437 <LoadingScreen text="Loading files from subdirectories, this might take a while.." />
438 </Show>
439 </div>
440 </>
441 );
442}
443