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 <div classList={{ "file-browser": true, hidden: !props.showFileBrowser() }}>
151 <div class="breadcrumb">
152 <For each={props.breadcrumbs()}>
153 {(crumb, index) => (
154 <>
155 <span onClick={() => handleBreadcrumbClick(index())}>{decodeURIComponent(crumb)}</span>
156 {index() < props.breadcrumbs().length - 1 && "/"}
157 </>
158 )}
159 </For>
160 </div>
161
162 <div class="browser-actions">
163 <div>
164 <button title="Replace playlist with all files in current directory" onClick={() => playFiles(false, false)}>
165 Play Directory
166 </button>
167 <button
168 title="Replace playlist with all files in current directory and all subdirectories"
169 onClick={() => playFiles(true, false)}
170 >
171 Play All
172 </button>
173 <button title="Add all files in current directory to playlist" onClick={() => playFiles(false, true)}>
174 Queue Directory
175 </button>
176 <button
177 title="Add all files in current directory and all subdirectories to playlist"
178 onClick={() => playFiles(true, true)}
179 >
180 Queue All
181 </button>
182 </div>
183 <div class="spacer" />
184 <Show when={!props.isOffline()}>
185 <button class="refresh-button" title="Refresh current directory, bypassing cache" onClick={reloadDirectory}>
186 <RefreshSVG />
187 </button>
188 </Show>
189 <button
190 title="Switch between online and offline mode. In offline mode only locally cached files are listed"
191 class="offline-button"
192 style={{ background: props.isOffline() ? "lightgreen" : "indianred" }}
193 textContent={props.isOffline() ? "Go online" : "Go offline"}
194 onclick={() => props.setOffline(!props.isOffline())}
195 />
196 </div>
197
198 <div class="file-list-wrapper">
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)) || playFile(item as AudioFile)}
213 >
214 {(isDirectory && <ClosedFolderSVG forButton={false} />) || <MusicSVG />}
215 <span>{item.name}</span>
216 <div class="spacer" />
217 <DownloadButton
218 item={item}
219 fetchFiles={fetchFiles}
220 isOffline={props.isOffline}
221 currentDir={props.currentDir}
222 options={props.options}
223 />
224 <button
225 title="Add to Playlist"
226 onClick={(event) => {
227 event.stopPropagation();
228 if (isDirectory) {
229 playFiles(true, true, fullPath);
230 } else {
231 addToPlaylist(item as AudioFile, props.currentDir());
232 }
233 }}
234 >
235 Add
236 </button>
237 </div>
238 </>
239 );
240 }}
241 </For>
242 </div>
243 </div>
244 <Show when={cover.loading || cover()}>
245 <div class="cover">
246 <Suspense fallback={<LoadingScreen />}>
247 <img src={cover()} alt="" />
248 </Suspense>
249 </div>
250 </Show>
251 <Show when={loadingActions() > 0}>
252 <LoadingScreen text="Loading files from subdirectories, this might take a while.." />
253 </Show>
254 </div>
255 );
256}
257