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