FileBrowser.tsx
⎇
Raw
1import "./FileBrowser.css";
2import {
3 type Accessor,
4 createEffect,
5 createResource,
6 createSignal,
7 For,
8 onMount,
9 type Setter,
10 Show,
11 Suspense,
12} from "solid-js";
13import { type MediaFile, type FileListing, fileSort } from "music-server-shared/types";
14import type { AppOptions, PlaylistItem } from "../types";
15import { generateUUID, joinPath, LocalStorageValues, toast } from "../utils";
16import { ClosedFolderSVG, MusicSVG, RefreshSVG } from "./SVG";
17import { clearCoverCache as deleteCover, clearDirectoryCache, getCover, listFiles } from "../offline";
18import { DownloadButton } from "./DownloadButton";
19import { LoadingScreen } from "./LoadingScreen";
20
21interface FileBrowserProps {
22 breadcrumbs: Accessor<string[]>;
23 currentDir: Accessor<string>;
24 setCurrentDir: Setter<string>;
25 playTrack: (track: PlaylistItem) => void;
26 playlist: Accessor<PlaylistItem[]>;
27 setPlaylist: Setter<PlaylistItem[]>;
28 showFileBrowser: Accessor<boolean>;
29 setShowFileBrowser: Setter<boolean>;
30 options: Accessor<AppOptions>;
31 isOffline: Accessor<boolean>;
32 setOffline: Setter<boolean>;
33}
34
35export default function FileBrowser(props: FileBrowserProps) {
36 const [loadingActions, setLoadingActions] = createSignal(0);
37 const [files, fetchFiles] = createResource(
38 //TODO: try using store
39 () => ({ dir: props.currentDir(), offline: props.isOffline() }),
40 async (args) => {
41 //new map to force rerender
42 return new Map(await listFiles(args.dir, args.offline, false));
43 },
44 { initialValue: new Map() },
45 );
46 const [cover, fetchCover] = createResource<string | undefined, { dir: string; offline: boolean }>(
47 () => ({ dir: props.currentDir(), offline: props.isOffline() }),
48 async (args, info) => {
49 if (info.value) URL.revokeObjectURL(info.value as string);
50 return await getCover(args.dir, args.offline);
51 },
52 { initialValue: undefined },
53 );
54
55 function handleBreadcrumbClick(index: number) {
56 if (index === 0) {
57 props.setCurrentDir("");
58 } else {
59 props.setCurrentDir(joinPath(...props.breadcrumbs().slice(1, index + 1)));
60 }
61 }
62
63 function fileToPlaylistItem(path: string, item: MediaFile): PlaylistItem {
64 return {
65 id: generateUUID(),
66 metadata: item.metadata,
67 path: path,
68 };
69 }
70
71 // Recursively convert file listing to PlaylistItem array
72 function listingToPlaylistItems(basePath: string, files: FileListing): PlaylistItem[] {
73 let result: PlaylistItem[] = [];
74 for (const [fileName, file] of files) {
75 const fullPath = joinPath(basePath, fileName);
76 if ("metadata" in file) {
77 // It's an audio file
78 result.push(fileToPlaylistItem(fullPath, file));
79 } else if (file.files && file.files.size > 0) {
80 // It's a directory with files, recursively add its contents
81 result = [...result, ...listingToPlaylistItems(fullPath, file.files)];
82 }
83 }
84 return result.sort((a, b) => a.path.localeCompare(b.path, undefined, { numeric: true }));
85 }
86
87 function playFile(path: string, item: MediaFile) {
88 props.playTrack(fileToPlaylistItem(path, item));
89 }
90
91 function addToPlaylist(path: string, file: MediaFile) {
92 const newItem: PlaylistItem = {
93 id: generateUUID(),
94 metadata: file.metadata,
95 path: path,
96 };
97
98 props.setPlaylist([...props.playlist(), newItem]);
99 if (props.playlist().length === 1) {
100 props.playTrack(newItem);
101 }
102 }
103
104 async function playFiles(recursive: boolean, queue: boolean, dir?: string) {
105 if (recursive) {
106 setLoadingActions(loadingActions() + 1);
107 }
108 try {
109 const selectedDir = dir || props.currentDir();
110 const files = await listFiles(selectedDir, props.isOffline(), recursive);
111 const newPlaylist = listingToPlaylistItems(selectedDir, files);
112 const oldPlaylist = props.playlist();
113 if (queue) {
114 props.setPlaylist([...oldPlaylist, ...newPlaylist]);
115 if (oldPlaylist.length === 0 && newPlaylist.length !== 0) props.playTrack(newPlaylist[0]);
116 } else {
117 props.setPlaylist(newPlaylist);
118 if (newPlaylist.length !== 0) props.playTrack(newPlaylist[0]);
119 }
120 } finally {
121 if (recursive) {
122 setLoadingActions(loadingActions() - 1);
123 }
124 }
125 }
126
127 function reloadDirectory() {
128 clearDirectoryCache(props.currentDir());
129 //TODO: only clears the current dir, not recursively like clearDirectoryCache
130 deleteCover(props.currentDir());
131 fetchFiles.refetch();
132 fetchCover.refetch();
133 }
134
135 onMount(() => {
136 try {
137 const savedOffline = localStorage.getItem(LocalStorageValues.offline);
138 props.setOffline(savedOffline === "true");
139 } catch (e) {
140 toast("loading values from localStorage failed", "error", e);
141 localStorage.clear();
142 }
143
144 createEffect(() => {
145 createEffect(() => localStorage.setItem(LocalStorageValues.offline, props.isOffline().toString()));
146 });
147 });
148
149 return (
150 <>
151 <div classList={{ "file-browser": true, hidden: !props.showFileBrowser() }}>
152 <div class="breadcrumb">
153 <For each={props.breadcrumbs()}>
154 {(crumb, index) => (
155 <>
156 <span onClick={() => handleBreadcrumbClick(index())}>{decodeURIComponent(crumb)}</span>
157 {index() < props.breadcrumbs().length - 1 && "/"}
158 </>
159 )}
160 </For>
161 </div>
162
163 <div class="browser-actions">
164 <div>
165 <button
166 title="Replace playlist with all files in current directory"
167 onClick={() => playFiles(false, false)}
168 >
169 Play Directory
170 </button>
171 <button
172 title="Replace playlist with all files in current directory and all subdirectories"
173 onClick={() => playFiles(true, false)}
174 >
175 Play All
176 </button>
177 <button title="Add all files in current directory to playlist" onClick={() => playFiles(false, true)}>
178 Queue Directory
179 </button>
180 <button
181 title="Add all files in current directory and all subdirectories to playlist"
182 onClick={() => playFiles(true, true)}
183 >
184 Queue All
185 </button>
186 </div>
187 <div class="spacer" />
188 <Show when={!props.isOffline()}>
189 <button class="refresh-button" title="Refresh current directory, bypassing cache" onClick={reloadDirectory}>
190 <RefreshSVG />
191 </button>
192 </Show>
193 <button
194 title="Switch between online and offline mode. In offline mode only locally cached files are listed"
195 class="offline-button"
196 style={{ background: props.isOffline() ? "lightgreen" : "indianred" }}
197 textContent={props.isOffline() ? "Go online" : "Go offline"}
198 onclick={() => props.setOffline(!props.isOffline())}
199 />
200 </div>
201
202 <div class="file-list-wrapper">
203 <Show when={files.loading}>
204 <LoadingScreen />
205 </Show>
206
207 <div class="file-list">
208 <For each={Array.from(files.latest.entries()).sort(fileSort)}>
209 {([fileName, item]) => {
210 const isDirectory = !("metadata" in item);
211 const fullPath = joinPath(props.currentDir(), fileName);
212 return (
213 <>
214 <div
215 class={(isDirectory && "directory-item") || "file-item"}
216 onClick={() =>
217 (isDirectory && props.setCurrentDir(fullPath)) || playFile(fullPath, item as MediaFile)
218 }
219 >
220 {(isDirectory && <ClosedFolderSVG forButton={false} />) || <MusicSVG />}
221 <span>{fileName}</span>
222 <div class="spacer" />
223 <DownloadButton
224 fileName={fileName}
225 item={item}
226 fetchFiles={fetchFiles}
227 isOffline={props.isOffline}
228 currentDir={props.currentDir}
229 options={props.options}
230 />
231 <button
232 title="Add to Playlist"
233 onClick={(event) => {
234 event.stopPropagation();
235 if (isDirectory) {
236 playFiles(true, true, fullPath);
237 } else {
238 addToPlaylist(fullPath, item as MediaFile);
239 }
240 }}
241 >
242 Add
243 </button>
244 </div>
245 </>
246 );
247 }}
248 </For>
249 </div>
250 </div>
251 <Show when={cover.loading || cover()}>
252 <div class="cover">
253 <Suspense fallback={<LoadingScreen />}>
254 <img src={cover()} alt="" />
255 </Suspense>
256 </div>
257 </Show>
258 <Show when={loadingActions() > 0}>
259 <LoadingScreen text="Loading files from subdirectories, this might take a while.." />
260 </Show>
261 </div>
262 <div
263 class="file-browser-overlay"
264 style={{ display: props.showFileBrowser() ? "block" : "none" }}
265 onClick={() => props.setShowFileBrowser(false)}
266 />
267 </>
268 );
269}
270