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