How to Add Lock-Screen and Headphone Controls to Your Website's Audio Player

Web Audio Development

8 min read

Dark glass smartphone on black stone with a liquid chrome waveform and a lime progress line on its screen, white earbuds beside it

A visitor presses play on your portfolio, likes what they hear, and puts the phone in a pocket.

The track keeps going. Then they reach for the lock screen to skip ahead, or squeeze their earbuds to pause, and the controls show the browser’s name, a generic icon and a play button that does half of what they expect.

Nothing is broken. The site simply never told the operating system what is playing. That is the job of the Media Session API, and it takes a few dozen lines of JavaScript.

We already covered how to keep audio playing while visitors move through your site and, last week, how to render web-ready files from REAPER. This note picks up where the sound leaves the page: the lock screen, the notification shade, the headphones and the keyboard’s media keys.

What the Media Session API actually controls

The API has two halves. One describes what is playing. The other decides what happens when someone presses a system control.

Metadata. Title, artist, album and artwork, passed through a MediaMetadata object. This is what replaces the browser’s name and icon in the system UI.

Action handlers. Functions you register for actions such as play, pause, seek, previous track and next track. The browser routes lock-screen buttons, notification buttons, hardware media keys and headset buttons to them.

Playback state and position. Whether the session is playing or paused, and where the playhead is. This keeps the system scrubber in sync with your own.

Support is broad for the basics. Google’s web.dev guide lists the core API in Chrome 73, Edge 79, Firefox 82 and Safari 15, and WebKit announced it in its Safari 15 feature notes. Individual actions still vary, which is why MDN labels the API as limited availability rather than Baseline. Feature-detect, then degrade quietly.

Start with metadata, after playback begins

The smallest useful version is metadata alone. It makes the lock screen and the notification show the right track and the right cover.

const audio = document.querySelector("audio");

function setTrackMetadata(track) {
  if (!("mediaSession" in navigator)) return;
  navigator.mediaSession.metadata = new MediaMetadata({
    title: track.title,
    artist: track.artist,
    album: track.album,
    artwork: [
      { src: track.cover256, sizes: "256x256", type: "image/jpeg" },
      { src: track.cover512, sizes: "512x512", type: "image/jpeg" },
    ],
  });
}

playButton.addEventListener("click", async () => {
  await audio.play();
  setTrackMetadata(currentTrack);
});
const audio = document.querySelector("audio");

function setTrackMetadata(track) {
  if (!("mediaSession" in navigator)) return;
  navigator.mediaSession.metadata = new MediaMetadata({
    title: track.title,
    artist: track.artist,
    album: track.album,
    artwork: [
      { src: track.cover256, sizes: "256x256", type: "image/jpeg" },
      { src: track.cover512, sizes: "512x512", type: "image/jpeg" },
    ],
  });
}

playButton.addEventListener("click", async () => {
  await audio.play();
  setTrackMetadata(currentTrack);
});
const audio = document.querySelector("audio");

function setTrackMetadata(track) {
  if (!("mediaSession" in navigator)) return;
  navigator.mediaSession.metadata = new MediaMetadata({
    title: track.title,
    artist: track.artist,
    album: track.album,
    artwork: [
      { src: track.cover256, sizes: "256x256", type: "image/jpeg" },
      { src: track.cover512, sizes: "512x512", type: "image/jpeg" },
    ],
  });
}

playButton.addEventListener("click", async () => {
  await audio.play();
  setTrackMetadata(currentTrack);
});
const audio = document.querySelector("audio");

function setTrackMetadata(track) {
  if (!("mediaSession" in navigator)) return;
  navigator.mediaSession.metadata = new MediaMetadata({
    title: track.title,
    artist: track.artist,
    album: track.album,
    artwork: [
      { src: track.cover256, sizes: "256x256", type: "image/jpeg" },
      { src: track.cover512, sizes: "512x512", type: "image/jpeg" },
    ],
  });
}

playButton.addEventListener("click", async () => {
  await audio.play();
  setTrackMetadata(currentTrack);
});

Three details matter more than they look.

Set it after play() resolves. MDN’s own example sets up the session inside the promise returned by play(), started from a user gesture. Mobile browsers often block playback without one, and a session with nothing playing has nothing to show.

Update it on every track change. The metadata belongs to the session, not to the file. If your player swaps sources without updating it, the lock screen keeps showing the previous track.

Give real artwork sizes. According to web.dev, Chrome for Android targets 512×512 artwork and 256×256 on low-end devices. Without artwork, Chrome falls back to the site’s <link rel="icon">. For a portfolio, that means your favicon stands in for the cover art. Export square covers at both sizes alongside the audio.

Wire the controls you actually support

Matte black headphones in darkness sending a thin lime light pulse toward a floating liquid chrome sphere

By default, a media notification shows a play/pause control. web.dev is explicit that other controls stay hidden until you register a handler for them. That is useful: you choose exactly which buttons appear.

function setHandler(action, handler) {
  try {
    navigator.mediaSession.setActionHandler(action, handler);
  } catch {
    // This browser does not know this action. Skip it.
  }
}

function seekBy(seconds) {
  const next = audio.currentTime + seconds;
  audio.currentTime = Math.min(Math.max(next, 0), audio.duration);
  updatePosition();
}

setHandler("play", () => audio.play());
setHandler("pause", () => audio.pause());
setHandler("seekbackward", (d) => seekBy(-(d.seekOffset || 10)));
setHandler("seekforward", (d) => seekBy(d.seekOffset || 10));
setHandler("seekto", (d) => {
  if (d.fastSeek && "fastSeek" in audio) audio.fastSeek(d.seekTime);
  else audio.currentTime = d.seekTime;
  updatePosition();
});
setHandler("previoustrack", hasPrevious() ? playPrevious : null);
setHandler("nexttrack", hasNext() ? playNext : null);
function setHandler(action, handler) {
  try {
    navigator.mediaSession.setActionHandler(action, handler);
  } catch {
    // This browser does not know this action. Skip it.
  }
}

function seekBy(seconds) {
  const next = audio.currentTime + seconds;
  audio.currentTime = Math.min(Math.max(next, 0), audio.duration);
  updatePosition();
}

setHandler("play", () => audio.play());
setHandler("pause", () => audio.pause());
setHandler("seekbackward", (d) => seekBy(-(d.seekOffset || 10)));
setHandler("seekforward", (d) => seekBy(d.seekOffset || 10));
setHandler("seekto", (d) => {
  if (d.fastSeek && "fastSeek" in audio) audio.fastSeek(d.seekTime);
  else audio.currentTime = d.seekTime;
  updatePosition();
});
setHandler("previoustrack", hasPrevious() ? playPrevious : null);
setHandler("nexttrack", hasNext() ? playNext : null);
function setHandler(action, handler) {
  try {
    navigator.mediaSession.setActionHandler(action, handler);
  } catch {
    // This browser does not know this action. Skip it.
  }
}

function seekBy(seconds) {
  const next = audio.currentTime + seconds;
  audio.currentTime = Math.min(Math.max(next, 0), audio.duration);
  updatePosition();
}

setHandler("play", () => audio.play());
setHandler("pause", () => audio.pause());
setHandler("seekbackward", (d) => seekBy(-(d.seekOffset || 10)));
setHandler("seekforward", (d) => seekBy(d.seekOffset || 10));
setHandler("seekto", (d) => {
  if (d.fastSeek && "fastSeek" in audio) audio.fastSeek(d.seekTime);
  else audio.currentTime = d.seekTime;
  updatePosition();
});
setHandler("previoustrack", hasPrevious() ? playPrevious : null);
setHandler("nexttrack", hasNext() ? playNext : null);
function setHandler(action, handler) {
  try {
    navigator.mediaSession.setActionHandler(action, handler);
  } catch {
    // This browser does not know this action. Skip it.
  }
}

function seekBy(seconds) {
  const next = audio.currentTime + seconds;
  audio.currentTime = Math.min(Math.max(next, 0), audio.duration);
  updatePosition();
}

setHandler("play", () => audio.play());
setHandler("pause", () => audio.pause());
setHandler("seekbackward", (d) => seekBy(-(d.seekOffset || 10)));
setHandler("seekforward", (d) => seekBy(d.seekOffset || 10));
setHandler("seekto", (d) => {
  if (d.fastSeek && "fastSeek" in audio) audio.fastSeek(d.seekTime);
  else audio.currentTime = d.seekTime;
  updatePosition();
});
setHandler("previoustrack", hasPrevious() ? playPrevious : null);
setHandler("nexttrack", hasNext() ? playNext : null);

A few decisions are baked into that block.

  • Wrap every registration. MDN’s examples wrap setActionHandler in try/catch, because an action one browser knows can be unknown to another.

  • Respect the seek offset. The seekbackward and seekforward actions may pass a seekOffset in seconds. When they do not, MDN suggests a reasonable default; web.dev suggests something in the 10 to 30 second range. Ten works well for previews.

  • Handle fast seeking. When the user drags the system scrubber, seekto can arrive in rapid bursts with fastSeek set to true. Use the element’s fast seek where it exists.

  • Remove what does not apply. Passing null removes a handler. On the first track, unset previous; on the last, unset next. A button that does nothing is worse than no button.

Keep the handlers thin. They should call the same functions your on-page buttons call, so there is one source of truth for what play, pause and next mean. On a site with a persistent player, that is the player’s store, not the component that happens to be on screen.

Keep the scrubber honest

Liquid chrome waveform ribbon over a reflective black floor, crossed by a single vertical lime light beam acting as a playhead

Metadata and handlers give you a working remote. Position state gives you a correct one. Without it, the system progress bar can drift, or show nothing at all.

function updatePosition() {
  if (!("setPositionState" in navigator.mediaSession)) return;
  if (!Number.isFinite(audio.duration)) return;
  navigator.mediaSession.setPositionState({
    duration: audio.duration,
    playbackRate: audio.playbackRate,
    position: Math.min(audio.currentTime, audio.duration),
  });
}

audio.addEventListener("loadedmetadata", updatePosition);
audio.addEventListener("ratechange", updatePosition);
audio.addEventListener("seeked", updatePosition);
audio.addEventListener("play", () => {
  navigator.mediaSession.playbackState = "playing";
  updatePosition();
});
audio.addEventListener("pause", () => {
  navigator.mediaSession.playbackState = "paused";
});
function updatePosition() {
  if (!("setPositionState" in navigator.mediaSession)) return;
  if (!Number.isFinite(audio.duration)) return;
  navigator.mediaSession.setPositionState({
    duration: audio.duration,
    playbackRate: audio.playbackRate,
    position: Math.min(audio.currentTime, audio.duration),
  });
}

audio.addEventListener("loadedmetadata", updatePosition);
audio.addEventListener("ratechange", updatePosition);
audio.addEventListener("seeked", updatePosition);
audio.addEventListener("play", () => {
  navigator.mediaSession.playbackState = "playing";
  updatePosition();
});
audio.addEventListener("pause", () => {
  navigator.mediaSession.playbackState = "paused";
});
function updatePosition() {
  if (!("setPositionState" in navigator.mediaSession)) return;
  if (!Number.isFinite(audio.duration)) return;
  navigator.mediaSession.setPositionState({
    duration: audio.duration,
    playbackRate: audio.playbackRate,
    position: Math.min(audio.currentTime, audio.duration),
  });
}

audio.addEventListener("loadedmetadata", updatePosition);
audio.addEventListener("ratechange", updatePosition);
audio.addEventListener("seeked", updatePosition);
audio.addEventListener("play", () => {
  navigator.mediaSession.playbackState = "playing";
  updatePosition();
});
audio.addEventListener("pause", () => {
  navigator.mediaSession.playbackState = "paused";
});
function updatePosition() {
  if (!("setPositionState" in navigator.mediaSession)) return;
  if (!Number.isFinite(audio.duration)) return;
  navigator.mediaSession.setPositionState({
    duration: audio.duration,
    playbackRate: audio.playbackRate,
    position: Math.min(audio.currentTime, audio.duration),
  });
}

audio.addEventListener("loadedmetadata", updatePosition);
audio.addEventListener("ratechange", updatePosition);
audio.addEventListener("seeked", updatePosition);
audio.addEventListener("play", () => {
  navigator.mediaSession.playbackState = "playing";
  updatePosition();
});
audio.addEventListener("pause", () => {
  navigator.mediaSession.playbackState = "paused";
});

MDN documents the rules, and they are strict. setPositionState() throws a TypeError if the duration is missing or negative, if the position is negative or greater than the duration, or if the playback rate is zero. That is why the function waits for a finite duration and clamps the position. Calling it with no arguments clears the state.

Update the position on events that change it: metadata loaded, seek, rate change, play. You do not need to call it on every timeupdate. The system extrapolates from the last position and the rate.

Where it fits in a Framer site

In Framer, this code belongs in the same code component that owns the audio element, not in a page-level script. The session is tied to whatever is playing, so it should live next to the thing that plays.

If you already built an interactive audio component in Framer, add the metadata call where playback starts and the handlers in a mount effect, removing them on unmount. If you use a library, the same applies: the API sits on top of whatever produces the sound. Our Tone.js vs Howler.js comparison covers which library suits which player.

For a producer site, the payoff is concrete. A visitor can work through a beat tape from the lock screen, with your name and cover art in front of them the whole time. That is the same branding work the portfolio player patterns do on the page, carried outside it.

What breaks in practice

  • Short sounds do not get a notification. web.dev notes that Chrome only shows media notifications for media that lasts at least five seconds. UI clicks and sound logos will not appear, and that is intended. Do not fight it.

  • Several audio elements, one session. There is one navigator.mediaSession per page. If a hover sound and a music player both play, the session should describe the music. Keep metadata updates inside the main player only.

  • Stale handlers after navigation. On a single-page site, a component that registered handlers and then unmounted can leave them pointing at nothing. Reset them on unmount, or own them from the persistent player.

  • Paused but still “playing”. If you pause through your own code path and forget to update playbackState, some system UIs keep showing a pause button. Set it on the element’s play and pause events, as above, rather than in each button.

Before you ship

  1. Feature-detect mediaSession and setPositionState; the player must work without them.

  2. Set metadata after playback starts, and again on every track change.

  3. Provide square artwork at 256 and 512 pixels.

  4. Register only the actions the player supports; wrap each in try/catch.

  5. Unset previous and next at the ends of the list.

  6. Clamp the position and wait for a finite duration before calling setPositionState.

  7. Test on a locked phone, with Bluetooth earbuds, and with the keyboard’s media keys.

The last item is the one people skip. Each surface routes actions a little differently, and accessible audio starts with controls that work wherever the listener reaches for them.

FAQ

Does the Media Session API make audio play in the background?

No. It describes and controls playback that is already happening. Whether a page keeps playing when the screen locks is decided by the browser and the operating system. The API makes that playback visible and controllable once it does.

Do I need it for a sound logo or UI sounds?

No. Chrome does not show a notification for media shorter than five seconds, according to web.dev, and nobody wants a lock-screen entry for a button click. Use it for music, previews, podcasts and anything a visitor might listen to for a while.

What happens in a browser that does not support it?

Nothing, if you feature-detect. The player works as before, and the system controls fall back to the browser’s defaults. That is why every call in this note sits behind a check or a try/catch.

The takeaway

Your player ends at the edge of the page. The listener does not. A few dozen lines of metadata, handlers and position state carry your name, your artwork and working controls onto the lock screen and into the headphones.

If you want a player that does this out of the box, or an audio-first site built around it, see what we build.

ON THIS PAGE

Share on X
Share
Share on Linkedin
Share
Share on Facebook
Share