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: add media session orchestrator

+242 -10
+1
deno.jsonc
··· 105 105 "./components/input/s3/element.js": "./src/components/input/s3/element.js", 106 106 "./components/input/s3/worker.js": "./src/components/input/s3/worker.js", 107 107 "./components/orchestrator/auto-queue/element.js": "./src/components/orchestrator/auto-queue/element.js", 108 + "./components/orchestrator/media-session/element.js": "./src/components/orchestrator/media-session/element.js", 108 109 "./components/orchestrator/input/element.js": "./src/components/orchestrator/input/element.js", 109 110 "./components/orchestrator/output/element.js": "./src/components/orchestrator/output/element.js", 110 111 "./components/orchestrator/process-tracks/element.js": "./src/components/orchestrator/process-tracks/element.js",
+29 -10
src/common/facets/foundation.js
··· 12 12 import ScopeEngine from "@components/engine/scope/element.js"; 13 13 import ScopedTracksOrchestrator from "@components/orchestrator/scoped-tracks/element.js"; 14 14 import FavouritesOrchestrator from "@components/orchestrator/favourites/element.js"; 15 + import MediaSessionOrchestrator from "@components/orchestrator/media-session/element.js"; 15 16 import SourcesOrchestrator from "@components/orchestrator/sources/element.js"; 16 17 17 18 /** ··· 45 46 autoQueue, 46 47 favourites, 47 48 input, 49 + mediaSession, 48 50 output, 49 51 queueAudio, 50 52 processTracks, ··· 85 87 queue: queue(), 86 88 }, 87 89 orchestrator: { 90 + mediaSession: mediaSession(), 88 91 queueAudio: queueAudio(), 89 92 }, 90 93 }; ··· 186 189 return findExistingOrAdd(aqo); 187 190 } 188 191 192 + function favourites() { 193 + const o = output(); 194 + 195 + const fo = new FavouritesOrchestrator(); 196 + fo.setAttribute("group", GROUP); 197 + fo.setAttribute("output-selector", o.selector); 198 + 199 + return findExistingOrAdd(fo); 200 + } 201 + 189 202 function input() { 190 203 const i = new InputOrchestrator(); 191 204 i.setAttribute("group", GROUP); ··· 194 207 return findExistingOrAdd(i); 195 208 } 196 209 210 + function mediaSession() { 211 + const a = audio(); 212 + const aw = artwork(); 213 + const o = output(); 214 + const q = queue(); 215 + 216 + const mso = new MediaSessionOrchestrator(); 217 + mso.setAttribute("group", GROUP); 218 + mso.setAttribute("audio-engine-selector", a.selector); 219 + mso.setAttribute("artwork-processor-selector", aw.selector); 220 + mso.setAttribute("output-selector", o.selector); 221 + mso.setAttribute("queue-engine-selector", q.selector); 222 + 223 + return findExistingOrAdd(mso); 224 + } 225 + 197 226 function output() { 198 227 const o = new OutputOrchestrator(); 199 228 o.setAttribute("group", GROUP); ··· 256 285 sto.setAttribute("search-processor-selector", s.selector); 257 286 258 287 return findExistingOrAdd(sto); 259 - } 260 - 261 - function favourites() { 262 - const o = output(); 263 - 264 - const fo = new FavouritesOrchestrator(); 265 - fo.setAttribute("group", GROUP); 266 - fo.setAttribute("output-selector", o.selector); 267 - 268 - return findExistingOrAdd(fo); 269 288 } 270 289 271 290 function sources() {
+212
src/components/orchestrator/media-session/element.js
··· 1 + import { 2 + BroadcastableDiffuseElement, 3 + query, 4 + queryOptional, 5 + } from "@common/element.js"; 6 + 7 + /** 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" 11 + */ 12 + 13 + //////////////////////////////////////////// 14 + // ELEMENT 15 + //////////////////////////////////////////// 16 + 17 + /** 18 + * Keeps the browser/OS Media Session in sync with queue and audio engine state. 19 + * 20 + * Forwards play, pause, seek and track-skip actions from the OS back to the engines. 21 + */ 22 + class MediaSessionOrchestrator extends BroadcastableDiffuseElement { 23 + static NAME = "diffuse/orchestrator/media-session"; 24 + 25 + // LIFECYCLE 26 + 27 + /** 28 + * @override 29 + */ 30 + async connectedCallback() { 31 + // Broadcast if needed 32 + if (this.hasAttribute("group")) { 33 + this.broadcast(this.nameWithGroup, {}); 34 + } 35 + 36 + // Super 37 + super.connectedCallback(); 38 + 39 + if (!("mediaSession" in navigator)) return; 40 + 41 + /** @type {import("@components/engine/audio/element.js").CLASS} */ 42 + this.audio = query(this, "audio-engine-selector"); 43 + 44 + /** @type {import("@components/engine/queue/element.js").CLASS} */ 45 + this.queue = query(this, "queue-engine-selector"); 46 + 47 + /** @type {OutputElement | null} */ 48 + this.output = queryOptional(this, "output-selector"); 49 + 50 + /** @type {ArtworkProcessor | null} */ 51 + this.artwork = queryOptional(this, "artwork-processor-selector"); 52 + 53 + // Wait until defined 54 + await customElements.whenDefined(this.audio.localName); 55 + await customElements.whenDefined(this.queue.localName); 56 + if (this.output) await customElements.whenDefined(this.output.localName); 57 + if (this.artwork) await customElements.whenDefined(this.artwork.localName); 58 + 59 + // Register Media Session action handlers 60 + this.#registerActionHandlers(); 61 + 62 + // Effects 63 + this.effect(() => this.#syncMetadata()); 64 + this.effect(() => this.#syncPlaybackState()); 65 + this.effect(() => this.#syncPositionState()); 66 + } 67 + 68 + // 🛠️ 69 + 70 + async #syncMetadata() { 71 + if (!this.queue) return; 72 + 73 + const now = this.queue.now(); 74 + const track = now && this.output 75 + ? this.output.tracks.collection().find((t) => t.id === now.id) 76 + : undefined; 77 + 78 + if (!track) { 79 + navigator.mediaSession.metadata = null; 80 + return; 81 + } 82 + 83 + const tags = track.tags ?? {}; 84 + 85 + navigator.mediaSession.metadata = new MediaMetadata({ 86 + title: tags.title ?? "", 87 + artist: tags.artist ?? tags.albumartist ?? "", 88 + album: tags.album ?? "", 89 + artwork: [], 90 + }); 91 + 92 + // Optionally fetch and attach artwork 93 + if (this.artwork) { 94 + const artworkProcessor = this.artwork; 95 + 96 + /** @type {Artwork[]} */ 97 + let artworkItems; 98 + 99 + try { 100 + artworkItems = await artworkProcessor.artwork({ 101 + cacheId: track.id, 102 + tags, 103 + }); 104 + } catch { 105 + artworkItems = []; 106 + } 107 + 108 + if (artworkItems?.length && navigator.mediaSession.metadata) { 109 + const { bytes, mime } = artworkItems[0]; 110 + const blob = new Blob([/** @type {ArrayBuffer} */ (bytes.buffer)], { 111 + type: mime, 112 + }); 113 + 114 + const url = URL.createObjectURL(blob); 115 + const nowLater = this.queue.now(); 116 + 117 + // If in the meantime the now-playing track has changed, 118 + // don't set the artwork. 119 + if (nowLater?.id !== now?.id) return; 120 + 121 + navigator.mediaSession.metadata.artwork = [ 122 + { src: url, type: mime }, 123 + ]; 124 + } 125 + } 126 + } 127 + 128 + #syncPlaybackState() { 129 + if (!this.audio) return; 130 + navigator.mediaSession.playbackState = this.audio.isPlaying() 131 + ? "playing" 132 + : "paused"; 133 + } 134 + 135 + #syncPositionState() { 136 + if (!this.audio || !this.queue) return; 137 + 138 + const now = this.queue.now(); 139 + if (!now) return; 140 + 141 + const state = this.audio.state(now.id); 142 + if (!state) return; 143 + 144 + const duration = state.duration(); 145 + const progress = state.progress(); 146 + 147 + if (!duration || isNaN(duration) || duration === 0) return; 148 + 149 + try { 150 + navigator.mediaSession.setPositionState({ 151 + duration, 152 + position: duration * progress, 153 + playbackRate: 1, 154 + }); 155 + } catch { 156 + // setPositionState may throw if duration is not finite 157 + } 158 + } 159 + 160 + #registerActionHandlers() { 161 + navigator.mediaSession.setActionHandler("play", async () => { 162 + if (!this.audio || !this.queue) return; 163 + if (!(await this.isLeader())) return; 164 + const now = this.queue.now(); 165 + if (now) this.audio.play({ audioId: now.id }); 166 + }); 167 + 168 + navigator.mediaSession.setActionHandler("pause", async () => { 169 + if (!this.audio || !this.queue) return; 170 + if (!(await this.isLeader())) return; 171 + const now = this.queue.now(); 172 + if (now) this.audio.pause({ audioId: now.id }); 173 + }); 174 + 175 + navigator.mediaSession.setActionHandler("previoustrack", async () => { 176 + if (!this.queue) return; 177 + if (!(await this.isLeader())) return; 178 + await this.queue.unshift(); 179 + }); 180 + 181 + navigator.mediaSession.setActionHandler("nexttrack", async () => { 182 + if (!this.queue) return; 183 + if (!(await this.isLeader())) return; 184 + await this.queue.shift(); 185 + }); 186 + 187 + navigator.mediaSession.setActionHandler("seekto", async (details) => { 188 + if (!this.audio || !this.queue) return; 189 + if (!(await this.isLeader())) return; 190 + const now = this.queue.now(); 191 + if (!now || details.seekTime == null) return; 192 + const state = this.audio.state(now.id); 193 + const duration = state?.duration(); 194 + if (!duration || duration === 0) return; 195 + this.audio.seek({ 196 + audioId: now.id, 197 + percentage: details.seekTime / duration, 198 + }); 199 + }); 200 + } 201 + } 202 + 203 + export default MediaSessionOrchestrator; 204 + 205 + //////////////////////////////////////////// 206 + // REGISTER 207 + //////////////////////////////////////////// 208 + 209 + export const CLASS = MediaSessionOrchestrator; 210 + export const NAME = "do-media-session"; 211 + 212 + customElements.define(NAME, MediaSessionOrchestrator);