ReactとMedia Sessionで、通知領域から再生を操作する方法

​​React​Media Session API,TypeScript,useEffectEvent,カスタムフック

音楽やポッドキャストを流す Web アプリを、別のタブやアプリを開いたまま、通知領域やロック画面、キーボードのメディアキーから操作できるようにします。使うのは Media Session API です。React では、navigator.mediaSession に曲名を渡す処理と、ボタンが押されたときの処理の登録を、useEffect の中で行います。

ここでは、それをまとめたフック useMediaSession を作りました。3曲のプレーヤーを、ブラウザの外から操作して確かめています。

補足

検証環境は Ubuntu 24.04、Node.js 22.23.4、Chromium 153 です。使ったのは React 19.3.1、TypeScript 7.0.2、esbuild 0.28.2、Playwright 1.63.0 です。検証環境の Linux の Chromium は、Media Session の内容を MPRIS(メディアプレーヤーを外から操作するための D-Bus の仕様)で公開していました。デスクトップ環境のない仮想ディスプレイ(Xvfb)で動かしたので、通知領域のボタンの代わりに、MPRIS を操作するコマンド playerctl 2.4.1 で同じ命令を送りました。あわせて、Chromium のツールバーにあるメディア操作のパネルも、xdotool でマウスポインターを動かしてクリックしています。Android や iPhone の通知・ロック画面、Windows と macOS、Firefox と Safari では試していません。

Chromium のメディア操作パネルで「次の曲」を2回、「一時停止」を1回押すと、ページの曲名と状態が変わる
パネルのボタンを3回押す様子。左下の回数表示と、ページの文字が変わった瞬間だけピンクにしている(回数表示とピンクの色は GIF 用に重ねたもの)

どのブラウザで使えるか

MDN の互換性データでは、MediaSession は Chrome と Edge が73から、Android 版 Chrome が57から、Firefox が82から、Safari が15から対応しています。Android 版 Firefox は82からの部分的な対応で、Android の WebView は対応していません。

後で作るフックは、MDN の例と同じく、最初に 'mediaSession' in navigator で使えるかを調べ、使えないときは何もしません。

曲名と操作を登録するフック

通知領域に出す曲名・アーティスト・画像は、MediaMetadata を作って navigator.mediaSession.metadata に入れます。ボタンが押されたときの処理は、setActionHandler() で操作の名前(play、nexttrack など)ごとに登録します。

ts
// useMediaSession.ts
import { useEffect, useEffectEvent } from 'react';

export type MediaSessionHandlers = Partial<
  Record<MediaSessionAction, (details: MediaSessionActionDetails) => void>
>;

const ACTIONS: MediaSessionAction[] = [
  'play', 'pause', 'stop', 'previoustrack', 'nexttrack',
  'seekbackward', 'seekforward', 'seekto',
];

export function useMediaSession(
  metadata: MediaMetadataInit | null,
  handlers: MediaSessionHandlers,
) {
  // 通知領域に出す曲名・アーティスト・画像
  useEffect(() => {
    if (!('mediaSession' in navigator)) return;
    navigator.mediaSession.metadata = metadata ? new MediaMetadata(metadata) : null;
    return () => {
      navigator.mediaSession.metadata = null;
    };
  }, [metadata]);

  // いつ呼ばれても、最新の handlers を使う
  const onAction = useEffectEvent((details: MediaSessionActionDetails) => {
    handlers[details.action]?.(details);
  });

  // 渡された操作の名前が変わったときだけ、登録し直す
  const names = ACTIONS.filter((a) => handlers[a]).join(',');
  useEffect(() => {
    if (!('mediaSession' in navigator)) return;
    const list = names ? (names.split(',') as MediaSessionAction[]) : [];
    for (const action of list) {
      try {
        navigator.mediaSession.setActionHandler(action, onAction);
      } catch {
        // このブラウザが知らない操作の名前は、登録できずに例外になる
      }
    }
    return () => {
      for (const action of list) {
        try {
          navigator.mediaSession.setActionHandler(action, null);
        } catch {}
      }
    };
  }, [names]);
}

登録する関数は、React 19.2 で入った useEffectEvent で作っています。こうすると、ボタンが押された時点の最新の state を使えます。それでいて、描画のたびに登録し直すこともありません。公式リファレンスにも、useEffect の中で connection.on('connected', onConnected) のように、外の仕組みへそのまま渡す例があります。後の節で、これをしないとどうなるかを確かめます。

try と catch で囲んでいるのは、MDN の setActionHandler() の例にならったものです。ブラウザが知らない操作の名前を登録すると、例外になります。検証環境の Chromium で 'no-such-action' を登録したところ、TypeError が出ました。クリーンアップ関数では、setActionHandler(action, null) で登録を外し、metadata も null に戻します。

TypeScript 7.0.2 では、標準の型定義(lib.dom)に MediaSessionAction や MediaMetadataInit が入っています。型を自分で書き足さなくても、tsc が通りました。

フックを使う3曲のプレーヤー

tsx
// Player.tsx
import { useRef, useState } from 'react';
import { tracks } from './tracks';
import { useMediaSession } from './useMediaSession';

export function Player() {
  const audioRef = useRef<HTMLAudioElement>(null);
  const [index, setIndex] = useState(0);
  const [isPlaying, setIsPlaying] = useState(false);
  const track = tracks[index];

  const changeTrack = (next: number) => {
    setIndex((next + tracks.length) % tracks.length);
  };

  useMediaSession(track.metadata, {
    play: () => { audioRef.current?.play(); },
    pause: () => { audioRef.current?.pause(); },
    previoustrack: () => changeTrack(index - 1),
    nexttrack: () => changeTrack(index + 1),
    seekto: (d) => {
      const audio = audioRef.current;
      if (audio && d.seekTime !== undefined) audio.currentTime = d.seekTime;
    },
    seekbackward: (d) => {
      const audio = audioRef.current;
      if (audio) audio.currentTime = Math.max(audio.currentTime - (d.seekOffset ?? 10), 0);
    },
    seekforward: (d) => {
      const audio = audioRef.current;
      if (audio) audio.currentTime = Math.min(audio.currentTime + (d.seekOffset ?? 10), audio.duration);
    },
  });

  return (
    <div>
      <p>{track.metadata.title}({index + 1}/{tracks.length})</p>
      <audio
        ref={audioRef}
        src={track.src}
        controls
        autoPlay={isPlaying}
        onPlay={() => setIsPlaying(true)}
        onPause={() => setIsPlaying(false)}
        onEnded={() => changeTrack(index + 1)}
      />
      <p>
        <button onClick={() => changeTrack(index - 1)}>前の曲</button>
        <button onClick={() => (isPlaying ? audioRef.current?.pause() : audioRef.current?.play())}>
          {isPlaying ? '一時停止' : '再生'}
        </button>
        <button onClick={() => changeTrack(index + 1)}>次の曲</button>
      </p>
      <p>{isPlaying ? '再生中' : '停止中'}</p>
    </div>
  );
}

tracks は、曲ごとに src と metadata を持つ配列です。

ts
// tracks.ts(3曲のうち1曲目だけを示す)
export type Track = { src: string; metadata: MediaMetadataInit };

export const tracks: Track[] = [
  { src: '/track1.webm', metadata: { title: '朝の散歩', artist: 'ねこの手楽団', album: '検証用の音', artwork: [{ src: '/art1.png', sizes: '256x256', type: 'image/png' }] } },
  // 2曲目、3曲目も同じ形
];

フックには配列の中の同じオブジェクトを渡すので、MediaMetadata が作り直されるのは曲が変わったときだけです。このコードのまま、次の節以降と同じ playerctl の操作(次の曲、前の曲、位置の移動、一時停止、再生)を試し、同じ結果になりました。届いた操作の名前は、各関数で画面に出すようにした版で確かめています。冒頭の GIF に出ている「受け取った操作」の行も、その版のものです。

isPlaying は、<audio> の play と pause のイベントだけで切り替えています。再生や停止は、ページのボタンのほかに、通知領域やメディアキーからも起きるからです。

Chromium のメディア操作パネルと MPRIS からどう見えるか

ページの再生ボタンを押すと、Chromium のツールバーにメディア操作のボタンが出ます。開いたパネルには、artwork の画像、title、artist が並びました。

Chromium のメディア操作パネル。水色の画像の横に「朝の散歩」「ねこの手楽団」と出て、前の曲・10秒戻し・再生位置のバー・10秒送り・次の曲・一時停止のボタンがある
ツールバーのボタンから開いたメディア操作パネル。画像・曲名・アーティストは metadata に渡した値

MPRIS の側から読んだ値も同じです。画像の一時ファイルのパスは(省略)に置き換えています。

text
$ playerctl metadata
chromium mpris:artUrl              file:///tmp/(省略)
chromium mpris:length              60008000
chromium mpris:trackid             '/org/chromium/MediaPlayer2/TrackList/Track6BBAAC37133CDE433FA5646A96232411'
chromium xesam:album               検証用の音
chromium xesam:artist              ねこの手楽団
chromium xesam:title               朝の散歩

metadata を設定しないページでは、xesam:title がページのタイトル(<title> の「Media Session の検証」)になり、アーティストとアルバムは空でした。

外から操作したときに届いた操作

playerctl で命令を送り、ページで受け取った操作の名前と、その後の様子を記録しました。

playerctl の命令 届いた操作 ページの様子
pause pause 停止中になった
play play 再生中になった
next nexttrack 1曲目→2曲目、もう1回で3曲目
previous previoustrack 3曲目→2曲目
position 30 seekto(seekTime は30) 30秒の位置へ移った
position 5- seekbackward(seekOffset なし) 約10秒戻った
position 10+ seekforward(seekOffset なし) 約10秒進んだ
play-pause pause、もう1回で play 停止と再生が交互に切り替わった

曲を変えた後は、playerctl metadata title も新しい曲名(「昼寝の時間」)になりました。

seekbackward と seekforward には、どちらも seekOffset が入っていませんでした。position 5- と5秒を指定しても、Chromium からページに秒数は渡されません。そのため、コードの ?? 10 の既定値で10秒戻っています。MDN の setActionHandler() の説明でも、seekOffset がないときは、7秒や10秒といった妥当な幅を自分で決めることになっています。

一方、Chromium のパネルにある10秒戻し・10秒送りのボタンは、seekbackward や seekforward ではなく、seekto を送ってきました。4.2秒の位置で10秒送りを押すと seekTime は14.3、続けて10秒戻しを押すと5.2でした。playerctl から位置を動かすと seekbackward・seekforward が届き、パネルのボタンでは seekto が届きました。

ポイント

seekbackward と seekforward は seekOffset がない場合に備えて既定の秒数を持たせ、seekto も必ず登録しておきます。

登録を最初の1回だけにすると、次の曲が進まない

useEffectEvent を使わず、useEffect の依存配列を空にして、最初の1回だけ登録した版も試しました。

tsx
useEffect(() => {
  navigator.mediaSession.setActionHandler('nexttrack', () => {
    setIndex((index + 1) % tracks.length);
  });
}, []); // index を使っているのに、最初の1回しか登録しない

playerctl next を3回送ると、1回目で2曲目に進んだ後は、2回目も3回目も2曲目のままでした。登録した関数が、最初の描画の index(0)を覚えたままだからです。完成形のフックでは、同じ操作で2曲目、3曲目と進みました。

登録しない操作は、ボタンが使えなくなる

setActionHandler() を1つも呼ばない版では、MPRIS の CanGoNext と CanGoPrevious が false になりました。完成形ではどちらも true です。この状態で playerctl next を送ると「No player could handle this command」と出て、ページの曲は変わりませんでした。検証環境では、MPRIS から前後の曲を操作するには previoustrack と nexttrack の登録が必要でした。

再生と一時停止は、登録しなくても CanPlay と CanPause が true のままでした。playerctl pause と play で <audio> は止まり、また動きました。MDN の Media Session API の概要にあるとおり、ページが処理を登録していないときはブラウザの既定の動きになります。このときも、onPause と onPlay で isPlaying を切り替えているので、ページの表示は「停止中」「再生中」に正しく変わりました。

プレーヤーを画面から消したとき

プレーヤーのコンポーネントを画面から消すと、<audio> がなくなって再生が止まりました。playerctl から見たプレーヤーは残ったまま、状態は Paused になりました。曲名はページのタイトルに戻りました(クリーンアップ関数では metadata を null に戻しています)。

setPositionState を呼ばなくても、再生位置と長さが MPRIS に出た

このプレーヤーも、比べるために作った metadata を設定しない版も、setPositionState() を呼んでいません。それでも mpris:length は、どちらも60008000(マイクロ秒の単位で、60秒の音の長さ)でした。metadata を設定しない版では、playerctl position が1.52秒、<audio> の currentTime が1.46秒で、ほぼ同じでした。<audio> の長さと位置が、そのまま MPRIS に出ていたことになります。MDN では setPositionState() は、ブラウザが直接扱わない種類のメディアを自前で再生するときに特に役立つと説明されています。

​Posted by 86