forked from
tokono.ma/diffuse
A music player that connects to your cloud/distributed storage.
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);