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