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['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});