Android SDK
Picture-in-Picture
目次
- PiP とは
- Jetpack Picture-in-Picture library の導入
- AndroidManifest の設定
- PiP の状態管理について
- Activity 側での PiP の初期化および状態監視
- ViewModel 側での SkyWay 配信状態の管理
- UI 側での PiP モードの自動切り替え
PiP とは
Picture-in-Picture(PiP) は、アプリをバックグラウンドに移行しても、画面上に小さなフローティングウィンドウを残して映像を継続表示できる Android の機能です。
SkyWay Android SDK が提供している LocalVideoStream/RemoteVideoStream と組み合わせて使うと、通話中にホームボタンを押しても相手の映像が見えたまま他の操作ができる、という体験を実現できます。
本ページでは、SkyWay Android SDK を利用する通話アプリに、Picture-in-Picture を実装する方法について解説します。
注意
- Jetpack Compose を利用する想定です。
- 利用する PiP library は
1.0.0-alpha02のため、今後実装方法が変更される可能性があります。- 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 を無効にします。
- ViewModel 内で SkyWay の通信を開始したタイミングで
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?で安全にキャストしています。
サンプルコード
本文のサンプルはこちらをご確認ください。