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 class="breadcrumb">
207 <For each={props.breadcrumbs()}>
208 {(crumb, index) => (
209 <>
210 <span onClick={() => handleBreadcrumbClick(index())}>{crumb}</span>
211 {index() < props.breadcrumbs().length - 1 && "/"}
212 </>
213 )}
214 </For>
215 <button
216 class="toggle-browser-actions"
217 title="Toggle filebrowser actions"
218 onclick={() => setShowActionButtons(!showActionButtons())}
219 >
220 {showActionButtons() ? <UpSVG /> : <DownSVG />}
221 </button>
222 </div>
223
224 <Show when={showActionButtons()}>
225 <div class="browser-actions">
226 <div>
227 <button
228 title="Replace playlist with all files in current directory"
229 onClick={() => playFiles("", false, false)}
230 >
231 Play Directory
232 </button>
233 <button
234 title="Replace playlist with all files in current directory and all subdirectories"
235 onClick={() => playFiles("", true, false)}
236 >
237 Play All
238 </button>
239 <button title="Add all files in current directory to playlist" onClick={() => playFiles("", false, true)}>
240 Queue Directory
241 </button>
242 <button
243 title="Add all files in current directory and all subdirectories to playlist"
244 onClick={() => playFiles("", true, true)}
245 >
246 Queue All
247 </button>
248 </div>
249 <div class="spacer" />
250 <Show when={!props.isOffline()}>
251 <button
252 class="refresh-button"
253 title="Refresh current directory, bypassing cache"
254 onClick={reloadDirectory}
255 >
256 <RefreshSVG />
257 </button>
258 </Show>
259 </div>
260 </Show>
261
262 <div class="file-list-wrapper">
263 <Show when={props.files.loading}>
264 <LoadingScreen />
265 </Show>
266
267 <div class="file-list" ref={filelistElement}>
268 <For each={fileStore}>
269 {([fullPath, item]) => {
270 const isDirectory = !("metadata" in item);
271 const fileName = fullPath.split("/").toReversed()[0];
272 return (
273 <>
274 <div
275 class={(isDirectory && "directory-item") || "file-item"}
276 onClick={() => {
277 (isDirectory && props.setCurrentDir(fullPath)) || playFile(fullPath, item as MediaFile);
278 }}
279 >
280 {(isDirectory && <ClosedFolderSVG forButton={false} />) || <MusicSVG />}
281 <span>{fileName}</span>
282 <div class="spacer" />
283 <DownloadButton
284 fileName={fileName}
285 item={item}
286 fetchFiles={props.fetchFiles}
287 isOffline={props.isOffline}
288 currentDir={props.currentDir}
289 options={props.options}
290 />
291 <button
292 title="Add to Playlist"
293 onClick={(event) => {
294 event.stopPropagation();
295 if (isDirectory) {
296 playFiles(fileName, true, true);
297 } else {
298 playFiles(fileName, false, true, item);
299 }
300 }}
301 >
302 Add
303 </button>
304 </div>
305 </>
306 );
307 }}
308 </For>
309 </div>
310 </div>
311 <Show when={!props.options.hideCover && (cover.loading || cover())}>
312 <div class="cover">
313 <Suspense fallback={<LoadingScreen />}>
314 <img src={cover()} alt="" />
315 </Suspense>
316 </div>
317 </Show>
318 <Show when={loadingActions() > 0}>
319 <LoadingScreen text="Loading files from subdirectories, this might take a while.." />
320 </Show>
321 </div>
322 <div
323 class="file-browser-overlay"
324 style={{ display: props.showFileBrowser() ? "block" : "none" }}
325 onClick={() => props.setShowFileBrowser(false)}
326 />
327 </>
328 );
329}
330