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
181 filelistElement.addEventListener("scrollend", () => {
182 scrollPositions[scrollPositions.length - 1].pos = filelistElement.scrollTop;
183 });
184 });
185
186 return (
187 <>
188 <dialog class="duplicate-warning-dialog" ref={dialogElement}>
189 <div>
190 Some of the items you're trying to add already exists in your current playlist
191 <div class="buttons">
192 <button ref={addAnywayButton}>Add anyway</button>
193 <button onClick={() => dialogElement.close()}>Cancel</button>
194 </div>
195 </div>
196 </dialog>
197 <div classList={{ "file-browser": true, hidden: !props.showFileBrowser() }}>
198 <div class="breadcrumb">
199 <For each={props.breadcrumbs()}>
200 {(crumb, index) => (
201 <>
202 <span onClick={() => handleBreadcrumbClick(index())}>{decodeURIComponent(crumb)}</span>
203 {index() < props.breadcrumbs().length - 1 && "/"}
204 </>
205 )}
206 </For>
207 <button
208 class="toggle-browser-actions"
209 title="Toggle filebrowser actions"
210 onclick={() => setShowActionButtons(!showActionButtons())}
211 >
212 {showActionButtons() ? <UpSVG /> : <DownSVG />}
213 </button>
214 </div>
215
216 <Show when={showActionButtons()}>
217 <div class="browser-actions">
218 <div>
219 <button
220 title="Replace playlist with all files in current directory"
221 onClick={() => playFiles("", false, false)}
222 >
223 Play Directory
224 </button>
225 <button
226 title="Replace playlist with all files in current directory and all subdirectories"
227 onClick={() => playFiles("", true, false)}
228 >
229 Play All
230 </button>
231 <button title="Add all files in current directory to playlist" onClick={() => playFiles("", false, true)}>
232 Queue Directory
233 </button>
234 <button
235 title="Add all files in current directory and all subdirectories to playlist"
236 onClick={() => playFiles("", true, true)}
237 >
238 Queue All
239 </button>
240 </div>
241 <div class="spacer" />
242 <Show when={!props.isOffline()}>
243 <button
244 class="refresh-button"
245 title="Refresh current directory, bypassing cache"
246 onClick={reloadDirectory}
247 >
248 <RefreshSVG />
249 </button>
250 </Show>
251 </div>
252 </Show>
253
254 <div class="file-list-wrapper">
255 <Show when={props.files.loading}>
256 <LoadingScreen />
257 </Show>
258
259 <div class="file-list" ref={filelistElement}>
260 <For each={Array.from(props.files?.latest.entries()).sort(fileSort)}>
261 {([fileName, item]) => {
262 const isDirectory = !("metadata" in item);
263 const fullPath = joinPath(props.currentDir(), fileName);
264 return (
265 <>
266 <div
267 class={(isDirectory && "directory-item") || "file-item"}
268 onClick={() => {
269 (isDirectory && props.setCurrentDir(fullPath)) || playFile(fullPath, item as MediaFile);
270 }}
271 >
272 {(isDirectory && <ClosedFolderSVG forButton={false} />) || <MusicSVG />}
273 <span>{fileName}</span>
274 <div class="spacer" />
275 <DownloadButton
276 fileName={fileName}
277 item={item}
278 fetchFiles={props.fetchFiles}
279 isOffline={props.isOffline}
280 currentDir={props.currentDir}
281 options={props.options}
282 />
283 <button
284 title="Add to Playlist"
285 onClick={(event) => {
286 event.stopPropagation();
287 if (isDirectory) {
288 playFiles(fileName, true, true);
289 } else {
290 playFiles(fileName, false, true, item);
291 }
292 }}
293 >
294 Add
295 </button>
296 </div>
297 </>
298 );
299 }}
300 </For>
301 </div>
302 </div>
303 <Show when={!props.options.hideCover && (cover.loading || cover())}>
304 <div class="cover">
305 <Suspense fallback={<LoadingScreen />}>
306 <img src={cover()} alt="" />
307 </Suspense>
308 </div>
309 </Show>
310 <Show when={loadingActions() > 0}>
311 <LoadingScreen text="Loading files from subdirectories, this might take a while.." />
312 </Show>
313 </div>
314 <div
315 class="file-browser-overlay"
316 style={{ display: props.showFileBrowser() ? "block" : "none" }}
317 onClick={() => props.setShowFileBrowser(false)}
318 />
319 </>
320 );
321}
322