A music player that connects to your cloud/distributed storage. diffuse.sh
Something went wrong. Try again.
9.0 kB · 273 lines
JavaScript
at main
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274import { BroadcastableDiffuseElement, defineElement, query, queryOptional,} from "~/common/element.js";
/** * @import {OutputElement} from "@specs/components/output/types.d.ts" * @import ArtworkOrchestrator from "~/components/orchestrator/artwork/element.js" * @import {default as AudioEngine} from "~/components/engine/audio/element.js" * @import {default as QueueEngine} from "~/components/engine/queue/element.js" */
////////////////////////////////////////////// ELEMENT////////////////////////////////////////////
/** * Keeps the browser/OS Media Session in sync with queue and audio engine state. * * Forwards play, pause, seek and track-skip actions from the OS back to the engines. */class MediaSessionOrchestrator extends BroadcastableDiffuseElement { static NAME = "diffuse/orchestrator/media-session";
// SIGNALS
/** @type {string | null} */ #artworkUrl = null;
// LIFECYCLE
/** * @override */ async connectedCallback() { if (this.hasAttribute("group")) { this.broadcast(this.identifier, {}); }
super.connectedCallback();
if (!("mediaSession" in navigator)) return;
/** @type {AudioEngine} */ this.audio = query(this, "audio-engine-selector"); /** @type {QueueEngine} */ this.queue = query(this, "queue-engine-selector"); /** @type {OutputElement | null} */ this.output = queryOptional(this, "output-selector"); /** @type {ArtworkOrchestrator | null} */ this.artwork = queryOptional(this, "artwork-selector");
await Promise.all([ customElements.whenDefined(this.audio.localName), customElements.whenDefined(this.queue.localName), this.output && customElements.whenDefined(this.output.localName), this.artwork && customElements.whenDefined(this.artwork.localName), ].filter(Boolean));
this.#registerActionHandlers();
this.effect(() => this.#syncMetadata()); this.effect(() => this.#syncPlaybackState()); this.effect(() => this.#syncPositionState()); }
// 🛠️
async #syncMetadata() { if (!this.queue) return;
const now = this.queue.now(); const tracksCol = this.output?.tracks.collection(); const track = now && tracksCol?.state === "loaded" ? tracksCol.data.find((t) => t.id === now.id) : undefined;
if (!track) { if (this.#artworkUrl) { URL.revokeObjectURL(this.#artworkUrl); this.#artworkUrl = null; } navigator.mediaSession.metadata = null; return; }
const tags = track.tags ?? {};
navigator.mediaSession.metadata = new MediaMetadata({ title: tags.title ?? "", artist: tags.artist ?? tags.albumartist ?? "", album: tags.album ?? "", artwork: [], });
// Optionally fetch and attach artwork if (this.artwork) { const artworkOrchestrator = this.artwork;
/** @type {Uint8Array | null} */ let bytes = null;
try { bytes = await artworkOrchestrator.get(track); } catch { bytes = null; }
if (bytes && navigator.mediaSession.metadata) { const mime = detectMime(bytes); const blob = new Blob([/** @type {ArrayBuffer} */ (bytes.buffer)], { type: mime, });
const url = URL.createObjectURL(blob); const nowLater = this.queue.now();
// If in the meantime the now-playing track has changed, // don't set the artwork. if (nowLater?.id !== now?.id) { URL.revokeObjectURL(url); return; }
if (this.#artworkUrl) URL.revokeObjectURL(this.#artworkUrl); this.#artworkUrl = url;
navigator.mediaSession.metadata = new MediaMetadata({ title: tags.title ?? "", artist: tags.artist ?? tags.albumartist ?? "", album: tags.album ?? "", artwork: [{ src: url, type: mime }], }); } } }
#syncPlaybackState() { if (!this.audio || !this.queue) return;
// Reflect the real state: "none" when nothing is loaded, so the OS media // controls don't linger with stale "paused" UI. Firefox keeps the media // session surface alive as long as playbackState is "playing"/"paused", // and its `setPositionState` throws while the state is "none" — so // keeping this in sync matters on Firefox in particular. const playing = this.audio.isPlaying();
navigator.mediaSession.playbackState = this.queue.now() ? playing ? "playing" : "paused" : "none"; }
#syncPositionState() { if (!this.audio || !this.queue) return;
const now = this.queue.now(); if (!now) return;
const state = this.audio.state(now.id); if (!state) return;
const duration = state.duration(); const progress = state.progress();
if (!duration || isNaN(duration) || duration === 0) return; if (typeof navigator.mediaSession.setPositionState !== "function") return;
// Don't gate on navigator.mediaSession.playbackState here: that read is // set by a separate effect (#syncPlaybackState) from browser state, so it // can still be "none" on the first run after a track loads — and on // Firefox the position would then never be (re)declared because this // effect doesn't re-run when playbackState flips. Firefox throws from // setPositionState while the session is "none", which the catch below // absorbs, so we only need to gate on whether there is actually a // playable current track (checked above via `now`/`state`/`duration`). try { navigator.mediaSession.setPositionState({ duration, position: Math.min(duration * progress, duration), playbackRate: 1, }); } catch { // setPositionState may throw (e.g. on Firefox) when the position is // not finite or the browser doesn't consider the session playable yet. } }
#registerActionHandlers() { // Register each handler defensively: `setActionHandler` throws for // actions a browser doesn't support (Firefox is stricter than Chrome), so // an unsupported action must never abort registration of the rest. An // exception here would also escape `connectedCallback` and prevent the // metadata / playback-state / position-state effects below from running, // leaving the media session completely inert. /** @type {Array<[MediaSessionAction, MediaSessionActionHandler]>} */ const handlers = [ ["play", () => { if (!this.audio || !this.queue) return; const now = this.queue.now(); if (now) this.audio.play({ audioId: now.id }); }], ["pause", () => { if (!this.audio || !this.queue) return; const now = this.queue.now(); if (now) this.audio.pause({ audioId: now.id }); }], ["previoustrack", () => { if (!this.queue) return; this.queue.unshift(); }], ["nexttrack", () => { if (!this.queue) return; this.queue.shift(); }], ["seekto", (details) => { if (!this.audio || !this.queue) return; const now = this.queue.now(); if (!now || details.seekTime == null) return; const state = this.audio.state(now.id); const duration = state?.duration(); if (!duration || duration === 0) return; this.audio.seek({ audioId: now.id, percentage: details.seekTime / duration, }); }], ];
// NOTE: These handlers deliberately do NOT gate on a local "leader" flag. // The underlying engines already resolve cross-tab leadership — `play`/ // `pause`/`seek` are broadcast "leaderOnly" from the audio engine, and // `shift`/`unshift` operate on the queue's shared worker state. Gating the // media-session actions here would make every handler a silent no-op in a // non-leading tab while metadata still updates (both effects run), which // looks exactly like "controls show but do nothing" on Firefox. for (const [action, handler] of handlers) { try { navigator.mediaSession.setActionHandler(action, handler); } catch { // Action not supported on this browser/platform — skip it. } } }}
export default MediaSessionOrchestrator;
////////////////////////////////////////////// 🛠️////////////////////////////////////////////
/** * @param {Uint8Array} bytes * @returns {string} */function detectMime(bytes) { if (bytes[0] === 0xFF && bytes[1] === 0xD8) return "image/jpeg"; if (bytes[0] === 0x89 && bytes[1] === 0x50) return "image/png"; if (bytes[0] === 0x47 && bytes[1] === 0x49) return "image/gif"; if (bytes[0] === 0x52 && bytes[1] === 0x49) return "image/webp"; return "image/jpeg";}
////////////////////////////////////////////// REGISTER////////////////////////////////////////////
export const CLASS = MediaSessionOrchestrator;export const NAME = "do-media-session";
defineElement(NAME, MediaSessionOrchestrator);