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}
32
33export default function FileBrowser(props: FileBrowserProps) {
34 const [isOffline, setOffline] = createSignal(true);
35 const [loadingActions, setLoadingActions] = createSignal(0);
36 const [files, fetchFiles] = createResource(
37 () => ({ dir: props.currentDir(), offline: isOffline() }),
38 async (args) => await listFiles(args.dir, args.offline, false),
39 { initialValue: [] },
40 );
41 const [cover, fetchCover] = createResource<string | undefined, { dir: string; offline: boolean }>(
42 () => ({ dir: props.currentDir(), offline: isOffline() }),
43 async (args, info) => {
44 if (info.value) URL.revokeObjectURL(info.value as string);
45 return await getCover(args.dir, args.offline);
46 },
47 { initialValue: undefined },
48 );
49
50 function handleBreadcrumbClick(index: number) {
51 if (index === 0) {
52 props.setCurrentDir("");
53 } else {
54 props.setCurrentDir(joinPath(...props.breadcrumbs().slice(1, index + 1)));
55 }
56 }
57
58 function fileToPlaylistItem(basePath: string, item: AudioFile) {
59 const normpath = basePath === "" ? "" : `${basePath}/`;
60
61 return {
62 id: generateUUID(),
63 name: item.name.split("/").pop() || item.name,
64 duration: item.duration,
65 path: `${normpath}${item.name.split("/").pop()}`,
66 };
67 }
68
69 // Recursively collect audio files
70 function collectAudioFiles(files: FileListing, basePath: string): PlaylistItem[] {
71 let result: PlaylistItem[] = [];
72 for (const file of files) {
73 if ("duration" in file) {
74 // It's an audio file
75 result.push(fileToPlaylistItem(basePath, file));
76 } else if ("files" in file && file.files.length > 0) {
77 // It's a directory with files, recursively add its contents
78 const normpath = basePath === "" ? "" : `${basePath}/`;
79 const subPath = `${normpath}${file.name}`;
80 result = [...result, ...collectAudioFiles(file.files, subPath)];
81 }
82 }
83 return result;
84 }
85
86 function playFile(item: AudioFile) {
87 props.playTrack(fileToPlaylistItem(props.currentDir(), item));
88 }
89
90 function addToPlaylist(file: AudioFile, path: string) {
91 const newItem: PlaylistItem = {
92 id: generateUUID(),
93 name: file.name,
94 duration: file.duration,
95 path: joinPath(path, file.name),
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, isOffline(), recursive);
111 const newPlaylist = collectAudioFiles(files, selectedDir);
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 clearCoverCache(props.currentDir());
130 fetchFiles.refetch();
131 fetchCover.refetch();
132 }
133
134 onMount(() => {
135 try {
136 const savedOffline = localStorage.getItem(LocalStorageValues.offline);
137 setOffline(savedOffline === "true");
138 } catch (e) {
139 toast("loading values from localStorage failed", "error", e);
140 localStorage.clear();
141 }
142
143 createEffect(() => {
144 createEffect(() => localStorage.setItem(LocalStorageValues.offline, isOffline().toString()));
145 });
146 });
147
148 return (
149 <div classList={{ "file-browser": true, hidden: !props.showFileBrowser() }}>
150 <div class="breadcrumb">
151 <For each={props.breadcrumbs()}>
152 {(crumb, index) => (
153 <>
154 <span onClick={() => handleBreadcrumbClick(index())}>{decodeURIComponent(crumb)}</span>
155 {index() < props.breadcrumbs().length - 1 && "/"}
156 </>
157 )}
158 </For>
159 </div>
160
161 <div class="browser-actions">
162 <div>
163 <button title="Replace playlist with all files in current directory" onClick={() => playFiles(false, false)}>
164 Play Directory
165 </button>
166 <button
167 title="Replace playlist with all files in current directory and all subdirectories"
168 onClick={() => playFiles(true, false)}
169 >
170 Play All
171 </button>
172 <button title="Add all files in current directory to playlist" onClick={() => playFiles(false, true)}>
173 Queue Directory
174 </button>
175 <button
176 title="Add all files in current directory and all subdirectories to playlist"
177 onClick={() => playFiles(true, true)}
178 >
179 Queue All
180 </button>
181 </div>
182 <div class="spacer" />
183 <Show when={!isOffline()}>
184 <button class="refresh-button" title="Refresh current directory, bypassing cache" onClick={reloadDirectory}>
185 <RefreshSVG />
186 </button>
187 </Show>
188 <button
189 title="Switch between online and offline mode. In offline mode only locally cached files are listed"
190 class="offline-button"
191 style={{ background: isOffline() ? "lightgreen" : "indianred" }}
192 textContent={isOffline() ? "Go online" : "Go offline"}
193 onclick={() => setOffline(!isOffline())}
194 />
195 </div>
196
197 <div class="file-list-wrapper">
198 <Show when={files.loading}>
199 <LoadingScreen />
200 </Show>
201
202 <div class="file-list">
203 <For each={files.latest}>
204 {(item) => {
205 const isDirectory = "files" in item;
206 const fullPath = joinPath(props.currentDir(), item.name);
207 return (
208 <>
209 <div
210 class={(isDirectory && "directory-item") || "file-item"}
211 onClick={() => (isDirectory && props.setCurrentDir(fullPath)) || playFile(item as AudioFile)}
212 >
213 {(isDirectory && <ClosedFolderSVG forButton={false} />) || <MusicSVG />}
214 <span>{item.name}</span>
215 <div class="spacer" />
216 <DownloadButton
217 item={item}
218 fetchFiles={fetchFiles}
219 isOffline={isOffline}
220 currentDir={props.currentDir}
221 options={props.options}
222 />
223 <button
224 title="Add to Playlist"
225 onClick={(event) => {
226 event.stopPropagation();
227 if (isDirectory) {
228 playFiles(true, true, fullPath);
229 } else {
230 addToPlaylist(item as AudioFile, props.currentDir());
231 }
232 }}
233 >
234 Add
235 </button>
236 </div>
237 </>
238 );
239 }}
240 </For>
241 </div>
242 </div>
243 <Show when={cover.loading || cover()}>
244 <div class="cover">
245 <Suspense fallback={<LoadingScreen />}>
246 <img src={cover()} alt="" />
247 </Suspense>
248 </div>
249 </Show>
250 <Show when={loadingActions() > 0}>
251 <LoadingScreen text="Loading files from subdirectories, this might take a while.." />
252 </Show>
253 </div>
254 );
255}
256