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 // Recursively collect audio files
59 function collectAudioFiles(files: FileListing, basePath: string): PlaylistItem[] {
60 let result: PlaylistItem[] = [];
61 const normpath = basePath === "" ? "" : `${basePath}/`;
62
63 for (const file of files) {
64 if ("duration" in file) {
65 // It's an audio file
66 result.push({
67 id: generateUUID(),
68 name: file.name.split("/").pop() || file.name,
69 duration: file.duration,
70 path: `${normpath}${file.name.split("/").pop()}`,
71 });
72 } else if ("files" in file && file.files.length > 0) {
73 // It's a directory with files, recursively add its contents
74 const subPath = `${normpath}${file.name}`;
75 result = [...result, ...collectAudioFiles(file.files, subPath)];
76 }
77 }
78
79 return result;
80 }
81
82 function addToPlaylist(file: AudioFile, path: string) {
83 const newItem: PlaylistItem = {
84 id: generateUUID(),
85 name: file.name,
86 duration: file.duration,
87 path: joinPath(path, file.name),
88 };
89
90 props.setPlaylist([...props.playlist(), newItem]);
91 if (props.playlist().length === 1) {
92 props.playTrack(newItem);
93 }
94 }
95
96 async function playFiles(recursive: boolean, queue: boolean, dir?: string) {
97 if (recursive) {
98 setLoadingActions(loadingActions() + 1);
99 }
100 try {
101 const selectedDir = dir || props.currentDir();
102 const files = await listFiles(selectedDir, isOffline(), recursive);
103 const newPlaylist = collectAudioFiles(files, selectedDir);
104 const oldPlaylist = props.playlist();
105 if (queue) {
106 props.setPlaylist([...oldPlaylist, ...newPlaylist]);
107 if (oldPlaylist.length === 0 && newPlaylist.length !== 0) props.playTrack(newPlaylist[0]);
108 } else {
109 props.setPlaylist(newPlaylist);
110 if (newPlaylist.length !== 0) props.playTrack(newPlaylist[0]);
111 }
112 } finally {
113 if (recursive) {
114 setLoadingActions(loadingActions() - 1);
115 }
116 }
117 }
118
119 function reloadDirectory() {
120 clearDirectoryCache(props.currentDir());
121 clearCoverCache(props.currentDir());
122 fetchFiles.refetch();
123 fetchCover.refetch();
124 }
125
126 onMount(() => {
127 try {
128 const savedOffline = localStorage.getItem(LocalStorageValues.offline);
129 setOffline(savedOffline === "true");
130 } catch (e) {
131 toast("loading values from localStorage failed", "error", e);
132 localStorage.clear();
133 }
134
135 createEffect(() => {
136 createEffect(() => localStorage.setItem(LocalStorageValues.offline, isOffline().toString()));
137 });
138 });
139
140 return (
141 <div classList={{ "file-browser": true, hidden: !props.showFileBrowser() }}>
142 <div class="breadcrumb">
143 <For each={props.breadcrumbs()}>
144 {(crumb, index) => (
145 <>
146 <span onClick={() => handleBreadcrumbClick(index())}>{decodeURIComponent(crumb)}</span>
147 {index() < props.breadcrumbs().length - 1 && "/"}
148 </>
149 )}
150 </For>
151 </div>
152
153 <div class="browser-actions">
154 <div>
155 <button title="Replace playlist with all files in current directory" onClick={() => playFiles(false, false)}>
156 Play Directory
157 </button>
158 <button
159 title="Replace playlist with all files in current directory and all subdirectories"
160 onClick={() => playFiles(true, false)}
161 >
162 Play All
163 </button>
164 <button title="Add all files in current directory to playlist" onClick={() => playFiles(false, true)}>
165 Queue Directory
166 </button>
167 <button
168 title="Add all files in current directory and all subdirectories to playlist"
169 onClick={() => playFiles(true, true)}
170 >
171 Queue All
172 </button>
173 </div>
174 <div class="spacer" />
175 <Show when={!isOffline()}>
176 <button class="refresh-button" title="Refresh current directory, bypassing cache" onClick={reloadDirectory}>
177 <RefreshSVG />
178 </button>
179 </Show>
180 <button
181 class="offline-button"
182 style={{ background: isOffline() ? "lightgreen" : "indianred" }}
183 textContent={isOffline() ? "Go online" : "Go offline"}
184 onclick={() => setOffline(!isOffline())}
185 />
186 </div>
187
188 <div class="file-list-wrapper">
189 <Show when={files.loading}>
190 <LoadingScreen />
191 </Show>
192
193 <div class="file-list">
194 <For each={files.latest}>
195 {(item) => {
196 const isDirectory = "files" in item;
197 const fullPath = joinPath(props.currentDir(), item.name);
198 return (
199 <>
200 <div
201 class={(isDirectory && "directory-item") || "file-item"}
202 onClick={() => isDirectory && props.setCurrentDir(fullPath)}
203 >
204 {(isDirectory && <ClosedFolderSVG forButton={false} />) || <MusicSVG />}
205 <span>{item.name}</span>
206 <div class="spacer" />
207 <DownloadButton
208 item={item}
209 fetchFiles={fetchFiles}
210 isOffline={isOffline}
211 currentDir={props.currentDir}
212 options={props.options}
213 />
214 <button
215 title="Add to Playlist"
216 onClick={(event) => {
217 event.stopPropagation();
218 if (isDirectory) {
219 playFiles(true, true, fullPath);
220 } else {
221 addToPlaylist(item as AudioFile, props.currentDir());
222 }
223 }}
224 >
225 Add
226 </button>
227 </div>
228 </>
229 );
230 }}
231 </For>
232 </div>
233 </div>
234 <Show when={cover.loading || cover()}>
235 <div class="cover">
236 <Suspense fallback={<LoadingScreen />}>
237 <img src={cover()} alt="" />
238 </Suspense>
239 </div>
240 </Show>
241 <Show when={loadingActions() > 0}>
242 <LoadingScreen text="Loading files from subdirectories, this might take a while.." />
243 </Show>
244 </div>
245 );
246}
247