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

Configure Feed

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

wip: improve loaders

+336 -189
+14
_config.ts
··· 140 140 141 141 site.add("/definitions"); 142 142 143 + // HELPERS 144 + 145 + site.helper("facetURL", (text) => { 146 + let key = "path"; 147 + 148 + if (text.includes("://")) { 149 + key = "uri"; 150 + } 151 + 152 + return `facets/l/?${key}=${encodeURIComponent(text)}`; 153 + }, { 154 + type: "filter", 155 + }); 156 + 143 157 // PHOSPHOR ICONS 144 158 145 159 function phosphor(path: string) {
+1
deno.jsonc
··· 31 31 "bs58check": "npm:bs58check@^4.0.0", 32 32 "codemirror": "npm:codemirror@^6.0.2", 33 33 "fast-average-color": "npm:fast-average-color@^9.5.0", 34 + "fast-uri": "npm:fast-uri@^3.1.0", 34 35 "idb-keyval": "npm:idb-keyval@^6.2.2", 35 36 "iso-base": "npm:iso-base@^4.3.0", 36 37 "lit-html": "npm:lit-html@^3.3.1",
+2 -2
src/_components/facets.vto
··· 2 2 {{ for index, item of items }} 3 3 <li data-url="{{item.url}}" data-name="{{item.title}}"> 4 4 <div style="position: relative;"> 5 - <a href="facets/l/?url={{encodeURIComponent(item.url)}}"> 5 + <a href="{{ item.url |> facetURL }}"> 6 6 {{item.title}} 7 7 </a> 8 8 <button ··· 24 24 style="position-anchor: --facet-anchor-{{id}}-{{index}}" 25 25 popover 26 26 > 27 - <a href="facets/l/?url={{encodeURIComponent(item.url)}}"> 27 + <a href="{{ item.url |> facetURL }}"> 28 28 <span class="with-icon"> 29 29 <i class="ph-fill ph-globe"></i> Open 30 30 </span>
+224
src/common/loader.js
··· 1 + import * as URI from "fast-uri"; 2 + import { Client, ok, simpleFetchHandler } from "@atcute/client"; 3 + import { 4 + CompositeDidDocumentResolver, 5 + LocalActorResolver, 6 + PlcDidDocumentResolver, 7 + WebDidDocumentResolver, 8 + XrpcHandleResolver, 9 + } from "@atcute/identity-resolver"; 10 + 11 + import * as CID from "@common/cid.js"; 12 + import { effect } from "@common/signal.js"; 13 + 14 + /** 15 + * @import {SignalReader} from "@common/signal.d.ts" 16 + */ 17 + 18 + /** 19 + * @typedef {{ html?: string; uri?: string; cid?: string; id: string; name: string; $type: string }} LoadableItem 20 + */ 21 + 22 + /** 23 + * @typedef {object} LoaderConfig 24 + * @property {string} $type - The atproto $type 25 + * @property {string} label - Human-readable label for error messages (e.g. "Facet", "Theme") 26 + * @property {() => { collection: SignalReader<LoadableItem[]>; state: SignalReader<"loading" | "loaded" | "sleeping"> }} source - The collection source 27 + * @property {(item: LoadableItem) => void} render - Renders the loaded item 28 + */ 29 + 30 + /** 31 + * Sets up the full loader effect: reads URL params, resolves the item 32 + * from the collection or creates a temporary one, ensures HTML is loaded, 33 + * and calls the render callback. 34 + * 35 + * @param {LoaderConfig} config 36 + */ 37 + export function createLoader(config) { 38 + const docUrl = new URL(document.location.href); 39 + 40 + const id = docUrl.searchParams.get("id"); 41 + const cid = docUrl.searchParams.get("cid"); 42 + const name = docUrl.searchParams.get("name"); 43 + const uri = docUrl.searchParams.get("uri"); 44 + const path = docUrl.searchParams.get("path"); 45 + 46 + const containerNull = document.querySelector("#container"); 47 + if (!containerNull) throw new Error("Container not found"); 48 + 49 + const container = /** @type {HTMLDivElement} */ (containerNull); 50 + 51 + /** @type {string | null} */ 52 + let loadedCid = null; 53 + 54 + /** @type {string | null} */ 55 + let loader = null; 56 + 57 + effect(async () => { 58 + /** @type {LoadableItem | undefined} */ 59 + let item = undefined; 60 + 61 + if (path) { 62 + item = { 63 + $type: config.$type, 64 + id: crypto.randomUUID(), 65 + name: "temporary", 66 + uri: `diffuse://${path}`, 67 + }; 68 + 69 + loader = "path"; 70 + } else if (uri) { 71 + item = { 72 + $type: config.$type, 73 + id: crypto.randomUUID(), 74 + name: "temporary", 75 + uri, 76 + }; 77 + 78 + loader = "uri"; 79 + } else { 80 + const source = config.source(); 81 + const collection = source.collection(); 82 + console.log(source.state(), collection); 83 + if (source.state() !== "loaded") return; 84 + 85 + if (id) { 86 + item = collection.find((c) => c.id === id); 87 + loader = "id"; 88 + } else if (cid) { 89 + item = collection.find((c) => c.cid === cid); 90 + loader = "cid"; 91 + } else if (name) { 92 + item = collection.find((c) => c.name === name); 93 + loader = "name"; 94 + } 95 + } 96 + 97 + if (!loader) { 98 + return renderError(container, "No loader specified"); 99 + } else if (!item) { 100 + return renderError(container, `${config.label} not found`); 101 + } 102 + 103 + // Make sure HTML is loaded when a URI is specified 104 + await ensureHTML(item).catch((err) => { 105 + renderError(container, `Failed to load URI: ${item.uri}`, { 106 + context: err, 107 + throw: true, 108 + }); 109 + }); 110 + 111 + if (item.cid === loadedCid) return; 112 + 113 + loadedCid = item.cid ?? null; 114 + config.render(item); 115 + }); 116 + } 117 + 118 + /** 119 + * @param {string} uri 120 + */ 121 + export async function loadURI(uri) { 122 + const u = URI.parse(uri); 123 + console.log(u); 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 + */ 146 + export 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 + */ 163 + export 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 + */ 185 + async function atprotoLoader(uri) { 186 + const parts = uri.replace(/at:\/\//, "").split("/"); 187 + const [repo, collection, rkey] = parts; 188 + 189 + const resolver = new LocalActorResolver({ 190 + handleResolver: new XrpcHandleResolver({ 191 + serviceUrl: "https://public.api.bsky.app", 192 + }), 193 + didDocumentResolver: new CompositeDidDocumentResolver({ 194 + methods: { 195 + plc: new PlcDidDocumentResolver(), 196 + web: new WebDidDocumentResolver(), 197 + }, 198 + }), 199 + }); 200 + 201 + const identity = await resolver.resolve( 202 + /** @type {import("@atcute/lexicons/syntax").ActorIdentifier} */ (repo), 203 + ); 204 + 205 + const rpc = new Client({ 206 + handler: simpleFetchHandler({ service: identity.pds }), 207 + }); 208 + 209 + /** @type {any} */ 210 + const { value } = await ok( 211 + /** @type {any} */ (rpc).get("com.atproto.repo.getRecord", { 212 + params: { repo: identity.did, collection, rkey }, 213 + }), 214 + ); 215 + 216 + return value.html ?? ""; 217 + } 218 + 219 + /** 220 + * @param {string} url 221 + */ 222 + async function httpLoader(url) { 223 + return fetch(url).then((res) => res.text()); 224 + }
+6 -8
src/facets/index.vto
··· 50 50 Make a list of what previously played in the queue. 51 51 --- 52 52 53 - {{ function facetUrl(facetPath) }}facets/l/?url={{encodeURIComponent(facetPath)}}{{ /function }} 54 - 55 53 <header> 56 54 <div> 57 55 <div> ··· 101 99 <li> 102 100 <p> 103 101 <i class="ph-fill ph-plus"></i> 104 - <strong><a href="{{ facetUrl('themes/webamp/configurators/input/facet.html.txt') }}">Add</a></strong> audio from various places on the web and your device. 102 + <strong><a href="{{ ('themes/webamp/configurators/input/facet.html.txt') |> facetURL }}">Add</a></strong> audio from various places on the web and your device. 105 103 </p> 106 104 </li> 107 105 <li> 108 106 <p> 109 107 <i class="ph-fill ph-queue"></i> 110 - <strong><a href="{{ facetUrl('themes/webamp/browser/facet.html.txt') }}">Browse</a></strong> your collection to put something into the queue. 108 + <strong><a href="{{ ('themes/webamp/browser/facet.html.txt') |> facetURL }}">Browse</a></strong> your collection to put something into the queue. 111 109 </p> 112 110 </li> 113 111 <li> 114 112 <p> 115 113 <i class="ph-fill ph-queue"></i> 116 - <strong><a href="{{ facetUrl('facets/tools/auto-queue.html.txt') }}">Automate</a></strong> adding items to the queue, for infinite playback or listening to a playlist. 114 + <strong><a href="{{ ('facets/tools/auto-queue.html.txt') |> facetURL }}">Automate</a></strong> adding items to the queue, for infinite playback or listening to a playlist. 117 115 </p> 118 116 </li> 119 117 <li> 120 118 <p> 121 119 <i class="ph-fill ph-music-note"></i> 122 - <strong><a href="{{ facetUrl('themes/blur/artwork-controller/facet.html.txt') }}">Play</a></strong> queued songs. 120 + <strong><a href="{{ ('themes/blur/artwork-controller/facet.html.txt') |> facetURL }}">Play</a></strong> queued songs. 123 121 </p> 124 122 </li> 125 123 <li> 126 124 <p> 127 125 <i class="ph-fill ph-person"></i> 128 - <strong><a href="{{ facetUrl('themes/webamp/configurators/output/facet.html.txt') }}">Manage</a></strong> your user data, sync with your other devices or other people. 126 + <strong><a href="{{ ('themes/webamp/configurators/output/facet.html.txt') |> facetURL }}">Manage</a></strong> your user data, sync with your other devices or other people. 129 127 </p> 130 128 </li> 131 129 </ol> ··· 137 135 To use these facets, simply open whichever ones provide the functionality that you're looking for at a given moment. You can browse existing ones here and create one below. 138 136 </p> 139 137 <p> 140 - For example, say you want to play music; two options would be: (1) <a href="{{ facetUrl('themes/webamp/browser/facet.html.txt') }}">browse</a> for a specific song and add it to the queue, or (2) <a href="{{ facetUrl('facets/tools/auto-queue.html.txt') }}">automatically</a> add a bunch of shuffled songs to the queue. Next, you need a way to play the items you added to the queue. That's where a <a href="{{ facetUrl('themes/blur/artwork-controller/facet.html.txt') }}">controller</a> could be used. 138 + For example, say you want to play music; two options would be: (1) <a href="{{ ('themes/webamp/browser/facet.html.txt') |> facetURL }}">browse</a> for a specific song and add it to the queue, or (2) <a href="{{ ('facets/tools/auto-queue.html.txt') |> facetURL }}">automatically</a> add a bunch of shuffled songs to the queue. Next, you need a way to play the items you added to the queue. That's where a <a href="{{ ('themes/blur/artwork-controller/facet.html.txt') |> facetURL }}">controller</a> could be used. 141 139 </p> 142 140 <p> 143 141 <em>You might ask, why can't I do all of this in just one window? That's what <a href="themes/">themes</a> are for, if you need something more streamlined. If you however want a customised experience, or prefer certain interfaces for certain things, that's what facets are for.</em>
+19 -91
src/facets/l/index.js
··· 1 - import * as CID from "@common/cid.js"; 2 1 import foundation from "@common/facets/foundation.js"; 3 - import { effect } from "@common/signal.js"; 4 - 5 - /** 6 - * @import {Facet} from "@definitions/types.d.ts" 7 - */ 8 - 9 - //////////////////////////////////////////// 10 - // OUTPUT 11 - //////////////////////////////////////////// 12 - 13 - const output = foundation.orchestrator.output(); 14 - 15 - //////////////////////////////////////////// 16 - // URL PARAMS 17 - //////////////////////////////////////////// 18 - 19 - const docUrl = new URL(document.location.href); 20 - 21 - const id = docUrl.searchParams.get("id"); 22 - const cid = docUrl.searchParams.get("cid"); 23 - const name = docUrl.searchParams.get("name"); 24 - const url = docUrl.searchParams.get("url"); 25 - 26 - //////////////////////////////////////////// 27 - // LOAD 28 - //////////////////////////////////////////// 29 - 30 - const containerNull = document.querySelector("#container"); 31 - if (!containerNull) throw new Error("Container not found"); 32 - 33 - const container = /** @type {HTMLDivElement} */ (containerNull); 34 - 35 - /** @type {string | null} */ 36 - let loadedCid = null; 37 - 38 - effect(async () => { 39 - const collection = output.facets.collection(); 40 - if (output.facets.state() !== "loaded") return; 41 - 42 - let facet; 43 - 44 - if (id) { 45 - facet = collection.find((c) => c.id === id); 46 - } else if (cid) { 47 - facet = collection.find((c) => c.cid === cid); 48 - } else if (name) { 49 - facet = collection.find((c) => c.name === name); 50 - } else if (url) { 51 - /** @type {Facet} */ 52 - const c = { 53 - $type: "sh.diffuse.output.facet", 54 - id: crypto.randomUUID(), 55 - name: "tryout", 56 - url, 57 - }; 2 + import { createLoader } from "@common/loader.js"; 58 3 59 - facet = c; 60 - } 4 + createLoader({ 5 + $type: "sh.diffuse.output.facet", 6 + label: "Facet", 7 + source: () => { 8 + const output = foundation.orchestrator.output(); 9 + return output.facets; 10 + }, 11 + render(facet) { 12 + // TODO: Validate if CID matches HTML 13 + const container = /** @type {HTMLDivElement} */ ( 14 + document.querySelector("#container") 15 + ); 61 16 62 - // TODO: Message that facet was not found 63 - if (!facet) { 64 - console.error("Facet not found"); 65 - return; 66 - } 17 + const range = document.createRange(); 18 + range.selectNode(container); 19 + const documentFragment = range.createContextualFragment(facet.html ?? ""); 67 20 68 - // Make sure HTML is loaded 69 - // TODO: Handle URL loading error 70 - if (!facet.html && facet.url) { 71 - const html = await fetch(facet.url).then((res) => res.text()); 72 - const cid = await CID.create(0x55, new TextEncoder().encode(html)); 73 - 74 - facet.html = html; 75 - facet.cid = cid; 76 - } 77 - 78 - if (facet.cid === loadedCid) return; 79 - 80 - loadedCid = facet.cid ?? null; 81 - loadIntoContainer(facet); 21 + container.innerHTML = ""; 22 + container.append(documentFragment); 23 + }, 82 24 }); 83 - 84 - /** 85 - * @param {Facet} facet 86 - */ 87 - function loadIntoContainer(facet) { 88 - // TODO: Validate if CID matches HTML 89 - 90 - const range = document.createRange(); 91 - range.selectNode(container); 92 - const documentFragment = range.createContextualFragment(facet.html ?? ""); 93 - 94 - container.innerHTML = ""; 95 - container.append(documentFragment); 96 - }
+12 -1
src/facets/l/index.vto
··· 2 2 layout: layouts/facet.vto 3 3 base: ../../ 4 4 5 + styles: 6 + - styles/vendor/phosphor/fill/style.css 7 + - styles/base.css 8 + - styles/loader.css 9 + 5 10 scripts: 6 11 - facets/l/index.js 7 12 --- 8 13 9 - <div id="container"></div> 14 + <div id="container"> 15 + <div class="diffuse"> 16 + <div id="diffuse-loader" class="flex"> 17 + <i class="ph-fill ph-music-notes animate-bounce"></i> 18 + </div> 19 + </div> 20 + </div>
+28
src/styles/loader.css
··· 1 + body { 2 + background: var(--bg-color); 3 + } 4 + 5 + @scope (.diffuse) { 6 + :scope { 7 + color: var(--text-color); 8 + font-size: var(--fs-base); 9 + font-weight: 500; 10 + left: 50%; 11 + position: absolute; 12 + top: 50%; 13 + transform: translate(-50%, -50%); 14 + } 15 + 16 + .flex { 17 + align-items: center; 18 + display: flex; 19 + gap: var(--space-2xs); 20 + justify-content: center; 21 + } 22 + 23 + #diffuse-loader { 24 + font-size: var(--fs-md); 25 + letter-spacing: var(--leading-loose); 26 + text-transform: uppercase; 27 + } 28 + }
+15 -83
src/themes/l/index.js
··· 1 - import * as CID from "@common/cid.js"; 2 1 import foundation from "@common/facets/foundation.js"; 3 - import { effect } from "@common/signal.js"; 4 - 5 - /** 6 - * @import {Theme} from "@definitions/types.d.ts" 7 - */ 8 - 9 - //////////////////////////////////////////// 10 - // OUTPUT 11 - //////////////////////////////////////////// 12 - 13 - const output = foundation.orchestrator.output(); 14 - 15 - //////////////////////////////////////////// 16 - // URL PARAMS 17 - //////////////////////////////////////////// 18 - 19 - const docUrl = new URL(document.location.href); 20 - 21 - const id = docUrl.searchParams.get("id"); 22 - const cid = docUrl.searchParams.get("cid"); 23 - const name = docUrl.searchParams.get("name"); 24 - const url = docUrl.searchParams.get("url"); 25 - 26 - //////////////////////////////////////////// 27 - // LOAD 28 - //////////////////////////////////////////// 29 - 30 - /** @type {string | null} */ 31 - let loadedCid = null; 32 - 33 - effect(async () => { 34 - const collection = output.themes.collection(); 35 - if (output.themes.state() !== "loaded") return; 36 - 37 - let theme; 38 - 39 - if (id) { 40 - theme = collection.find((t) => t.id === id); 41 - } else if (cid) { 42 - theme = collection.find((t) => t.cid === cid); 43 - } else if (name) { 44 - theme = collection.find((t) => t.name === name); 45 - } else if (url) { 46 - /** @type {Theme} */ 47 - const t = { 48 - $type: "sh.diffuse.output.theme", 49 - id: crypto.randomUUID(), 50 - name: "tryout", 51 - url, 52 - }; 2 + import { createLoader } from "@common/loader.js"; 53 3 54 - theme = t; 55 - } 56 - 57 - // TODO: Message that theme was not found 58 - if (!theme) return; 4 + createLoader({ 5 + $type: "sh.diffuse.output.theme", 6 + label: "Theme", 7 + source: () => { 8 + const output = foundation.orchestrator.output(); 9 + return output.themes; 10 + }, 11 + render(theme) { 12 + // TODO: Validate if CID matches HTML 13 + const iframe = document.createElement("iframe"); 14 + iframe.srcdoc = theme.html ?? ""; 59 15 60 - // Make sure HTML is loaded 61 - // TODO: Handle URL loading error 62 - if (!theme.html && theme.url) { 63 - const html = await fetch(theme.url).then((res) => res.text()); 64 - const cid = await CID.create(0x55, new TextEncoder().encode(html)); 65 - 66 - theme.html = html; 67 - theme.cid = cid; 68 - } 69 - 70 - if (theme.cid === loadedCid) return; 71 - 72 - loadedCid = theme.cid ?? null; 73 - loadIntoContainer(theme); 16 + document.body.innerHTML = ""; 17 + document.body.append(iframe); 18 + }, 74 19 }); 75 - 76 - /** 77 - * @param {Theme} theme 78 - */ 79 - function loadIntoContainer(theme) { 80 - // TODO: Validate if CID matches HTML 81 - 82 - const iframe = document.createElement("iframe"); 83 - iframe.srcdoc = theme.html ?? ""; 84 - 85 - document.body.innerHTML = ""; 86 - document.body.append(iframe); 87 - }
+15 -4
src/themes/l/index.vto
··· 1 1 --- 2 - layout: layouts/facet.vto 2 + layout: layouts/diffuse.vto 3 3 base: ../../ 4 4 5 + styles: 6 + - styles/vendor/phosphor/fill/style.css 7 + - styles/base.css 8 + - styles/loader.css 9 + - themes/l/index.css 10 + 5 11 scripts: 6 12 - themes/l/index.js 13 + --- 7 14 8 - styles: 9 - - themes/l/index.css 10 - --- 15 + <div id="container"> 16 + <div class="diffuse"> 17 + <div id="diffuse-loader" class="flex"> 18 + <i class="ph-fill ph-music-notes animate-bounce"></i> 19 + </div> 20 + </div> 21 + </div>