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 { AudioFile, FileListing } 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, 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 () => ({ dir: props.currentDir(), offline: props.isOffline() }),
39 async (args) => await listFiles(args.dir, args.offline, false),
40 { initialValue: [] },
41 );
42 const [cover, fetchCover] = createResource<string | undefined, { dir: string; offline: boolean }>(
43 () => ({ dir: props.currentDir(), offline: props.isOffline() }),
44 async (args, info) => {
45 if (info.value) URL.revokeObjectURL(info.value as string);
46 return await getCover(args.dir, args.offline);
47 },
48 { initialValue: undefined },
49 );
50
51 function handleBreadcrumbClick(index: number) {
52 if (index === 0) {
53 props.setCurrentDir("");
54 } else {
55 props.setCurrentDir(joinPath(...props.breadcrumbs().slice(1, index + 1)));
56 }
57 }
58
59 function fileToPlaylistItem(basePath: string, item: AudioFile) {
60 const normpath = basePath === "" ? "" : `${basePath}/`;
61
62 return {
63 id: generateUUID(),
64 name: item.name.split("/").pop() || item.name,
65 duration: item.duration,
66 path: `${normpath}${item.name.split("/").pop()}`,
67 };
68 }
69
70 // Recursively collect audio files
71 function collectAudioFiles(files: FileListing, basePath: string): PlaylistItem[] {
72 let result: PlaylistItem[] = [];
73 for (const file of files) {
74 if ("duration" in file) {
75 // It's an audio file
76 result.push(fileToPlaylistItem(basePath, file));
77 } else if ("files" in file && file.files.length > 0) {
78 // It's a directory with files, recursively add its contents
79 const normpath = basePath === "" ? "" : `${basePath}/`;
80 const subPath = `${normpath}${file.name}`;
81 result = [...result, ...collectAudioFiles(file.files, subPath)];
82 }
83 }
84 return result;
85 }
86
87 function playFile(item: AudioFile) {
88 props.playTrack(fileToPlaylistItem(props.currentDir(), item));
89 }
90
91 function addToPlaylist(file: AudioFile, path: string) {
92 const newItem: PlaylistItem = {
93 id: generateUUID(),
94 name: file.name,
95 duration: file.duration,
96 path: joinPath(path, file.name),
97 };
98
99 props.setPlaylist([...props.playlist(), newItem]);
100 if (props.playlist().length === 1) {
101 props.playTrack(newItem);
102 }
103 }
104
105 async function playFiles(recursive: boolean, queue: boolean, dir?: string) {
106 if (recursive) {
107 setLoadingActions(loadingActions() + 1);
108 }
109 try {
110 const selectedDir = dir || props.currentDir();
111 const files = await listFiles(selectedDir, props.isOffline(), recursive);
112 const newPlaylist = collectAudioFiles(files, selectedDir);
113 const oldPlaylist = props.playlist();
114 if (queue) {
115 props.setPlaylist([...oldPlaylist, ...newPlaylist]);
116 if (oldPlaylist.length === 0 && newPlaylist.length !== 0) props.playTrack(newPlaylist[0]);
117 } else {
118 props.setPlaylist(newPlaylist);
119 if (newPlaylist.length !== 0) props.playTrack(newPlaylist[0]);
120 }
121 } finally {
122 if (recursive) {
123 setLoadingActions(loadingActions() - 1);
124 }
125 }
126 }
127
128 function reloadDirectory() {
129 clearDirectoryCache(props.currentDir());
130 clearCoverCache(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={files.latest}>
209 {(item) => {
210 const isDirectory = "files" in item;
211 const fullPath = joinPath(props.currentDir(), item.name);
212 return (
213 <>
214 <div
215 class={(isDirectory && "directory-item") || "file-item"}
216 onClick={() => (isDirectory && props.setCurrentDir(fullPath)) || playFile(item as AudioFile)}
217 >
218 {(isDirectory && <ClosedFolderSVG forButton={false} />) || <MusicSVG />}
219 <span>{item.name}</span>
220 <div class="spacer" />
221 <DownloadButton
222 item={item}
223 fetchFiles={fetchFiles}
224 isOffline={props.isOffline}
225 currentDir={props.currentDir}
226 options={props.options}
227 />
228 <button
229 title="Add to Playlist"
230 onClick={(event) => {
231 event.stopPropagation();
232 if (isDirectory) {
233 playFiles(true, true, fullPath);
234 } else {
235 addToPlaylist(item as AudioFile, props.currentDir());
236 }
237 }}
238 >
239 Add
240 </button>
241 </div>
242 </>
243 );
244 }}
245 </For>
246 </div>
247 </div>
248 <Show when={cover.loading || cover()}>
249 <div class="cover">
250 <Suspense fallback={<LoadingScreen />}>
251 <img src={cover()} alt="" />
252 </Suspense>
253 </div>
254 </Show>
255 <Show when={loadingActions() > 0}>
256 <LoadingScreen text="Loading files from subdirectories, this might take a while.." />
257 </Show>
258 </div>
259 <div
260 class="file-browser-overlay"
261 style={{ display: props.showFileBrowser() ? "block" : "none" }}
262 onClick={() => props.setShowFileBrowser(false)}
263 />
264 </>
265 );
266}
267