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