A music player that connects to your cloud/distributed storage.
1import * as URI from "fast-uri";
2import * as TID from "@atcute/tid";
3import { Client, ok, simpleFetchHandler } from "@atcute/client";
4import {
5 CompositeDidDocumentResolver,
6 LocalActorResolver,
7 PlcDidDocumentResolver,
8 WebDidDocumentResolver,
9 XrpcHandleResolver,
10} from "@atcute/identity-resolver";
11
12import * as CID from "~/common/cid.js";
13import { effect } from "~/common/signal.js";
14
15/**
16 * @import {SignalReader} from "~/common/signal.d.ts"
17 */
18
19/**
20 * @typedef {{ html?: string; uri?: string; cid?: string; id: string; name: string; $type: string }} LoadableItem
21 */
22
23/**
24 * @typedef {object} LoaderConfig
25 * @property {string} $type - The atproto $type
26 * @property {string} label - Human-readable label for error messages (e.g. "Facet", "Theme")
27 * @property {() => { collection: SignalReader<{ state: "loading" } | { state: "loaded"; data: LoadableItem[] }> }} source - The collection source
28 * @property {(item: LoadableItem) => void} render - Renders the loaded item
29 */
30
31/**
32 * Sets up the full loader effect: reads URL params, resolves the item
33 * from the collection or creates a temporary one, ensures HTML is loaded,
34 * and calls the render callback.
35 *
36 * @param {LoaderConfig} config
37 */
38export function createLoader(config) {
39 const docUrl = new URL(document.location.href);
40
41 const id = docUrl.searchParams.get("id");
42 const cid = docUrl.searchParams.get("cid");
43 const name = docUrl.searchParams.get("name");
44 const uri = docUrl.searchParams.get("uri");
45 const path = docUrl.searchParams.get("path");
46
47 const containerNull = document.querySelector("#container");
48 if (!containerNull) throw new Error("Container not found");
49
50 const container = /** @type {HTMLDivElement} */ (containerNull);
51
52 /** @type {string | null} */
53 let loadedCid = null;
54
55 /** @type {string | null} */
56 let loader = null;
57
58 effect(async () => {
59 /** @type {LoadableItem | undefined} */
60 let item = undefined;
61
62 if (path) {
63 item = {
64 $type: config.$type,
65 id: TID.now(),
66 name: "temporary",
67 uri: `diffuse://${path}`,
68 };
69
70 loader = "path";
71 } else if (uri) {
72 item = {
73 $type: config.$type,
74 id: TID.now(),
75 name: "temporary",
76 uri,
77 };
78
79 loader = "uri";
80 } else {
81 const source = config.source();
82 const col = source.collection();
83 if (col.state !== "loaded") return;
84 const collection = col.data;
85
86 if (id) {
87 item = collection.find((c) => c.id === id);
88 loader = "id";
89 } else if (cid) {
90 item = collection.find((c) => c.cid === cid);
91 loader = "cid";
92 } else if (name) {
93 item = collection.find((c) => c.name === name);
94 loader = "name";
95 }
96 }
97
98 if (!loader) {
99 return renderError(container, "No loader specified");
100 } else if (!item) {
101 return renderError(container, `${config.label} not found`);
102 }
103
104 // Make sure HTML is loaded when a URI is specified
105 await ensureHTML(item).catch((err) => {
106 renderError(container, `Failed to load URI: ${item.uri}`, {
107 context: err,
108 throw: true,
109 });
110 });
111
112 if (item.cid === loadedCid) return;
113
114 loadedCid = item.cid ?? null;
115 config.render(item);
116 });
117}
118
119/**
120 * @param {string} uri
121 */
122export async function loadURI(uri) {
123 const u = URI.parse(uri);
124
125 switch (u.scheme) {
126 case "at":
127 return atprotoLoader(uri);
128 case "diffuse":
129 return httpLoader(uri.replace(/^diffuse:\/\//, ""));
130 case "http":
131 case "https":
132 return httpLoader(uri);
133 default:
134 throw new Error(`Unsupported scheme: ${u.scheme}`);
135 }
136}
137
138/**
139 * Ensures the item has HTML loaded. If it has a URI but no HTML,
140 * fetches the HTML and computes the CID.
141 *
142 * @template {{ html?: string; uri?: string; cid?: string }} T
143 * @param {T} item
144 * @returns {Promise<T>}
145 */
146export async function ensureHTML(item) {
147 if (!item.html && item.uri) {
148 const html = await loadURI(item.uri);
149 const cid = await CID.create(0x55, new TextEncoder().encode(html));
150
151 item.html = html;
152 item.cid = cid;
153 }
154
155 return item;
156}
157
158/**
159 * @param {HTMLElement} container
160 * @param {string} error
161 * @param {{ context?: Error; throw?: boolean }} [options]
162 */
163export function renderError(container, error, options) {
164 container.innerHTML = `
165 <div class="diffuse">
166 <div class="flex">
167 <i class="ph-fill ph-warning"></i>
168 <span>${error}</span>
169 </div>
170 </div>
171 `;
172
173 if (options?.throw) {
174 throw options.context ?? new Error(error);
175 }
176}
177
178////////////////////////////////////////////
179// 🛠️ | LOADERS
180////////////////////////////////////////////
181
182/**
183 * @param {string} uri
184 * @returns {Promise<string>}
185 */
186async function atprotoLoader(uri) {
187 const parts = uri.replace(/at:\/\//, "").split("/");
188 const [repo, collection, rkey] = parts;
189
190 const resolver = new LocalActorResolver({
191 handleResolver: new XrpcHandleResolver({
192 serviceUrl: "https://public.api.bsky.app",
193 }),
194 didDocumentResolver: new CompositeDidDocumentResolver({
195 methods: {
196 plc: new PlcDidDocumentResolver(),
197 web: new WebDidDocumentResolver(),
198 },
199 }),
200 });
201
202 const identity = await resolver.resolve(
203 /** @type {import("@atcute/lexicons/syntax").ActorIdentifier} */ (repo),
204 );
205
206 const rpc = new Client({
207 handler: simpleFetchHandler({ service: identity.pds }),
208 });
209
210 /** @type {any} */
211 const { value } = await ok(
212 /** @type {any} */ (rpc).get("com.atproto.repo.getRecord", {
213 params: { repo: identity.did, collection, rkey },
214 }),
215 );
216
217 if (value.html) {
218 return value.html;
219 }
220
221 if (value.uri) {
222 return loadURI(value.uri);
223 }
224
225 return "";
226}
227
228/**
229 * @param {string} url
230 * @returns {Promise<string>}
231 */
232async function httpLoader(url) {
233 return fetch(url).then((res) => res.text());
234}