Add lock screen and media key controls to a custom HTML5 video player
TL;DR We'll register a custom player with the browser's "now playing" surface so lock screens, headphone buttons and keyboard media keys work. You'll set metadata, artwork, action handlers and position state, and learn
TL;DR
We'll register a custom player with the browser's "now playing" surface so lock screens, headphone buttons and keyboard media keys work. You'll set metadata, artwork, action handlers and position state, and learn which calls throw.
What we're building 🎧
A tiny wrapper, registerMediaSession(video, info), that you can drop into any custom player built on a <video> or <audio> element. No dependencies. Browser support per web.dev: Chrome 73, Edge 79, Firefox 82, Safari 15. Feature-detect, never assume.
1. Feature-detect
// media-session.js
export function supported() {
return "mediaSession" in navigator;
}
If it returns false, do nothing. The player still works, it just has no system controls.
2. Set metadata and artwork
// media-session.js
export function setMetadata({ title, artist, album, artwork }) {
navigator.mediaSession.metadata = new MediaMetadata({
title, artist, album, artwork,
});
}
Call it with sized artwork:
setMetadata({
title: "Episode 12: Segment lengths",
artist: "Video Infra Weekly",
artwork: [
{ src: "/art/256.png", sizes: "256x256", type: "image/png" },
{ src: "/art/512.png", sizes: "512x512", type: "image/png" },
],
});
💡 Tip: Per web.dev, Chrome on Android targets 512x512 artwork, or 256x256 on low-end devices. Without artwork the browser falls back to the largest page icon, which is usually too small.
⚠️ Note: Inside an iframe, session info must be set from the embedded context, not the parent page.
Call setMetadata again whenever the source changes, otherwise a playlist keeps showing the previous title.
3. Register action handlers
Controls on the system card only appear when the matching handler exists.
// media-session.js
export function bindActions(video) {
const actions = {
play: () => video.play(),
pause: () => video.pause(),
seekbackward: (d) => { video.currentTime = Math.max(0, video.currentTime - (d.seekOffset ?? 10)); },
seekforward: (d) => { video.currentTime = Math.min(video.duration, video.currentTime + (d.seekOffset ?? 10)); },
seekto: (d) => { video.currentTime = d.seekTime; },
};
for (const [name, handler] of Object.entries(actions)) {
try {
navigator.mediaSession.setActionHandler(name, handler);
} catch (e) {
console.warn(`mediaSession action "${name}" unsupported`, e);
}
}
}
Why the try: unsupported actions can throw, and web.dev recommends guarding each call. Why ?? 10: seekOffset is optional and the guide suggests 10 to 30 seconds when absent.
seekto also receives fastSeek, true for intermediate calls while the user drags. Skip expensive work then:
seekto: (d) => {
if (d.fastSeek && "fastSeek" in video) { video.fastSeek(d.seekTime); return; }
video.currentTime = d.seekTime;
},
4. Keep position state honest
// media-session.js
export function updatePosition(video) {
const { duration, currentTime, playbackRate } = video;
if (!Number.isFinite(duration) || duration <= 0) {
navigator.mediaSession.setPositionState(null);
return;
}
navigator.mediaSession.setPositionState({
duration,
playbackRate: playbackRate || 1,
position: Math.min(currentTime, duration),
});
}
The rules from web.dev: duration must be provided and positive, position must be positive and less than the duration, playback rate must be greater than zero. Violate one and the call throws, which is why we guard Infinity (typical for live streams, so the card ends up without a scrubber) and clamp position.
5. Wire it together
// player.js
import { supported, setMetadata, bindActions, updatePosition } from "./media-session.js";
const video = document.querySelector("video");
if (supported()) {
bindActions(video);
video.addEventListener("loadedmetadata", () => updatePosition(video));
video.addEventListener("play", () => {
navigator.mediaSession.playbackState = "playing";
updatePosition(video);
});
video.addEventListener("pause", () => {
navigator.mediaSession.playbackState = "paused";
});
video.addEventListener("seeked", () => updatePosition(video));
video.addEventListener("ratechange", () => updatePosition(video));
}
Setting playbackState yourself matters because the browser can think nothing is playing during a seek or load.
6. Test it ✅
| Check | How |
|---|---|
| Lock screen card | Lock a real phone while playing |
| Headphone button | Press once (pause) and twice |
| Keyboard media keys | Desktop, with the tab in the background |
| Scrubber | Seek from the lock screen, watch seeked fire |
| Live stream | Confirm no console error from position state |
⚠️ Note: web.dev says Chrome requests full audio focus only when media is at least 5 seconds long, so very short clips will not appear.
Wrapping up
That is about forty lines. Next steps: chapters (the guide notes Chrome 127 support, ChromeOS notifications only for now) and wiring previoustrack and nexttrack if you have a playlist. If you use an off-the-shelf player, check whether it already calls the API before adding this, or you will register twice.
Originally published by Dev.to WebDev. Aggregated on AIWithGhost for educational purposes — full credit and traffic to the original publisher.