---
lang: ja
path: cookbook/android-sdk/pip
labels: クックブック/Android SDK/Picture-in-Picture
metaTitle: PiP | Picture-in-Picture | Android SDK | クックブック | SkyWay（スカイウェイ）
---
# Picture-in-Picture

## 目次

1. [PiP とは](#pip-とは)
2. [Jetpack Picture-in-Picture library の導入](#jetpack-picture-in-picture-library-の導入)
3. [AndroidManifest の設定](#androidmanifest-の設定)
4. [PiP の状態管理について](#pip-の状態管理について)
5. [Activity 側での PiP の初期化および状態監視](#activity-側での-pip-の初期化および状態監視)
6. [ViewModel 側での SkyWay 配信状態の管理](#viewmodel-側での-skyway-配信状態の管理)
7. [UI 側での PiP モードの自動切り替え](#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 を追加します。
```toml
[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 への依存を追加します。
```groovy
dependencies {
    ...
    implementation libs.androidx.core.pip // 追加
}
```

## AndroidManifest の設定

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

```xml
<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 関連の状態を共有するためのブリッジとして設計します。

```kotlin
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` があるとします。
```kotlin
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 は通話状態の更新を行います。

```kotlin
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 を切り替えます。

```kotlin
@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` の場合に描画される部分）にボタンを追加する例です。

```kotlin
// 通常 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?` で安全にキャストしています。

## サンプルコード
本文のサンプルは[こちら](https://github.com/skyway/android-sdk/tree/main/examples)をご確認ください。

---


