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