Picture-in-Picture

目次

  1. PiP とは
  2. Jetpack Picture-in-Picture library の導入
  3. AndroidManifest の設定
  4. PiP の状態管理について
  5. Activity 側での PiP の初期化および状態監視
  6. ViewModel 側での SkyWay 配信状態の管理
  7. UI 側での PiP モードの自動切り替え

PiP とは

Picture-in-Picture(PiP) は、アプリをバックグラウンドに移行しても、画面上に小さなフローティングウィンドウを残して映像を継続表示できる Android の機能です。

SkyWay Android SDK が提供している LocalVideoStream/RemoteVideoStream と組み合わせて使うと、通話中にホームボタンを押しても相手の映像が見えたまま他の操作ができる、という体験を実現できます。

本ページでは、SkyWay Android SDK を利用する通話アプリに、Picture-in-Picture を実装する方法について解説します。

注意

  1. Jetpack Compose を利用する想定です。
  2. 利用する PiP library は 1.0.0-alpha02 のため、今後実装方法が変更される可能性があります。
  3. PiP 機能を利用できるのは Android 8 以降になります。

Jetpack Picture-in-Picture library の導入

  • libs.versions.toml にて、Jetpack Picture-in-Picture library を追加します。
[versions] corePip = "1.0.0-alpha02" # 追加 [libraries] androidx-core-pip = { group = "androidx.core", name = "core-pip", version.ref = "corePip" } # 追加
  • アプリ側の build.gradle の dependencies に、Picture-in-Picture library への依存を追加します。
dependencies { ... implementation libs.androidx.core.pip // 追加 }

AndroidManifest の設定

PiP を使う Activity には supportsPictureInPicture="true" と configChanges を設定します。

<activity android:name="..." android:supportsPictureInPicture="true" android:configChanges="screenSize|smallestScreenSize|screenLayout|orientation" ... />

configChanges を設定する理由は、意図しない Activity の再起動による映像の途切れを回避するためです。

PiP の状態管理について

PiP 機能は、SkyWay の配信状況、Android の PiP 機能、そして UI の切り替えと連動しているため、PiP の状態を管理する必要があります。 今回のサンプルでは PipStateHolder を作成し、PiP の状態を管理します。

PipStateHolder は、Activity(MainActivity)と、アプリのビジネスロジックを管理する ViewModel、そして UI である Composable の間で PiP 関連の状態を共有するためのブリッジとして設計します。

object PipStateHolder { val isCallActive = MutableStateFlow(false) val isPipMode = MutableStateFlow(false) val pipVideoTarget = MutableStateFlow(PipVideoTarget.LOCAL) } enum class PipVideoTarget { LOCAL, REMOTE }
  • isCallActive: SkyWay を通じて通話中の場合のみ PiP を有効にするフラグです。
    • ViewModel 内で SkyWay の通信を開始したタイミングで true になり、MainActivity に通知して PiP を有効にします。SkyWay の通信が終了するタイミングで false になり、PiP を無効にします。
  • isPipMode: PiP ウィンドウを表示中かどうかを表すフラグです。
    • PictureInPictureDelegate.OnPictureInPictureEventListener で PiP の状態を監視し、それに応じて値を変更します。
    • Composable 側ではこの値に応じて UI を PiP モードに切り替えます。
  • pipVideoTarget: SkyWay 通信中に、ローカル(自分)の映像を PiP モードで表示するか、リモート(対向側)の映像を PiP モードで表示するかを判別するためのフラグです。
    • 主に Composable 側で利用します。
    • LOCAL の場合は SkyWay の LocalVideoStream を利用して PiP モードを表示します。
    • REMOTE の場合は SkyWay の RemoteVideoStream を利用して PiP モードを表示します。

Activity 側での PiP の初期化および状態監視

アプリの Activity として MainActivity があるとします。

class MainActivity : ComponentActivity(), PictureInPictureDelegate.OnPictureInPictureEventListener { private lateinit var pipImpl: BasicPictureInPicture override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) // PiP 機能の初期化 pipImpl = BasicPictureInPicture(this) // PiP の状態変化(ENTERED/EXITED など)の監視を開始 pipImpl.addOnPictureInPictureEventListener( ContextCompat.getMainExecutor(this), this, ) lifecycleScope.launch { // 通話中のみ PiP を有効化し、ホームボタン押下時の自動突入を許可する PipStateHolder.isCallActive.collect { active -> pipImpl.setEnabled(active) } } } override fun onPictureInPictureEvent( event: PictureInPictureDelegate.Event, config: Configuration?, ) { when (event) { // PiP ウィンドウへ遷移 → Composable 側でミニマル UI に切り替える PictureInPictureDelegate.Event.ENTERED -> PipStateHolder.isPipMode.value = true // 通常画面に復帰 → Composable 側で通常 UI に戻す PictureInPictureDelegate.Event.EXITED -> PipStateHolder.isPipMode.value = false else -> Unit } } override fun onDestroy() { super.onDestroy() // PiP が残ったまま Activity が破棄される場合に備えて状態をリセット PipStateHolder.isPipMode.value = false PipStateHolder.isCallActive.value = false } }

ポイント

  • BasicPictureInPicture を Activity のインスタンスで初期化し、addOnPictureInPictureEventListener() でイベントを購読します。
  • isCallActive を監視して通話中のみ setEnabled(true) にすることで、通話していない画面でホームボタンを押しても PiP に入らないようにします。
  • PiP の状態変化(ENTERED/EXITED)を isPipMode に反映し、Composable 側の UI 切り替えのトリガーにします。

ViewModel 側での SkyWay 配信状態の管理

ViewModel は通話状態の更新を行います。

fun joinRoom(roomName: String, memberName: String) { viewModelScope.launch { // Room.join 成功後、PiP の自動突入を許可する PipStateHolder.isCallActive.value = true } } fun leaveRoom() { viewModelScope.launch { // 離脱処理より先に無効化し、処理中の意図しない PiP 突入を防ぐ PipStateHolder.isCallActive.value = false // member.leave() など } }

ポイント

  • joinRoom() で通話開始後に isCallActive = true にし、PiP の自動突入を許可します。
  • leaveRoom() では退出処理(member.leave() など)より先に isCallActive = false にすることで、離脱処理中にバックグラウンドへ遷移しても PiP に入らないようにします。
  • ViewModel は PiP API や Activity API を直接扱わず、状態の更新のみを担当します。

UI 側での PiP モードの自動切り替え

Composable は isPipMode を見て UI を切り替えます。

@Composable fun PipSampleScreen(vm: PipSampleViewModel = viewModel()) { val isPipMode by PipStateHolder.isPipMode.collectAsState() val pipTarget by PipStateHolder.pipVideoTarget.collectAsState() // PiP 中は専用のフルスクリーン UI に切り替えて通常 UI を描画しない if (isPipMode) { PipVideoFullScreen(target = pipTarget, vm = vm) return } // 通常 UI } @Composable private fun PipVideoFullScreen(target: PipVideoTarget, vm: PipSampleViewModel) { // LocalVideoStream/RemoteVideoStream はどちらも VideoStream(addRenderer/removeRenderer を持つ)を実装している val stream: VideoStream? = when (target) { PipVideoTarget.LOCAL -> vm.getLocalVideoStream() PipVideoTarget.REMOTE -> vm.getRemoteVideoStream() } Box( modifier = Modifier .fillMaxSize() .background(Color.Black), contentAlignment = Alignment.Center, ) { // 映像ストリームが切り替わったら AndroidView を作り直す key(stream) { AndroidView( factory = { ctx -> SurfaceViewRenderer(ctx).also { renderer -> renderer.setup() stream?.addRenderer(renderer) } }, onRelease = { renderer -> // Composable 破棄時に renderer を解放してリークを防ぐ stream?.removeRenderer(renderer) renderer.dispose() }, modifier = Modifier.fillMaxSize(), ) } } }

ポイント

  • isPipMode が true の間は PipVideoFullScreen のみを表示し、通常 UI は描画しません。
  • LocalVideoStream/RemoteVideoStream はどちらも共通の VideoStream インターフェースを実装しているため、target に応じて取得したストリームを共通の AndroidView ブロックで扱えます。
  • key(stream) により、映像ストリームが切り替わった際に AndroidView を再生成します。
  • onRelease で removeRenderer() と dispose() を呼び、Composable 破棄時のリソースリークを防ぎます。

PiP モードの手動切り替え

アプリがバックグラウンドに遷移したときは、Activity 側の PictureInPictureDelegate.OnPictureInPictureEventListener の実装により自動で PiP モードに入りますが、手動で PiP モードに入ることも可能です。

以下は、先述の通常 UI(isPipMode が false の場合に描画される部分)にボタンを追加する例です。

// 通常 UI(抜粋)。ローカル映像のプレビューに PiP ボタンを添える Column { key(vm.getLocalVideoStream()) { AndroidView( factory = { ctx -> SurfaceViewRenderer(ctx).also { renderer -> renderer.setup() vm.getLocalVideoStream()?.addRenderer(renderer) } }, update = { renderer -> vm.getLocalVideoStream()?.removeRenderer(renderer) vm.getLocalVideoStream()?.addRenderer(renderer) }, modifier = Modifier .fillMaxWidth() .height(200.dp), ) } Button(onClick = { PipStateHolder.pipVideoTarget.value = PipVideoTarget.LOCAL enterPip(context) }) { Text("PiP (Local)") } } /** * PiP モードに手動で入る。 * * [BasicPictureInPicture.setEnabled] による「ホームボタン時の自動 PiP 開始」とは別に、 * ボタンタップによる明示的な PiP 開始はここで直接 Activity API を呼ぶ。 */ private fun enterPip(context: android.content.Context) { // enterPictureInPictureMode() は Android 8(API 26)以降のみ利用可能 if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { // Activity 以外の Context(Application など)の場合は PiP に入れないため安全にキャスト val activity = context as? ComponentActivity ?: return val params = PictureInPictureParams.Builder() .setAspectRatio(Rational(16, 9)) .build() activity.enterPictureInPictureMode(params) } }

ポイント

  • アスペクト比は横向き映像を想定して 16:9 を指定しています。セルフィー(縦向き)映像を PiP 表示する場合は Rational(9, 16) に変更してください。
  • enterPictureInPictureMode() は Android 8(API 26)以降でしか利用できないため、Build.VERSION.SDK_INT でガードしています。
  • context が ComponentActivity にキャストできない場合(非 Activity の Context の場合)は何もしないように、as? で安全にキャストしています。

サンプルコード

本文のサンプルはこちらをご確認ください。