A music player that connects to your cloud/distributed storage.
5
fork

Configure Feed

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

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