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

Configure Feed

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

at v4 226 lines 6.4 kB view raw
1import { 2 BroadcastableDiffuseElement, 3 defineElement, 4 query, 5 queryOptional, 6} from "~/common/element.js"; 7 8/** 9 * @import {OutputElement} from "~/components/output/types.d.ts" 10 * @import ArtworkOrchestrator from "~/components/orchestrator/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 */ 22class 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.identifier, {}); 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 {ArtworkOrchestrator | null} */ 51 this.artwork = queryOptional(this, "artwork-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 tracksCol = this.output?.tracks.collection(); 75 const track = now && tracksCol?.state === "loaded" 76 ? tracksCol.data.find((t) => t.id === now.id) 77 : undefined; 78 79 if (!track) { 80 navigator.mediaSession.metadata = null; 81 return; 82 } 83 84 const tags = track.tags ?? {}; 85 86 navigator.mediaSession.metadata = new MediaMetadata({ 87 title: tags.title ?? "", 88 artist: tags.artist ?? tags.albumartist ?? "", 89 album: tags.album ?? "", 90 artwork: [], 91 }); 92 93 // Optionally fetch and attach artwork 94 if (this.artwork) { 95 const artworkOrchestrator = this.artwork; 96 97 /** @type {Uint8Array | null} */ 98 let bytes = null; 99 100 try { 101 bytes = await artworkOrchestrator.get(track); 102 } catch { 103 bytes = null; 104 } 105 106 if (bytes && navigator.mediaSession.metadata) { 107 const mime = detectMime(bytes); 108 const blob = new Blob([/** @type {ArrayBuffer} */ (bytes.buffer)], { 109 type: mime, 110 }); 111 112 const url = URL.createObjectURL(blob); 113 const nowLater = this.queue.now(); 114 115 // If in the meantime the now-playing track has changed, 116 // don't set the artwork. 117 if (nowLater?.id !== now?.id) return; 118 119 navigator.mediaSession.metadata.artwork = [ 120 { src: url, type: mime }, 121 ]; 122 } 123 } 124 } 125 126 #syncPlaybackState() { 127 if (!this.audio) return; 128 navigator.mediaSession.playbackState = this.audio.isPlaying() 129 ? "playing" 130 : "paused"; 131 } 132 133 #syncPositionState() { 134 if (!this.audio || !this.queue) return; 135 136 const now = this.queue.now(); 137 if (!now) return; 138 139 const state = this.audio.state(now.id); 140 if (!state) return; 141 142 const duration = state.duration(); 143 const progress = state.progress(); 144 145 if (!duration || isNaN(duration) || duration === 0) return; 146 147 try { 148 navigator.mediaSession.setPositionState({ 149 duration, 150 position: duration * progress, 151 playbackRate: 1, 152 }); 153 } catch { 154 // setPositionState may throw if duration is not finite 155 } 156 } 157 158 #registerActionHandlers() { 159 navigator.mediaSession.setActionHandler("play", async () => { 160 if (!this.audio || !this.queue) return; 161 if (!(await this.isLeader())) return; 162 const now = this.queue.now(); 163 if (now) this.audio.play({ audioId: now.id }); 164 }); 165 166 navigator.mediaSession.setActionHandler("pause", async () => { 167 if (!this.audio || !this.queue) return; 168 if (!(await this.isLeader())) return; 169 const now = this.queue.now(); 170 if (now) this.audio.pause({ audioId: now.id }); 171 }); 172 173 navigator.mediaSession.setActionHandler("previoustrack", async () => { 174 if (!this.queue) return; 175 if (!(await this.isLeader())) return; 176 await this.queue.unshift(); 177 }); 178 179 navigator.mediaSession.setActionHandler("nexttrack", async () => { 180 if (!this.queue) return; 181 if (!(await this.isLeader())) return; 182 await this.queue.shift(); 183 }); 184 185 navigator.mediaSession.setActionHandler("seekto", async (details) => { 186 if (!this.audio || !this.queue) return; 187 if (!(await this.isLeader())) return; 188 const now = this.queue.now(); 189 if (!now || details.seekTime == null) return; 190 const state = this.audio.state(now.id); 191 const duration = state?.duration(); 192 if (!duration || duration === 0) return; 193 this.audio.seek({ 194 audioId: now.id, 195 percentage: details.seekTime / duration, 196 }); 197 }); 198 } 199} 200 201export default MediaSessionOrchestrator; 202 203//////////////////////////////////////////// 204// 🛠️ 205//////////////////////////////////////////// 206 207/** 208 * @param {Uint8Array} bytes 209 * @returns {string} 210 */ 211function detectMime(bytes) { 212 if (bytes[0] === 0xFF && bytes[1] === 0xD8) return "image/jpeg"; 213 if (bytes[0] === 0x89 && bytes[1] === 0x50) return "image/png"; 214 if (bytes[0] === 0x47 && bytes[1] === 0x49) return "image/gif"; 215 if (bytes[0] === 0x52 && bytes[1] === 0x49) return "image/webp"; 216 return "image/jpeg"; 217} 218 219//////////////////////////////////////////// 220// REGISTER 221//////////////////////////////////////////// 222 223export const CLASS = MediaSessionOrchestrator; 224export const NAME = "do-media-session"; 225 226defineElement(NAME, MediaSessionOrchestrator);