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 { 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, (showFilter) => {
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, 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 let result: PlaylistItem[] = [];
88 for (const [fileName, file] of files) {
89 const fullPath = joinPath(basePath, fileName);
90 if ("metadata" in file) {
91 // It's an audio file
92 result.push(fileToPlaylistItem(fullPath, file));
93 } else if (file.files && file.files.size > 0) {
94 // It's a directory with files, recursively add its contents
95 result = [...result, ...listingToPlaylistItems(fullPath, file.files)];
96 }
97 }
98 return result;
99 }
100
101 function playFile(path: string, item: MediaFile) {
102 props.playTrack(fileToPlaylistItem(path, item));
103 }
104
105 function scrollPlaylistToBottom() {
106 const element = document.getElementById("playlist");
107 if (!element) return;
108 element.scrollTop = element.scrollHeight;
109 }
110
111 const addedToast = toast("", "info");
112 addedToast.hideToast();
113
114 async function playFiles(fileName: string, recursive: boolean, queue: boolean, item?: MediaFile) {
115 if (recursive) {
116 setLoadingActions(loadingActions() + 1);
117 }
118 try {
119 const fullPath = joinPath(props.currentDir(), fileName);
120 let oldLength = 0;
121 let newPlaylist: PlaylistItem[];
122 const oldPlaylist = queue ? props.playlist() : [];
123 if (item) {
124 newPlaylist = [fileToPlaylistItem(fullPath, item)];
125 } else {
126 const files = await listFiles(fullPath, props.isOffline(), recursive);
127 newPlaylist = listingToPlaylistItems(fullPath, files);
128 if (queue) {
129 oldLength = oldPlaylist.length;
130 }
131 }
132 const addAction = () => {
133 if (newPlaylist.length !== 0 && oldLength === 0) props.playTrack(newPlaylist[0]);
134 props.setPlaylist([...oldPlaylist, ...newPlaylist]);
135 addedToast.hideToast();
136 addedToast.options.text = `Added ${newPlaylist.length} tracks`;
137 addedToast.showToast();
138 if (queue) scrollPlaylistToBottom();
139 else props.setShowFileBrowser(false);
140 };
141 if (
142 props.options.duplicateWarning &&
143 oldPlaylist.some((oldItem) => newPlaylist.some((newItem) => oldItem.path === newItem.path))
144 ) {
145 addAnywayButton.onclick = () => {
146 addAction();
147 dialogElement.close();
148 };
149 dialogElement.showModal();
150 } else {
151 addAction();
152 }
153 } finally {
154 if (recursive) {
155 setLoadingActions(loadingActions() - 1);
156 }
157 }
158 }
159
160 function reloadDirectory() {
161 clearDirectoryCache(props.currentDir());
162 //TODO: only clears the current dir, not recursively like clearDirectoryCache
163 deleteCover(props.currentDir());
164 props.fetchFiles.refetch();
165 fetchCover.refetch();
166 }
167
168 let filelistElement!: HTMLDivElement;
169 const scrollPositions: { part: string; pos: number }[] = [];
170 createEffect(
171 on(props.currentDir, () => {
172 //need the timeout, otherwise we could execute the scroll before rendering finishes
173 setTimeout(() => {
174 let parts = `${props.currentDir()}`.split("/");
175 if (props.currentDir() !== "") parts = ["", ...parts];
176 scrollPositions.length = Math.min(parts.length, scrollPositions.length);
177 for (let index = 0; index < parts.length; index++) {
178 const part = parts[index];
179 if (scrollPositions.length - 1 < index) {
180 scrollPositions.push({ part, pos: 0 });
181 } else if (scrollPositions[index].part !== part) {
182 scrollPositions[index] = { part, pos: 0 };
183 }
184 }
185 filelistElement.scrollTop = scrollPositions[scrollPositions.length - 1].pos;
186 }, 0);
187 }),
188 );
189
190 onMount(() => {
191 const savedFileBrowserButtons = localStorage.getItem(LocalStorageValues.fileBrowserButtons);
192 if (savedFileBrowserButtons !== null) setShowActionButtons(savedFileBrowserButtons === "true");
193 createEffect(() => localStorage.setItem(LocalStorageValues.fileBrowserButtons, showActionButtons().toString()));
194
195 filelistElement.addEventListener("scrollend", () => {
196 scrollPositions[scrollPositions.length - 1].pos = filelistElement.scrollTop;
197 });
198 });
199
200 function getAbsolutePathFiles() {
201 return props.files.latest.map(([name, item]) => {
202 return [joinPath(props.currentDir(), name), item] as [string, Directory | MediaFile];
203 });
204 }
205
206 //this proxy store allows us to diff changes correctly when the filelisting changes, only rerendering the changed items
207 //absolute paths are required here to make sure it's really the same file in the same directory
208 const [fileStore, setFileStore] = createStore<FlatFileListing>([]);
209 createEffect(() => {
210 setFileStore(
211 reconcile(
212 getAbsolutePathFiles().filter((item) => {
213 const fileName = item[0].split("/").toReversed()[0];
214 return fileName.toLowerCase().includes(filter());
215 }),
216 { key: "0" },
217 ),
218 );
219 });
220
221 return (
222 <>
223 <dialog
224 class="duplicate-warning-dialog"
225 ref={dialogElement}
226 onclick={(e) => e.target.nodeName === "DIALOG" && dialogElement.close()}
227 >
228 <div>
229 Some of the items you're trying to add already exists in your current playlist
230 <div class="buttons">
231 <button ref={addAnywayButton}>Add anyway</button>
232 <button onClick={() => dialogElement.close()}>Cancel</button>
233 </div>
234 </div>
235 </dialog>
236 <div classList={{ "file-browser": true, hidden: !props.showFileBrowser() }}>
237 <div classList={{ breadcrumb: true, "breadcrumb-nowrap": !showActionButtons() }}>
238 <For each={props.breadcrumbs()}>
239 {(crumb, index) => (
240 <>
241 <span title={crumb} onClick={() => handleBreadcrumbClick(index())}>
242 {crumb}
243 </span>
244 {index() < props.breadcrumbs().length - 1 && "/"}
245 </>
246 )}
247 </For>
248 <button
249 class="toggle-browser-actions"
250 title="Toggle filebrowser actions"
251 onclick={() => setShowActionButtons(!showActionButtons())}
252 >
253 {showActionButtons() ? <UpSVG /> : <DownSVG />}
254 </button>
255 </div>
256
257 <Show when={showActionButtons()}>
258 <div class="browser-actions">
259 <div>
260 <button
261 title="Replace playlist with all files in current directory"
262 onClick={() => playFiles("", false, false)}
263 >
264 Play Directory
265 </button>
266 <button
267 title="Replace playlist with all files in current directory and all subdirectories"
268 onClick={() => playFiles("", true, false)}
269 >
270 Play All
271 </button>
272 <button title="Add all files in current directory to playlist" onClick={() => playFiles("", false, true)}>
273 Queue Directory
274 </button>
275 <button
276 title="Add all files in current directory and all subdirectories to playlist"
277 onClick={() => playFiles("", true, true)}
278 >
279 Queue All
280 </button>
281 </div>
282 <div class="spacer" />
283 <button title="Filter current directory" onClick={() => setShowFilter(!showFilter())}>
284 <FilterSVG />
285 </button>
286 <Show when={!props.isOffline()}>
287 <button
288 class="refresh-button"
289 title="Refresh current directory, bypassing cache"
290 onClick={reloadDirectory}
291 >
292 <RefreshSVG />
293 </button>
294 </Show>
295 </div>
296 <Show when={showFilter()}>
297 <input
298 oninput={(e) => setFilter(e.target.value)}
299 onkeydown={(e) => e.key === "Escape" && setShowFilter(false)}
300 class="filter-files"
301 type="text"
302 placeholder="Filter files..."
303 />
304 </Show>
305 </Show>
306
307 <div class="file-list-wrapper">
308 <Show when={props.files.loading}>
309 <LoadingScreen />
310 </Show>
311
312 <div class="file-list" ref={filelistElement}>
313 <For each={fileStore}>
314 {([fullPath, item]) => {
315 const isDirectory = !("metadata" in item);
316 const fileName = fullPath.split("/").toReversed()[0];
317 return (
318 <>
319 <div
320 class={(isDirectory && "directory-item") || "file-item"}
321 onClick={() => {
322 (isDirectory && props.setCurrentDir(fullPath)) || playFile(fullPath, item as MediaFile);
323 }}
324 >
325 {(isDirectory && <ClosedFolderSVG forButton={false} />) || <MusicSVG />}
326 <span>{fileName}</span>
327 <div class="spacer" />
328 <DownloadButton
329 fileName={fileName}
330 item={item}
331 fetchFiles={props.fetchFiles}
332 isOffline={props.isOffline}
333 currentDir={props.currentDir}
334 options={props.options}
335 />
336 <button
337 title="Add to Playlist"
338 onClick={(event) => {
339 event.stopPropagation();
340 if (isDirectory) {
341 playFiles(fileName, true, true);
342 } else {
343 playFiles(fileName, false, true, item);
344 }
345 }}
346 >
347 Add
348 </button>
349 </div>
350 </>
351 );
352 }}
353 </For>
354 </div>
355 </div>
356 <Show when={!props.options.hideCover && (cover.loading || cover())}>
357 <div class="cover">
358 <Suspense fallback={<LoadingScreen />}>
359 <img src={cover()} alt="" />
360 </Suspense>
361 </div>
362 </Show>
363 <Show when={loadingActions() > 0}>
364 <LoadingScreen text="Loading files from subdirectories, this might take a while.." />
365 </Show>
366 </div>
367 </>
368 );
369}
370