A decentralized music tracking and discovery platform built on AT Protocol ๐ŸŽต rocksky.app
spotify atproto lastfm musicbrainz scrobbling listenbrainz
0

Configure Feed

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

fix(web-mobile): media session never started on first play

The silent <audio> anchor that surfaces the OS/lock-screen media controls was
rendered by the mini-player, which returns null (and so mounts no DOM) whenever
nothing is playing. The one-time registration effect therefore captured a null
element and never re-ran, so playMediaAnchor() was a no-op on the very first
track โ€” the gesture that most needs it โ€” and Media Session never appeared.

Move ownership of the anchor into media-session-anchor.ts: it lazily creates a
persistent, self-appended <audio> the first time it's asked to play, so the
in-gesture start (playNow / resume / media keys) always has a real element to
kick, independent of React render timing. Drop the React-rendered <audio>, ref,
and registration; sync effect now just mirrors state. Also stop muting the
element (the clip is already silent) since a zero-volume element can suppress
Media Session in some browsers.

+37 -33
+11 -23
apps/web-mobile/src/components/MiniPlayer/index.tsx
··· 35 35 } from "../../lib/audio/rockbox-engine"; 36 36 import { ensureStreamToken } from "../../api/uploads"; 37 37 import { 38 + pauseMediaAnchor, 38 39 playMediaAnchor, 39 - registerMediaAnchor, 40 40 } from "../../lib/audio/media-session-anchor"; 41 - import { SILENT_AUDIO_DATA_URI } from "../../lib/audio/silence"; 42 41 import EqualizerSheet from "../EqualizerSheet"; 43 42 import PlayerScreen from "../PlayerScreen"; 44 43 import axios from "axios"; ··· 156 155 const [eqSheetOpen, setEqSheetOpen] = useState(false); 157 156 const [shuffle, setShuffle] = useAtom(shuffleAtom); 158 157 const [repeatMode, setRepeatMode] = useAtom(repeatModeAtom); 159 - // Hidden silent <audio> that plays while the wasm engine plays, so the OS / 160 - // lock-screen media controls (Media Session) surface โ€” Web Audio alone 161 - // doesn't trigger them. 162 - const silentRef = useRef<HTMLAudioElement>(null); 158 + // A hidden silent <audio> (owned by media-session-anchor, not React) plays 159 + // while the wasm engine plays, so the OS / lock-screen media controls (Media 160 + // Session) surface โ€” Web Audio alone doesn't trigger them. 163 161 164 162 // Keep refs in sync 165 163 useEffect(() => { ··· 467 465 // and would be blocked by the autoplay policy. onPlayPause routes to the 468 466 // active backend (wasm engine / Spotify / device). 469 467 set("play", () => { 470 - silentRef.current?.play().catch(() => {}); 468 + playMediaAnchor(); 471 469 if (!nowPlayingRef.current?.isPlaying) onPlayPause(); 472 470 }); 473 471 set("pause", () => { 474 - silentRef.current?.pause(); 472 + pauseMediaAnchor(); 475 473 if (nowPlayingRef.current?.isPlaying) onPlayPause(); 476 474 }); 477 475 set("previoustrack", onPrevious); ··· 497 495 navigator.mediaSession.playbackState = nowPlaying?.isPlaying ? "playing" : "paused"; 498 496 }, [nowPlaying?.isPlaying]); 499 497 500 - // Register the silent <audio> so in-gesture click handlers (playNow, resume, 501 - // media-key play) can start it โ€” see media-session-anchor. 502 - useEffect(() => { 503 - registerMediaAnchor(silentRef.current); 504 - return () => registerMediaAnchor(null); 505 - }, []); 506 - 507 - // Keep the silent Media Session anchor playing whenever the engine plays. 498 + // Keep the silent Media Session anchor in sync with engine playback. The 499 + // in-gesture start happens in the click handlers (playNow / resume / media 500 + // keys); this effect only mirrors state afterwards and pauses when idle. 508 501 useEffect(() => { 509 - const el = silentRef.current; 510 - if (!el) return; 511 - if (player === "upload" && nowPlaying?.isPlaying) el.play().catch(() => {}); 512 - else el.pause(); 502 + if (player === "upload" && nowPlaying?.isPlaying) playMediaAnchor(); 503 + else pauseMediaAnchor(); 513 504 }, [player, nowPlaying?.isPlaying]); 514 505 515 506 useEffect(() => { ··· 543 534 544 535 return ( 545 536 <> 546 - {/* Silent Media Session anchor for the Web Audio (engine) playback path. */} 547 - <audio ref={silentRef} src={SILENT_AUDIO_DATA_URI} loop preload="auto" /> 548 - 549 537 <EqualizerSheet open={eqSheetOpen} onClose={() => setEqSheetOpen(false)} /> 550 538 551 539 <PlayerScreen
+26 -10
apps/web-mobile/src/lib/audio/media-session-anchor.ts
··· 1 - // Media Session anchor โ€” a hidden, silent <audio> element kept playing while 2 - // the wasm engine (Web Audio) plays, so the OS / lock-screen media controls 3 - // surface. Web Audio alone doesn't trigger Media Session. 1 + import { SILENT_AUDIO_DATA_URI } from "./silence"; 2 + 3 + // Media Session anchor โ€” a hidden, silent, looping <audio> element kept playing 4 + // while the wasm engine (Web Audio) plays, so the OS / lock-screen media 5 + // controls (Media Session) surface. Web Audio alone doesn't trigger them. 4 6 // 5 - // The catch: browsers only let <audio>.play() start from a user gesture. React 6 - // effects that react to state run AFTER the gesture, so play() there is rejected 7 - // by the autoplay policy and the anchor never starts. These helpers let the 8 - // click handlers (playNow, resume, media-key play) start the anchor 9 - // synchronously, inside the gesture. 7 + // The element is created lazily and owned by THIS module, not React. That's 8 + // deliberate: the mini-player unmounts its DOM when nothing is playing, so a 9 + // React-rendered <audio> doesn't exist yet at the moment of the very first 10 + // play() โ€” and browsers only let <audio>.play() start from inside a user 11 + // gesture. By owning a persistent element here, the click handlers (playNow, 12 + // resume, media-key play) can start it synchronously, in-gesture, every time. 10 13 11 14 let anchor: HTMLAudioElement | null = null; 12 15 13 - export function registerMediaAnchor(el: HTMLAudioElement | null): void { 16 + function getAnchor(): HTMLAudioElement | null { 17 + if (typeof document === "undefined") return null; 18 + if (anchor) return anchor; 19 + const el = document.createElement("audio"); 20 + el.src = SILENT_AUDIO_DATA_URI; 21 + el.loop = true; 22 + el.preload = "auto"; 23 + // The clip is silent audio content (inaudible), so no need to mute โ€” a 24 + // zero-volume element can stop some browsers from surfacing Media Session. 25 + el.setAttribute("aria-hidden", "true"); 26 + el.style.display = "none"; 27 + document.body.appendChild(el); 14 28 anchor = el; 29 + return anchor; 15 30 } 16 31 17 32 export function playMediaAnchor(): void { 18 - anchor?.play().catch(() => { 33 + // Touch the element synchronously so play() lands inside the gesture. 34 + getAnchor()?.play().catch(() => { 19 35 // Autoplay policy or no gesture โ€” nothing we can do; ignore. 20 36 }); 21 37 }