Experiment to rebuild Diffuse using web applets.
1<main class="container">
2 <h1>Native file system input</h1>
3 <p>
4 Add music from your device.
5 <br />Music added so far:
6 </p>
7 <div id="directories">
8 <p>
9 <span class="with-icon">
10 <i class="iconoir-bonfire"></i>
11 <small>Just a moment, loading mounted directories.</small>
12 </span>
13 </p>
14 </div>
15 <button id="mount">Mount directory</button>
16</main>
17
18<script>
19 import { applets } from "@web-applets/sdk";
20 import { computed, effect, Signal, signal } from "spellcaster";
21 import { repeat, tags, text } from "spellcaster/hyperscript.js";
22 import { type FileSystemDirectoryHandle, showDirectoryPicker } from "native-file-system-adapter";
23 import * as IDB from "idb-keyval";
24 import * as URI from "uri-js";
25 import QS from "query-string";
26
27 import type { Track } from "@applets/core/types.d.ts";
28 import { isAudioFile } from "@scripts/inputs/common";
29
30 import manifest from "./_manifest.json";
31
32 type Handles = Record<string, FileSystemDirectoryHandle>;
33
34 // TODO: Add ability to list cached tracks from other devices (ie. unknown handle ids)
35
36 ////////////////////////////////////////////
37 // SETUP
38 ////////////////////////////////////////////
39 const IDB_PREFIX = "@applets/input/native-fs";
40 const IDB_HANDLES = `${IDB_PREFIX}/handles`;
41 const SCHEME = manifest.input_properties.scheme;
42
43 // Register applet
44 const context = applets.register();
45
46 ////////////////////////////////////////////
47 // UI
48 ////////////////////////////////////////////
49 const [mounts, setMounts] = signal(await fetchHandlesList());
50
51 // Mount button
52 document.getElementById("mount")?.addEventListener("click", () => mount());
53
54 // Directories
55 const dirList = computed(() => {
56 return new Map(
57 mounts().map((mount) => {
58 return [mount.id, mount];
59 }),
60 );
61 });
62
63 const Item = (signal: Signal<{ id: string; handle: FileSystemDirectoryHandle }>) => {
64 const { id, handle } = signal();
65
66 return tags.li({}, [
67 tags.span(
68 { onclick: () => unmount(id), style: "cursor: pointer;", title: "Click/tap to delete" },
69 text(handle.name),
70 ),
71 ]);
72 };
73
74 const Directories = computed(() => {
75 if (mounts().length === 0) {
76 return tags.p({ id: "directories" }, [
77 tags.small({}, [
78 tags.em({}, text("No audio added yet, click the button below to add some.")),
79 ]),
80 ]);
81 }
82
83 return tags.ul({ id: "directories" }, repeat(dirList, Item));
84 });
85
86 // Add to DOM
87 effect(() => {
88 document.getElementById("directories")?.replaceWith(Directories());
89 });
90
91 ////////////////////////////////////////////
92 // ACTIONS
93 ////////////////////////////////////////////
94 const consult = async (fileUriOrScheme: string) => {
95 if (!isSupported()) {
96 return { supported: false, reason: "File System Access API is not supported" };
97 }
98
99 if (!fileUriOrScheme.includes(":")) {
100 if (fileUriOrScheme !== SCHEME) return { supported: false, reason: "Scheme does not match" };
101 return { supported: true };
102 }
103
104 const handles = await fetchHandles();
105 const uri = URI.parse(fileUriOrScheme);
106 if (uri.scheme !== SCHEME) return { supported: false, reason: "Scheme does not match" };
107 return { supported: true, consultation: uri.host && !!handles[uri.host] };
108 };
109
110 const list = async (cachedTracks: Track[] = []) => {
111 if (!isSupported()) {
112 return cachedTracks;
113 }
114
115 // Continue if supported
116 const handles = await fetchHandlesList();
117
118 // Recursive listing of all tracks of available handles
119 const processed: Track[][] = await Promise.all(
120 handles.map(({ id, handle }) => {
121 return recursiveList(handle, id, []);
122 }),
123 );
124
125 // Group tracks by handle id & index by track uri
126 const cache = cachedTracks.reduce(
127 (acc: Record<string, Record<string, Track>>, track: Track) => {
128 const handleId = trackHandleId(track);
129 if (!handleId) return acc;
130
131 return { ...acc, [handleId]: { ...(acc[handleId] || {}), [track.uri]: track } };
132 },
133 {},
134 );
135
136 // Replace indexes in groups of which we have the handle.
137 // Keeping around tracks with handles we don't have access to,
138 // and removing tracks that are no longer available (for handles we do have access to).
139 const groups = processed.flat(1).reduce(
140 (acc, track) => {
141 const handleId = trackHandleId(track);
142 if (!handleId) throw new Error("New tracks are missing a handle id!");
143
144 return { ...acc, [handleId]: { ...acc[handleId], [track.uri]: track } };
145 },
146 handles.reduce((acc: Record<string, Record<string, Track>>, handle) => {
147 return { ...acc, [handle.id]: {} };
148 }, cache),
149 );
150
151 // Transform in track list and sort by uri
152 const data = Object.values(groups)
153 .map((tracks) => Object.values(tracks))
154 .flat(1)
155 .sort((a: any, b: any) => {
156 if (a.uri < b.uri) return -1;
157 if (a.uri > b.uri) return 1;
158 return 0;
159 });
160
161 // Fin
162 return data;
163 };
164
165 const resolve = async (args: { uri: string }) => {
166 const fileUri = args.uri;
167
168 if (!isSupported()) {
169 return undefined;
170 }
171
172 const uri = URI.parse(fileUri);
173 if (uri.scheme !== SCHEME) return undefined;
174 if (!uri.host || !uri.path) return undefined;
175
176 const handles = await fetchHandles();
177 const handle = handles[uri.host];
178 if (!handle) return undefined;
179
180 const path = URI.unescapeComponent(uri.path);
181 const parts = (path.startsWith("/") ? path.slice(1) : path).split("/");
182 const filename = parts[parts.length - 1];
183
184 const dirHandle = await parts
185 .slice(0, -1)
186 .reduce(
187 async (
188 acc: Promise<FileSystemDirectoryHandle>,
189 part: string,
190 ): Promise<FileSystemDirectoryHandle> => {
191 const h = await acc;
192 return await h.getDirectoryHandle(part);
193 },
194 Promise.resolve(handle),
195 );
196
197 const fileHandle = await dirHandle.getFileHandle(filename);
198 const file = await fileHandle.getFile();
199 const url = URL.createObjectURL(file);
200
201 return url;
202 };
203
204 const mount = async () => {
205 await showDirectoryPicker()
206 .then(async (handle) => {
207 const existingHandles = await fetchHandles();
208 const id = crypto.randomUUID();
209
210 await handle.requestPermission({ mode: "read" });
211 await IDB.set(IDB_HANDLES, { ...existingHandles, [id]: handle });
212 setMounts(await fetchHandlesList());
213 })
214 .catch(() => {});
215 };
216
217 const unmount = async (handleId: string) => {
218 const handles = await fetchHandles();
219 delete handles[handleId];
220 await IDB.set(IDB_HANDLES, { ...handles });
221 setMounts(await fetchHandlesList());
222 };
223
224 context.setActionHandler("consult", consult);
225 context.setActionHandler("list", list);
226 context.setActionHandler("resolve", resolve);
227 context.setActionHandler("mount", mount);
228 context.setActionHandler("unmount", unmount);
229
230 ////////////////////////////////////////////
231 // 🛠️
232 ////////////////////////////////////////////
233 async function fetchHandles(): Promise<Handles> {
234 return (await IDB.get(IDB_HANDLES)) ?? {};
235 }
236
237 async function fetchHandlesList() {
238 return Object.entries(await fetchHandles()).map(([id, handle]) => {
239 return { id, handle };
240 });
241 }
242
243 function isSupported() {
244 return !!(globalThis as any).showDirectoryPicker;
245 }
246
247 function trackCid(track: Track): string | undefined {
248 const a = URI.parse(track.uri);
249 const cid = a.query ? QS.parse(a.query).cid || undefined : undefined;
250 return Array.isArray(cid) && cid[0] ? cid[0] : typeof cid === "string" ? cid : undefined;
251 }
252
253 function trackHandleId(track: Track): string | undefined {
254 const a = URI.parse(track.uri);
255 return a.host;
256 }
257
258 async function recursiveList(
259 dir: FileSystemDirectoryHandle,
260 rootHandleId: string,
261 path: string[],
262 ): Promise<Track[]> {
263 const tracks: Track[] = [];
264
265 for await (const item of dir.values()) {
266 if (item.kind === "file" && isAudioFile(item.name)) {
267 const uri = URI.serialize({
268 scheme: SCHEME,
269 host: rootHandleId,
270 path: `${path.length ? "/" + path.join("/") : ""}/${item.name}`,
271 });
272
273 const track: Track = {
274 id: crypto.randomUUID(),
275 uri,
276 };
277
278 tracks.push(track);
279 } else if (item.kind === "directory") {
280 const nestedItems = await recursiveList(item as FileSystemDirectoryHandle, rootHandleId, [
281 ...path,
282 item.name,
283 ]);
284
285 tracks.push(...nestedItems);
286 }
287 }
288
289 return tracks;
290 }
291</script>