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, MusicSVG, RefreshSVG, UpSVG } from "./SVG";
20import { clearCoverCache as deleteCover, clearDirectoryCache, getCover, listFiles } from "../offline";
21import { DownloadButton } from "./DownloadButton";
22import { LoadingScreen } from "./LoadingScreen";
23
24interface FileBrowserProps {
25 breadcrumbs: Accessor<string[]>;
26 currentDir: Accessor<string>;
27 setCurrentDir: Setter<string>;
28 playTrack: (track: PlaylistItem) => void;
29 playlist: Accessor<PlaylistItem[]>;
30 setPlaylist: Setter<PlaylistItem[]>;
31 showFileBrowser: Accessor<boolean>;
32 setShowFileBrowser: Setter<boolean>;
33 options: AppOptions;
34 isOffline: Accessor<boolean>;
35 files: InitializedResource<FileListing>;
36 fetchFiles: ResourceActions<FileListing>;
37}
38
39export default function FileBrowser(props: FileBrowserProps) {
40 const [loadingActions, setLoadingActions] = createSignal(0);
41 const [showActionButtons, setShowActionButtons] = createSignal(true);
42 const [cover, fetchCover] = createResource<string | undefined, { dir: string; offline: boolean }>(
43 () => ({ dir: props.currentDir(), offline: props.isOffline() }),
44 async (args, info) => {
45 if (info.value) URL.revokeObjectURL(info.value as string);
46 return await getCover(args.dir, args.offline);
47 },
48 { initialValue: undefined },
49 );
50
51 let dialogElement!: HTMLDialogElement;
52 let addAnywayButton!: HTMLButtonElement;
53
54 function handleBreadcrumbClick(index: number) {
55 if (index === 0) {
56 props.setCurrentDir("");
57 } else {
58 props.setCurrentDir(joinPath(...props.breadcrumbs().slice(1, index + 1)));
59 }
60 }
61
62 function fileToPlaylistItem(path: string, item: MediaFile): PlaylistItem {
63 return {
64 id: generateUUID(),
65 metadata: item.metadata,
66 path: path,
67 };
68 }
69
70 // Recursively convert file listing to PlaylistItem array
71 function listingToPlaylistItems(basePath: string, files: FileListing): PlaylistItem[] {
72 let result: PlaylistItem[] = [];
73 for (const [fileName, file] of files) {
74 const fullPath = joinPath(basePath, fileName);
75 if ("metadata" in file) {
76 // It's an audio file
77 result.push(fileToPlaylistItem(fullPath, file));
78 } else if (file.files && file.files.size > 0) {
79 // It's a directory with files, recursively add its contents
80 result = [...result, ...listingToPlaylistItems(fullPath, file.files)];
81 }
82 }
83 return result.sort((a, b) => a.path.localeCompare(b.path, undefined, { numeric: true }));
84 }
85
86 function playFile(path: string, item: MediaFile) {
87 props.playTrack(fileToPlaylistItem(path, item));
88 }
89
90 function scrollPlaylistToBottom() {
91 const element = document.getElementById("playlist");
92 if (!element) return;
93 element.scrollTop = element.scrollHeight;
94 }
95
96 async function playFiles(fileName: string, recursive: boolean, queue: boolean, item?: MediaFile) {
97 if (recursive) {
98 setLoadingActions(loadingActions() + 1);
99 }
100 try {
101 const fullPath = joinPath(props.currentDir(), fileName);
102 let oldLength = 0;
103 let newPlaylist: PlaylistItem[];
104 const oldPlaylist = queue ? props.playlist() : [];
105 if (item) {
106 newPlaylist = [fileToPlaylistItem(fullPath, item)];
107 } else {
108 const files = await listFiles(fullPath, props.isOffline(), recursive);
109 newPlaylist = listingToPlaylistItems(fullPath, files);
110 if (queue) {
111 oldLength = oldPlaylist.length;
112 }
113 }
114 const addAction = () => {
115 if (newPlaylist.length !== 0 && oldLength === 0) props.playTrack(newPlaylist[0]);
116 props.setPlaylist([...oldPlaylist, ...newPlaylist]);
117 if (queue) scrollPlaylistToBottom();
118 };
119 if (
120 props.options.duplicateWarning &&
121 oldPlaylist.some((oldItem) => newPlaylist.some((newItem) => oldItem.path === newItem.path))
122 ) {
123 addAnywayButton.onclick = () => {
124 addAction();
125 dialogElement.close();
126 };
127 dialogElement.showModal();
128 } else {
129 addAction();
130 }
131 } finally {
132 if (recursive) {
133 setLoadingActions(loadingActions() - 1);
134 }
135 }
136 }
137
138 function reloadDirectory() {
139 clearDirectoryCache(props.currentDir());
140 //TODO: only clears the current dir, not recursively like clearDirectoryCache
141 deleteCover(props.currentDir());
142 props.fetchFiles.refetch();
143 fetchCover.refetch();
144 }
145
146 function fileSort(a: [string, Directory | MediaFile], b: [string, Directory | MediaFile]): number {
147 //sort directories first
148 if ("metadata" in b[1] && !("metadata" in a[1])) return -1;
149 if ("metadata" in a[1] && !("metadata" in b[1])) return 1;
150 return a[0].localeCompare(b[0], undefined, { numeric: true });
151 }
152
153 let filelistElement!: HTMLDivElement;
154 const scrollPositions: { part: string; pos: number }[] = [];
155 createEffect(
156 on(props.currentDir, () => {
157 //need the timeout, otherwise we could execute the scroll before rendering finishes
158 setTimeout(() => {
159 let parts = `${props.currentDir()}`.split("/");
160 if (props.currentDir() !== "") parts = ["", ...parts];
161 scrollPositions.length = Math.min(parts.length, scrollPositions.length);
162 for (let index = 0; index < parts.length; index++) {
163 const part = parts[index];
164 if (scrollPositions.length - 1 < index) {
165 scrollPositions.push({ part, pos: 0 });
166 } else if (scrollPositions[index].part !== part) {
167 scrollPositions[index] = { part, pos: 0 };
168 }
169 }
170 filelistElement.scrollTop = scrollPositions[scrollPositions.length - 1].pos;
171 }, 0);
172 }),
173 );
174
175 onMount(() => {
176 const savedFileBrowserButtons = localStorage.getItem(LocalStorageValues.fileBrowserButtons);
177 if (savedFileBrowserButtons !== null) setShowActionButtons(savedFileBrowserButtons === "true");
178 createEffect(() => localStorage.setItem(LocalStorageValues.fileBrowserButtons, showActionButtons().toString()));
179
180 filelistElement.addEventListener("scrollend", () => {
181 scrollPositions[scrollPositions.length - 1].pos = filelistElement.scrollTop;
182 });
183 });
184
185 return (
186 <>
187 <dialog
188 class="duplicate-warning-dialog"
189 ref={dialogElement}
190 onclick={(e) => e.target.nodeName === "DIALOG" && dialogElement.close()}
191 >
192 <div>
193 Some of the items you're trying to add already exists in your current playlist
194 <div class="buttons">
195 <button ref={addAnywayButton}>Add anyway</button>
196 <button onClick={() => dialogElement.close()}>Cancel</button>
197 </div>
198 </div>
199 </dialog>
200 <div classList={{ "file-browser": true, hidden: !props.showFileBrowser() }}>
201 <div class="breadcrumb">
202 <For each={props.breadcrumbs()}>
203 {(crumb, index) => (
204 <>
205 <span onClick={() => handleBreadcrumbClick(index())}>{crumb}</span>
206 {index() < props.breadcrumbs().length - 1 && "/"}
207 </>
208 )}
209 </For>
210 <button
211 class="toggle-browser-actions"
212 title="Toggle filebrowser actions"
213 onclick={() => setShowActionButtons(!showActionButtons())}
214 >
215 {showActionButtons() ? <UpSVG /> : <DownSVG />}
216 </button>
217 </div>
218
219 <Show when={showActionButtons()}>
220 <div class="browser-actions">
221 <div>
222 <button
223 title="Replace playlist with all files in current directory"
224 onClick={() => playFiles("", false, false)}
225 >
226 Play Directory
227 </button>
228 <button
229 title="Replace playlist with all files in current directory and all subdirectories"
230 onClick={() => playFiles("", true, false)}
231 >
232 Play All
233 </button>
234 <button title="Add all files in current directory to playlist" onClick={() => playFiles("", false, true)}>
235 Queue Directory
236 </button>
237 <button
238 title="Add all files in current directory and all subdirectories to playlist"
239 onClick={() => playFiles("", true, true)}
240 >
241 Queue All
242 </button>
243 </div>
244 <div class="spacer" />
245 <Show when={!props.isOffline()}>
246 <button
247 class="refresh-button"
248 title="Refresh current directory, bypassing cache"
249 onClick={reloadDirectory}
250 >
251 <RefreshSVG />
252 </button>
253 </Show>
254 </div>
255 </Show>
256
257 <div class="file-list-wrapper">
258 <Show when={props.files.loading}>
259 <LoadingScreen />
260 </Show>
261
262 <div class="file-list" ref={filelistElement}>
263 <For each={Array.from(props.files?.latest.entries()).sort(fileSort)}>
264 {([fileName, item]) => {
265 const isDirectory = !("metadata" in item);
266 const fullPath = joinPath(props.currentDir(), fileName);
267 return (
268 <>
269 <div
270 class={(isDirectory && "directory-item") || "file-item"}
271 onClick={() => {
272 (isDirectory && props.setCurrentDir(fullPath)) || playFile(fullPath, item as MediaFile);
273 }}
274 >
275 {(isDirectory && <ClosedFolderSVG forButton={false} />) || <MusicSVG />}
276 <span>{fileName}</span>
277 <div class="spacer" />
278 <DownloadButton
279 fileName={fileName}
280 item={item}
281 fetchFiles={props.fetchFiles}
282 isOffline={props.isOffline}
283 currentDir={props.currentDir}
284 options={props.options}
285 />
286 <button
287 title="Add to Playlist"
288 onClick={(event) => {
289 event.stopPropagation();
290 if (isDirectory) {
291 playFiles(fileName, true, true);
292 } else {
293 playFiles(fileName, false, true, item);
294 }
295 }}
296 >
297 Add
298 </button>
299 </div>
300 </>
301 );
302 }}
303 </For>
304 </div>
305 </div>
306 <Show when={!props.options.hideCover && (cover.loading || cover())}>
307 <div class="cover">
308 <Suspense fallback={<LoadingScreen />}>
309 <img src={cover()} alt="" />
310 </Suspense>
311 </div>
312 </Show>
313 <Show when={loadingActions() > 0}>
314 <LoadingScreen text="Loading files from subdirectories, this might take a while.." />
315 </Show>
316 </div>
317 <div
318 class="file-browser-overlay"
319 style={{ display: props.showFileBrowser() ? "block" : "none" }}
320 onClick={() => props.setShowFileBrowser(false)}
321 />
322 </>
323 );
324}
325