# Media Session API

> navigator.mediaSession describes what is playing and receives play, pause, seek, and track actions from OS media controls. Members, actions, errors, examples.

`navigator.mediaSession` lets a page tell the operating system what it is playing (title, artist, album, artwork) and receive the play, pause, seek, and track-change commands that users issue from lock screens, headset buttons, keyboard media keys, and the browser's own media hub. Without it those controls still toggle the `<audio>` or `<video>` element, but the OS shows the page URL instead of a track name and offers no skip or seek buttons.

`MediaSession` and `setActionHandler()` are available from Chrome 73 on desktop and Chrome 57 on Android, Firefox 82, and Safari 15 on macOS and iOS; `MediaMetadata` is older (Chrome 57, Safari 14). Firefox for Android exposes the API but shows no media-control UI, and Android WebView has no implementation (BCD `api.MediaSession`). Individual actions arrive later than the interface: `seekto` in Chrome 78, `stop` in Chrome 77, `togglemicrophone`, `togglecamera`, and `hangup` in Chrome 93 (the two toggles also in Safari 18.4), `previousslide` and `nextslide` in Chrome 111, `enterpictureinpicture` in Chrome 120, and `skipad` in Chrome 128.

## Syntax

```js
navigator.mediaSession.metadata = new MediaMetadata(init)
navigator.mediaSession.playbackState = "none" | "paused" | "playing"

navigator.mediaSession.setActionHandler(action, handler)
navigator.mediaSession.setActionHandler(action, null)
navigator.mediaSession.setPositionState()
navigator.mediaSession.setPositionState(state)

navigator.mediaSession.setMicrophoneActive(active)
navigator.mediaSession.setCameraActive(active)
navigator.mediaSession.setScreenshareActive(active)
```

`metadata` is `null` until the page assigns a `MediaMetadata`. `setActionHandler()` and `setPositionState()` return `undefined`; passing `null` as the handler removes the action and its button from the OS controls, and calling `setPositionState()` with no argument clears the position so the OS stops drawing a progress bar. The three capture-state setters return `Promise<undefined>` and only matter for conferencing pages that want the OS "mute" button to mirror their own state. The interface is `[Exposed=Window]` and does not require a secure context.

## Parameters

`setActionHandler(action, handler)` takes a `MediaSessionAction` enum value and a `MediaSessionActionHandler` callback that receives one `MediaSessionActionDetails` argument. The enum has seventeen members: `play`, `pause`, `seekbackward`, `seekforward`, `previoustrack`, `nexttrack`, `skipad`, `stop`, `seekto`, `togglemicrophone`, `togglecamera`, `togglescreenshare`, `hangup`, `previousslide`, `nextslide`, `enterpictureinpicture`, and `voiceactivity`.

| `MediaSessionActionDetails` member | Type | Present when | Description |
|---|---|---|---|
| `action` | `MediaSessionAction` | every call | The action being delivered; `required`. |
| `seekOffset` | `double` | `seekbackward`, `seekforward` (optional) | Seconds to move by; when absent the page picks its own step. |
| `seekTime` | `double` | `seekto` (required) | Absolute target time in seconds. |
| `fastSeek` | `boolean` | `seekto` (optional) | `true` while the user is still scrubbing; the final call in the sequence is `false`. |
| `isActivating` | `boolean` | `togglemicrophone`, `togglecamera`, `togglescreenshare` | `false` when the browser is about to pause all inputs of that kind. |
| `enterPictureInPictureReason` | `"other"`, `"useraction"`, `"contentoccluded"` | `enterpictureinpicture` (required) | Why the browser asked the page to enter picture-in-picture. |

`new MediaMetadata(init)` takes a `MediaMetadataInit` dictionary; every member is optional.

| Member | Type | Default | Description |
|---|---|---|---|
| `title` | `DOMString` | `""` | Track or episode title. |
| `artist` | `DOMString` | `""` | Performer or author. |
| `album` | `DOMString` | `""` | Album, show, or playlist name. |
| `artwork` | `sequence<MediaImage>` | `[]` | Images for the OS to choose from. Each `MediaImage` has a required `src` (`USVString`, resolved against the document base URL), optional `sizes` (same syntax as `<link sizes>`, for example `"512x512"`), and optional `type` (MIME type). |
| `chapterInfo` | `sequence<ChapterInformationInit>` | `[]` | Chapter titles, start times, and artwork; Chrome 127 only (BCD `api.MediaMetadata.chapterInfo`). |

`setPositionState(state)` takes a `MediaPositionState` dictionary.

| Member | Type | Required | Description |
|---|---|---|---|
| `duration` | `unrestricted double` | Yes, unless the dictionary is empty | Length in seconds; `Infinity` for live streams. |
| `position` | `double` | No (default `0`) | Last reported playback position in seconds, from `0` to `duration`. |
| `playbackRate` | `double` | No (default `1.0`) | Positive for forward playback, negative for backwards; must not be zero. |

## Exceptions

Every exception the specification defines is a `TypeError`.

| Method | Condition |
|---|---|
| `setActionHandler()` | `action` is not one of the seventeen enum values (WebIDL enum conversion). |
| `setPositionState()` | `duration` is absent from a non-empty dictionary; `duration` is negative or `NaN`; `position` is negative or greater than `duration`; `playbackRate` is `0`. |
| `new MediaMetadata()` | An `artwork` entry's `src` fails to parse as a URL. |

`setMicrophoneActive()`, `setCameraActive()`, and `setScreenshareActive()` reject with `InvalidStateError` when the document is not fully active, and may reject with `InvalidStateError` when `active` is `true` while the document is not visible. `playbackState` throws nothing: an invalid string is ignored and the last valid value stays in place. Chromium throws one extra `TypeError`, for `skipad` when its runtime feature is off (see the observed callout).

:::observed
In Chrome, `navigator.mediaSession.setPositionState({ position: 10 })` throws `TypeError: The duration must be provided.`; `setPositionState({ duration: 100, position: 200 })` throws `TypeError: The provided position cannot be greater than the duration.`; `setPositionState({ duration: 100, playbackRate: 0 })` throws `TypeError: The provided playbackRate cannot be equal to zero.`; and a `NaN` duration throws `TypeError: The provided duration cannot be NaN.` A `setActionHandler('skipad', fn)` call in a build with the SkipAd feature disabled throws `TypeError: The provided value 'skipad' is not a valid enum value of type MediaSessionAction.`, the same message WebIDL produces for a misspelt action. All strings are in Chromium's [`media_session.cc`](https://chromium.googlesource.com/chromium/src/+/main/third_party/blink/renderer/modules/mediasession/media_session.cc) (chromium.googlesource.com).
:::

## Examples

Each example checks `'mediaSession' in navigator` first. The fallback is the page's own transport controls, which keep working in Android WebView and in any browser that lacks the API because the `<audio>` element itself is unaffected.

### Registering metadata and handlers for a podcast player

Wrapping each `setActionHandler()` call in its own `try` block means an action unknown to the browser (`seekto` in Chrome 73 to 77, `previousslide` outside Chrome) is skipped instead of aborting the loop and leaving the remaining buttons unregistered. Setting `playbackState` from the element's own events keeps the OS play/pause icon correct when the user pauses inside the page rather than from the OS.

```js
const audio = document.querySelector('audio');

function registerMediaSession(episode) {
  if (!('mediaSession' in navigator)) {
    audio.controls = true; // on-page transport only
    return;
  }

  navigator.mediaSession.metadata = new MediaMetadata({
    title: episode.title,
    artist: episode.show,
    album: `Season ${episode.season}`,
    artwork: [
      { src: episode.art512, sizes: '512x512', type: 'image/png' },
      { src: episode.art96, sizes: '96x96', type: 'image/png' },
    ],
  });

  const handlers = {
    play: () => audio.play(),
    pause: () => audio.pause(),
    seekbackward: ({ seekOffset }) => { audio.currentTime -= seekOffset ?? 15; },
    seekforward: ({ seekOffset }) => { audio.currentTime += seekOffset ?? 30; },
    nexttrack: () => loadEpisode(episode.next),
  };

  for (const [action, handler] of Object.entries(handlers)) {
    try {
      navigator.mediaSession.setActionHandler(action, handler);
    } catch (err) {
      console.info(`Media Session action "${action}" unavailable: ${err.message}`);
    }
  }

  audio.addEventListener('play', () => { navigator.mediaSession.playbackState = 'playing'; });
  audio.addEventListener('pause', () => { navigator.mediaSession.playbackState = 'paused'; });
}
```

Omitting `previoustrack` is deliberate for a podcast: with no handler registered the OS hides that button, which is better than a handler that does nothing.

### Reporting position so the lock screen shows a scrubber

The OS extrapolates the position from the last report, so the page only needs to call `setPositionState()` when playback starts, seeks, or changes rate, not on every `timeupdate`. The `seekto` handler honours `fastSeek` by using `fastSeek()` for intermediate scrub events and a precise assignment for the final one. The guard against a `NaN` duration matters before `loadedmetadata`, because the specification rejects `NaN` with a `TypeError`.

```js
function reportPosition() {
  if (!('mediaSession' in navigator) || !('setPositionState' in navigator.mediaSession)) return;
  if (!Number.isFinite(audio.duration) && audio.duration !== Infinity) return; // metadata not loaded yet

  navigator.mediaSession.setPositionState({
    duration: audio.duration,
    playbackRate: audio.playbackRate,
    position: Math.min(audio.currentTime, audio.duration),
  });
}

for (const event of ['loadedmetadata', 'play', 'seeked', 'ratechange']) {
  audio.addEventListener(event, reportPosition);
}

if ('mediaSession' in navigator) {
  try {
    navigator.mediaSession.setActionHandler('seekto', ({ seekTime, fastSeek }) => {
      if (fastSeek && 'fastSeek' in audio) {
        audio.fastSeek(seekTime);
        return;
      }
      audio.currentTime = seekTime;
      reportPosition();
    });
  } catch {
    // seekto unsupported: the OS shows no scrubber, on-page seeking still works
  }
}
```

Clamping `position` to `duration` avoids the `TypeError` that an `ended` event can otherwise trigger when `currentTime` overshoots by a few milliseconds.

## See also

- [Document Picture-in-Picture API](/reference/capabilities/document-picture-in-picture/), which pairs with the `enterpictureinpicture` action
- [Screen Wake Lock API](/reference/capabilities/wake-lock/), for video that must keep the screen on
- [Notification actions and badge options](/reference/notifications/notification-actions-badge/)
- [Media Session Standard: setActionHandler() method](https://w3c.github.io/mediasession/#dom-mediasession-setactionhandler) (w3.org)
- [Media Session Standard: setPositionState() method](https://w3c.github.io/mediasession/#dom-mediasession-setpositionstate) (w3.org)
- [Customize media notifications and playback controls with the Media Session API](https://web.dev/articles/media-session) (web.dev)
- [MediaSession browser compatibility](https://developer.mozilla.org/en-US/docs/Web/API/MediaSession#browser_compatibility) (developer.mozilla.org)