~/nyuma.dev

Bypassing mobile autoplay restrictions in audio players

Mobile browsers block autoplay between media, so Muse required a workaround.

5 mins read

If you don't already know, I'm working on the greatest music downloader ever made - its called Muse.

it's free & self-hostable, allowing users to download and stream their favorite songs from Youtube, SoundCloud, and more. When building the music player for Muse, everything worked fine, at first.

Click a song? It plays.
Click next? It plays the next song.
Press spacebar? It pauses.
All good.

But then, I noticed a weird bug. On mobile, once the current track ended — nothing happened. If you just waited, the next song would never start playing. If you tried skipping the media, it would just reset back to your native music app’s recently played list.

What was happening?

To no surprise, most browsers (especially Safari on iOS) are strict about autoplay. They allow it once the user has tapped play, but creating a new Audio() instance resets the playback context — which mobile browsers treat as a new sound request.

So even if I had gesture-based approval from the user initially, doing this in production broke Muse's autoplay:

1audioElement = new Audio(song.stream_url);
2await audioElement.play();

On desktop though, Muse seemed fine. That’s because on desktop browsers like Chrome use something called the Media Engagement Index (MEI), which allows autoplay in more cases once your site is 'trusted' - typically after a user interacts or plays media for at least 7 seconds.

You can find the MEI in Chrome by going to chrome://media-engagement. It shows how many times a user has interacted with media on your site, and once you hit a certain threshold, autoplay works more freely.

Here's mine:

Like I said, with mobile browsers, the autoplay policy is farrr stricter. They allow autoplay only if there’s been a direct user gesture — like a tap — and get this, only within the same playback context. If you create a new Audio element or new AudioContext, it resets the trust. So even though the user had tapped once, the browser saw it as a new sound request — and blocked it, especially if the tab was in the background or the screen was off.

🤔But wait...

If Muse wanted to rival native alternatives like Apple Music, I knew the UX had to match the status quo: uninterrupted audio playback.

That’s when I realized: while desktop let Muse create new audio instances freely without issue, mobile required us to reuse the original playback context if we wanted autoplay to work reliably across tracks.


The fix

Instead of creating a new audio element each time, I decided to reuse a single persistent <audio> element for the user's entire session on the app.

1if (!audioElement) {
2 audioElement = document.createElement("audio");
3 audioElement.preload = "auto";
4 audioElement.crossOrigin = "anonymous";
5 audioElement.playsInline = true;
6 document.body.appendChild(audioElement);
7}
8
9audioElement.src = song.stream_url!;
10await audioElement.play();
Tip

The overhead of updating .src continually is quite minimal in all modern browsers. We do it because on mobile user agents, that's what is treated as continuous playback.

Sometimes, the audio element would be null, so I check for that and create it if necessary. I also set crossOrigin to anonymous to avoid CORS issues with streaming media. I set playsInline to true so it plays inline on iOS instead of fullscreen, and I append it to the body so it’s always in the DOM.

This way, the audio element persists across song changes, and I only call .play() after the initial user gesture. Meaning that once the user taps play on the first song, the same audio element continues to play subsequent tracks without needing to create new instances.

And as we know, this fixed it. Playback continues across songs. The phone screen can be off. No prompts. No crazy buffering. No hacks.


Rich media controls and metadata

Since I was already touching this, I also looked into:

  • navigator.mediaSession to show song info + scrub buttons on the lock screen and carplay
  • navigator.wakeLock to keep the screen from dimming during playback
1navigator.mediaSession.metadata = new MediaMetadata({
2 title: song.title,
3 artist: song.uploader,
4 artwork: [{ src: song.thumbnail!, sizes: "512x512", type: "image/jpeg" }]
5});
6
7navigator.mediaSession.setActionHandler("nexttrack", () => get().nextSong());
8navigator.mediaSession.setActionHandler("previoustrack", () => get().previousSong());
Important

mediaSession doesn't unlock autoplay — but it makes the UX feel native, especially with proper artwork and skip controls.


The result

Muse now:

  • Autoplays the next song, even with the screen off
  • Keeps the lock screen media controls updated
  • Doesn’t break when switching tabs or backgrounding

All by avoiding new Audio(...) calls after the first play and rethinking how I structured playback logic.


If you're hitting this bug

You don’t need a workaround, hack, or service worker.

You just need:

  1. One persistent <audio> element
  2. .src = songUrl on song change
  3. .play() only after initial user gesture
  4. mediaSession if you care about lockscreen polish

That’s it.

1let audioElement: HTMLAudioElement | null = null;
2audioElement = document.createElement("audio");
3
4audioElement.src = songList[currentSongIndex].stream_url;
5audioElement.preload = "auto";
6audioElement.crossOrigin = "anonymous";
7audioElement.playsInline = true;
8
9document.body.appendChild(audioElement);
10await audioElement.play();
11
12// On song change or gesture:
13if (!audioElement) {
14 audioElement = document.createElement("audio");
15 audioElement.preload = "auto";
16 audioElement.crossOrigin = "anonymous";
17 audioElement.playsInline = true;
18 document.body.appendChild(audioElement);
19}
20
21const nextSongUrl = songList[currentSongIndex + 1].stream_url;
22audioElement.src = nextSongUrl;
23await audioElement.play();

Final thoughts

This bug made Muse feel broken for a while on mobile. But understanding how the browser treats playback contexts cleared everything up.

If your autoplay works until the second track — now you know why.