動画配信アプリに求められるユーザー体験は大きく3つあります。
一見シンプルに思えますが、React Nativeでこれを実現しようとすると2つのライブラリの領域が衝突する問題に直面します。
問題の根本はNowPlayingInfoCenterです。iOSには「今再生中のメディア情報」を管理する仕組みが1つしかありません。react-native-videoとreact-native-track-playerの両方がこの1つのリソースを奪い合い、Control Centerの表示がおかしくなったり、再生コントロールが効かなくなったりします。
設計の原則は「PiPが使える場面ではPiPを優先し、PiPが終了/非対応の場合はバックグラウンド音声にフォールバックする」です。
ポイントは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 | フル制御 |
最も設計が難しいのはフォアグラウンド再生中です。ユーザーが動画を見ている間も、Control Center / Lock Screenに「再生中」を表示したいからです。
Control Centerに表示するにはreact-native-track-playerが再生中である必要があります。しかし、映像の音声はreact-native-videoから出ています。両方がフル音量で鳴ったら二重に聞こえます。
解決策が「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なら維持される。
};
0.01(実質無音)にすることで、iOSにAudioSessionをアクティブに保たせます。人間の耳には聞こえません。
Silent Syncの全体像をシーケンスで見てみましょう。
最大の技術的課題はNowPlayingInfoCenterの競合です。
react-native-videoの内部実装(NowPlayingInfoCenterManager)は、プレイヤーが破棄されるとremoveTarget(nil)を呼び出します。このnil指定はすべてのリモートコマンドハンドラーを削除するという意味です。つまり、TrackPlayerが登録したPlay/Pause/Seekのハンドラーまで巻き添えで消えます。
// 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辞書のクリア条件も修正が必要です。
// 修正前: プレイヤーが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テストで回帰を検出する仕組みと合わせて運用しています。
iOSのAudioSessionは、アプリがどのように音声を使うかをOSに宣言する仕組みです。バックグラウンド再生にはいくつかの設定が必要です。
{
"ios": {
"infoPlist": {
"UIBackgroundModes": ["audio"] // バックグラウンド音声再生を許可
}
}
}
await TrackPlayer.setupPlayer({
iosCategory: "playback", // バックグラウンド再生用カテゴリ
iosCategoryMode: "default", // 標準モード
iosCategoryOptions: [
"allowBluetooth", // Bluetooth出力対応
"allowBluetoothA2DP", // 高音質Bluetooth
"allowAirPlay", // AirPlay対応
"defaultToSpeaker", // デフォルトスピーカー
],
autoUpdateMetadata: true, // NowPlayingInfo自動更新
});
<Video
playInBackground={true}
playWhenInactive={true}
ignoreSilentSwitch="ignore"
audioCategory="playback"
disableAudioSessionManagement={true} // ← 重要
/>
disableAudioSessionManagement={true}を設定することで、react-native-videoが独自にAudioSessionカテゴリを変更するのを防ぎます。AudioSessionの管理権はTrackPlayerに一本化され、競合が発生しません。
ユーザーがホームに戻ったとき(AppState: active → background)、映像の表示が不要になるのでreact-native-videoを停止し、TrackPlayerの音量を上げて音声を引き継ぎます。
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を追加する場合、以下の状態遷移を新たに管理する必要があります。
<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中はreact-native-videoがNowPlayingを管理し、PiP終了後にTrackPlayerに制御を返す必要があります。この切替にタイミングのズレがあると、Control Centerの表示が一瞬空白になるか、前の状態が残ります。onPictureInPictureStatusChangedのコールバック内で確実に切り替えることが重要です。
| 機能 | 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 | 非対応 | ブラウザ経由 | 非対応 |
<!-- バックグラウンド再生に必要 -->
<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": アプリ終了後も再生継続(メモリ消費大) -->
| 順 | 対応 | 工数 | 効果 |
|---|---|---|---|
| 1 | バックグラウンド音声再生(TrackPlayer + Silent Sync) | 中 | Control Center / Lock Screen対応が完成 |
| 2 | NowPlayingInfoCenterのパッチ | 小 | TrackPlayerとVideoの競合解消 |
| 3 | PiP有効化 + 遷移ハンドリング | 中〜大 | ながら見ユースケース対応 |
| 4 | プレイリスト自動進行(2-Track Sliding Window) | 中 | 連続視聴体験の完成 |
PiP対応はNowPlayingInfoCenterの管理権切替が複雑なため、まずバックグラウンド音声再生を安定させてからPiPを追加する方が安全です。バックグラウンド音声が正しく動いていれば、PiPを閉じたときのフォールバック先が確保されているので、段階的にリリースできます。
react-native-videoとreact-native-track-playerは、それぞれ単体では優れたライブラリです。しかし、両方を同時に使おうとした瞬間、NowPlayingInfoCenterとAudioSessionというiOSのシングルトンリソースの奪い合いが始まります。
この問題はライブラリの設定を変えるだけでは解決しません。「誰がいつ何を管理するか」を状態遷移として明確に設計し、必要な箇所ではネイティブコードにパッチを当てる覚悟が必要です。
Silent Syncパターンのように、一見ハックに見える手法も、OSの制約を正しく理解した上での合理的な設計判断です。React Nativeのメディア系実装は「JavaScript APIの向こう側にあるOS層の挙動」を知っていることが前提になる、という点で他の領域とは一線を画します。