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