# Media Session API

> navigator.mediaSession 向系统描述正在播放的内容，并接收来自系统媒体控件的播放、暂停、跳转与切曲动作。本页列出成员、全部动作、每个 TypeError 及可运行示例。

`navigator.mediaSession` 让页面告诉操作系统正在播放什么（标题、艺术家、专辑、封面），并接收用户在锁屏、耳机按键、键盘媒体键和浏览器自带媒体中心里发出的播放、暂停、跳转与切曲命令。没有它，这些控件仍能切换 `<audio>` 或 `<video>` 元素的播放状态，但系统显示的是页面 URL 而不是曲目名，也不提供跳过或进度按钮。

`MediaSession` 与 `setActionHandler()` 自桌面 Chrome 73、Android 上的 Chrome 57、Firefox 82、macOS 与 iOS 上的 Safari 15 起可用；`MediaMetadata` 更早（Chrome 57、Safari 14）。Android 上的 Firefox 暴露了 API 但没有媒体控件界面，Android WebView 没有实现（BCD `api.MediaSession`）。各个动作比接口本身来得晚：`seekto` 在 Chrome 78，`stop` 在 Chrome 77，`togglemicrophone`、`togglecamera`、`hangup` 在 Chrome 93（两个 toggle 动作在 Safari 18.4 也有），`previousslide` 与 `nextslide` 在 Chrome 111，`enterpictureinpicture` 在 Chrome 120，`skipad` 在 Chrome 128。

## 语法

```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)
```

页面赋值 `MediaMetadata` 之前 `metadata` 为 `null`。`setActionHandler()` 与 `setPositionState()` 返回 `undefined`；handler 传 `null` 会移除该动作及其在系统控件中的按钮，不带参数调用 `setPositionState()` 会清除位置信息，系统随之不再绘制进度条。三个采集状态 setter 返回 `Promise<undefined>`，只对希望系统「静音」按钮与自身状态同步的会议类页面有意义。该接口为 `[Exposed=Window]`，不要求安全上下文。

## 参数

`setActionHandler(action, handler)` 接受一个 `MediaSessionAction` 枚举值和一个 `MediaSessionActionHandler` 回调，回调收到一个 `MediaSessionActionDetails` 参数。枚举共十七个成员：`play`、`pause`、`seekbackward`、`seekforward`、`previoustrack`、`nexttrack`、`skipad`、`stop`、`seekto`、`togglemicrophone`、`togglecamera`、`togglescreenshare`、`hangup`、`previousslide`、`nextslide`、`enterpictureinpicture`、`voiceactivity`。

| `MediaSessionActionDetails` 成员 | 类型 | 出现时机 | 说明 |
|---|---|---|---|
| `action` | `MediaSessionAction` | 每次调用 | 正在投递的动作；`required`。 |
| `seekOffset` | `double` | `seekbackward`、`seekforward`（可选） | 要移动的秒数；缺省时由页面自定步长。 |
| `seekTime` | `double` | `seekto`（必有） | 目标时间点，单位秒。 |
| `fastSeek` | `boolean` | `seekto`（可选） | 用户仍在拖动进度条时为 `true`；序列中最后一次调用为 `false`。 |
| `isActivating` | `boolean` | `togglemicrophone`、`togglecamera`、`togglescreenshare` | 浏览器即将暂停该类全部输入源时为 `false`。 |
| `enterPictureInPictureReason` | `"other"`、`"useraction"`、`"contentoccluded"` | `enterpictureinpicture`（必有） | 浏览器要求页面进入画中画的原因。 |

`new MediaMetadata(init)` 接受一个 `MediaMetadataInit` 字典，所有成员均可选。

| 成员 | 类型 | 默认值 | 说明 |
|---|---|---|---|
| `title` | `DOMString` | `""` | 曲目或单集标题。 |
| `artist` | `DOMString` | `""` | 表演者或作者。 |
| `album` | `DOMString` | `""` | 专辑、节目或播放列表名。 |
| `artwork` | `sequence<MediaImage>` | `[]` | 供系统挑选的图片。每个 `MediaImage` 有必填的 `src`（`USVString`，按文档 base URL 解析）、可选的 `sizes`（与 `<link sizes>` 语法相同，如 `"512x512"`）和可选的 `type`（MIME 类型）。 |
| `chapterInfo` | `sequence<ChapterInformationInit>` | `[]` | 章节标题、起始时间与封面；仅 Chrome 127（BCD `api.MediaMetadata.chapterInfo`）。 |

`setPositionState(state)` 接受一个 `MediaPositionState` 字典。

| 成员 | 类型 | 必填 | 说明 |
|---|---|---|---|
| `duration` | `unrestricted double` | 是（字典为空时除外） | 时长，单位秒；直播流用 `Infinity`。 |
| `position` | `double` | 否（默认 `0`） | 最近一次报告的播放位置，单位秒，范围 `0` 到 `duration`。 |
| `playbackRate` | `double` | 否（默认 `1.0`） | 正数表示正向播放，负数表示倒放；不得为零。 |

## 异常

规范定义的每个异常都是 `TypeError`。

| 方法 | 条件 |
|---|---|
| `setActionHandler()` | `action` 不是十七个枚举值之一（WebIDL 枚举转换）。 |
| `setPositionState()` | 非空字典缺少 `duration`；`duration` 为负数或 `NaN`；`position` 为负数或大于 `duration`；`playbackRate` 为 `0`。 |
| `new MediaMetadata()` | 某个 `artwork` 条目的 `src` 无法解析为 URL。 |

`setMicrophoneActive()`、`setCameraActive()`、`setScreenshareActive()` 在文档不是 fully active 时以 `InvalidStateError` 拒绝；`active` 为 `true` 而文档不可见时也可能以 `InvalidStateError` 拒绝。`playbackState` 不抛任何异常：无效字符串被忽略，保留上一个有效值。Chromium 多抛一个 `TypeError`：运行时特性关闭时的 `skipad`（见实测框）。

:::observed
在 Chrome 中，`navigator.mediaSession.setPositionState({ position: 10 })` 抛出 `TypeError: The duration must be provided.`；`setPositionState({ duration: 100, position: 200 })` 抛出 `TypeError: The provided position cannot be greater than the duration.`；`setPositionState({ duration: 100, playbackRate: 0 })` 抛出 `TypeError: The provided playbackRate cannot be equal to zero.`；`NaN` 时长抛出 `TypeError: The provided duration cannot be NaN.`。在 SkipAd 特性关闭的构建中调用 `setActionHandler('skipad', fn)` 抛出 `TypeError: The provided value 'skipad' is not a valid enum value of type MediaSessionAction.`，与 WebIDL 对拼错动作名给出的消息相同。这些字符串都在 Chromium 的 [`media_session.cc`](https://chromium.googlesource.com/chromium/src/+/main/third_party/blink/renderer/modules/mediasession/media_session.cc)（chromium.googlesource.com）中。
:::

## 示例

每个示例都先检查 `'mediaSession' in navigator`。回退方案是页面自己的播放控件：`<audio>` 元素本身不受影响，所以在 Android WebView 和任何没有该 API 的浏览器中它们照常工作。

### 为播客播放器注册元数据与动作处理函数

把每个 `setActionHandler()` 调用包进各自的 `try` 块，浏览器不认识的动作（Chrome 73 到 77 的 `seekto`、Chrome 之外的 `previousslide`）就会被跳过，而不是中断循环、让剩下的按钮没有注册。根据元素自身事件设置 `playbackState`，用户在页面内而不是在系统控件里暂停时，系统的播放/暂停图标也能保持正确。

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

function registerMediaSession(episode) {
  if (!('mediaSession' in navigator)) {
    audio.controls = true; // 只保留页面内控件
    return;
  }

  navigator.mediaSession.metadata = new MediaMetadata({
    title: episode.title,
    artist: episode.show,
    album: `第 ${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}" 不可用：${err.message}`);
    }
  }

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

播客有意不注册 `previoustrack`：没有处理函数时系统会隐藏该按钮，比注册一个什么都不做的处理函数更好。

### 报告播放位置，让锁屏显示进度条

系统会根据最近一次报告外推位置，所以页面只需在播放开始、跳转或变速时调用 `setPositionState()`，不必在每个 `timeupdate` 上调用。`seekto` 处理函数尊重 `fastSeek`：拖动过程中的中间事件用 `fastSeek()`，最后一次用精确赋值。对 `NaN` 时长的防护在 `loadedmetadata` 之前很重要，因为规范对 `NaN` 以 `TypeError` 拒绝。

```js
function reportPosition() {
  if (!('mediaSession' in navigator) || !('setPositionState' in navigator.mediaSession)) return;
  if (!Number.isFinite(audio.duration) && audio.duration !== Infinity) return; // 元数据尚未加载

  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：系统不显示进度条，页面内跳转仍可用
  }
}
```

把 `position` 钳制到 `duration` 以内，可以避免 `ended` 事件时 `currentTime` 多出几毫秒而触发的 `TypeError`。

## 另请参阅

- [Document Picture-in-Picture API](/zh/reference/capabilities/document-picture-in-picture/)，与 `enterpictureinpicture` 动作配套
- [Screen Wake Lock API](/zh/reference/capabilities/wake-lock/)，需要保持屏幕常亮的视频场景
- [通知的 actions 与 badge 选项](/zh/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）