Skip to content

Commit

Permalink
feat: Picture in picture support for Android (#614)
Browse files Browse the repository at this point in the history
* Picture in picture support for Android

* tweak

* renamed pip method

* vale fix
  • Loading branch information
Brazol authored Mar 27, 2024
1 parent 065f3ce commit d09c2be
Show file tree
Hide file tree
Showing 15 changed files with 299 additions and 2 deletions.
68 changes: 68 additions & 0 deletions docusaurus/docs/Flutter/05-advanced/04-picture-in-picture.mdx
Original file line number Diff line number Diff line change
@@ -0,0 +1,68 @@
---
id: picture_in_picture
sidebar_position: 4
title: Picture in Picture (PiP)
---

Picture in picture (PIP) keeps the call running and visible while you navigate to other apps.

:::info
At the moment Picture in Picture is only supported on Android.
:::

### Enable Picture-in-Picture
You can enable Picture in Picture by setting the `enablePictureInPicture` property to `true` in the `StreamCallContainer` or `StreamCallContent` widget.

```dart
StreamCallContainer(
call: widget.call,
enablePictureInPicture: true,
)
```

You can customize the widget rendered while app is in Picture-in-Picture mode by providing `callPictureInPictureBuilder` to `StreamCallContent`.

```dart
StreamCallContainer(
call: widget.call,
callContentBuilder: (
BuildContext context,
Call call,
CallState callState,
) {
return StreamCallContent(
call: call,
callState: callState,
enablePictureInPicture: true,
callPictureInPictureBuilder: (context, call, callState) => // YOUR CUSTOM WIDGET
})
```

### Android Configuration
To enable Picture in Picture on Android, you need to add the following configuration to your `AndroidManifest.xml` file.

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

Then you need to add this code to your `MainActivity` class. It will enter Picture in Picture mode when the user leaves the app but only if the call is active.

```kotlin
import io.flutter.embedding.android.FlutterActivity
import io.getstream.video.flutter.stream_video_flutter.service.PictureInPictureHelper

class MainActivity: FlutterActivity() {
override fun onUserLeaveHint() {
super.onUserLeaveHint()
PictureInPictureHelper.enterPictureInPictureIfInCall(this)
}
}
```

Done. Now after leaving the app, you'll see that the call will be still alive in the background like the one below:

![Picture in Picture example](../assets/advanced_assets/pip_example.png)
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
1 change: 1 addition & 0 deletions dogfooding/android/app/src/main/AndroidManifest.xml
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@
android:icon="@mipmap/ic_launcher">
<activity
android:name=".MainActivity"
android:supportsPictureInPicture="true"
android:exported="true"
android:launchMode="singleTask"
android:theme="@style/LaunchTheme"
Expand Down
Original file line number Diff line number Diff line change
@@ -1,6 +1,11 @@
package io.getstream.video.flutter.dogfooding

import io.flutter.embedding.android.FlutterActivity
import io.getstream.video.flutter.stream_video_flutter.service.PictureInPictureHelper

class MainActivity: FlutterActivity() {
override fun onUserLeaveHint() {
super.onUserLeaveHint()
PictureInPictureHelper.enterPictureInPictureIfInCall(this)
}
}
1 change: 1 addition & 0 deletions dogfooding/lib/screens/call_screen.dart
Original file line number Diff line number Diff line change
Expand Up @@ -143,6 +143,7 @@ class _CallScreenState extends State<CallScreen> {
call: call,
callState: callState,
layoutMode: _currentLayoutMode,
enablePictureInPicture: true,
callParticipantsBuilder: (context, call, callState) {
return Stack(
children: [
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -2,10 +2,12 @@ package io.getstream.video.flutter.stream_video_flutter

import android.Manifest
import android.app.Activity
import android.app.PictureInPictureParams
import android.content.Context
import android.content.Intent
import android.content.pm.PackageManager
import android.os.Build
import android.util.Rational
import androidx.core.app.ActivityCompat
import androidx.core.content.ContextCompat
import io.flutter.embedding.android.FlutterFlags
Expand All @@ -15,12 +17,14 @@ import io.flutter.plugin.common.MethodCall
import io.flutter.plugin.common.MethodChannel
import io.flutter.plugin.common.PluginRegistry
import io.getstream.log.taggedLogger
import io.getstream.video.flutter.stream_video_flutter.service.PictureInPictureHelper
import io.getstream.video.flutter.stream_video_flutter.service.ServiceManager
import io.getstream.video.flutter.stream_video_flutter.service.ServiceManagerImpl
import io.getstream.video.flutter.stream_video_flutter.service.ServiceType
import io.getstream.video.flutter.stream_video_flutter.service.StreamCallService
import io.getstream.video.flutter.stream_video_flutter.service.StreamScreenShareService
import io.getstream.video.flutter.stream_video_flutter.service.notification.NotificationPayload
import io.getstream.video.flutter.stream_video_flutter.service.utils.putBoolean

class MethodCallHandlerImpl(
appContext: Context,
Expand Down Expand Up @@ -64,6 +68,15 @@ class MethodCallHandlerImpl(
override fun onMethodCall(call: MethodCall, result: MethodChannel.Result) {
logger.d { "[onMethodCall] method: ${call.method}" }
when (call.method) {
"enablePictureInPictureMode" -> {
val activity = getActivity()
putBoolean(activity, PictureInPictureHelper.PIP_ENABLED_PREF_KEY, true)
}
"disablePictureInPictureMode" -> {
val activity = getActivity()
putBoolean(activity, PictureInPictureHelper.PIP_ENABLED_PREF_KEY, false)
PictureInPictureHelper.disablePictureInPicture(activity!!)
}
"isBackgroundServiceRunning" -> {
val statusString = call.argument<String>("type")
val serviceType = ServiceType.valueOf(statusString ?: "call")
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,56 @@
package io.getstream.video.flutter.stream_video_flutter.service

import android.app.Activity
import android.app.PictureInPictureParams
import android.content.pm.ActivityInfo
import android.content.pm.PackageManager
import android.os.Build
import android.util.Rational
import io.getstream.video.flutter.stream_video_flutter.service.utils.getBoolean

class PictureInPictureHelper {
companion object {
const val PIP_ENABLED_PREF_KEY = "pip_enabled"

fun disablePictureInPicture(activity: Activity) {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) {
val params = PictureInPictureParams.Builder()
params.setAutoEnterEnabled(false)
activity.setPictureInPictureParams(params.build())
}
}

fun enterPictureInPictureIfInCall(activity: Activity) {
val pipEnabled = getBoolean(activity, PIP_ENABLED_PREF_KEY)
if (!pipEnabled) return

if (activity.packageManager.hasSystemFeature(PackageManager.FEATURE_PICTURE_IN_PICTURE)) {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
val currentOrientation = activity.resources.configuration.orientation

val aspect =
if (currentOrientation == ActivityInfo.SCREEN_ORIENTATION_PORTRAIT) {
Rational(9, 16)
} else {
Rational(16, 9)
}

val params = PictureInPictureParams.Builder()
params.setAspectRatio(aspect).apply {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) {
setAutoEnterEnabled(true)
}
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
setTitle("Video Player")
setSeamlessResizeEnabled(true)
}
}

activity.enterPictureInPictureMode(params.build())
} else if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.N) {
activity.enterPictureInPictureMode()
}
}
}
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
package io.getstream.video.flutter.stream_video_flutter.service.utils

import android.content.Context
import android.content.SharedPreferences

private const val PREFERENCES_FILE_NAME = "stream_video_flutter"
private var prefs: SharedPreferences? = null
private var editor: SharedPreferences.Editor? = null

private fun initInstance(context: Context) {
prefs = context.getSharedPreferences(PREFERENCES_FILE_NAME, Context.MODE_PRIVATE)
editor = prefs?.edit()
}

fun putBoolean(context: Context?, key: String, value: Boolean) {
if (context == null) return
initInstance(context)
editor?.putBoolean(key, value)
editor?.commit()
}

fun getBoolean(context: Context?, key: String, defaultValue: Boolean = false): Boolean {
if (context == null) return defaultValue;
initInstance(context)
return prefs?.getBoolean(key, defaultValue) ?: defaultValue
}

fun remove(context: Context?, key: String) {
if (context == null) return
initInstance(context)
editor?.remove(key)
editor?.commit()
}
Original file line number Diff line number Diff line change
Expand Up @@ -158,6 +158,14 @@ class _StreamCallParticipantsState extends State<StreamCallParticipants> {

@override
Widget build(BuildContext context) {
if (widget.layoutMode == ParticipantLayoutMode.pictureInPicture) {
return widget.callParticipantBuilder(
context,
widget.call,
_participants.first,
);
}

if (_screenShareParticipant != null) {
return ScreenShareCallParticipantsContent(
call: widget.call,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,9 @@ enum ParticipantLayoutMode {
grid,

/// The layout mode is set to spotlight view.
spotlight;
spotlight,

pictureInPicture;
}

extension SortingExtension on ParticipantLayoutMode {
Expand All @@ -15,6 +17,8 @@ extension SortingExtension on ParticipantLayoutMode {
return CallParticipantSortingPresets.regular;
case ParticipantLayoutMode.spotlight:
return CallParticipantSortingPresets.speaker;
case ParticipantLayoutMode.pictureInPicture:
return CallParticipantSortingPresets.speaker;
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -56,6 +56,7 @@ class StreamCallContainer extends StatefulWidget {
this.incomingCallBuilder,
this.outgoingCallBuilder,
this.callContentBuilder,
this.enablePictureInPicture = false,
});

/// Represents a call.
Expand Down Expand Up @@ -88,6 +89,9 @@ class StreamCallContainer extends StatefulWidget {
/// Builder used to create a custom call content widget.
final CallContentBuilder? callContentBuilder;

/// Whether to enable picture-in-picture mode. (available only on Android)
final bool enablePictureInPicture;

@override
State<StreamCallContainer> createState() => _StreamCallContainerState();
}
Expand Down Expand Up @@ -154,6 +158,7 @@ class _StreamCallContainerState extends State<StreamCallContainer> {
callState: _callState,
onBackPressed: widget.onBackPressed,
onLeaveCallTap: widget.onLeaveCallTap,
enablePictureInPicture: widget.enablePictureInPicture,
);
}
}
Expand Down
Loading

0 comments on commit d09c2be

Please sign in to comment.