A music player that connects to your cloud/distributed storage.
at v4 240 lines 5.9 kB view raw
1import * as TID from "@atcute/tid"; 2import { ostiary, rpc } from "~/common/worker.js"; 3import { groupKey } from "~/components/input/common.js"; 4import { 5 buildURI, 6 enumerateAudioFiles, 7 getHandleFile, 8 groupTracksByTid, 9 groupUrisByTid, 10 isSupported, 11 loadHandles, 12 parseURI, 13 saveHandles, 14} from "./common.js"; 15import { SCHEME } from "./constants.js"; 16 17/** 18 * @import { InputActions as Actions, ConsultGrouping } from "~/components/input/types.d.ts"; 19 * @import { Track } from "~/definitions/types.d.ts" 20 */ 21 22//////////////////////////////////////////// 23// ACTIONS 24//////////////////////////////////////////// 25 26/** 27 * @type {Actions['consult']} 28 */ 29export async function consult(fileUriOrScheme) { 30 if (!isSupported()) { 31 return { supported: false, reason: "No browser support" }; 32 } 33 34 if (!fileUriOrScheme.includes(":")) { 35 return { supported: true, consult: "undetermined" }; 36 } 37 38 const parsed = parseURI(fileUriOrScheme); 39 if (!parsed) return { supported: false, reason: "Unknown handle" }; 40 41 const handles = await loadHandles(); 42 const handle = handles[parsed.tid]; 43 44 if (!handle) return { supported: false, reason: "Unknown handle" }; 45 46 const permission = await /** @type {any} */ (handle).queryPermission({ 47 mode: "read", 48 }); 49 50 return { supported: true, consult: permission === "granted" }; 51} 52 53/** 54 * @type {Actions['detach']} 55 */ 56export async function detach({ fileUriOrScheme, tracks }) { 57 if (!fileUriOrScheme.includes("://")) { 58 if (fileUriOrScheme === SCHEME) return []; 59 return tracks; 60 } 61 62 const parsed = parseURI(fileUriOrScheme); 63 if (!parsed) return tracks; 64 65 const { tid } = parsed; 66 const groups = groupTracksByTid(tracks); 67 delete groups[tid]; 68 69 const handles = await loadHandles(); 70 delete handles[tid]; 71 await saveHandles(handles); 72 73 return Object.values(groups).map((g) => g.tracks).flat(1); 74} 75 76/** 77 * @type {Actions['groupConsult']} 78 */ 79export async function groupConsult(uris) { 80 const groups = groupUrisByTid(uris); 81 const handles = await loadHandles(); 82 83 const promises = Object.entries(groups).flatMap(async ([tid, { uris }]) => { 84 const handle = handles[tid]; 85 if (!handle) return []; 86 87 const available = 88 (await /** @type {any} */ (handle).queryPermission({ mode: "read" })) === 89 "granted"; 90 91 /** @type {ConsultGrouping} */ 92 const grouping = available ? { available, scheme: SCHEME, uris } : { 93 available: false, 94 reason: "Permission not granted", 95 scheme: SCHEME, 96 uris, 97 }; 98 99 return [{ key: groupKey(SCHEME, tid), grouping }]; 100 }); 101 102 const results = (await Promise.all(promises)).flat(1); 103 return Object.fromEntries(results.map((e) => [e.key, e.grouping])); 104} 105 106/** 107 * @type {Actions['list']} 108 */ 109export async function list(cachedTracks = []) { 110 const handles = await loadHandles(); 111 const now = new Date().toISOString(); 112 113 /** @type {Record<string, Track>} */ 114 const cacheByUri = {}; 115 116 cachedTracks.forEach((t) => { 117 cacheByUri[t.uri] = t; 118 }); 119 120 const trackGroups = groupTracksByTid(cachedTracks); 121 122 const allTids = new Set(Object.keys(trackGroups)); 123 124 const promises = [...allTids].map(async (tid) => { 125 const handle = handles[tid]; 126 if (!handle) return trackGroups[tid]?.tracks ?? /** @type {Track[]} */ ([]); 127 128 const perm = await /** @type {any} */ (handle).queryPermission({ 129 mode: "read", 130 }); 131 132 if (perm !== "granted") { 133 const cached = trackGroups[tid]?.tracks[0]; 134 135 /** @type {Track} */ 136 const placeholder = { 137 $type: "sh.diffuse.output.track", 138 id: cached?.id ?? TID.now(), 139 createdAt: cached?.createdAt ?? now, 140 updatedAt: now, 141 kind: "placeholder", 142 uri: buildURI(tid), 143 }; 144 145 return [placeholder]; 146 } 147 148 if (handle.kind === "file") { 149 const uri = buildURI(tid); 150 const cached = cacheByUri[uri]; 151 152 /** @type {Track} */ 153 const track = { 154 $type: "sh.diffuse.output.track", 155 id: cached?.id ?? TID.now(), 156 createdAt: cached?.createdAt ?? now, 157 updatedAt: cached?.updatedAt ?? now, 158 stats: cached?.stats, 159 tags: cached?.tags, 160 uri, 161 }; 162 163 return [track]; 164 } 165 166 const paths = await enumerateAudioFiles( 167 /** @type {FileSystemDirectoryHandle} */ (handle), 168 ); 169 170 if (!paths.length) { 171 /** @type {Track} */ 172 const placeholder = { 173 $type: "sh.diffuse.output.track", 174 id: TID.now(), 175 createdAt: now, 176 updatedAt: now, 177 kind: "placeholder", 178 uri: buildURI(tid), 179 }; 180 181 return [placeholder]; 182 } 183 184 return paths.map((path) => { 185 const uri = buildURI(tid, path); 186 const cached = cacheByUri[uri]; 187 188 /** @type {Track} */ 189 const track = { 190 $type: "sh.diffuse.output.track", 191 id: cached?.id ?? TID.now(), 192 createdAt: cached?.createdAt ?? now, 193 updatedAt: cached?.updatedAt ?? now, 194 stats: cached?.stats, 195 tags: cached?.tags, 196 uri, 197 }; 198 199 return track; 200 }); 201 }); 202 203 const tracks = (await Promise.all(promises)).flat(1); 204 return tracks; 205} 206 207/** 208 * @type {Actions['resolve']} 209 */ 210export async function resolve({ uri }) { 211 const parsed = parseURI(uri); 212 if (!parsed) return undefined; 213 214 const handles = await loadHandles(); 215 const handle = handles[parsed.tid]; 216 const path = parsed.path.replace(/^\//, ""); 217 218 if (!handle) return undefined; 219 if (handle.kind === "directory" && path === "") return undefined; 220 221 const fileHandle = await getHandleFile(handle, path); 222 const file = await fileHandle.getFile(); 223 224 const url = URL.createObjectURL(file); 225 return { url, expiresAt: Infinity }; 226} 227 228//////////////////////////////////////////// 229// ⚡️ 230//////////////////////////////////////////// 231 232ostiary((context) => { 233 rpc(context, { 234 consult, 235 detach, 236 groupConsult, 237 list, 238 resolve, 239 }); 240});