Experiment to rebuild Diffuse using web applets.
0

Configure Feed

Select the types of activity you want to include in your feed.

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>