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