Experiment to rebuild Diffuse using web applets.
0
fork

Configure Feed

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

feat: load artwork into artwork controller

+478 -297
+162 -17
src/pages/constituents/desktop/artwork-controller/_applet.astro
··· 3 3 import "@styles/variables.css"; 4 4 import "@styles/fonts.css"; 5 5 import "@styles/icons.css"; 6 + 7 + import "@styles/diffuse/colors.css"; 8 + import "@styles/diffuse/fonts.css"; 6 9 --- 7 10 8 11 <main>TODO</main> 9 12 10 13 <style> 11 14 main { 15 + background: var(--color-3); 16 + background-size: cover; 17 + color: var(--color-2); 12 18 padding: var(--space-md); 19 + } 20 + </style> 21 + 22 + <style is:global> 23 + iframe { 24 + display: none; 13 25 } 14 26 </style> 15 27 16 28 <script> 17 - import scope from "astro:scope"; 29 + import * as Uint8 from "uint8arrays"; 18 30 19 31 import { computed, effect, type Signal, signal } from "spellcaster"; 20 32 import { repeat, tags, text } from "spellcaster/hyperscript.js"; 33 + import { xxh32 } from "xxh32"; 21 34 22 35 import { applets } from "@web-applets/sdk"; 23 36 24 - import type { State } from "./types.d.ts"; 37 + import type { ManagedOutput } from "@applets/core/types"; 38 + import { applet, comparable, inputUrl, reactive, wait } from "@scripts/applets/common"; 39 + 40 + //////////////////////////////////////////// 41 + // SETUP 42 + //////////////////////////////////////////// 43 + import type * as AudioEngine from "@applets/engine/audio/types.d.ts"; 44 + import type * as QueueEngine from "@applets/engine/queue/types.d.ts"; 25 45 26 - const context = applets.register<State>(); 46 + import type { Artwork } from "@applets/processor/artwork/types"; 27 47 28 - //////////////////////////////////////////// 48 + const context = applets.register(); 49 + 29 50 // Initial state 30 - //////////////////////////////////////////// 31 - context.data = { 32 - isPlaying: false, 51 + const [artwork, setArtwork] = signal<Artwork[]>([]); 52 + const [groupId, setGroupId] = signal(crypto.randomUUID()); 53 + const [isPlaying, setIsPlaying] = signal(false); 54 + 55 + // Applet connections 56 + const configurator = { 57 + input: await applet("../../../configurator/input"), 58 + output: await applet<ManagedOutput>("../../../configurator/output"), 59 + }; 60 + 61 + const engine = { 62 + audio: await applet<AudioEngine.State>("../../engine/audio", { groupId: groupId() }), 63 + queue: await applet<QueueEngine.State>("../../engine/queue", { groupId: groupId() }), 64 + }; 65 + 66 + const orchestrator = { 67 + inputCache: await applet("../../../orchestrator/input-cache"), 68 + }; 69 + 70 + const processor = { 71 + artwork: await applet("../../../processor/artwork"), 33 72 }; 34 73 35 74 //////////////////////////////////////////// 36 - // Actions 75 + // 🎢 QUEUE 37 76 //////////////////////////////////////////// 38 - context.setActionHandler("modifyIsPlaying", (isPlaying: boolean) => { 39 - // NOTE: Doesn't trigger a `data` event 40 - context.data.isPlaying = isPlaying; 41 - render(); 77 + 78 + // TODO: Shuffle, limit amount, etc. 79 + async function fillQueue() { 80 + await engine.queue.sendAction("add", configurator.output.data.tracks.collection, { 81 + timeoutDuration: 60000, 82 + }); 83 + } 84 + 85 + // When the active audio has ended, 86 + // shift the queue. 87 + 88 + // NOTE: 89 + // This could probably be optimised, but it works. 90 + 91 + reactive( 92 + engine.audio, 93 + (data) => data.items[engine.queue.data.now?.id ?? Infinity]?.hasEnded ?? false, 94 + (hasEnded) => { 95 + if (hasEnded) engine.queue.sendAction("shift"); 96 + }, 97 + ); 98 + 99 + // When the active queue item has changed, 100 + // coordinate the audio engine accordingly. 101 + 102 + reactive( 103 + engine.queue, 104 + (data) => data.now?.id, 105 + async () => { 106 + const playingNow = engine.queue.data.now; 107 + const volume = engine.audio.data.volume; 108 + 109 + // Play new active queue item 110 + // TODO: Take URL expiration timestamp into account 111 + // TODO: Preload next queue item 112 + engine.audio.sendAction( 113 + "render", 114 + { 115 + audio: playingNow 116 + ? [ 117 + { 118 + id: playingNow.id, 119 + isPreload: false, 120 + url: await inputUrl(configurator.input, playingNow.uri), 121 + }, 122 + ] 123 + : // NOTE: This probably isn't correct, keep preloads? 124 + [], 125 + play: playingNow 126 + ? { 127 + audioId: playingNow.id, 128 + volume, 129 + } 130 + : undefined, 131 + }, 132 + { 133 + timeoutDuration: 60000, 134 + }, 135 + ); 136 + 137 + // Add more tracks to the queue if needed 138 + if (playingNow) fillQueue(); 139 + }, 140 + ); 141 + 142 + // Add tracks to the queue once the tracks have been loaded. 143 + 144 + wait(configurator.output, (d) => d?.tracks.state === "loaded").then(() => { 145 + reactive(configurator.output, (d) => d.tracks.cacheId, fillQueue); 42 146 }); 43 147 44 - context.setActionHandler("modifyProgress", (progress: number) => { 45 - // TODO 46 - }); 148 + // Show artwork of active queue item. 149 + 150 + reactive( 151 + engine.queue, 152 + (data) => comparable(data.future), 153 + async () => { 154 + const track = engine.queue.data.now || engine.queue.data.future[0]; 155 + 156 + console.log(track); 157 + 158 + if (!track) return; 159 + 160 + const art = await processor.artwork.sendAction( 161 + "artwork", 162 + { 163 + cacheId: xxh32(track.uri), // TODO: Probably switch to SHA256 or another more secure hashing algo 164 + tags: track.tags, 165 + urls: { 166 + get: await inputUrl(configurator.input, track.uri, "GET").then((a) => a?.url), 167 + head: await inputUrl(configurator.input, track.uri, "HEAD").then((a) => a?.url), 168 + }, 169 + }, 170 + { 171 + timeoutDuration: 60000 * 5, 172 + }, 173 + ); 174 + 175 + console.log("Art", art); 176 + 177 + setArtwork(art); 178 + }, 179 + ); 47 180 48 181 //////////////////////////////////////////// 49 - // DOM 182 + // UI 50 183 //////////////////////////////////////////// 51 - // TODO: Events 184 + 185 + effect(() => { 186 + const art = artwork(); 187 + 188 + // TODO: Remove existing art? 189 + if (art.length === 0) return; 190 + 191 + // Show artwork 192 + const blob = new Blob([art[0].bytes], { type: art[0].mime }); 193 + const url = URL.createObjectURL(blob); 194 + 195 + document.querySelector("main")?.setAttribute("style", `background-image: url(${url})`); 196 + }); 52 197 53 198 function render() { 54 199 // TODO
+1 -1
src/pages/constituents/desktop/artwork-controller/_manifest.json
··· 1 1 { 2 2 "name": "diffuse/constituents/desktop/artwork-controller", 3 - "title": "", 3 + "title": "Diffuse Desktop Theme | Artwork Controller", 4 4 "entrypoint": "index.html", 5 5 "actions": { 6 6 "modifyIsPlaying": {
-4
src/pages/constituents/desktop/artwork-controller/types.d.ts
··· 1 - export interface State { 2 - isPlaying: boolean; 3 - seekPosition?: number; 4 - }
+5 -4
src/pages/constituents/pilot/audio/_applet.astro
··· 98 98 99 99 import type { State } from "./types.d.ts"; 100 100 101 + //////////////////////////////////////////// 102 + // SETUP 103 + //////////////////////////////////////////// 101 104 const context = applets.register<State>(); 102 105 103 - //////////////////////////////////////////// 104 106 // Initial state 105 - //////////////////////////////////////////// 106 107 context.data = { 107 108 isPlaying: false, 108 109 }; 109 110 110 111 //////////////////////////////////////////// 111 - // Actions 112 + // ACTIONS 112 113 //////////////////////////////////////////// 113 114 context.setActionHandler("modifyIsPlaying", (isPlaying: boolean) => { 114 115 // NOTE: Doesn't trigger a `data` event ··· 123 124 }); 124 125 125 126 //////////////////////////////////////////// 126 - // DOM 127 + // UI 127 128 //////////////////////////////////////////// 128 129 document.body.querySelector(".controls__playpause")?.addEventListener("click", () => { 129 130 context.data = { ...context.data, isPlaying: !(context.data?.isPlaying ?? false) };
+53 -53
src/pages/engine/audio/_applet.astro
··· 1 1 <script> 2 - import type { State, Track, TrackState } from "./types"; 2 + import type { State, Audio, AudioState } from "./types"; 3 3 import { register } from "@scripts/applets/common"; 4 4 5 5 //////////////////////////////////////////// ··· 31 31 context.data = { ...context.data, ...partial }; 32 32 } 33 33 34 - function updateItems(trackId: string, partial: Partial<TrackState>): void { 34 + function updateItems(audioId: string, partial: Partial<AudioState>): void { 35 35 update({ 36 36 ...context.data, 37 37 items: { 38 38 ...(context.data?.items || {}), 39 - [trackId]: { ...(context.data?.items?.[trackId] || {}), ...partial }, 39 + [audioId]: { ...(context.data?.items?.[audioId] || {}), ...partial }, 40 40 }, 41 41 }); 42 42 } ··· 51 51 context.setActionHandler("seek", seek); 52 52 context.setActionHandler("volume", volume); 53 53 54 - function pause({ trackId }: { trackId: string }) { 55 - withAudioNode(trackId, (audio) => audio.pause()); 54 + function pause({ audioId }: { audioId: string }) { 55 + withAudioNode(audioId, (audio) => audio.pause()); 56 56 } 57 57 58 - function play({ trackId, volume }: { trackId: string; volume?: number }) { 59 - withAudioNode(trackId, (audio) => { 58 + function play({ audioId, volume }: { audioId: string; volume?: number }) { 59 + withAudioNode(audioId, (audio) => { 60 60 audio.volume = volume ?? 0.5; 61 61 audio.muted = false; 62 62 ··· 78 78 return; /* The node was removed from the DOM, we can ignore this error */ 79 79 const err = "Couldn't play audio automatically. Please resume playback manually."; 80 80 console.error(err, e); 81 - updateItems(trackId, { isPlaying: false }); 81 + updateItems(audioId, { isPlaying: false }); 82 82 }); 83 83 }); 84 84 } 85 85 86 - function reload(args: { play: boolean; progress?: number; trackId: string }) { 87 - withAudioNode(args.trackId, (audio) => { 86 + function reload(args: { play: boolean; progress?: number; audioId: string }) { 87 + withAudioNode(args.audioId, (audio) => { 88 88 if (audio.readyState === 0 || audio.error?.code === 2) { 89 89 audio.load(); 90 90 ··· 93 93 } 94 94 95 95 if (args.play) { 96 - play({ trackId: args.trackId, volume: audio.volume }); 96 + play({ audioId: args.audioId, volume: audio.volume }); 97 97 } 98 98 } 99 99 }); 100 100 } 101 101 102 - async function render(args: { play?: { trackId: string; volume?: number }; tracks: Track[] }) { 103 - await renderTracks(args.tracks); 104 - if (args.play) play({ trackId: args.play.trackId, volume: args.play.volume }); 102 + async function render(args: { play?: { audioId: string; volume?: number }; audio: Audio[] }) { 103 + await renderAudio(args.audio); 104 + if (args.play) play({ audioId: args.play.audioId, volume: args.play.volume }); 105 105 } 106 106 107 - function seek({ percentage, trackId }: { percentage: number; trackId: string }) { 108 - withAudioNode(trackId, (audio) => { 107 + function seek({ percentage, audioId }: { percentage: number; audioId: string }) { 108 + withAudioNode(audioId, (audio) => { 109 109 if (!isNaN(audio.duration)) { 110 110 audio.currentTime = audio.duration * percentage; 111 111 } 112 112 }); 113 113 } 114 114 115 - function volume(args: { trackId?: string; volume: number }) { 115 + function volume(args: { audioId?: string; volume: number }) { 116 116 Array.from(container.querySelectorAll('audio[data-is-preload="false"]')).forEach((node) => { 117 117 const audio = node as HTMLAudioElement; 118 - if (args.trackId === undefined || args.trackId === audio.id) { 118 + if (args.audioId === undefined || args.audioId === audio.id) { 119 119 audio.volume = args.volume; 120 120 } 121 121 }); ··· 124 124 //////////////////////////////////////////// 125 125 // RENDER 126 126 //////////////////////////////////////////// 127 - async function renderTracks(tracks: Array<Track>) { 128 - const ids = tracks.map((e) => e.id); 127 + async function renderAudio(audio: Array<Audio>) { 128 + const ids = audio.map((a) => a.id); 129 129 const existingNodes: Record<string, HTMLAudioElement> = {}; 130 130 131 131 // Manage existing nodes ··· 139 139 }); 140 140 141 141 // Adjust existing and add new 142 - await tracks.reduce(async (acc: Promise<void>, track: Track) => { 142 + await audio.reduce(async (acc: Promise<void>, item: Audio) => { 143 143 await acc; 144 144 145 - const existingNode = existingNodes[track.id]; 145 + const existingNode = existingNodes[item.id]; 146 146 147 147 if (existingNode) { 148 148 const isPreload = existingNode.getAttribute("data-is-preload"); 149 149 if (isPreload === "true") existingNode.setAttribute("data-did-preload", "true"); 150 150 151 - existingNode.setAttribute("data-is-preload", track.isPreload ? "true" : "false"); 151 + existingNode.setAttribute("data-is-preload", item.isPreload ? "true" : "false"); 152 152 } else { 153 - await createElement(track); 153 + await createElement(item); 154 154 } 155 155 }, Promise.resolve()); 156 156 157 157 // Now playing state 158 - const items = tracks.reduce((acc, track) => { 158 + const items = audio.reduce((acc, item) => { 159 159 return { 160 160 ...acc, 161 - [track.id]: context.data?.items?.[track.id] || { 161 + [item.id]: context.data?.items?.[item.id] || { 162 162 duration: 0, 163 - id: track.id, 163 + id: item.id, 164 164 loadingState: "loading", 165 165 isPlaying: true, 166 - isPreload: track.isPreload ?? false, 167 - progress: track.progress ?? 0, 166 + isPreload: item.isPreload ?? false, 167 + progress: item.progress ?? 0, 168 168 }, 169 169 }; 170 170 }, {}); ··· 172 172 update({ items }); 173 173 } 174 174 175 - export async function createElement(track: Track) { 175 + export async function createElement(audio: Audio) { 176 176 const source = document.createElement("source"); 177 - if (track.mimeType) source.setAttribute("type", track.mimeType); 178 - source.setAttribute("src", track.url); 177 + if (audio.mimeType) source.setAttribute("type", audio.mimeType); 178 + source.setAttribute("src", audio.url); 179 179 180 180 // Audio node 181 - const audio = new Audio(); 182 - audio.setAttribute("id", track.id); 183 - audio.setAttribute("crossorigin", "anonymous"); 184 - audio.setAttribute("data-is-preload", track.isPreload ? "true" : "false"); 185 - audio.setAttribute("muted", "true"); 186 - audio.setAttribute("preload", "auto"); 181 + const node = new Audio(); 182 + node.setAttribute("id", audio.id); 183 + node.setAttribute("crossorigin", "anonymous"); 184 + node.setAttribute("data-is-preload", audio.isPreload ? "true" : "false"); 185 + node.setAttribute("muted", "true"); 186 + node.setAttribute("preload", "auto"); 187 187 188 - if (track.progress !== undefined) { 189 - audio.setAttribute("data-initial-progress", JSON.stringify(track.progress)); 188 + if (audio.progress !== undefined) { 189 + node.setAttribute("data-initial-progress", JSON.stringify(audio.progress)); 190 190 } 191 191 192 - audio.appendChild(source); 192 + node.appendChild(source); 193 193 194 - audio.addEventListener("canplay", canplayEvent); 195 - audio.addEventListener("durationchange", durationchangeEvent); 196 - audio.addEventListener("ended", endedEvent); 197 - audio.addEventListener("error", errorEvent); 198 - audio.addEventListener("pause", pauseEvent); 199 - audio.addEventListener("play", playEvent); 200 - audio.addEventListener("suspend", suspendEvent); 201 - audio.addEventListener("timeupdate", timeupdateEvent); 202 - audio.addEventListener("waiting", waitingEvent); 194 + node.addEventListener("canplay", canplayEvent); 195 + node.addEventListener("durationchange", durationchangeEvent); 196 + node.addEventListener("ended", endedEvent); 197 + node.addEventListener("error", errorEvent); 198 + node.addEventListener("pause", pauseEvent); 199 + node.addEventListener("play", playEvent); 200 + node.addEventListener("suspend", suspendEvent); 201 + node.addEventListener("timeupdate", timeupdateEvent); 202 + node.addEventListener("waiting", waitingEvent); 203 203 204 - container?.appendChild(audio); 204 + container?.appendChild(node); 205 205 } 206 206 207 207 //////////////////////////////////////////// ··· 298 298 if (node) fn(node); 299 299 } 300 300 301 - function withAudioNode(trackId: string, fn: (node: HTMLAudioElement) => void): void { 302 - const node = container.querySelector(`audio[id="${trackId}"][data-is-preload="false"]`); 301 + function withAudioNode(audioId: string, fn: (node: HTMLAudioElement) => void): void { 302 + const node = container.querySelector(`audio[id="${audioId}"][data-is-preload="false"]`); 303 303 if (node) fn(node as HTMLAudioElement); 304 304 } 305 305 </script>
+37 -37
src/pages/engine/audio/_manifest.json
··· 5 5 "actions": { 6 6 "pause": { 7 7 "title": "Pause", 8 - "description": "Pause a track", 8 + "description": "Pause audio", 9 9 "params_schema": { 10 10 "type": "object", 11 11 "properties": { 12 - "trackId": { 12 + "audioId": { 13 13 "type": "string" 14 14 } 15 15 }, 16 - "required": ["trackId"] 16 + "required": ["audioId"] 17 17 } 18 18 }, 19 19 "play": { 20 20 "title": "Play", 21 - "description": "Play a track", 21 + "description": "Play audio", 22 22 "params_schema": { 23 23 "type": "object", 24 24 "properties": { 25 - "trackId": { 25 + "audioId": { 26 26 "type": "string" 27 27 }, 28 28 "volume": { ··· 30 30 "default": 0.5 31 31 } 32 32 }, 33 - "required": ["trackId"] 33 + "required": ["audioId"] 34 34 } 35 35 }, 36 36 "render": { ··· 39 39 "params_schema": { 40 40 "type": "object", 41 41 "properties": { 42 - "play": { 43 - "type": "object", 44 - "description": "Pass in this object to immediately start playing one of the rendered tracks.", 45 - "properties": { 46 - "trackId": { 47 - "type": "string", 48 - "description": "The id of the rendered track we want to play." 49 - }, 50 - "volume": { 51 - "type": "number", 52 - "default": 0.5, 53 - "description": "A number equal to, or between, 0 and 1, that determines how loud the track should play." 54 - } 55 - }, 56 - "required": ["trackId"] 57 - }, 58 - "tracks": { 42 + "audio": { 59 43 "type": "array", 60 - "description": "The tracks we want to render.", 44 + "description": "The audio items we want to render. These represent the audio elements that are in the DOM.", 61 45 "items": { 62 46 "type": "object", 63 47 "properties": { ··· 69 53 }, 70 54 "required": ["id", "url"] 71 55 } 56 + }, 57 + "play": { 58 + "type": "object", 59 + "description": "Pass in this object to immediately start playing one of the rendered audio items.", 60 + "properties": { 61 + "audioId": { 62 + "type": "string", 63 + "description": "The id of the rendered audio item we want to play." 64 + }, 65 + "volume": { 66 + "type": "number", 67 + "default": 0.5, 68 + "description": "A number equal to, or between, 0 and 1, that determines how loud the audio should play." 69 + } 70 + }, 71 + "required": ["audioId"] 72 72 } 73 73 }, 74 - "required": ["tracks"] 74 + "required": ["audio"] 75 75 } 76 76 }, 77 77 "reload": { 78 78 "title": "Reload", 79 - "description": "Make sure the audio node with the given track id is loading properly. This should be used when for example, the internet connection comes back and the loading of the track depended on said internet connection.", 79 + "description": "Make sure the audio with the given id is loading properly. This should be used when for example, the internet connection comes back and the loading of the audio depended on said internet connection.", 80 80 "params_schema": { 81 81 "type": "object", 82 82 "properties": { 83 + "audioId": { 84 + "type": "string" 85 + }, 83 86 "play": { 84 87 "type": "boolean" 85 88 }, 86 89 "progress": { 87 90 "type": "number" 88 - }, 89 - "trackId": { 90 - "type": "string" 91 91 } 92 92 }, 93 - "required": ["percentage", "trackId"] 93 + "required": ["audioId", "percentage"] 94 94 } 95 95 }, 96 96 "seek": { 97 97 "title": "Seek", 98 - "description": "Seek a track to a given position", 98 + "description": "Seek audio to a given position", 99 99 "params_schema": { 100 100 "type": "object", 101 101 "properties": { 102 + "audioId": { 103 + "type": "string" 104 + }, 102 105 "percentage": { 103 106 "type": "number", 104 107 "description": "A number between 0 and 1 that determines the new current position in the audio" 105 - }, 106 - "trackId": { 107 - "type": "string" 108 108 } 109 109 }, 110 - "required": ["percentage", "trackId"] 110 + "required": ["audioId", "percentage"] 111 111 } 112 112 }, 113 113 "volume": { 114 114 "title": "Volume", 115 - "description": "Set the volume of all tracks, or a specific track.", 115 + "description": "Set the volume of all audio, or a specific audio node.", 116 116 "params_schema": { 117 117 "type": "object", 118 118 "properties": { 119 - "trackId": { 119 + "audioId": { 120 120 "type": "string" 121 121 }, 122 122 "volume": {
+3 -3
src/pages/engine/audio/types.d.ts
··· 1 1 export interface State { 2 - items: Record<string, TrackState>; 2 + items: Record<string, AudioState>; 3 3 volume: number; 4 4 } 5 5 6 - export interface Track { 6 + export interface Audio { 7 7 id: string; 8 8 isPreload: boolean; 9 9 mimeType?: string; ··· 11 11 url: string; 12 12 } 13 13 14 - export interface TrackState { 14 + export interface AudioState { 15 15 duration: number; 16 16 id: string; 17 17 hasEnded: boolean;
+2 -3
src/pages/engine/queue/_manifest.json
··· 13 13 { 14 14 "type": "object", 15 15 "properties": { 16 - "expiresAt": { "type": "number" }, 17 16 "id": { "type": "string" }, 18 - "url": { "type": "string" } 17 + "uri": { "type": "string" } 19 18 }, 20 - "required": ["expiresAt", "id", "url"] 19 + "required": ["id", "uri"] 21 20 } 22 21 ] 23 22 }
+5 -9
src/pages/engine/queue/types.d.ts
··· 1 - export interface QueueItem { 2 - expiresAt: number; 3 - id: string; 4 - url: string; 5 - } 1 + import type { Track } from "@applets/core/types"; 6 2 7 - export interface State { 8 - past: QueueItem[]; 9 - now: QueueItem | null; 10 - future: QueueItem[]; 3 + export interface State<Stats = TrackStats, Tags = TrackTags> { 4 + past: Track<Stats, Tags>[]; 5 + now: Track<Stats, Tags> | null; 6 + future: Track<Stats, Tags>[]; 11 7 }
+12
src/pages/index.astro
··· 133 133 abstraction, you can mix and match as you like. You can even use them on their own. 134 134 </p> 135 135 136 + <p> 137 + Some themes may be constructed out of various applets that are not listed here. The reason 138 + for that is those applets cannot be used solely on their own, they require an external 139 + context to coordinate them. 140 + </p> 141 + 142 + <p> 143 + There's tradeoffs to both approaches. A particular tradeoff to keep in mind for constituents 144 + is that they'll have nested dependencies. So when overriding applets dependencies, the 145 + overrides need to passed down the tree. 146 + </p> 147 + 136 148 <List items={constituents} /> 137 149 </section> 138 150
+3 -3
src/pages/orchestrator/input-cache/_applet.astro
··· 1 1 <script> 2 2 import type { ManagedOutput, ResolvedUri, Track } from "@applets/core/types.d.ts"; 3 - import { applet, register, waitUntilAppletData } from "@scripts/applets/common"; 3 + import { applet, register, wait } from "@scripts/applets/common"; 4 4 5 5 //////////////////////////////////////////// 6 6 // SETUP ··· 22 22 metadata: applet("../../processor/metadata"), 23 23 }; 24 24 25 - // Start processing when tracks are loaded 25 + // Start processing once settled and tracks are loaded 26 26 context 27 27 .settled() 28 28 .then(() => configurator.output) 29 - .then((output) => waitUntilAppletData(output, (d) => d?.tracks.state === "loaded")) 29 + .then((output) => wait(output, (d) => d?.tracks.state === "loaded")) 30 30 .then(() => process()); 31 31 32 32 ////////////////////////////////////////////
+62 -53
src/pages/orchestrator/single-queue/_applet.astro
··· 1 1 <script> 2 2 import type { ManagedOutput, ResolvedUri, Track } from "@applets/core/types.d.ts"; 3 - import { applet, reactive, register, waitUntilAppletData } from "@scripts/applets/common"; 3 + import { applet, inputUrl, reactive, register, wait } from "@scripts/applets/common"; 4 4 5 5 //////////////////////////////////////////// 6 6 // SETUP 7 7 //////////////////////////////////////////// 8 8 import type * as AudioEngine from "@applets/engine/audio/types.d.ts"; 9 9 import type * as QueueEngine from "@applets/engine/queue/types.d.ts"; 10 + import type { Applet } from "@web-applets/sdk"; 11 + import { undefined } from "astro:schema"; 10 12 11 13 // Register applet 12 14 const context = register(); ··· 27 29 //////////////////////////////////////////// 28 30 context.setActionHandler("fill", fill); 29 31 32 + // TODO: Shuffle, limit track amount, etc. 30 33 async function fill(tracks: Track[]) { 31 - const queueItems = await tracks.reduce( 32 - async (promise: Promise<QueueEngine.QueueItem[]>, track: Track) => { 33 - const acc = await promise; 34 - const res = await configurator.input.sendAction<ResolvedUri>( 35 - "resolve", 36 - { 37 - method: "GET", 38 - uri: track.uri, 39 - }, 40 - { 41 - timeoutDuration: 60000 * 5, 42 - }, 43 - ); 34 + await engine.queue.sendAction("add", tracks, { 35 + timeoutDuration: 60000, 36 + }); 37 + } 44 38 45 - if (!res) return acc; 46 - 47 - return [ 48 - ...acc, 49 - { 50 - expiresAt: res.expiresAt, 51 - id: track.id, 52 - url: res.url, 53 - }, 54 - ]; 55 - }, 56 - Promise.resolve([]), 57 - ); 39 + //////////////////////////////////////////// 40 + // Connections 41 + //////////////////////////////////////////// 42 + await context.settled(); 58 43 59 - await engine.queue.sendAction("add", queueItems); 44 + function connect<D, T>( 45 + applet: Applet<D>, 46 + dataFn: (data: D) => T, 47 + effectFn: (t: T, setter: (t: T) => void) => void, 48 + ) { 49 + if (!context.isMainInstance()) return; 50 + return reactive(applet, dataFn, effectFn); 60 51 } 61 52 62 53 //////////////////////////////////////////// ··· 64 55 // 🔉 AUDIO 65 56 //////////////////////////////////////////// 66 57 58 + // When the active audio has ended, 59 + // shift the queue. 60 + 67 61 // NOTE: 68 - // These could probably be optimised, but it works. 62 + // This could probably be optimised, but it works. 69 63 70 - reactive( 64 + connect( 71 65 engine.audio, 72 66 (data) => data.items[engine.queue.data.now?.id ?? Infinity]?.hasEnded ?? false, 73 67 (hasEnded) => { ··· 79 73 // ⚙️ [Connections → Engines] 80 74 // 🚏 QUEUE 81 75 //////////////////////////////////////////// 82 - reactive( 76 + 77 + // When the active queue item has changed, 78 + // coordinate the audio engine accordingly. 79 + 80 + connect( 83 81 engine.queue, 84 82 (data) => data.now?.id, 85 - () => { 83 + async () => { 86 84 const playingNow = engine.queue.data.now; 87 85 const volume = engine.audio.data.volume; 88 86 89 - if (!playingNow) { 90 - // NOTE: This probably isn't correct, keep preloads? 91 - engine.audio.sendAction("render", { tracks: [] }); 92 - return; 93 - } 94 - 95 87 // Play new active queue item 96 - engine.audio.sendAction("render", { 97 - tracks: [ 98 - { 99 - id: playingNow.id, 100 - isPreload: false, 101 - url: playingNow.url, 102 - }, 103 - ], 104 - play: { 105 - trackId: playingNow.id, 106 - volume, 88 + // TODO: Take URL expiration timestamp into account 89 + // TODO: Preload next queue item 90 + engine.audio.sendAction( 91 + "render", 92 + { 93 + audio: playingNow 94 + ? [ 95 + { 96 + id: playingNow.id, 97 + isPreload: false, 98 + url: await inputUrl(configurator.input, playingNow.uri), 99 + }, 100 + ] 101 + : // NOTE: This probably isn't correct, keep preloads? 102 + [], 103 + play: playingNow 104 + ? { 105 + audioId: playingNow.id, 106 + volume, 107 + } 108 + : undefined, 107 109 }, 108 - }); 110 + { 111 + timeoutDuration: 60000, 112 + }, 113 + ); 109 114 110 - fill(configurator.output.data.tracks.collection); 115 + // Add more tracks to the queue if needed 116 + if (playingNow) fill(configurator.output.data.tracks.collection); 111 117 }, 112 118 ); 113 119 ··· 115 121 // 🎻 [Connections → Configurators] 116 122 // 📦 OUTPUT 117 123 //////////////////////////////////////////// 118 - waitUntilAppletData(configurator.output, (d) => d?.tracks.state === "loaded").then(() => { 119 - reactive( 124 + 125 + // Add tracks to the queue once the tracks have been loaded. 126 + 127 + wait(configurator.output, (d) => d?.tracks.state === "loaded").then(() => { 128 + connect( 120 129 configurator.output, 121 130 (data) => data.tracks.cacheId, 122 131 () => {
+41 -14
src/pages/processor/artwork/_applet.astro
··· 21 21 }; 22 22 23 23 // Applet connections 24 + // TODO: Ideally only configurator, orchestrator and UI applets have nested applets. 25 + // Can we find a way to remove this dependency? 24 26 const processor = { 25 27 metadata: applet("../../processor/metadata"), 26 28 }; ··· 42 44 //////////////////////////////////////////// 43 45 // ACTIONS 44 46 //////////////////////////////////////////// 47 + function artwork(request: ArtworkRequest) { 48 + return processRequest(request); 49 + } 50 + 45 51 function supply(items: ArtworkRequest[]) { 46 52 const exe = !queue[0]; 47 53 queue = [...queue, ...items]; 48 54 if (exe) shiftQueue(); 49 55 } 50 56 57 + context.setActionHandler("artwork", artwork); 51 58 context.setActionHandler("supply", supply); 52 59 53 60 //////////////////////////////////////////// ··· 111 118 .catch(() => musicBrainzCover(remainingReleases.slice(1))); 112 119 } 113 120 114 - async function shiftQueue() { 115 - const next = queue.shift(); 116 - if (!next) return; 121 + async function processRequest(req: ArtworkRequest): Promise<Artwork[]> { 122 + // Check if already processed 123 + // TODO: Retry if none was found? 124 + const cache = await IDB.get(`${IDB_PREFIX}/${req.cacheId}`); 125 + console.log("fromCache", cache); 126 + if (cache) return cache; 117 127 118 - // Check if already processed 119 - const cache = await IDB.get(`${IDB_PREFIX}/${next.cacheId}`); 120 - if (cache && cache.length > 0) return; 128 + console.log(req); 121 129 122 130 // 🚀 123 131 let art: Artwork[] = []; 124 132 125 133 // Get metadata + possible artwork from file metadata 126 134 const proc = await processor.metadata; 127 - const meta = await proc.sendAction<Extraction>("supply", { ...next, includeArtwork: true }); 128 - if (!next.tags) next.tags = meta.tags; 135 + const meta = await proc.sendAction<Extraction>( 136 + "supply", 137 + { ...req, includeArtwork: true }, 138 + { 139 + timeoutDuration: 60000 * 5, 140 + }, 141 + ); 142 + 143 + if (!req.tags) req.tags = meta.tags; 144 + 145 + console.log(meta); 129 146 130 147 // Add artwork from metadata 131 148 const fromMeta = 132 149 meta.artwork?.map((a: IPicture) => { 133 150 return { bytes: a.data, mime: a.format }; 134 151 }) || []; 152 + 153 + console.log(fromMeta); 135 154 136 155 art.push(...fromMeta); 137 156 138 157 // If no artwork, try finding it on other sources 139 158 if (art.length === 0) { 140 - const fromMusicBrainz = await musicBrainz(next); 159 + const fromMusicBrainz = await musicBrainz(req); 141 160 art.push(...fromMusicBrainz); 142 161 } 143 162 144 163 if (art.length === 0) { 145 - const fromLastFm = await lastFm(next); 164 + const fromLastFm = await lastFm(req); 146 165 art.push(...fromLastFm); 147 166 } 148 167 149 168 // Save artwork to IDB 150 - await IDB.set(`${IDB_ARTWORK_PREFIX}/${next.cacheId}`, art); 151 - context.data.artwork[next.cacheId] = art; 169 + await IDB.set(`${IDB_ARTWORK_PREFIX}/${req.cacheId}`, art); 170 + context.data.artwork[req.cacheId] = art; 171 + 172 + // Fin 173 + return art; 174 + } 175 + 176 + async function shiftQueue() { 177 + const next = queue.shift(); 178 + if (!next) return; 152 179 153 - // 🏹 154 - shiftQueue(); 180 + await processRequest(next); 181 + await shiftQueue(); 155 182 } 156 183 </script>
+51 -29
src/scripts/applets/common.ts
··· 6 6 import { effect, isSignal, type Signal, signal } from "spellcaster/spellcaster.js"; 7 7 import { xxh32 } from "xxh32"; 8 8 9 - import type { Track } from "@applets/core/types"; 9 + import type { ResolvedUri, Track } from "@applets/core/types"; 10 10 11 11 //////////////////////////////////////////// 12 - // 🪟 Applet connector 12 + // 🪟 Applet connecting 13 13 //////////////////////////////////////////// 14 14 export async function applet<D>( 15 15 src: string, 16 16 opts: { 17 17 addSlashSuffix?: boolean; 18 - applets?: Record<string, string>; 19 18 container?: HTMLElement | Element; 20 - id?: string; 19 + frameId?: string; 20 + groupId?: string; 21 21 setHeight?: boolean; 22 22 } = {}, 23 23 ): Promise<Applet<D>> { ··· 29 29 : "" 30 30 }`; 31 31 32 - if (opts.applets) { 33 - src = QS.stringifyUrl({ url: src, query: opts.applets }); 32 + let query: undefined | Record<string, string>; 33 + 34 + if (opts.groupId) { 35 + query = { groupId: opts.groupId }; 36 + } 37 + 38 + if (query) { 39 + src = QS.stringifyUrl({ url: src, query }); 34 40 } 35 41 36 42 const existingFrame: HTMLIFrameElement | null = window.document.querySelector(`[src="${src}"]`); ··· 42 48 } else { 43 49 frame = document.createElement("iframe"); 44 50 frame.src = src; 45 - if (opts.id) frame.id = opts.id; 51 + if (opts.frameId) frame.id = opts.frameId; 46 52 47 53 if (opts.container) { 48 54 opts.container.appendChild(frame); ··· 86 92 87 93 settled(): Promise<void>; 88 94 89 - get id(): string; 95 + get instanceId(): string; 90 96 set data(data: T); 91 97 92 98 codec: { ··· 99 105 }; 100 106 101 107 export function register<DataType = any>() { 102 - const channelId = `${location.host}${location.pathname}`; 108 + const url = new URL(location.href); 103 109 const scope = applets.register<DataType>(); 104 - const id = crypto.randomUUID(); 110 + 111 + const groupId = url.searchParams.get("groupId") || "main"; 112 + const channelId = `${location.host}${location.pathname}/${groupId}`; 113 + const instanceId = crypto.randomUUID(); 105 114 106 115 let isMainInstance = true; 107 116 ··· 119 128 case "PING": { 120 129 channel.postMessage({ 121 130 type: "PONG", 122 - id: event.data.id, 131 + instanceId: event.data.instanceId, 123 132 }); 124 133 125 134 if (isMainInstance) { ··· 132 141 } 133 142 134 143 case "PONG": { 135 - if (event.data.id === id) { 144 + if (event.data.instanceId === instanceId) { 136 145 isMainInstance = false; 137 146 } 138 147 break; ··· 143 152 const result = await scope.actionHandlers[event.data.actionId]?.(...event.data.arguments); 144 153 channel.postMessage({ 145 154 type: "actioncomplete", 146 - id: event.data.id, 155 + actionInstanceId: event.data.actionInstanceId, 147 156 result, 148 157 }); 149 158 } ··· 159 168 160 169 // Promise that fullfills whenever it figures out its the main instance or not. 161 170 const promise = new Promise<void>((resolve) => { 162 - const id = setTimeout(() => { 171 + const timeoutId = setTimeout(() => { 163 172 channel.removeEventListener("message", handler); 164 173 resolve(undefined); 165 174 }, 1000); 166 175 167 176 const handler = (event: MessageEvent) => { 168 177 if (event.data === "pong" || event.data === "ping") { 169 - clearTimeout(id); 178 + clearTimeout(timeoutId); 170 179 channel.removeEventListener("message", handler); 171 180 resolve(undefined); 172 181 } ··· 178 187 // Send out ping 179 188 channel.postMessage({ 180 189 type: "PING", 181 - id, 190 + instanceId, 182 191 }); 183 192 184 193 // If the data on the main instance changes, ··· 202 211 return promise; 203 212 }, 204 213 205 - get id() { 206 - return id; 214 + get instanceId() { 215 + return instanceId; 207 216 }, 208 217 209 218 get data() { ··· 230 239 } 231 240 232 241 const actionMessage = { 233 - id: crypto.randomUUID(), 234 - type: "action", 242 + actionInstanceId: crypto.randomUUID(), 235 243 actionId, 244 + type: "action", 236 245 arguments: args, 237 246 }; 238 247 239 248 return new Promise((resolve) => { 240 249 const actionCallback = (event: MessageEvent) => { 241 - if (event.data?.type === "actioncomplete" && event.data?.id === actionMessage.id) { 250 + if ( 251 + event.data?.type === "actioncomplete" && 252 + event.data?.actionInstanceId === actionMessage.actionInstanceId 253 + ) { 242 254 channel.removeEventListener("message", actionCallback); 243 255 resolve(event.data.result); 244 256 } ··· 277 289 } 278 290 279 291 //////////////////////////////////////////// 292 + // ⚡️ COMMON ACTION CALLS 293 + //////////////////////////////////////////// 294 + 295 + export async function inputUrl(input: Applet, uri: string, method = "GET") { 296 + return await input.sendAction<ResolvedUri>( 297 + "resolve", 298 + { 299 + method, 300 + uri, 301 + }, 302 + { 303 + timeoutDuration: 60000 * 5, 304 + }, 305 + ); 306 + } 307 + 308 + //////////////////////////////////////////// 280 309 // 🛠️ 281 310 //////////////////////////////////////////// 282 311 export function addScope<O extends object>(astroScope: string, object: O): O { ··· 352 381 return new TextEncoder().encode(JSON.stringify(a)); 353 382 } 354 383 355 - export function waitUntilAppletData<A>( 356 - applet: Applet<A>, 357 - dataFn: (a: A | undefined) => boolean, 358 - ): Promise<void> { 384 + export function wait<A>(applet: Applet<A>, dataFn: (a: A | undefined) => boolean): Promise<void> { 359 385 return new Promise((resolve) => { 360 386 if (dataFn(applet.data) === true) { 361 387 resolve(); ··· 372 398 applet.addEventListener("data", callback); 373 399 }); 374 400 } 375 - 376 - export function waitUntilAppletIsReady(applet: Applet): Promise<void> { 377 - return waitUntilAppletData(applet, (data) => !!data?.ready); 378 - }
+6 -33
src/scripts/themes/desktop/index.ts
··· 1 1 import * as Uint8 from "uint8arrays"; 2 - import { applet, reactive, waitUntilAppletData } from "@scripts/applets/common"; 2 + import { applet, reactive, wait } from "@scripts/applets/common"; 3 3 4 4 //////////////////////////////////////////// 5 5 // 🎨 Styles ··· 9 9 //////////////////////////////////////////// 10 10 // 🗂️ Applets 11 11 //////////////////////////////////////////// 12 - import type * as AudioEngine from "@applets/engine/audio/types.d.ts"; 13 - import type * as QueueEngine from "@applets/engine/queue/types.d.ts"; 14 - import type { ManagedOutput, Track } from "@applets/core/types"; 15 - import type { Urls } from "@applets/processor/artwork/types"; 16 - 17 - const configurator = { 18 - input: await applet("../../configurator/input"), 19 - output: await applet<ManagedOutput>("../../configurator/output"), 20 - }; 21 - 22 - const engine = { 23 - audio: await applet<AudioEngine.State>("../../engine/audio"), 24 - queue: await applet<QueueEngine.State>("../../engine/queue"), 25 - }; 26 - 27 - const _orchestrator = { 28 - input: await applet("../../orchestrator/input-cache"), 29 - queue: await applet("../../orchestrator/single-queue"), 30 - }; 31 - 32 - const processor = { 33 - artwork: await applet("../../processor/artwork"), 34 - search: await applet("../../processor/search"), 35 - }; 36 - 37 12 const ui = {}; 38 13 39 14 //////////////////////////////////////////// ··· 52 27 53 28 // TESTING 54 29 55 - waitUntilAppletData(configurator.output, (data) => data?.tracks?.state === "loaded").then( 56 - async () => { 57 - await processor.search.sendAction("supply", configurator.output.data.tracks.collection); 58 - const tracks = await processor.search.sendAction("search", "artist lee last"); 59 - tracks.forEach((t: Track) => console.log(t.tags)); 60 - }, 61 - ); 30 + // wait(configurator.output, (data) => data?.tracks?.state === "loaded").then(async () => { 31 + // await processor.search.sendAction("supply", configurator.output.data.tracks.collection); 32 + // const tracks = await processor.search.sendAction("search", "artist lee last"); 33 + // tracks.forEach((t: Track) => console.log(t.tags)); 34 + // });
+2 -2
src/scripts/themes/webamp/index.ts
··· 2 2 import Webamp from "webamp"; 3 3 4 4 import type { ManagedOutput, ResolvedUri, Track } from "@applets/core/types.d.ts"; 5 - import { applet, waitUntilAppletData } from "@scripts/applets/common"; 5 + import { applet, wait } from "@scripts/applets/common"; 6 6 7 7 //////////////////////////////////////////// 8 8 // 🎨 Styles ··· 33 33 document.body.appendChild(ampNode); 34 34 amp.renderWhenReady(ampNode); 35 35 36 - waitUntilAppletData(configurator.output, (d) => d?.tracks.state === "loaded").then(loadAndInsert); 36 + wait(configurator.output, (d) => d?.tracks.state === "loaded").then(loadAndInsert); 37 37 configurator.output.ondata = loadAndInsert; 38 38 39 39 let inserting = false;
+17
src/styles/diffuse/colors.css
··· 1 + :root { 2 + /* Colors */ 3 + --color-1: oklch(4.1308% 0.25306 109.22); 4 + --color-2: oklch(98.369% 0.01834 67.664); 5 + --color-3: oklch(26.787% 0.00168 186.65); 6 + --accent: oklch(86.947% 0.25527 28.789); 7 + 8 + --bg-color: var(--color-2); 9 + --text-color: var(--color-1); 10 + } 11 + 12 + @media (prefers-color-scheme: dark) { 13 + :root { 14 + --bg-color: var(--color-3); 15 + --text-color: var(--color-2); 16 + } 17 + }
+14
src/styles/diffuse/fonts.css
··· 1 + :root { 2 + font-family: "Inter", sans-serif; 3 + font-size: var(--fs-base); 4 + } 5 + 6 + @supports (font-variation-settings: normal) { 7 + :root { 8 + font-family: "InterVariable", sans-serif; 9 + font-feature-settings: 10 + "zero" 2, 11 + "ss03" 2; 12 + font-optical-sizing: auto; 13 + } 14 + }
+2 -32
src/styles/pages/index.css
··· 1 - @import "../variables.css"; 2 - 3 - :root { 4 - font-family: "Inter", sans-serif; 5 - font-size: var(--fs-base); 6 - 7 - /* Colors */ 8 - --color-1: oklch(4.1308% 0.25306 109.22); 9 - --color-2: oklch(98.369% 0.01834 67.664); 10 - --color-3: oklch(26.787% 0.00168 186.65); 11 - --accent: oklch(86.947% 0.25527 28.789); 12 - 13 - --bg-color: var(--color-2); 14 - --text-color: var(--color-1); 15 - } 16 - 17 - @media (prefers-color-scheme: dark) { 18 - :root { 19 - --bg-color: var(--color-3); 20 - --text-color: var(--color-2); 21 - } 22 - } 23 - 24 - @supports (font-variation-settings: normal) { 25 - :root { 26 - font-family: "InterVariable", sans-serif; 27 - font-feature-settings: 28 - "zero" 2, 29 - "ss03" 2; 30 - font-optical-sizing: auto; 31 - } 32 - } 1 + @import "../diffuse/colors.css"; 2 + @import "../diffuse/fonts.css"; 33 3 34 4 body { 35 5 background-color: var(--bg-color);