sue@blog ~ /posts/react-native-pip-background-audio
$ cd ../
$ cat post.metadata

React Nativeでバックグラウンド再生と
PiPを両立させる設計

date: 2026-03-17 React Native iOS Android Media
動画配信サービスのモバイルアプリで、Picture-in-Picture(PiP)とバックグラウンド音声再生を両立させる必要が出てきた。react-native-videoとreact-native-track-playerの共存は一筋縄ではいかない。NowPlayingInfoCenterの競合、AudioSessionの奪い合い、Silent Syncパターンによる解決策を実プロジェクトの経験から解説する。

問題: なぜPiPとバックグラウンド再生の両立は難しいのか

$ cat problem-statement.md

動画配信アプリに求められるユーザー体験は大きく3つあります。

  1. PiP(Picture-in-Picture): 動画を見ながらメモアプリを使いたい
  2. バックグラウンド音声再生: 講義を聞きながら歩きたい(画面ロック)
  3. Control Center / Lock Screen制御: ロック画面から再生・一時停止・シーク

一見シンプルに思えますが、React Nativeでこれを実現しようとすると2つのライブラリの領域が衝突する問題に直面します。

flowchart TB subgraph Video["react-native-video"] V1["動画表示(フォアグラウンド)"] V2["PiP表示"] V3["NowPlayingInfoCenter
(動画メタデータ)"] end subgraph Audio["react-native-track-player"] A1["バックグラウンド音声再生"] A2["Control Center 制御"] A3["NowPlayingInfoCenter
(音声メタデータ)"] end V3 -.->|"競合"| A3 style Video fill:#161b22,stroke:#58a6ff,color:#c9d1d9 style Audio fill:#161b22,stroke:#7ee787,color:#c9d1d9

問題の根本はNowPlayingInfoCenterです。iOSには「今再生中のメディア情報」を管理する仕組みが1つしかありません。react-native-videoreact-native-track-playerの両方がこの1つのリソースを奪い合い、Control Centerの表示がおかしくなったり、再生コントロールが効かなくなったりします。

実際に起きる症状

解決策: PiP優先・音声フォールバックモデル

$ cat architecture.md

設計の原則は「PiPが使える場面ではPiPを優先し、PiPが終了/非対応の場合はバックグラウンド音声にフォールバックする」です。

stateDiagram-v2 [*] --> Foreground: アプリ起動 Foreground --> PiP: ホームに戻る
(PiP対応) Foreground --> BackgroundAudio: ホームに戻る
(PiP非対応) Foreground --> BackgroundAudio: 画面ロック PiP --> Foreground: PiPタップ PiP --> BackgroundAudio: PiP閉じる BackgroundAudio --> Foreground: アプリ復帰 Foreground --> [*]: 再生停止 BackgroundAudio --> [*]: Control Centerで停止 PiP --> [*]: PiP閉じる+停止 state Foreground { [*] --> VideoPlaying VideoPlaying: react-native-video
+ TrackPlayer (Silent Sync) } state PiP { [*] --> PiPWindow PiPWindow: AVPictureInPictureController
映像+音声 } state BackgroundAudio { [*] --> AudioOnly AudioOnly: react-native-track-player
音声のみ + Control Center }

ポイントは3つの再生状態(フォアグラウンド / PiP / バックグラウンド音声)を明確に分離し、それぞれで誰がNowPlayingInfoCenterを管理するかを決めることです。

状態 映像 音声 NowPlaying管理 Control Center
フォアグラウンド react-native-video react-native-video TrackPlayer (Silent Sync) 表示あり
PiP AVPictureInPictureController react-native-video react-native-video PiPウィンドウ
バックグラウンド音声 なし TrackPlayer TrackPlayer フル制御

Silent Syncパターン: フォアグラウンドの設計

$ cat silent-sync.ts

最も設計が難しいのはフォアグラウンド再生中です。ユーザーが動画を見ている間も、Control Center / Lock Screenに「再生中」を表示したいからです。

Control Centerに表示するにはreact-native-track-playerが再生中である必要があります。しかし、映像の音声はreact-native-videoから出ています。両方がフル音量で鳴ったら二重に聞こえます。

解決策が「Silent Sync」パターンです。

Silent Sync の実装
// フォアグラウンド再生開始時
const startForegroundPlayback = async () => {
  // 1. TrackPlayer にメタデータをセット(ポスター画像、タイトル等)
  await TrackPlayer.add({
    id: trackId,
    url: videoUri,          // 動画と同じURLを渡す
    title: "講義タイトル",
    artist: "講師名",
    artwork: posterUri,
    duration: videoDuration,
  });

  // 2. 超極小音量で再生(0ではなく0.01)
  await TrackPlayer.play();
  await TrackPlayer.setVolume(0.01);

  // ↑ volume=0 にするとiOSがAudioSessionを非アクティブと判定し、
  //   Control Centerから表示が消えてしまう。0.01なら維持される。
};
なぜ volume=0 ではダメなのか
iOSのAudioSessionは、音量0の再生を「実質的に再生していない」と判断する場合があります。その結果、AudioSessionが非アクティブになり、Control CenterのNow Playingウィジェットが消えます。0.01(実質無音)にすることで、iOSにAudioSessionをアクティブに保たせます。人間の耳には聞こえません。

Silent Syncの全体像をシーケンスで見てみましょう。

sequenceDiagram participant User as ユーザー participant Video as react-native-video participant TP as TrackPlayer
(Silent Sync) participant CC as Control Center participant App as AppState User->>Video: 動画再生開始 Video->>TP: Silent Sync 開始
(volume=0.01) TP->>CC: NowPlayingInfo 設定
(タイトル・サムネイル) Note over CC: Control Center に
再生中表示 User->>App: ホームに戻る App->>Video: 映像停止 App->>TP: volume=1.0 に変更 Note over TP: バックグラウンド
音声再生を引き継ぎ User->>CC: 一時停止ボタン CC->>TP: pause() Note over TP: 再生停止 User->>App: アプリに復帰 App->>TP: position 取得 App->>Video: seek(position) で同期 App->>Video: 映像再開 App->>TP: volume=0.01 に戻す

NowPlayingInfoCenter競合の解決

$ git diff patches/react-native-video.patch

最大の技術的課題はNowPlayingInfoCenterの競合です。

react-native-videoの内部実装(NowPlayingInfoCenterManager)は、プレイヤーが破棄されるとremoveTarget(nil)を呼び出します。このnil指定はすべてのリモートコマンドハンドラーを削除するという意味です。つまり、TrackPlayerが登録したPlay/Pause/Seekのハンドラーまで巻き添えで消えます。

問題: removeTarget(nil) による全ハンドラー削除
// react-native-video の NowPlayingInfoCenterManager(修正前)
func invalidateCommandTargets() {
    let center = MPRemoteCommandCenter.shared()

    // nil を渡すとすべてのターゲットが削除される
    center.playCommand.removeTarget(nil)     // ← TrackPlayerのも消える
    center.pauseCommand.removeTarget(nil)    // ← TrackPlayerのも消える
    center.skipForwardCommand.removeTarget(nil)
    center.skipBackwardCommand.removeTarget(nil)
}
修正: 自分が登録したハンドラーのみ削除
// react-native-video の NowPlayingInfoCenterManager(パッチ後)
func invalidateCommandTargets() {
    let center = MPRemoteCommandCenter.shared()

    // 自分が登録した参照のみを削除する
    if let target = playTarget {
        center.playCommand.removeTarget(target)
    }
    playTarget = nil

    if let target = pauseTarget {
        center.pauseCommand.removeTarget(target)
    }
    pauseTarget = nil

    // → TrackPlayerのハンドラーは残る
}

さらに、NowPlayingInfo辞書のクリア条件も修正が必要です。

NowPlayingInfo のクリア条件修正
// 修正前: プレイヤーがnilになると常にNowPlayingInfoをクリア
guard let player = currentPlayer else {
    invalidateCommandTargets()
    MPNowPlayingInfoCenter.default().nowPlayingInfo = [:]  // ← 全消し
    return
}

// 修正後: リモートコントロールイベントを受信している場合のみクリア
if receivingRemoveControlEvents {
    invalidateCommandTargets()
    MPNowPlayingInfoCenter.default().nowPlayingInfo = [:]
}
// → TrackPlayerが管理するNowPlayingInfoは残る
パッチの管理方法
この修正はpatch-packageを使って管理しています。patches/react-native-video+6.x.x.patchとして保存し、postinstallで自動適用。ライブラリのバージョンアップ時にパッチの再適用が必要になるため、E2Eテストで回帰を検出する仕組みと合わせて運用しています。

AudioSessionの設計

$ cat audio-session.conf

iOSのAudioSessionは、アプリがどのように音声を使うかをOSに宣言する仕組みです。バックグラウンド再生にはいくつかの設定が必要です。

1. Info.plistの設定

app.json / app.config.ts
{
  "ios": {
    "infoPlist": {
      "UIBackgroundModes": ["audio"]  // バックグラウンド音声再生を許可
    }
  }
}

2. TrackPlayer のAudioSession設定

track-player-service.ts
await TrackPlayer.setupPlayer({
  iosCategory: "playback",           // バックグラウンド再生用カテゴリ
  iosCategoryMode: "default",        // 標準モード
  iosCategoryOptions: [
    "allowBluetooth",               // Bluetooth出力対応
    "allowBluetoothA2DP",           // 高音質Bluetooth
    "allowAirPlay",                 // AirPlay対応
    "defaultToSpeaker",             // デフォルトスピーカー
  ],
  autoUpdateMetadata: true,        // NowPlayingInfo自動更新
});

3. react-native-videoとの分離

VideoPlayer.tsx
<Video
  playInBackground={true}
  playWhenInactive={true}
  ignoreSilentSwitch="ignore"
  audioCategory="playback"
  disableAudioSessionManagement={true}  // ← 重要
/>
disableAudioSessionManagement が鍵
disableAudioSessionManagement={true}を設定することで、react-native-videoが独自にAudioSessionカテゴリを変更するのを防ぎます。AudioSessionの管理権はTrackPlayerに一本化され、競合が発生しません。

バックグラウンド遷移と復帰の同期

$ cat app-state-handler.ts

ユーザーがホームに戻ったとき(AppState: active → background)、映像の表示が不要になるのでreact-native-videoを停止し、TrackPlayerの音量を上げて音声を引き継ぎます。

AppState による遷移ハンドリング
const handleAppStateChange = async (nextState: AppStateStatus) => {
  if (appState.match(/active/) && nextState.match(/background|inactive/)) {
    // フォアグラウンド → バックグラウンド

    // TrackPlayerの位置をビデオに同期
    const currentPos = videoRef.current?.getCurrentPosition();
    await TrackPlayer.seekTo(currentPos);

    // TrackPlayerを通常音量に
    await TrackPlayer.setVolume(1.0);

    // ビデオプレイヤーの映像は自動的に停止される
    // (iOSがバックグラウンドのGPUレンダリングを停止)
  }

  if (appState.match(/background|inactive/) && nextState === "active") {
    // バックグラウンド → フォアグラウンド復帰

    // TrackPlayerの再生位置を取得
    const tpPosition = await TrackPlayer.getPosition();
    const tpState = await TrackPlayer.getPlaybackState();

    // ビデオプレイヤーをTrackPlayerの位置に同期
    videoRef.current?.seek(tpPosition);

    // TrackPlayerを再びSilent Syncに戻す
    await TrackPlayer.setVolume(0.01);

    // TrackPlayerが再生中ならビデオも再開
    if (tpState === State.Playing) {
      setIsPlaying(true);
    }
  }
};

PiPの追加: 段階的な拡張

$ git log --oneline feature/pip

既存のバックグラウンド音声再生にPiPを追加する場合、以下の状態遷移を新たに管理する必要があります。

flowchart TB FG["フォアグラウンド再生
(Video + TrackPlayer Silent Sync)"] PiP["PiP再生
(AVPictureInPictureController)"] BG["バックグラウンド音声
(TrackPlayer通常音量)"] Stop["停止"] FG -->|"ホームに戻る
(PiP対応)"| PiP FG -->|"画面ロック"| BG FG -->|"PiPボタン"| PiP PiP -->|"PiPタップ"| FG PiP -->|"PiP閉じる"| BG PiP -->|"PiP閉じる+停止"| Stop BG -->|"アプリ復帰"| FG BG -->|"Control Center停止"| Stop style FG fill:#21262d,stroke:#58a6ff,color:#c9d1d9 style PiP fill:#21262d,stroke:#d2a8ff,color:#d2a8ff style BG fill:#21262d,stroke:#7ee787,color:#7ee787 style Stop fill:#21262d,stroke:#ff5f56,color:#ff5f56
PiP 有効化と遷移ハンドリング
<Video
  pictureInPicture={true}      // PiP 有効化
  onPictureInPictureStatusChanged={(e) => {
    if (e.isActive) {
      // PiP開始 → TrackPlayerのSilent Syncを停止
      // NowPlayingはreact-native-videoが管理
      TrackPlayer.pause();
    } else {
      // PiP終了 → バックグラウンド音声にフォールバック
      const pos = videoRef.current?.getCurrentPosition();
      TrackPlayer.seekTo(pos);
      TrackPlayer.setVolume(1.0);
      TrackPlayer.play();
    }
  }}
/>
PiP中のNowPlaying管理権の切替が最大の難所

PiP中はreact-native-videoがNowPlayingを管理し、PiP終了後にTrackPlayerに制御を返す必要があります。この切替にタイミングのズレがあると、Control Centerの表示が一瞬空白になるか、前の状態が残ります。onPictureInPictureStatusChangedのコールバック内で確実に切り替えることが重要です。

プラットフォーム別の対応状況

$ cat platform-matrix.md
機能 iOS (Native) Android (Native) Web (Safari) Web (PWA)
バックグラウンド再生 TrackPlayer + UIBackgroundModes TrackPlayer + FOREGROUND_SERVICE 非対応 非対応
PiP AVPictureInPictureController Android 8.0+ (O) ブラウザネイティブ iOS制約で不可
Control Center NowPlayingInfoCenter MediaSession + Notification 非対応 非対応
AirPlay allowsExternalPlayback 非対応 ブラウザ経由 非対応

Android固有の注意点

AndroidManifest — 必要なパーミッション
<!-- バックグラウンド再生に必要 -->
<uses-permission android:name="android.permission.WAKE_LOCK" />
<uses-permission android:name="android.permission.FOREGROUND_SERVICE" />
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_MEDIA_PLAYBACK" />

<!-- appKilledPlaybackBehavior の設定 -->
<!-- "stop-playback-and-remove-notification": アプリ強制終了時は再生停止 -->
<!-- "continue-playback": アプリ終了後も再生継続(メモリ消費大) -->

実装の推奨優先順位

$ cat roadmap.md
対応 工数 効果
1 バックグラウンド音声再生(TrackPlayer + Silent Sync) Control Center / Lock Screen対応が完成
2 NowPlayingInfoCenterのパッチ TrackPlayerとVideoの競合解消
3 PiP有効化 + 遷移ハンドリング 中〜大 ながら見ユースケース対応
4 プレイリスト自動進行(2-Track Sliding Window) 連続視聴体験の完成
段階的に進める理由

PiP対応はNowPlayingInfoCenterの管理権切替が複雑なため、まずバックグラウンド音声再生を安定させてからPiPを追加する方が安全です。バックグラウンド音声が正しく動いていれば、PiPを閉じたときのフォールバック先が確保されているので、段階的にリリースできます。


おわりに

$ echo "2つのライブラリが1つのリソースを奪い合うとき、設計で解決する"

react-native-videoreact-native-track-playerは、それぞれ単体では優れたライブラリです。しかし、両方を同時に使おうとした瞬間、NowPlayingInfoCenterとAudioSessionというiOSのシングルトンリソースの奪い合いが始まります。

この問題はライブラリの設定を変えるだけでは解決しません。「誰がいつ何を管理するか」を状態遷移として明確に設計し、必要な箇所ではネイティブコードにパッチを当てる覚悟が必要です。

Silent Syncパターンのように、一見ハックに見える手法も、OSの制約を正しく理解した上での合理的な設計判断です。React Nativeのメディア系実装は「JavaScript APIの向こう側にあるOS層の挙動」を知っていることが前提になる、という点で他の領域とは一線を画します。

$ _