|
| 1 | +--- |
| 2 | +title: "Media Session API — Lock-Screen Controls for Web Audio + Video" |
| 3 | +description: "Make your podcast / music / video player look native in OS media controls. Two API calls, full lock-screen + Bluetooth-button integration." |
| 4 | +date: 2026-05-10 |
| 5 | +slug: media-session-api |
| 6 | +tags: [javascript, ux] |
| 7 | +--- |
| 8 | + |
| 9 | +When you press play/pause on Bluetooth headphones, the OS routes the keypress to "the app currently playing media." For native apps this is straightforward; for browser tabs it requires the Media Session API. Without it, your podcast site goes silent the moment the user locks their phone. With it, it looks indistinguishable from Spotify on the lock screen. |
| 10 | + |
| 11 | +## The basic setup |
| 12 | + |
| 13 | +```js |
| 14 | +if ("mediaSession" in navigator) { |
| 15 | + navigator.mediaSession.metadata = new MediaMetadata({ |
| 16 | + title: "Episode 42 — On Caching", |
| 17 | + artist: "Web Crispies Podcast", |
| 18 | + album: "Season 3", |
| 19 | + artwork: [ |
| 20 | + { src: "/cover-96.png", sizes: "96x96", type: "image/png" }, |
| 21 | + { src: "/cover-256.png", sizes: "256x256", type: "image/png" }, |
| 22 | + { src: "/cover-512.png", sizes: "512x512", type: "image/png" } |
| 23 | + ] |
| 24 | + }); |
| 25 | +} |
| 26 | +``` |
| 27 | + |
| 28 | +That's it. As soon as a `<video>` or `<audio>` element on the page starts playing, the OS picks up the metadata and shows it in: |
| 29 | +- Lock screen (iOS, Android) |
| 30 | +- Notification shade media widget (Android) |
| 31 | +- Now Playing in macOS Control Center |
| 32 | +- Volume overlay HUD on most platforms |
| 33 | + |
| 34 | +## Action handlers |
| 35 | + |
| 36 | +```js |
| 37 | +navigator.mediaSession.setActionHandler("play", () => audio.play()); |
| 38 | +navigator.mediaSession.setActionHandler("pause", () => audio.pause()); |
| 39 | +navigator.mediaSession.setActionHandler("previoustrack", () => skipToPrevious()); |
| 40 | +navigator.mediaSession.setActionHandler("nexttrack", () => skipToNext()); |
| 41 | +navigator.mediaSession.setActionHandler("seekbackward", (details) => { |
| 42 | + audio.currentTime -= details.seekOffset || 10; |
| 43 | +}); |
| 44 | +navigator.mediaSession.setActionHandler("seekforward", (details) => { |
| 45 | + audio.currentTime += details.seekOffset || 10; |
| 46 | +}); |
| 47 | +navigator.mediaSession.setActionHandler("seekto", (details) => { |
| 48 | + audio.currentTime = details.seekTime; |
| 49 | +}); |
| 50 | +``` |
| 51 | + |
| 52 | +Each handler corresponds to a control surface: lock-screen buttons, hardware media keys, Bluetooth headphones, smart-watches, voice assistants ("Hey Siri, skip this track"). |
| 53 | + |
| 54 | +## Position state for accurate scrubbing |
| 55 | + |
| 56 | +```js |
| 57 | +audio.addEventListener("timeupdate", () => { |
| 58 | + navigator.mediaSession.setPositionState({ |
| 59 | + duration: audio.duration, |
| 60 | + playbackRate: audio.playbackRate, |
| 61 | + position: audio.currentTime |
| 62 | + }); |
| 63 | +}); |
| 64 | +``` |
| 65 | + |
| 66 | +Without this, the lock-screen scrubber shows a generic progress (or nothing). With it, the scrubber accurately reflects current position + duration, AND seeking from the lock screen calls your `seekto` handler. |
| 67 | + |
| 68 | +## Full minimal podcast player |
| 69 | + |
| 70 | +```js |
| 71 | +function setupMedia(episode) { |
| 72 | + navigator.mediaSession.metadata = new MediaMetadata({ |
| 73 | + title: episode.title, |
| 74 | + artist: "Web Crispies", |
| 75 | + artwork: [ |
| 76 | + { src: episode.cover, sizes: "512x512", type: "image/png" } |
| 77 | + ] |
| 78 | + }); |
| 79 | + |
| 80 | + const handlers = { |
| 81 | + play: () => audio.play(), |
| 82 | + pause: () => audio.pause(), |
| 83 | + previoustrack: () => loadEpisode(episode.prev), |
| 84 | + nexttrack: () => loadEpisode(episode.next), |
| 85 | + seekbackward: (e) => audio.currentTime -= (e.seekOffset || 15), |
| 86 | + seekforward: (e) => audio.currentTime += (e.seekOffset || 30), |
| 87 | + seekto: (e) => audio.currentTime = e.seekTime |
| 88 | + }; |
| 89 | + for (const [action, handler] of Object.entries(handlers)) { |
| 90 | + try { |
| 91 | + navigator.mediaSession.setActionHandler(action, handler); |
| 92 | + } catch (e) { |
| 93 | + // Browser may not support all actions |
| 94 | + } |
| 95 | + } |
| 96 | + |
| 97 | + audio.addEventListener("playing", () => { |
| 98 | + navigator.mediaSession.playbackState = "playing"; |
| 99 | + }); |
| 100 | + audio.addEventListener("pause", () => { |
| 101 | + navigator.mediaSession.playbackState = "paused"; |
| 102 | + }); |
| 103 | + audio.addEventListener("timeupdate", () => { |
| 104 | + if (audio.duration) { |
| 105 | + navigator.mediaSession.setPositionState({ |
| 106 | + duration: audio.duration, |
| 107 | + playbackRate: audio.playbackRate, |
| 108 | + position: audio.currentTime |
| 109 | + }); |
| 110 | + } |
| 111 | + }); |
| 112 | +} |
| 113 | +``` |
| 114 | + |
| 115 | +That's a complete podcast player with full OS integration. |
| 116 | + |
| 117 | +## Why this matters |
| 118 | + |
| 119 | +Without Media Session: |
| 120 | +- Lock the phone → audio plays for ~30s, then OS suspends background tabs |
| 121 | +- Play/pause buttons on headphones do nothing for the tab |
| 122 | +- Lock screen shows generic "browser is playing media" |
| 123 | +- User has to unlock + return to tab to control playback |
| 124 | +- Bluetooth-car interfaces show nothing |
| 125 | + |
| 126 | +With Media Session: |
| 127 | +- Audio survives lock-screen indefinitely |
| 128 | +- Headphone buttons work |
| 129 | +- Lock screen shows artwork, title, full controls |
| 130 | +- Car infotainment shows your podcast as native |
| 131 | +- Voice assistants integrate ("hey Siri, pause") |
| 132 | + |
| 133 | +## Browser support |
| 134 | + |
| 135 | +- Chrome / Edge 73+ (March 2019) |
| 136 | +- Safari 15+ (September 2021) |
| 137 | +- Firefox 82+ (October 2020) |
| 138 | +- Universal in 2025+ |
| 139 | + |
| 140 | +Per-action support varies (Safari + Firefox don't all support `seekto`); the `try/catch` per `setActionHandler` is intentional. |
| 141 | + |
| 142 | +## When it's worth wiring |
| 143 | + |
| 144 | +- **Podcast / music players** — table stakes; users expect lock-screen controls |
| 145 | +- **Video players** — same; bonus is play/pause from car interfaces |
| 146 | +- **Live audio** (radio streams) — at minimum metadata + play/pause |
| 147 | +- **Audiobook readers** |
| 148 | + |
| 149 | +## When it's overkill |
| 150 | + |
| 151 | +- **Auto-playing background music on a marketing site** — the user didn't opt in to media-session controls; respects them less than they'd like |
| 152 | +- **UI sound effects** — short bleeps don't need lock-screen entries |
| 153 | +- **Video previews** (auto-pausing when scrolled away) — same |
| 154 | + |
| 155 | +## The ~5-minute integration tax |
| 156 | + |
| 157 | +For any audio/video app, this is the highest-value 5-minute change you can ship. The user experience difference is enormous; the code is short. Most apps skip it because they don't know the API exists. |
| 158 | + |
| 159 | +--- |
| 160 | + |
| 161 | +Practice DOM + events in the [`js-events`](/js-events/0/) module on [Code Crispies](/) — covers the patterns the audio element listeners use. |
0 commit comments