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.

feat: make artwork extendable

+1052 -602
+1
_config.ts
··· 275 275 276 276 site.add([".html"]); 277 277 site.add([".json"]); 278 + site.add([".webmanifest"]); 278 279 279 280 site.use(brotli()); 280 281 site.use(sourceMaps());
+7
src/_data/facets.json
··· 79 79 "desc": "Export all data as a JSON snapshot, or restore from a previously exported file." 80 80 }, 81 81 { 82 + "url": "facets/data/artwork-bundle/index.html", 83 + "title": "Default Artwork Bundle", 84 + "kind": "prelude", 85 + "category": "Data", 86 + "desc": "The default setup for track artwork retrieval. Adds support for: embedded audio metadata, Last.fm, and MusicBrainz." 87 + }, 88 + { 82 89 "url": "facets/data/input-bundle/index.html", 83 90 "title": "Default Input Bundle", 84 91 "kind": "prelude",
+1 -1
src/build.vto
··· 88 88 {{ echo -}}await foundation.orchestrator.scrobbleAudio(){{- /echo }} 89 89 {{ echo -}}await foundation.orchestrator.sources(){{- /echo }} 90 90 91 - {{ echo -}}await foundation.processor.artwork(){{- /echo }} 91 + {{ echo -}}await foundation.orchestrator.artwork(){{- /echo }} 92 92 {{ echo -}}await foundation.processor.metadata(){{- /echo }} 93 93 {{ echo -}}await foundation.processor.search(){{- /echo -}} 94 94 </code>
+1
src/common/facets/constants.js
··· 6 6 "themes/blur/artwork-controller/facet/index.html", 7 7 8 8 // PRELUDES 9 + "facets/data/artwork-bundle/index.html", 9 10 "facets/data/input-bundle/index.html", 10 11 "facets/data/output-bundle/index.html", 11 12 "facets/data/process-tracks/prelude/index.html",
+31 -12
src/common/foundation.js
··· 14 14 */ 15 15 const signals = { 16 16 configurator: { 17 + artwork: signal( 18 + /** @type {import("~/components/configurator/artwork/element.js").CLASS | null} */ (null), 19 + ), 17 20 input: signal( 18 21 /** @type {import("~/components/configurator/input/element.js").CLASS | null} */ (null), 19 22 ), ··· 38 41 }, 39 42 40 43 orchestrator: { 44 + artwork: signal( 45 + /** @type {import("~/components/orchestrator/artwork/element.js").CLASS | null} */ (null), 46 + ), 41 47 autoQueue: signal( 42 48 /** @type {import("~/components/orchestrator/auto-queue/element.js").CLASS | null} */ (null), 43 49 ), ··· 71 77 }, 72 78 73 79 processor: { 74 - artwork: signal( 75 - /** @type {import("~/components/processor/artwork/element.js").CLASS | null} */ (null), 76 - ), 77 80 metadata: signal( 78 81 /** @type {import("~/components/processor/metadata/element.js").CLASS | null} */ (null), 79 82 ), ··· 91 94 92 95 // Elements 93 96 configurator: { 97 + artwork: configuratorArtwork, 94 98 input, 95 99 scrobbles, 96 100 }, ··· 103 107 }, 104 108 105 109 orchestrator: { 110 + artwork, 106 111 autoQueue, 107 112 favourites, 108 113 mediaSession, ··· 116 121 }, 117 122 118 123 processor: { 119 - artwork, 120 124 metadata, 121 125 search, 122 126 }, ··· 126 130 */ 127 131 signals: { 128 132 configurator: { 133 + artwork: signals.configurator.artwork.get, 129 134 input: signals.configurator.input.get, 130 135 scrobbles: signals.configurator.scrobbles.get, 131 136 }, ··· 138 143 }, 139 144 140 145 orchestrator: { 146 + artwork: signals.orchestrator.artwork.get, 141 147 autoQueue: signals.orchestrator.autoQueue.get, 142 148 favourites: signals.orchestrator.favourites.get, 143 149 mediaSession: signals.orchestrator.mediaSession.get, ··· 151 157 }, 152 158 153 159 processor: { 154 - artwork: signals.processor.artwork.get, 155 160 metadata: signals.processor.metadata.get, 156 161 search: signals.processor.search.get, 157 162 }, ··· 190 195 191 196 // Configurators 192 197 198 + async function configuratorArtwork() { 199 + const { default: ArtworkConfigurator } = await import( 200 + "~/components/configurator/artwork/element.js" 201 + ); 202 + 203 + const ac = new ArtworkConfigurator(); 204 + ac.setAttribute("group", GROUP); 205 + ac.setAttribute("id", "artwork"); 206 + 207 + return findExistingOrAdd(ac, signals.configurator.artwork); 208 + } 209 + 193 210 async function input() { 194 211 const { default: InputConfigurator } = await import( 195 212 "~/components/configurator/input/element.js" ··· 269 286 return findExistingOrAdd(s, signals.engine.scope); 270 287 } 271 288 272 - // Processors 289 + // Orchestrators (cont.) 273 290 async function artwork() { 274 - const { default: ArtworkProcessor } = await import( 275 - "~/components/processor/artwork/element.js" 276 - ); 291 + const [{ default: ArtworkOrchestrator }, ac] = await Promise.all([ 292 + import("~/components/orchestrator/artwork/element.js"), 293 + configuratorArtwork(), 294 + ]); 277 295 278 - const a = new ArtworkProcessor(); 296 + const a = new ArtworkOrchestrator(); 279 297 a.setAttribute("group", GROUP); 298 + a.setAttribute("artwork-selector", ac.selector); 280 299 281 - return findExistingOrAdd(a, signals.processor.artwork); 300 + return findExistingOrAdd(a, signals.orchestrator.artwork); 282 301 } 283 302 284 303 async function metadata() { ··· 347 366 const mso = new MediaSessionOrchestrator(); 348 367 mso.setAttribute("group", GROUP); 349 368 mso.setAttribute("audio-engine-selector", a.selector); 350 - mso.setAttribute("artwork-processor-selector", aw.selector); 369 + mso.setAttribute("artwork-selector", aw.selector); 351 370 mso.setAttribute("output-selector", o.selector); 352 371 mso.setAttribute("queue-engine-selector", q.selector); 353 372
+61
src/components/artwork/audio-metadata/element.js
··· 1 + import { DiffuseElement, query } from "~/common/element.js"; 2 + 3 + /** 4 + * @import {ProxiedActions} from "~/common/worker.d.ts" 5 + * @import {InputElement} from "~/components/input/types.d.ts" 6 + * @import {Actions} from "~/components/artwork/types.d.ts" 7 + */ 8 + 9 + //////////////////////////////////////////// 10 + // ELEMENT 11 + //////////////////////////////////////////// 12 + 13 + /** 14 + * @implements {ProxiedActions<Actions>} 15 + */ 16 + class AudioMetadataArtwork extends DiffuseElement { 17 + static NAME = "diffuse/artwork/audio-metadata"; 18 + static WORKER_URL = "components/artwork/audio-metadata/worker.js"; 19 + 20 + constructor() { 21 + super(); 22 + 23 + /** @type {ProxiedActions<Actions>} */ 24 + const p = this.workerProxy(); 25 + 26 + this.get = p.get; 27 + } 28 + 29 + // LIFECYCLE 30 + 31 + /** @override */ 32 + async connectedCallback() { 33 + super.connectedCallback(); 34 + 35 + /** @type {InputElement} */ 36 + this.input = query(this, "input-selector"); 37 + 38 + await customElements.whenDefined(this.input.localName); 39 + } 40 + 41 + // WORKERS 42 + 43 + /** 44 + * @override 45 + */ 46 + dependencies() { 47 + if (!this.input) throw new Error("Input element not defined yet"); 48 + return { input: this.input }; 49 + } 50 + } 51 + 52 + export default AudioMetadataArtwork; 53 + 54 + //////////////////////////////////////////// 55 + // REGISTER 56 + //////////////////////////////////////////// 57 + 58 + export const CLASS = AudioMetadataArtwork; 59 + export const NAME = "da-audio-metadata"; 60 + 61 + customElements.define(NAME, AudioMetadataArtwork);
+59
src/components/artwork/audio-metadata/worker.js
··· 1 + import { musicMetadataTags } from "~/components/processor/metadata/common.js"; 2 + import { ostiary, rpc, workerProxy } from "~/common/worker.js"; 3 + 4 + /** 5 + * @import {Extraction} from "~/components/processor/metadata/types.d.ts" 6 + * @import {ActionsWithTunnel, ProxiedActions} from "~/common/worker.d.ts" 7 + * @import {InputActions} from "~/components/input/types.d.ts" 8 + * @import {Actions} from "~/components/artwork/types.d.ts" 9 + */ 10 + 11 + //////////////////////////////////////////// 12 + // ACTIONS 13 + //////////////////////////////////////////// 14 + 15 + /** 16 + * @type {ActionsWithTunnel<Actions>['get']} 17 + */ 18 + export async function get({ data: track, ports }) { 19 + /** @type {ProxiedActions<InputActions>} */ 20 + const input = workerProxy(() => { 21 + ports.input.start(); 22 + return ports.input; 23 + }); 24 + 25 + const resGet = await input.resolve({ method: "GET", uri: track.uri }); 26 + if (!resGet) return null; 27 + 28 + const resHead = "stream" in resGet 29 + ? undefined 30 + : await input.resolve({ method: "HEAD", uri: track.uri }); 31 + 32 + const meta = await musicMetadataTags({ 33 + includeArtwork: true, 34 + stream: "stream" in resGet ? resGet.stream : undefined, 35 + mimeType: "stream" in resGet ? resGet.mimeType : undefined, 36 + urls: "url" in resGet 37 + ? { 38 + get: resGet.url, 39 + head: resHead && "url" in resHead ? resHead.url : resGet.url, 40 + } 41 + : undefined, 42 + }).catch(/** @param {Error} err */ (err) => { 43 + console.error("music-metadata error", err); 44 + return /** @type {Extraction} */ ({}); 45 + }); 46 + 47 + const pictures = meta.artwork ?? []; 48 + if (!pictures.length) return null; 49 + 50 + return pictures[0].data; 51 + } 52 + 53 + //////////////////////////////////////////// 54 + // ⚡️ 55 + //////////////////////////////////////////// 56 + 57 + ostiary((context) => { 58 + rpc(context, { get }); 59 + });
+38
src/components/artwork/last.fm/element.js
··· 1 + import { DiffuseElement } from "~/common/element.js"; 2 + 3 + /** 4 + * @import {ProxiedActions} from "~/common/worker.d.ts" 5 + * @import {Actions} from "~/components/artwork/types.d.ts" 6 + */ 7 + 8 + //////////////////////////////////////////// 9 + // ELEMENT 10 + //////////////////////////////////////////// 11 + 12 + /** 13 + * @implements {ProxiedActions<Actions>} 14 + */ 15 + class LastFmArtwork extends DiffuseElement { 16 + static NAME = "diffuse/artwork/last.fm"; 17 + static WORKER_URL = "components/artwork/last.fm/worker.js"; 18 + 19 + constructor() { 20 + super(); 21 + 22 + /** @type {ProxiedActions<Actions>} */ 23 + const p = this.workerProxy(); 24 + 25 + this.get = p.get; 26 + } 27 + } 28 + 29 + export default LastFmArtwork; 30 + 31 + //////////////////////////////////////////// 32 + // REGISTER 33 + //////////////////////////////////////////// 34 + 35 + export const CLASS = LastFmArtwork; 36 + export const NAME = "da-lastfm"; 37 + 38 + customElements.define(NAME, LastFmArtwork);
+59
src/components/artwork/last.fm/worker.js
··· 1 + import { ostiary, rpc } from "~/common/worker.js"; 2 + 3 + /** 4 + * @import {Actions} from "~/components/artwork/types.d.ts" 5 + */ 6 + 7 + //////////////////////////////////////////// 8 + // ACTIONS 9 + //////////////////////////////////////////// 10 + 11 + /** 12 + * @type {Actions['get']} 13 + */ 14 + export async function get(track) { 15 + if (!navigator.onLine) return null; 16 + 17 + const query = track.tags?.artist; 18 + if (!query) return null; 19 + 20 + return await fetch( 21 + `https://ws.audioscrobbler.com/2.0/?method=album.search&album=${query}&api_key=4f0fe85b67baef8bb7d008a8754a95e5&format=json`, 22 + ) 23 + .then((r) => r.json()) 24 + .then((r) => findCover(r.results.albummatches.album)) 25 + .catch((err) => { 26 + console.error(err); 27 + return null; 28 + }); 29 + } 30 + 31 + //////////////////////////////////////////// 32 + // ⚡️ 33 + //////////////////////////////////////////// 34 + 35 + ostiary((context) => { 36 + rpc(context, { get }); 37 + }); 38 + 39 + //////////////////////////////////////////// 40 + // 🛠️ 41 + //////////////////////////////////////////// 42 + 43 + /** 44 + * @param {any[]} remainingMatches 45 + * @returns {Promise<Uint8Array | null>} 46 + */ 47 + async function findCover(remainingMatches) { 48 + const album = remainingMatches[0]; 49 + const url = album ? album.image[album.image.length - 1]["#text"] : null; 50 + 51 + return url && url !== "" 52 + ? await fetch(url) 53 + .then((r) => r.blob()) 54 + .then(async (b) => new Uint8Array(await b.arrayBuffer())) 55 + .catch(() => findCover(remainingMatches.slice(1))) 56 + : album 57 + ? findCover(remainingMatches.slice(1)) 58 + : null; 59 + }
+38
src/components/artwork/musicbrainz/element.js
··· 1 + import { DiffuseElement } from "~/common/element.js"; 2 + 3 + /** 4 + * @import {ProxiedActions} from "~/common/worker.d.ts" 5 + * @import {Actions} from "~/components/artwork/types.d.ts" 6 + */ 7 + 8 + //////////////////////////////////////////// 9 + // ELEMENT 10 + //////////////////////////////////////////// 11 + 12 + /** 13 + * @implements {ProxiedActions<Actions>} 14 + */ 15 + class MusicBrainzArtwork extends DiffuseElement { 16 + static NAME = "diffuse/artwork/musicbrainz"; 17 + static WORKER_URL = "components/artwork/musicbrainz/worker.js"; 18 + 19 + constructor() { 20 + super(); 21 + 22 + /** @type {ProxiedActions<Actions>} */ 23 + const p = this.workerProxy(); 24 + 25 + this.get = p.get; 26 + } 27 + } 28 + 29 + export default MusicBrainzArtwork; 30 + 31 + //////////////////////////////////////////// 32 + // REGISTER 33 + //////////////////////////////////////////// 34 + 35 + export const CLASS = MusicBrainzArtwork; 36 + export const NAME = "da-musicbrainz"; 37 + 38 + customElements.define(NAME, MusicBrainzArtwork);
+132
src/components/artwork/musicbrainz/worker.js
··· 1 + import { ostiary, rpc } from "~/common/worker.js"; 2 + 3 + /** 4 + * @import {Track} from "~/definitions/types.d.ts" 5 + * @import {Actions} from "~/components/artwork/types.d.ts" 6 + */ 7 + 8 + //////////////////////////////////////////// 9 + // ACTIONS 10 + //////////////////////////////////////////// 11 + 12 + /** 13 + * @type {Actions['get']} 14 + */ 15 + export async function get(track) { 16 + const artist = track.tags?.artist; 17 + const album = track.tags?.album; 18 + 19 + if (!navigator.onLine) return null; 20 + if (!album && !artist) return null; 21 + 22 + const variousArtists = artist?.toUpperCase() === "VA"; 23 + 24 + return search(track, variousArtists); 25 + } 26 + 27 + //////////////////////////////////////////// 28 + // ⚡️ 29 + //////////////////////////////////////////// 30 + 31 + ostiary((context) => { 32 + rpc(context, { get }); 33 + }); 34 + 35 + //////////////////////////////////////////// 36 + // 🛠️ 37 + //////////////////////////////////////////// 38 + 39 + /** 40 + * @param {string} str 41 + */ 42 + function escapeLucene(str) { 43 + return [].map 44 + .call(str, (char) => { 45 + if ( 46 + char === "+" || 47 + char === "-" || 48 + char === "&" || 49 + char === "|" || 50 + char === "!" || 51 + char === "(" || 52 + char === ")" || 53 + char === "{" || 54 + char === "}" || 55 + char === "[" || 56 + char === "]" || 57 + char === "^" || 58 + char === '"' || 59 + char === "~" || 60 + char === "*" || 61 + char === "?" || 62 + char === ":" || 63 + char === "\\" || 64 + char === "/" 65 + ) { 66 + return "\\" + char; 67 + } else return char; 68 + }) 69 + .join(""); 70 + } 71 + 72 + /** 73 + * @param {Track} track 74 + * @param {boolean} variousArtists 75 + * @returns {Promise<Uint8Array | null>} 76 + */ 77 + async function search(track, variousArtists) { 78 + const artist = track.tags?.artist; 79 + const album = track.tags?.album; 80 + 81 + const query = `release:"${escapeLucene(album || "")}"` + 82 + (variousArtists 83 + ? `` 84 + : ` AND artistname:"${escapeLucene(artist || "")}"`); 85 + const encodedQuery = encodeURIComponent(query); 86 + 87 + return await fetch( 88 + `https://musicbrainz.org/ws/2/release/?query=${encodedQuery}&fmt=json`, 89 + ) 90 + .then((r) => r.json()) 91 + .then((r) => { 92 + if (r.releases.length === 0 && !variousArtists) { 93 + return search(track, true); 94 + } else { 95 + return findCover(r.releases, track, variousArtists); 96 + } 97 + }) 98 + .catch(() => null); 99 + } 100 + 101 + /** 102 + * @param {any[]} remainingReleases 103 + * @param {Track} track 104 + * @param {boolean} variousArtists 105 + * @returns {Promise<Uint8Array | null>} 106 + */ 107 + async function findCover(remainingReleases, track, variousArtists) { 108 + const release = remainingReleases[0]; 109 + if (!release) return null; 110 + 111 + const credit = release?.["artist-credit"]?.[0]?.name; 112 + if ( 113 + variousArtists && credit !== "Various Artists" && 114 + credit !== track.tags?.artist 115 + ) return null; 116 + 117 + return await fetch( 118 + `https://coverartarchive.org/release/${release.id}/front-1200`, 119 + ) 120 + .then((r) => r.blob()) 121 + .then(async (b) => { 122 + if (b.type.startsWith("image/")) { 123 + return new Uint8Array(await b.arrayBuffer()); 124 + } else { 125 + return findCover(remainingReleases.slice(1), track, variousArtists); 126 + } 127 + }) 128 + .catch((err) => { 129 + console.error(err); 130 + return findCover(remainingReleases.slice(1), track, variousArtists); 131 + }); 132 + }
+9
src/components/artwork/types.d.ts
··· 1 + import type { Track } from "~/definitions/types.d.ts"; 2 + import type { DiffuseElement } from "~/common/element.js"; 3 + import type { ProxiedActions } from "~/common/worker.d.ts"; 4 + 5 + export type Actions = { 6 + get(track: Track): Promise<Uint8Array | null>; 7 + }; 8 + 9 + export type ArtworkElement = DiffuseElement & ProxiedActions<Actions>;
+53
src/components/configurator/artwork/element.js
··· 1 + import { DiffuseElement } from "~/common/element.js"; 2 + 3 + /** 4 + * @import {ProxiedActions} from "~/common/worker.d.ts" 5 + * @import {ArtworkElement} from "~/components/artwork/types.d.ts" 6 + * @import {Actions} from "./types.d.ts" 7 + */ 8 + 9 + //////////////////////////////////////////// 10 + // ELEMENT 11 + //////////////////////////////////////////// 12 + 13 + /** 14 + * @implements {ProxiedActions<Actions>} 15 + */ 16 + class ArtworkConfigurator extends DiffuseElement { 17 + static NAME = "diffuse/configurator/artwork"; 18 + static WORKER_URL = "components/configurator/artwork/worker.js"; 19 + 20 + constructor() { 21 + super(); 22 + 23 + /** @type {ProxiedActions<Actions>} */ 24 + const proxy = this.workerProxy(); 25 + 26 + this.get = proxy.get; 27 + } 28 + 29 + // WORKERS 30 + 31 + /** 32 + * @override 33 + */ 34 + dependencies() { 35 + return Object.fromEntries( 36 + Array.from(this.children).map((element) => { 37 + const artwork = /** @type {ArtworkElement} */ (element); 38 + return [artwork.localName, artwork]; 39 + }), 40 + ); 41 + } 42 + } 43 + 44 + export default ArtworkConfigurator; 45 + 46 + //////////////////////////////////////////// 47 + // REGISTER 48 + //////////////////////////////////////////// 49 + 50 + export const CLASS = ArtworkConfigurator; 51 + export const NAME = "dc-artwork"; 52 + 53 + customElements.define(NAME, ArtworkConfigurator);
+3
src/components/configurator/artwork/types.d.ts
··· 1 + import type { Actions } from "~/components/artwork/types.d.ts"; 2 + 3 + export type { Actions };
+39
src/components/configurator/artwork/worker.js
··· 1 + import { ostiary, rpc, workerProxy } from "~/common/worker.js"; 2 + 3 + /** 4 + * @import {ActionsWithTunnel, ProxiedActions} from "~/common/worker.d.ts" 5 + * @import {Actions} from "~/components/artwork/types.d.ts" 6 + * @import {Actions as ConfiguratorActions} from "./types.d.ts" 7 + */ 8 + 9 + //////////////////////////////////////////// 10 + // ACTIONS 11 + //////////////////////////////////////////// 12 + 13 + /** 14 + * @type {ActionsWithTunnel<ConfiguratorActions>['get']} 15 + */ 16 + export async function get({ data, ports }) { 17 + const track = data; 18 + 19 + for (const port of Object.values(ports)) { 20 + /** @type {ProxiedActions<Actions>} */ 21 + const artwork = workerProxy(() => { 22 + port.start(); 23 + return port; 24 + }); 25 + 26 + const bytes = await artwork.get(track); 27 + if (bytes !== null) return bytes; 28 + } 29 + 30 + return null; 31 + } 32 + 33 + //////////////////////////////////////////// 34 + // ⚡️ 35 + //////////////////////////////////////////// 36 + 37 + ostiary((context) => { 38 + rpc(context, { get }); 39 + });
+6
src/components/input/https/worker.js
··· 105 105 * @type {Actions['resolve']} 106 106 */ 107 107 export async function resolve({ method, uri }) { 108 + if (uri.startsWith("blob:")) { 109 + const expiresInSeconds = 60 * 60 * 24 * 365; 110 + const expiresAtSeconds = Math.round(Date.now() / 1000) + expiresInSeconds; 111 + return { url: uri, expiresAt: expiresAtSeconds }; 112 + } 113 + 108 114 const parsed = parseURI(uri); 109 115 if (!parsed) return undefined; 110 116
+62
src/components/orchestrator/artwork/element.js
··· 1 + import { DiffuseElement, query } from "~/common/element.js"; 2 + 3 + /** 4 + * @import {ProxiedActions} from "~/common/worker.d.ts" 5 + * @import {Actions} from "~/components/artwork/types.d.ts" 6 + */ 7 + 8 + //////////////////////////////////////////// 9 + // ELEMENT 10 + //////////////////////////////////////////// 11 + 12 + class ArtworkOrchestrator extends DiffuseElement { 13 + static NAME = "diffuse/orchestrator/artwork"; 14 + static WORKER_URL = "components/orchestrator/artwork/worker.js"; 15 + 16 + constructor() { 17 + super(); 18 + 19 + /** @type {ProxiedActions<Actions>} */ 20 + const p = this.workerProxy(); 21 + 22 + this.get = p.get; 23 + } 24 + 25 + // LIFECYCLE 26 + 27 + /** @override */ 28 + async connectedCallback() { 29 + super.connectedCallback(); 30 + 31 + /** @type {import("~/components/configurator/artwork/element.js").CLASS} */ 32 + this.artworkConfigurator = query(this, "artwork-selector"); 33 + 34 + await customElements.whenDefined(this.artworkConfigurator.localName); 35 + } 36 + 37 + // WORKERS 38 + 39 + /** 40 + * @override 41 + */ 42 + dependencies() { 43 + if (!this.artworkConfigurator) { 44 + throw new Error("Artwork configurator element not defined yet"); 45 + } 46 + 47 + return { 48 + artwork: this.artworkConfigurator, 49 + }; 50 + } 51 + } 52 + 53 + export default ArtworkOrchestrator; 54 + 55 + //////////////////////////////////////////// 56 + // REGISTER 57 + //////////////////////////////////////////// 58 + 59 + export const CLASS = ArtworkOrchestrator; 60 + export const NAME = "do-artwork"; 61 + 62 + customElements.define(NAME, ArtworkOrchestrator);
+4
src/components/orchestrator/artwork/types.d.ts
··· 1 + export type Artwork = { 2 + bytes: Uint8Array; 3 + mime: string; 4 + };
+105
src/components/orchestrator/artwork/worker.js
··· 1 + import * as IDB from "idb-keyval"; 2 + 3 + import { create as createCid } from "~/common/cid.js"; 4 + import { ostiary, rpc, workerProxy } from "~/common/worker.js"; 5 + 6 + /** 7 + * @import {Track} from "~/definitions/types.d.ts" 8 + * @import {ActionsWithTunnel, ProxiedActions} from "~/common/worker.d.ts" 9 + * @import {Actions} from "~/components/artwork/types.d.ts" 10 + * @import {Artwork} from "./types.d.ts" 11 + */ 12 + 13 + // multicodec raw bytes 14 + const RAW = 0x55; 15 + 16 + const IDB_PREFIX = "~/components/orchestrator/artwork"; 17 + const IDB_ARTWORK_PREFIX = `${IDB_PREFIX}/cache`; 18 + 19 + //////////////////////////////////////////// 20 + // ACTIONS 21 + //////////////////////////////////////////// 22 + 23 + /** 24 + * @type {ActionsWithTunnel<Actions>['get']} 25 + */ 26 + export async function get({ data: track, ports }) { 27 + return processRequest(track, ports); 28 + } 29 + 30 + //////////////////////////////////////////// 31 + // ⚡️ 32 + //////////////////////////////////////////// 33 + 34 + ostiary((context) => { 35 + rpc(context, { get }); 36 + }); 37 + 38 + //////////////////////////////////////////// 39 + // 🛠️ 40 + //////////////////////////////////////////// 41 + 42 + /** 43 + * @param {Track} track 44 + * @param {Record<string, MessagePort>} ports 45 + * @returns {Promise<Uint8Array | null>} 46 + */ 47 + async function processRequest(track, ports) { 48 + // Check if already processed 49 + 50 + /** @type {string[] | undefined} */ 51 + const cachedCids = await IDB.get( 52 + `${IDB_ARTWORK_PREFIX}/track/${track.id}`, 53 + ); 54 + 55 + if (cachedCids?.length) { 56 + /** @type {Artwork[]} */ 57 + const art = await Promise.all( 58 + cachedCids.map((cid) => IDB.get(`${IDB_ARTWORK_PREFIX}/image/${cid}`)), 59 + ); 60 + 61 + const found = art.filter(Boolean); 62 + if (found.length) return found[0].bytes; 63 + } 64 + 65 + // 🚀 66 + 67 + /** @type {ProxiedActions<Actions>} */ 68 + const configurator = workerProxy(() => { 69 + ports.artwork.start(); 70 + return ports.artwork; 71 + }); 72 + 73 + const bytes = await configurator.get(track); 74 + 75 + if (bytes === null) { 76 + await IDB.set(`${IDB_ARTWORK_PREFIX}/track/${track.id}`, []); 77 + return null; 78 + } 79 + 80 + const mime = detectMime(bytes); 81 + 82 + /** @type {Artwork} */ 83 + const art = { bytes, mime }; 84 + 85 + // Save artwork to IDB — store by content CID, map track to that CID 86 + const cid = await createCid(RAW, bytes); 87 + const key = `${IDB_ARTWORK_PREFIX}/image/${cid}`; 88 + if (!await IDB.get(key)) await IDB.set(key, art); 89 + 90 + await IDB.set(`${IDB_ARTWORK_PREFIX}/track/${track.id}`, [cid]); 91 + 92 + return bytes; 93 + } 94 + 95 + /** 96 + * @param {Uint8Array} bytes 97 + * @returns {string} 98 + */ 99 + function detectMime(bytes) { 100 + if (bytes[0] === 0xFF && bytes[1] === 0xD8) return "image/jpeg"; 101 + if (bytes[0] === 0x89 && bytes[1] === 0x50) return "image/png"; 102 + if (bytes[0] === 0x47 && bytes[1] === 0x49) return "image/gif"; 103 + if (bytes[0] === 0x52 && bytes[1] === 0x49) return "image/webp"; 104 + return "image/jpeg"; 105 + }
+26 -14
src/components/orchestrator/media-session/element.js
··· 6 6 7 7 /** 8 8 * @import {OutputElement} from "~/components/output/types.d.ts" 9 - * @import {Artwork} from "~/components/processor/artwork/types.d.ts" 10 - * @import ArtworkProcessor from "~/components/processor/artwork/element.js" 9 + * @import ArtworkOrchestrator from "~/components/orchestrator/artwork/element.js" 11 10 */ 12 11 13 12 //////////////////////////////////////////// ··· 47 46 /** @type {OutputElement | null} */ 48 47 this.output = queryOptional(this, "output-selector"); 49 48 50 - /** @type {ArtworkProcessor | null} */ 51 - this.artwork = queryOptional(this, "artwork-processor-selector"); 49 + /** @type {ArtworkOrchestrator | null} */ 50 + this.artwork = queryOptional(this, "artwork-selector"); 52 51 53 52 // Wait until defined 54 53 await customElements.whenDefined(this.audio.localName); ··· 92 91 93 92 // Optionally fetch and attach artwork 94 93 if (this.artwork) { 95 - const artworkProcessor = this.artwork; 94 + const artworkOrchestrator = this.artwork; 96 95 97 - /** @type {Artwork[]} */ 98 - let artworkItems; 96 + /** @type {Uint8Array | null} */ 97 + let bytes = null; 99 98 100 99 try { 101 - artworkItems = await artworkProcessor.artwork({ 102 - cacheId: track.id, 103 - tags, 104 - }); 100 + bytes = await artworkOrchestrator.get(track); 105 101 } catch { 106 - artworkItems = []; 102 + bytes = null; 107 103 } 108 104 109 - if (artworkItems?.length && navigator.mediaSession.metadata) { 110 - const { bytes, mime } = artworkItems[0]; 105 + if (bytes && navigator.mediaSession.metadata) { 106 + const mime = detectMime(bytes); 111 107 const blob = new Blob([/** @type {ArrayBuffer} */ (bytes.buffer)], { 112 108 type: mime, 113 109 }); ··· 202 198 } 203 199 204 200 export default MediaSessionOrchestrator; 201 + 202 + //////////////////////////////////////////// 203 + // 🛠️ 204 + //////////////////////////////////////////// 205 + 206 + /** 207 + * @param {Uint8Array} bytes 208 + * @returns {string} 209 + */ 210 + function detectMime(bytes) { 211 + if (bytes[0] === 0xFF && bytes[1] === 0xD8) return "image/jpeg"; 212 + if (bytes[0] === 0x89 && bytes[1] === 0x50) return "image/png"; 213 + if (bytes[0] === 0x47 && bytes[1] === 0x49) return "image/gif"; 214 + if (bytes[0] === 0x52 && bytes[1] === 0x49) return "image/webp"; 215 + return "image/jpeg"; 216 + } 205 217 206 218 //////////////////////////////////////////// 207 219 // REGISTER
-2
src/components/processor/artwork/constants.js
··· 1 - export const IDB_PREFIX = "~/components/processor/artwork"; 2 - export const IDB_ARTWORK_PREFIX = `${IDB_PREFIX}/cache`;
-39
src/components/processor/artwork/element.js
··· 1 - import { DiffuseElement } from "~/common/element.js"; 2 - 3 - /** 4 - * @import {ProxiedActions} from "~/common/worker.d.ts" 5 - * @import {Actions} from "./types.d.ts" 6 - */ 7 - 8 - //////////////////////////////////////////// 9 - // ELEMENT 10 - //////////////////////////////////////////// 11 - 12 - /** 13 - * @implements {ProxiedActions<Actions>} 14 - */ 15 - class ArtworkProcessor extends DiffuseElement { 16 - static NAME = "diffuse/processor/artwork"; 17 - static WORKER_URL = "components/processor/artwork/worker.js"; 18 - 19 - constructor() { 20 - super(); 21 - 22 - /** @type {ProxiedActions<Actions>} */ 23 - const p = this.workerProxy(); 24 - 25 - this.artwork = p.artwork; 26 - this.supply = p.supply; 27 - } 28 - } 29 - 30 - export default ArtworkProcessor; 31 - 32 - //////////////////////////////////////////// 33 - // REGISTER 34 - //////////////////////////////////////////// 35 - 36 - export const CLASS = ArtworkProcessor; 37 - export const NAME = "dp-artwork"; 38 - 39 - customElements.define(NAME, ArtworkProcessor);
-26
src/components/processor/artwork/types.d.ts
··· 1 - import type { TrackTags } from "~/definitions/types.d.ts"; 2 - 3 - export type Actions = { 4 - artwork(request: ArtworkRequest): Promise<Artwork[]>; 5 - supply(items: ArtworkRequest[]): void; 6 - }; 7 - 8 - export type Artwork = { 9 - bytes: Uint8Array; 10 - mime: string; 11 - }; 12 - 13 - export type ArtworkRequest<Tags = TrackTags> = { 14 - cacheId: string; 15 - mimeType?: string; 16 - stream?: ReadableStream; 17 - tags?: Tags; 18 - urls?: Urls; 19 - variousArtists?: boolean; 20 - }; 21 - 22 - // export type State = { 23 - // artwork: Record<string, Artwork[]>; 24 - // }; 25 - 26 - export type Urls = { get: string; head: string };
-300
src/components/processor/artwork/worker.js
··· 1 - import * as IDB from "idb-keyval"; 2 - 3 - import { IDB_ARTWORK_PREFIX } from "./constants.js"; 4 - import { create as createCid } from "~/common/cid.js"; 5 - import { musicMetadataTags } from "../metadata/common.js"; 6 - import { ostiary, rpc } from "~/common/worker.js"; 7 - 8 - // multicodec raw bytes 9 - const RAW = 0x55; 10 - 11 - /** 12 - * @import {IPicture} from "music-metadata" 13 - * @import {Actions, Artwork, ArtworkRequest} from "./types.d.ts" 14 - * @import {Extraction} from "../metadata/types.d.ts" 15 - */ 16 - 17 - /** 18 - * @type {ArtworkRequest[]} 19 - */ 20 - let queue = []; 21 - 22 - //////////////////////////////////////////// 23 - // ACTIONS 24 - //////////////////////////////////////////// 25 - 26 - /** 27 - * @type {Actions['artwork']} 28 - */ 29 - export async function artwork(request) { 30 - const art = await processRequest(request); 31 - return art; 32 - } 33 - 34 - /** 35 - * @type {Actions['supply']} 36 - */ 37 - export function supply(items) { 38 - const exe = !queue[0]; 39 - queue = [...queue, ...items]; 40 - if (exe) shiftQueue(); 41 - } 42 - 43 - //////////////////////////////////////////// 44 - // ⚡️ 45 - //////////////////////////////////////////// 46 - 47 - ostiary((context) => { 48 - rpc(context, { 49 - artwork, 50 - supply, 51 - }); 52 - }); 53 - 54 - //////////////////////////////////////////// 55 - // 🛠️ 56 - //////////////////////////////////////////// 57 - 58 - /** 59 - * @param {string} str 60 - */ 61 - function escapeLucene(str) { 62 - return [].map 63 - .call(str, (char) => { 64 - if ( 65 - char === "+" || 66 - char === "-" || 67 - char === "&" || 68 - char === "|" || 69 - char === "!" || 70 - char === "(" || 71 - char === ")" || 72 - char === "{" || 73 - char === "}" || 74 - char === "[" || 75 - char === "]" || 76 - char === "^" || 77 - char === '"' || 78 - char === "~" || 79 - char === "*" || 80 - char === "?" || 81 - char === ":" || 82 - char === "\\" || 83 - char === "/" 84 - ) { 85 - return "\\" + char; 86 - } else return char; 87 - }) 88 - .join(""); 89 - } 90 - 91 - /** 92 - * @param {ArtworkRequest} req 93 - * @returns {Promise<Artwork[]>} 94 - */ 95 - async function lastFm(req) { 96 - if (!navigator.onLine) return []; 97 - 98 - const query = req.tags?.artist; 99 - 100 - return await fetch( 101 - `https://ws.audioscrobbler.com/2.0/?method=album.search&album=${query}&api_key=4f0fe85b67baef8bb7d008a8754a95e5&format=json`, 102 - ) 103 - .then((r) => r.json()) 104 - .then((r) => lastFmCover(r.results.albummatches.album)) 105 - .catch((err) => { 106 - console.error(err); 107 - return []; 108 - }); 109 - } 110 - 111 - /** 112 - * @param {any[]} remainingMatches 113 - * @returns {Promise<Artwork[]>} 114 - */ 115 - async function lastFmCover(remainingMatches) { 116 - const album = remainingMatches[0]; 117 - const url = album ? album.image[album.image.length - 1]["#text"] : null; 118 - 119 - return url && url !== "" 120 - ? await fetch(url) 121 - .then((r) => r.blob()) 122 - .then(async (b) => [ 123 - { 124 - bytes: await b.arrayBuffer().then((buf) => new Uint8Array(buf)), 125 - mime: b.type, 126 - }, 127 - ]) 128 - .catch((err) => { 129 - // console.error(err); 130 - return lastFmCover(remainingMatches.slice(1)); 131 - }) 132 - : album 133 - ? lastFmCover(remainingMatches.slice(1)) 134 - : []; 135 - } 136 - 137 - /** 138 - * @param {ArtworkRequest} req 139 - * @returns {Promise<Artwork[]>} 140 - */ 141 - async function musicBrainz(req) { 142 - const artist = req.tags?.artist; 143 - const album = req.tags?.album; 144 - 145 - if (!navigator.onLine) return []; 146 - if (!album && !artist) return []; 147 - 148 - const query = `release:"${escapeLucene(album || "")}"` + 149 - (req.variousArtists 150 - ? `` 151 - : ` AND artistname:"${escapeLucene(artist || "")}"`); 152 - const encodedQuery = encodeURIComponent(query); 153 - 154 - return await fetch( 155 - `https://musicbrainz.org/ws/2/release/?query=${encodedQuery}&fmt=json`, 156 - ) 157 - .then((r) => r.json()) 158 - .then((r) => { 159 - if (r.releases.length === 0 && !req.variousArtists) { 160 - return musicBrainz({ ...req, variousArtists: true }); 161 - } else { 162 - return musicBrainzCover(r.releases, req); 163 - } 164 - }) 165 - .catch((err) => { 166 - // console.error(err); 167 - return []; 168 - }); 169 - } 170 - 171 - /** 172 - * @param {any[]} remainingReleases 173 - * @param {ArtworkRequest} req 174 - * @returns {Promise<Artwork[]>} 175 - */ 176 - async function musicBrainzCover(remainingReleases, req) { 177 - const release = remainingReleases[0]; 178 - if (!release) return []; 179 - 180 - const credit = release?.["artist-credit"]?.[0]?.name; 181 - if ( 182 - req.variousArtists && credit !== "Various Artists" && 183 - credit !== req.tags?.artist 184 - ) return []; 185 - 186 - return await fetch( 187 - `https://coverartarchive.org/release/${release.id}/front-1200`, 188 - ) 189 - .then((r) => r.blob()) 190 - .then(async (b) => { 191 - if (b.type.startsWith("image/")) { 192 - return [{ 193 - bytes: await b.arrayBuffer().then((buf) => new Uint8Array(buf)), 194 - mime: b.type, 195 - }]; 196 - } else { 197 - return musicBrainzCover(remainingReleases.slice(1), req); 198 - } 199 - }) 200 - .catch((err) => { 201 - console.error(err); 202 - return musicBrainzCover(remainingReleases.slice(1), req); 203 - }); 204 - } 205 - 206 - /** 207 - * @param {ArtworkRequest} req 208 - * @returns {Promise<Artwork[]>} 209 - */ 210 - async function processRequest(req) { 211 - // Check if already processed 212 - 213 - /** @type {string[] | undefined} */ 214 - const cachedCids = await IDB.get( 215 - `${IDB_ARTWORK_PREFIX}/track/${req.cacheId}`, 216 - ); 217 - 218 - if (cachedCids?.length) { 219 - /** @type {Artwork[]} */ 220 - const art = await Promise.all( 221 - cachedCids.map((cid) => IDB.get(`${IDB_ARTWORK_PREFIX}/image/${cid}`)), 222 - ); 223 - 224 - const found = art.filter(Boolean); 225 - if (found.length) return found; 226 - } 227 - 228 - // Request override 229 - if (req.tags?.artist?.toUpperCase() === "VA") { 230 - req.variousArtists = true; 231 - } 232 - 233 - // 🚀 234 - 235 - /** @type {Artwork[]} */ 236 - let art = []; 237 - 238 - // Get metadata + possible artwork from file metadata 239 - const meta = await musicMetadataTags({ ...req, includeArtwork: true }).catch( 240 - /** @param {Error} err */ (err) => { 241 - console.error("music-metadata error", err); 242 - /** @type {Extraction} */ 243 - const extraction = {}; 244 - return extraction; 245 - }, 246 - ); 247 - 248 - if (!req.tags && meta.tags) req.tags = meta.tags; 249 - 250 - // Add artwork from metadata 251 - const fromMeta = meta.artwork?.map( 252 - /** 253 - * @param {IPicture} a 254 - */ 255 - (a) => { 256 - return { bytes: a.data, mime: a.format }; 257 - }, 258 - ) || []; 259 - 260 - art.push(...fromMeta); 261 - 262 - // Stop here if insufficient metadata is present 263 - if (!req.tags?.artist || !req.tags?.album) return art; 264 - 265 - // If no artwork, try finding it on other sources 266 - if (art.length === 0) { 267 - const fromMusicBrainz = await musicBrainz(req); 268 - art.push(...fromMusicBrainz); 269 - } 270 - 271 - if (art.length === 0) { 272 - const fromLastFm = await lastFm(req); 273 - art.push(...fromLastFm); 274 - } 275 - 276 - // Save artwork to IDB — store each image by its content CID, 277 - // then map the track to those CIDs 278 - const cids = await Promise.all( 279 - art.map(async (a) => { 280 - const cid = await createCid(RAW, a.bytes); 281 - const key = `${IDB_ARTWORK_PREFIX}/image/${cid}`; 282 - if (await IDB.get(key)) return cid; 283 - await IDB.set(key, a); 284 - return cid; 285 - }), 286 - ); 287 - 288 - await IDB.set(`${IDB_ARTWORK_PREFIX}/track/${req.cacheId}`, cids); 289 - 290 - // Fin 291 - return art; 292 - } 293 - 294 - async function shiftQueue() { 295 - const next = queue.shift(); 296 - if (!next) return; 297 - 298 - await processRequest(next); 299 - await shiftQueue(); 300 - }
+26 -3
src/elements.vto
··· 14 14 15 15 # ELEMENTS 16 16 17 + artwork: 18 + - url: "components/artwork/audio-metadata/element.js" 19 + title: "Audio Metadata" 20 + desc: "Extracts embedded artwork from audio files using the music-metadata library." 21 + - url: "components/artwork/last.fm/element.js" 22 + title: "Last.fm" 23 + desc: "Fetches cover art from the Last.fm API using track artist and album tags." 24 + - url: "components/artwork/musicbrainz/element.js" 25 + title: "MusicBrainz" 26 + desc: "Fetches cover art from MusicBrainz and the Cover Art Archive using track artist and album tags." 27 + 17 28 configurators: 29 + - url: "components/configurator/artwork/element.js" 30 + title: "Artwork" 31 + desc: "Takes artwork components as children and tries each in sequence, returning the first non-null result." 18 32 - url: "components/configurator/input/element.js" 19 33 title: "Input" 20 34 desc: "Allows for multiple inputs to be used at once." ··· 106 120 - url: "components/orchestrator/sources/element.js" 107 121 title: "Sources" 108 122 desc: "Monitor tracks from the given output to form a list of sources based on the input's sources return value." 123 + - url: "components/orchestrator/artwork/element.js" 124 + title: "Artwork" 125 + desc: "Fetches cover art for a given set of tracks, stored locally in indexedDB. Uses the artwork configurator to try each configured source in sequence." 109 126 - url: "components/orchestrator/scoped-tracks/element.js" 110 127 title: "Scoped Tracks" 111 128 desc: "Supplies the tracks from the given output to the given search processor whenever the tracks collection changes. Additionally it can perform a search and other ways to reduce the scope of tracks based on the given scope engine. Provides a `tracks` signal similar to `output.tracks.collection`" ··· 124 141 Store your user data on the storage associated with your ATProtocol identity. Data is lexicon shaped by default so this element takes in that data directly without any transformations. 125 142 126 143 processors: 127 - - url: "components/processor/artwork/element.js" 128 - title: "Artwork" 129 - desc: "Fetches cover art for a given set of tracks, stored locally in indexedDB. Checks the audio metadata first, then MusicBrainz and uses Last.fm as the fallback." 130 144 - url: "components/processor/metadata/element.js" 131 145 title: "Metadata" 132 146 desc: "Fetch audio metadata for a given set of tracks, adding to the `Track` object." ··· 219 233 Diffuse was built using these web components, consume these using the <a href="../build/">build tool</a>, the Javascript <a href="https://jsr.io/@toko/diffuse">package</a>, or the linked Javascript files down below. 220 234 </p> 221 235 <ul class="table-of-contents"> 236 + <li><a href="elements/#artwork">Artwork</a></li> 222 237 <li><a href="elements/#configurators">Configurators</a></li> 223 238 <li><a href="elements/#engines">Engines</a></li> 224 239 <li><a href="elements/#input">Input</a></li> ··· 237 252 <!-- ELEMENTS --> 238 253 <section> 239 254 <div class="columns"> 255 + {{ await comp.element({ 256 + title: "Artwork", 257 + items: artwork, 258 + content: ` 259 + Artwork sources for tracks. Each implements a <code>get(track)</code> action and returns artwork bytes or null. Use an artwork configurator to combine multiple sources. 260 + ` 261 + }) }} 262 + 240 263 {{ await comp.element({ 241 264 title: "Configurators", 242 265 items: configurators,
+1
src/facets/data/artwork-bundle/index.html
··· 1 + <script type="module" src="facets/data/artwork-bundle/index.inline.js"></script>
+59
src/facets/data/artwork-bundle/index.inline.js
··· 1 + import foundation from "~/common/foundation.js"; 2 + import { effect } from "~/common/signal.js"; 3 + 4 + import { NAME as AUDIO_METADATA_NAME } from "~/components/artwork/audio-metadata/element.js"; 5 + import { NAME as LAST_FM_NAME } from "~/components/artwork/last.fm/element.js"; 6 + import { NAME as MUSICBRAINZ_NAME } from "~/components/artwork/musicbrainz/element.js"; 7 + 8 + /** 9 + * @import ArtworkConfigurator from "~/components/configurator/artwork/element.js" 10 + */ 11 + 12 + /** 13 + * Setup DOM elements when needed. 14 + */ 15 + effect(() => { 16 + const artwork = foundation.signals.configurator.artwork(); 17 + const input = foundation.signals.configurator.input(); 18 + if (!artwork || !input) return; 19 + 20 + audioMetadata(artwork, input); 21 + lastFm(artwork); 22 + musicBrainz(artwork); 23 + }); 24 + 25 + //////////////////////////////////////////// 26 + // AUDIO METADATA 27 + //////////////////////////////////////////// 28 + 29 + /** 30 + * @param {ArtworkConfigurator} artwork 31 + * @param {import("~/components/configurator/input/element.js").default} input 32 + */ 33 + export function audioMetadata(artwork, input) { 34 + const el = document.createElement(AUDIO_METADATA_NAME); 35 + el.setAttribute("input-selector", input.selector); 36 + artwork.append(el); 37 + } 38 + 39 + //////////////////////////////////////////// 40 + // LAST.FM 41 + //////////////////////////////////////////// 42 + 43 + /** 44 + * @param {ArtworkConfigurator} artwork 45 + */ 46 + export function lastFm(artwork) { 47 + artwork.append(document.createElement(LAST_FM_NAME)); 48 + } 49 + 50 + //////////////////////////////////////////// 51 + // MUSICBRAINZ 52 + //////////////////////////////////////////// 53 + 54 + /** 55 + * @param {ArtworkConfigurator} artwork 56 + */ 57 + export function musicBrainz(artwork) { 58 + artwork.append(document.createElement(MUSICBRAINZ_NAME)); 59 + }
+2 -2
src/site.webmanifest
··· 3 3 "short_name": "Diffuse", 4 4 "icons": [ 5 5 { 6 - "src": "favicons/android-chrome-192x192.png", 6 + "src": "android-chrome-192x192.png", 7 7 "sizes": "192x192", 8 8 "type": "image/png" 9 9 }, 10 10 { 11 - "src": "favicons/android-chrome-512x512.png", 11 + "src": "android-chrome-512x512.png", 12 12 "sizes": "512x512", 13 13 "type": "image/png" 14 14 }
+42 -54
src/themes/blur/artwork-controller/element.js
··· 18 18 * 19 19 * @import {InputElement} from "~/components/input/types.d.ts" 20 20 * @import {OutputElement} from "~/components/output/types.d.ts" 21 - * @import {Artwork} from "~/components/processor/artwork/types.d.ts" 22 21 * @import AudioEngine from "~/components/engine/audio/element.js" 23 22 * @import QueueEngine from "~/components/engine/queue/element.js" 24 - * @import ArtworkProcessor from "~/components/processor/artwork/element.js" 23 + * @import ArtworkOrchestrator from "~/components/orchestrator/artwork/element.js" 25 24 * @import FavouritesOrchestrator from "~/components/orchestrator/favourites/element.js" 26 25 */ 27 26 ··· 39 38 // SIGNALS 40 39 41 40 #artwork = signal( 42 - /** @type {{ current: (Artwork & { hash: string; index: number; loaded: boolean; url: string }) | null; previous: (Artwork & { hash: string; index: number; loaded: boolean; url: string }) | null }} */ ({ 41 + /** @type {{ current: ({ bytes: Uint8Array; mime: string; hash: string; index: number; loaded: boolean; url: string }) | null; previous: ({ bytes: Uint8Array; mime: string; hash: string; index: number; loaded: boolean; url: string }) | null }} */ ({ 43 42 current: null, 44 43 previous: null, 45 44 }), ··· 53 52 54 53 // SIGNALS - DEPENDENCIES 55 54 56 - $artwork = signal(/** @type {ArtworkProcessor | undefined} */ (undefined)); 55 + $artwork = signal(/** @type {ArtworkOrchestrator | undefined} */ (undefined)); 57 56 $audio = signal(/** @type {AudioEngine | undefined} */ (undefined)); 58 57 $favourites = signal( 59 58 /** @type {FavouritesOrchestrator | undefined} */ (undefined), ··· 89 88 connectedCallback() { 90 89 super.connectedCallback(); 91 90 92 - /** @type {ArtworkProcessor} */ 93 - const artwork = query(this, "artwork-processor-selector"); 91 + /** @type {ArtworkOrchestrator} */ 92 + const artwork = query(this, "artwork-selector"); 94 93 95 94 /** @type {AudioEngine} */ 96 95 const audio = query(this, "audio-engine-selector"); ··· 176 175 if (currArtwork.current) { 177 176 this.#artwork.value = { current: null, previous: currArtwork.current }; 178 177 } 178 + 179 179 return; 180 180 } 181 181 182 - const cacheId = track.id; 183 - 184 - const resGet = await this.$input.value?.resolve({ 185 - method: "GET", 186 - uri: track.uri, 187 - }); 188 - 189 - const resHead = await this.$input.value?.resolve({ 190 - method: "HEAD", 191 - uri: track.uri, 192 - }); 193 - 194 - if (!resGet) return; 195 - 196 - const request = "stream" in resGet 197 - ? { 198 - cacheId, 199 - stream: resGet.stream, 200 - tags: track.tags, 201 - } 202 - : { 203 - cacheId, 204 - tags: track.tags, 205 - urls: { 206 - get: resGet.url, 207 - head: resHead && "url" in resHead ? resHead.url : resGet.url, 208 - }, 209 - }; 210 - 211 182 if (this.$queue.value?.now()?.id !== track?.id) { 212 183 return; 213 184 } 214 185 215 - const allArt = await this.$artwork.value?.artwork(request) ?? []; 186 + const bytes = await this.$artwork.value?.get(track) ?? null; 216 187 217 188 // Check if queue item has changed while fetching the artwork 218 189 const currTrack = this.currentTrack(); 219 - const currCacheId = currTrack ? currTrack.id : undefined; 220 190 221 - if (cacheId === currCacheId) { 222 - const art = allArt[0]; 223 - 191 + if (track.id === currTrack?.id) { 224 192 this.#artwork.set({ 225 193 previous: currArtwork.current 226 194 ? { ...currArtwork.current, loaded: false } 227 195 : null, 228 - current: art 229 - ? { 230 - ...art, 231 - hash: xxh32r(art.bytes).toString(), 232 - index: (currArtwork.current?.index ?? 0) + 1, 233 - loaded: false, 234 - url: URL.createObjectURL( 235 - new Blob( 236 - [/** @type {ArrayBuffer} */ (art.bytes.buffer)], 237 - { type: art.mime }, 196 + current: bytes 197 + ? (() => { 198 + const mime = detectMime(bytes); 199 + return { 200 + bytes, 201 + mime, 202 + hash: xxh32r(bytes).toString(), 203 + index: (currArtwork.current?.index ?? 0) + 1, 204 + loaded: false, 205 + url: URL.createObjectURL( 206 + new Blob([/** @type {ArrayBuffer} */ (bytes.buffer)], { type: mime }), 238 207 ), 239 - ), 240 - } 208 + }; 209 + })() 241 210 : null, 242 211 }); 243 212 244 - if (!art) { 213 + if (!bytes) { 245 214 this.#artworkColor.value = undefined; 246 215 this.#artworkLightMode.value = false; 247 216 } ··· 480 449 <!-- PROGRESS --> 481 450 482 451 <div class="progress" @click="${this.seek}"> 483 - <progress max="100" value="${(this.audio()?.loadingState() === "loaded" ? (this.audio()?.progress() ?? 0) : 0) * 100}"></progress> 452 + <progress max="100" value="${(this.audio()?.loadingState() === 453 + "loaded" 454 + ? (this.audio()?.progress() ?? 0) 455 + : 0) * 100}"></progress> 484 456 <div class="timestamps"> 485 457 <time datetime="${this.#time.value}">${this.#time.value}</time> 486 458 <time datetime="${this.#time.value}">${this.#duration ··· 562 534 } 563 535 564 536 export default ArtworkController; 537 + 538 + //////////////////////////////////////////// 539 + // 🛠️ 540 + //////////////////////////////////////////// 541 + 542 + /** 543 + * @param {Uint8Array} bytes 544 + * @returns {string} 545 + */ 546 + function detectMime(bytes) { 547 + if (bytes[0] === 0xFF && bytes[1] === 0xD8) return "image/jpeg"; 548 + if (bytes[0] === 0x89 && bytes[1] === 0x50) return "image/png"; 549 + if (bytes[0] === 0x47 && bytes[1] === 0x49) return "image/gif"; 550 + if (bytes[0] === 0x52 && bytes[1] === 0x49) return "image/webp"; 551 + return "image/jpeg"; 552 + } 565 553 566 554 //////////////////////////////////////////// 567 555 // REGISTER
+2 -2
src/themes/blur/artwork-controller/facet/index.inline.js
··· 10 10 11 11 const [aud, art, fav, inp, out, que] = await Promise.all([ 12 12 foundation.engine.audio(), 13 - foundation.processor.artwork(), 13 + foundation.orchestrator.artwork(), 14 14 foundation.orchestrator.favourites(), 15 15 foundation.configurator.input(), 16 16 foundation.orchestrator.output(), ··· 19 19 20 20 // Controller 21 21 const dac = new ArtworkController(); 22 - dac.setAttribute("artwork-processor-selector", art.selector); 22 + dac.setAttribute("artwork-selector", art.selector); 23 23 dac.setAttribute("audio-engine-selector", aud.selector); 24 24 dac.setAttribute("input-selector", inp.selector); 25 25 dac.setAttribute("output-selector", out.selector);
+5
src/themes/winamp/facet/index.html
··· 2 2 <link rel="stylesheet" href="themes/winamp/index.css" /> 3 3 4 4 <style> 5 + /*body { 6 + background: #3a6ea5 url("https://wallpapercave.com/wp/wp2808001.jpg") no-repeat; 7 + background-size: cover; 8 + }*/ 9 + 5 10 main { 6 11 opacity: 0; 7 12 pointer-events: none;
+41
tests/components/artwork/audio-metadata/test.ts
··· 1 + import { describe, it } from "@std/testing/bdd"; 2 + import { expect } from "@std/expect"; 3 + 4 + import { testWeb } from "@tests/common/index.ts"; 5 + 6 + describe("components/artwork/audio-metadata", () => { 7 + it("extracts embedded artwork from the sample audio file", async () => { 8 + const byteLength = await testWeb(async () => { 9 + const HttpsInput = await import("~/components/input/https/element.js"); 10 + const AudioMetadata = await import( 11 + "~/components/artwork/audio-metadata/element.js" 12 + ); 13 + 14 + const input = new HttpsInput.CLASS(); 15 + input.id = "test-https-input"; 16 + document.body.append(input); 17 + 18 + const audioMetadata = new AudioMetadata.CLASS(); 19 + audioMetadata.setAttribute("input-selector", "#test-https-input"); 20 + document.body.append(audioMetadata); 21 + 22 + await customElements.whenDefined(input.localName); 23 + await customElements.whenDefined(audioMetadata.localName); 24 + 25 + const blob = await fetch("http://localhost:3000/testing/sample/audio.mp3") 26 + .then((r) => r.blob()); 27 + const blobUri = URL.createObjectURL(blob); 28 + 29 + const result = await audioMetadata.get({ 30 + $type: "sh.diffuse.output.track" as const, 31 + id: "audio-metadata-artwork-test", 32 + uri: blobUri, 33 + }); 34 + 35 + URL.revokeObjectURL(blobUri); 36 + return result?.length ?? 0; 37 + }); 38 + 39 + expect(byteLength).toBeGreaterThan(0); 40 + }); 41 + });
+52
tests/components/configurator/artwork/test.ts
··· 1 + import { describe, it } from "@std/testing/bdd"; 2 + import { expect } from "@std/expect"; 3 + 4 + import { testWeb } from "@tests/common/index.ts"; 5 + 6 + describe("components/configurator/artwork", () => { 7 + it("returns null when there are no children", async () => { 8 + const result = await testWeb(async () => { 9 + const { CLASS } = await import( 10 + "~/components/configurator/artwork/element.js" 11 + ); 12 + 13 + const configurator = new CLASS(); 14 + document.body.append(configurator); 15 + await customElements.whenDefined(configurator.localName); 16 + 17 + return configurator.get({ 18 + $type: "sh.diffuse.output.track" as const, 19 + id: "artwork-configurator-test", 20 + uri: "local://test", 21 + }); 22 + }); 23 + 24 + expect(result).toBe(null); 25 + }); 26 + 27 + it("returns the result of the first child that returns non-null", async () => { 28 + const result = await testWeb(async () => { 29 + const { CLASS } = await import( 30 + "~/components/configurator/artwork/element.js" 31 + ); 32 + const MusicBrainz = await import( 33 + "~/components/artwork/musicbrainz/element.js" 34 + ); 35 + const LastFm = await import("~/components/artwork/last.fm/element.js"); 36 + 37 + const configurator = new CLASS(); 38 + configurator.append(new MusicBrainz.CLASS(), new LastFm.CLASS()); 39 + document.body.append(configurator); 40 + await customElements.whenDefined(configurator.localName); 41 + 42 + // Track has no artist or album — both components return null 43 + return configurator.get({ 44 + $type: "sh.diffuse.output.track" as const, 45 + id: "artwork-configurator-test", 46 + uri: "local://test", 47 + }); 48 + }); 49 + 50 + expect(result).toBe(null); 51 + }); 52 + });
+87
tests/components/orchestrator/artwork/test.ts
··· 1 + import { describe, it } from "@std/testing/bdd"; 2 + import { expect } from "@std/expect"; 3 + 4 + import { testWeb } from "@tests/common/index.ts"; 5 + 6 + describe("components/orchestrator/artwork", () => { 7 + it("returns null when track has no artist or album", async () => { 8 + const result = await testWeb(async () => { 9 + const LastFm = await import("~/components/artwork/last.fm/element.js"); 10 + const MusicBrainz = await import( 11 + "~/components/artwork/musicbrainz/element.js" 12 + ); 13 + const Configurator = await import( 14 + "~/components/configurator/artwork/element.js" 15 + ); 16 + const Orchestrator = await import( 17 + "~/components/orchestrator/artwork/element.js" 18 + ); 19 + 20 + const configurator = new Configurator.CLASS(); 21 + configurator.id = "test-artwork-configurator-1"; 22 + configurator.append(new LastFm.CLASS(), new MusicBrainz.CLASS()); 23 + document.body.append(configurator); 24 + 25 + const orchestrator = new Orchestrator.CLASS(); 26 + orchestrator.setAttribute( 27 + "artwork-selector", 28 + "#test-artwork-configurator-1", 29 + ); 30 + document.body.append(orchestrator); 31 + 32 + await customElements.whenDefined(configurator.localName); 33 + await customElements.whenDefined(orchestrator.localName); 34 + 35 + return orchestrator.get({ 36 + $type: "sh.diffuse.output.track" as const, 37 + id: "artwork-orch-test-no-tags", 38 + uri: "local://no-tags", 39 + }); 40 + }); 41 + 42 + expect(result).toBe(null); 43 + }); 44 + 45 + it("returns cached result on subsequent calls for the same track", async () => { 46 + const [first, second] = await testWeb(async () => { 47 + const LastFm = await import("~/components/artwork/last.fm/element.js"); 48 + const MusicBrainz = await import( 49 + "~/components/artwork/musicbrainz/element.js" 50 + ); 51 + const Configurator = await import( 52 + "~/components/configurator/artwork/element.js" 53 + ); 54 + const Orchestrator = await import( 55 + "~/components/orchestrator/artwork/element.js" 56 + ); 57 + 58 + const configurator = new Configurator.CLASS(); 59 + configurator.id = "test-artwork-configurator-2"; 60 + configurator.append(new LastFm.CLASS(), new MusicBrainz.CLASS()); 61 + document.body.append(configurator); 62 + 63 + const orchestrator = new Orchestrator.CLASS(); 64 + orchestrator.setAttribute( 65 + "artwork-selector", 66 + "#test-artwork-configurator-2", 67 + ); 68 + document.body.append(orchestrator); 69 + 70 + await customElements.whenDefined(configurator.localName); 71 + await customElements.whenDefined(orchestrator.localName); 72 + 73 + const track = { 74 + $type: "sh.diffuse.output.track" as const, 75 + id: "artwork-orch-test-cached", 76 + uri: "local://no-tags", 77 + }; 78 + 79 + const first = await orchestrator.get(track); 80 + const second = await orchestrator.get(track); 81 + return [first?.length ?? null, second?.length ?? null]; 82 + }); 83 + 84 + expect(first).toEqual(second); 85 + }); 86 + 87 + });
-147
tests/components/processor/artwork/test.ts
··· 1 - import { describe, it } from "@std/testing/bdd"; 2 - import { expect } from "@std/expect"; 3 - 4 - import { testWeb } from "@tests/common/index.ts"; 5 - 6 - describe("components/processor/artwork", () => { 7 - it("returns empty array when tags have no artist", async () => { 8 - // processRequest short-circuits when artist or album is missing, 9 - // so no network calls are made 10 - const result = await testWeb(async () => { 11 - const mod = await import("~/components/processor/artwork/element.js"); 12 - const processor = new mod.CLASS(); 13 - 14 - document.body.append(processor); 15 - 16 - return processor.artwork({ 17 - cacheId: "test-no-artist", 18 - tags: { album: "Some Album" }, 19 - }); 20 - }); 21 - 22 - expect(result).toEqual([]); 23 - }); 24 - 25 - it("returns empty array when tags have no album", async () => { 26 - const result = await testWeb(async () => { 27 - const mod = await import("~/components/processor/artwork/element.js"); 28 - const processor = new mod.CLASS(); 29 - 30 - document.body.append(processor); 31 - 32 - return processor.artwork({ 33 - cacheId: "test-no-album", 34 - tags: { artist: "Some Artist" }, 35 - }); 36 - }); 37 - 38 - expect(result).toEqual([]); 39 - }); 40 - 41 - it("returns empty array when tags are not provided and URL is unreachable", async () => { 42 - const result = await testWeb(async () => { 43 - const mod = await import("~/components/processor/artwork/element.js"); 44 - const processor = new mod.CLASS(); 45 - 46 - document.body.append(processor); 47 - 48 - // musicMetadataTags will fail (bad URL), meta.tags stays undefined 49 - // → still short-circuits on missing artist/album 50 - return processor.artwork({ 51 - cacheId: "test-no-tags", 52 - urls: { 53 - get: "http://localhost/nonexistent.mp3", 54 - head: "http://localhost/nonexistent.mp3", 55 - }, 56 - }); 57 - }); 58 - 59 - expect(result).toEqual([]); 60 - }); 61 - 62 - it("artwork with artist 'VA' and no album returns empty array", async () => { 63 - // 'VA' sets variousArtists=true but still short-circuits on missing album 64 - const result = await testWeb(async () => { 65 - const mod = await import("~/components/processor/artwork/element.js"); 66 - const processor = new mod.CLASS(); 67 - 68 - document.body.append(processor); 69 - 70 - return processor.artwork({ 71 - cacheId: "test-va", 72 - tags: { artist: "VA", title: "Various Track" }, 73 - }); 74 - }); 75 - 76 - expect(result).toEqual([]); 77 - }); 78 - 79 - it("supply queues requests and returns without throwing", async () => { 80 - const result = await testWeb(async () => { 81 - const mod = await import("~/components/processor/artwork/element.js"); 82 - const processor = new mod.CLASS(); 83 - 84 - document.body.append(processor); 85 - 86 - const r = await processor.supply([ 87 - { cacheId: "queued-1", tags: { title: "Track One" } }, 88 - { cacheId: "queued-2", tags: { title: "Track Two" } }, 89 - ]); 90 - 91 - return r ?? null; 92 - }); 93 - 94 - // supply() returns void — proxy resolves to undefined 95 - expect(result).toBe(null); 96 - }); 97 - 98 - it("extracts tags from sample audio file and returns array", async () => { 99 - // Passes the real file URL; musicMetadataTags runs successfully. 100 - // The file has no embedded artwork so processRequest short-circuits 101 - // on the missing album tag passed via explicit tags override. 102 - const result = await testWeb(async () => { 103 - const mod = await import("~/components/processor/artwork/element.js"); 104 - const processor = new mod.CLASS(); 105 - 106 - document.body.append(processor); 107 - 108 - const blob = await fetch("/testing/sample/audio.mp3").then((r) => 109 - r.blob() 110 - ); 111 - const blobUrl = URL.createObjectURL(blob); 112 - 113 - // Provide only a title — processRequest short-circuits before hitting 114 - // the network because artist+album are not both present 115 - const art = await processor.artwork({ 116 - cacheId: "sample-audio-title-only", 117 - tags: { title: "Mr. Sandman" }, 118 - urls: { get: blobUrl, head: blobUrl }, 119 - }); 120 - 121 - URL.revokeObjectURL(blobUrl); 122 - return art; 123 - }); 124 - 125 - expect(Array.isArray(result)).toBe(true); 126 - }); 127 - 128 - it("returns cached artwork on subsequent calls with the same cacheId", async () => { 129 - const [first, second] = await testWeb(async () => { 130 - const mod = await import("~/components/processor/artwork/element.js"); 131 - const processor = new mod.CLASS(); 132 - 133 - document.body.append(processor); 134 - 135 - const req = { cacheId: "cached-id", tags: { title: "Track" } }; 136 - 137 - // Both calls go through processRequest; second should hit IDB cache path 138 - const first = await processor.artwork(req); 139 - const second = await processor.artwork(req); 140 - 141 - return [first, second]; 142 - }); 143 - 144 - // Both should return the same empty result ([] in this case) 145 - expect(first).toEqual(second); 146 - }); 147 - });