A music player that connects to your cloud/distributed storage.
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});