Skip to content

Commit f6e0f47

Browse files
authored
chore(ui): improved Android PiP implementation (#1003)
* improved Android PiP implementation * changelog tweak * tweaks * tweak * changelog update * fix * changelog tweak * tweaks * fixes
1 parent 24cb87d commit f6e0f47

File tree

13 files changed

+448
-143
lines changed

13 files changed

+448
-143
lines changed

dogfooding/android/app/src/main/kotlin/io/getstream/video/flutter/dogfooding/MainActivity.kt

Lines changed: 3 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -6,31 +6,22 @@ import android.graphics.ColorMatrix
66
import android.graphics.ColorMatrixColorFilter
77
import android.graphics.Paint
88

9-
import io.flutter.embedding.android.FlutterActivity
109
import io.flutter.embedding.engine.FlutterEngine
11-
import io.flutter.embedding.engine.plugins.FlutterPlugin
12-
import io.flutter.embedding.engine.plugins.activity.ActivityAware
13-
import io.flutter.embedding.engine.plugins.activity.ActivityPluginBinding
1410
import io.flutter.plugin.common.MethodChannel
15-
import io.flutter.plugin.common.PluginRegistry
1611

17-
import io.getstream.video.flutter.stream_video_flutter.service.PictureInPictureHelper
12+
import io.getstream.video.flutter.stream_video_flutter.StreamFlutterActivity
1813
import io.getstream.video.flutter.stream_video_flutter.videoFilters.common.VideoFrameProcessorWithBitmapFilter
1914
import io.getstream.video.flutter.stream_video_flutter.videoFilters.common.BitmapVideoFilter
2015
import io.getstream.webrtc.flutter.videoEffects.ProcessorProvider
2116
import io.getstream.webrtc.flutter.videoEffects.VideoFrameProcessor
2217
import io.getstream.webrtc.flutter.videoEffects.VideoFrameProcessorFactoryInterface
2318

24-
class MainActivity: FlutterActivity() {
19+
class MainActivity: StreamFlutterActivity() {
2520
private val CHANNEL = "io.getstream.video.flutter.dogfooding.channel"
2621

27-
override fun onUserLeaveHint() {
28-
super.onUserLeaveHint()
29-
PictureInPictureHelper.enterPictureInPictureIfInCall(this)
30-
}
31-
3222
override fun configureFlutterEngine(flutterEngine: FlutterEngine) {
3323
super.configureFlutterEngine(flutterEngine)
24+
3425
MethodChannel(flutterEngine.dartExecutor.binaryMessenger, CHANNEL).setMethodCallHandler { call, result ->
3526
if (call.method == "registerGreyscaleEffect") {
3627
ProcessorProvider.addProcessor("grayscale", GrayScaleVideoFilterFactory())

packages/stream_video_flutter/CHANGELOG.md

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,12 @@
11
## Unreleased
22

3+
🚧 (Android) Picture-in-Picture (PiP) Improvements - Breaking Change
4+
* **Simplified Setup:** Introduced `StreamFlutterActivity` - extend it instead of `FlutterActivity` for automatic PiP support.
5+
* **Automatic Activation:** PiP now triggers automatically when users press home button or background the app during calls.
6+
* **Fixed Overlay Issues:** PiP view can no longer be overlapped by other widgets and will always display the correct video layout.
7+
* **Migration Required:** In your `MainActivity`, remove the manual `onUserLeaveHint()` implementation and extend the MainActivity with `StreamFlutterActivity`. Previously required manually calling `PictureInPictureHelper.enterPictureInPictureIfInCall(this)` - now handled automatically.
8+
* **Removed Deprecated Methods:** Removed the deprecated `setPictureInPictureEnabled` method from `StreamVideoFlutterPlatform`, `StreamVideoFlutterBackground`, and `MethodChannelStreamVideoFlutter` classes, and the deprecated `enterPictureInPictureIfInCall` method from `PictureInPictureHelper` (Android). PiP is now handled automatically by `StreamPictureInPictureAndroidView`.
9+
310
🔄 Partial State Updates:
411
* Added `call.partialState` for more specific and efficient state updates.
512
* Added callbacks in `StreamCallContainer`, `StreamCallContent`, `StreamIncomingCallContent`, and others that no longer return a state.

packages/stream_video_flutter/android/src/main/kotlin/io/getstream/video/flutter/stream_video_flutter/MethodCallHandlerImpl.kt

Lines changed: 1 addition & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -24,7 +24,6 @@ import io.getstream.video.flutter.stream_video_flutter.service.ServiceType
2424
import io.getstream.video.flutter.stream_video_flutter.service.StreamCallService
2525
import io.getstream.video.flutter.stream_video_flutter.service.StreamScreenShareService
2626
import io.getstream.video.flutter.stream_video_flutter.service.notification.NotificationPayload
27-
import io.getstream.video.flutter.stream_video_flutter.service.utils.putBoolean
2827
import io.getstream.webrtc.flutter.videoEffects.ProcessorProvider
2928
import io.getstream.video.flutter.stream_video_flutter.videoFilters.factories.BackgroundBlurFactory
3029
import io.getstream.video.flutter.stream_video_flutter.videoFilters.factories.BlurIntensity
@@ -105,15 +104,7 @@ class MethodCallHandlerImpl(
105104

106105
result.success(null)
107106
}
108-
"enablePictureInPictureMode" -> {
109-
val activity = getActivity()
110-
putBoolean(activity, PictureInPictureHelper.PIP_ENABLED_PREF_KEY, true)
111-
}
112-
"disablePictureInPictureMode" -> {
113-
val activity = getActivity()
114-
putBoolean(activity, PictureInPictureHelper.PIP_ENABLED_PREF_KEY, false)
115-
PictureInPictureHelper.disablePictureInPicture(activity!!)
116-
}
107+
117108
"isBackgroundServiceRunning" -> {
118109
val typeString = call.argument<String>("type")
119110
val serviceType = ServiceType.valueOf(typeString ?: "call")
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,55 @@
1+
package io.getstream.video.flutter.stream_video_flutter
2+
3+
import android.content.res.Configuration
4+
import io.flutter.embedding.android.FlutterActivity
5+
import io.flutter.embedding.engine.FlutterEngine
6+
import io.getstream.video.flutter.stream_video_flutter.service.PictureInPictureHelper
7+
8+
/**
9+
* A base FlutterActivity that automatically handles Picture-in-Picture setup.
10+
*
11+
* Extend this class instead of FlutterActivity to get automatic PiP support:
12+
*
13+
* ```kotlin
14+
* class MainActivity : StreamFlutterActivity() {
15+
* // Your activity code here
16+
* }
17+
* ```
18+
*/
19+
abstract class StreamFlutterActivity : FlutterActivity() {
20+
21+
override fun configureFlutterEngine(flutterEngine: FlutterEngine) {
22+
super.configureFlutterEngine(flutterEngine)
23+
24+
// Initialize PictureInPictureHelper with Flutter engine
25+
PictureInPictureHelper.initializeWithFlutterEngine(flutterEngine) { this }
26+
}
27+
28+
override fun cleanUpFlutterEngine(flutterEngine: FlutterEngine) {
29+
super.cleanUpFlutterEngine(flutterEngine)
30+
PictureInPictureHelper.cleanup()
31+
}
32+
33+
override fun onUserLeaveHint() {
34+
super.onUserLeaveHint()
35+
// Trigger PiP when user leaves the app (e.g., presses home button)
36+
PictureInPictureHelper.handlePipTrigger(this)
37+
}
38+
39+
override fun onPause() {
40+
super.onPause()
41+
// Trigger PiP when app is backgrounded (but not finishing)
42+
if (!isFinishing) {
43+
PictureInPictureHelper.handlePipTrigger(this)
44+
}
45+
}
46+
47+
override fun onPictureInPictureModeChanged(
48+
isInPictureInPictureMode: Boolean,
49+
newConfig: Configuration
50+
) {
51+
super.onPictureInPictureModeChanged(isInPictureInPictureMode, newConfig)
52+
// Notify Flutter about PiP mode changes
53+
PictureInPictureHelper.notifyPictureInPictureModeChanged(this, isInPictureInPictureMode)
54+
}
55+
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,55 @@
1+
package io.getstream.video.flutter.stream_video_flutter
2+
3+
import android.content.res.Configuration
4+
import io.flutter.embedding.android.FlutterFragmentActivity
5+
import io.flutter.embedding.engine.FlutterEngine
6+
import io.getstream.video.flutter.stream_video_flutter.service.PictureInPictureHelper
7+
8+
/**
9+
* A base FlutterFragmentActivity that automatically handles Picture-in-Picture setup.
10+
*
11+
* Extend this class instead of FlutterFragmentActivity to get automatic PiP support:
12+
*
13+
* ```kotlin
14+
* class MainActivity : StreamFlutterFragmentActivity() {
15+
* // Your activity code here
16+
* }
17+
* ```
18+
*/
19+
abstract class StreamFlutterFragmentActivity : FlutterFragmentActivity() {
20+
21+
override fun configureFlutterEngine(flutterEngine: FlutterEngine) {
22+
super.configureFlutterEngine(flutterEngine)
23+
24+
// Initialize PictureInPictureHelper with Flutter engine
25+
PictureInPictureHelper.initializeWithFlutterEngine(flutterEngine) { this }
26+
}
27+
28+
override fun cleanUpFlutterEngine(flutterEngine: FlutterEngine) {
29+
super.cleanUpFlutterEngine(flutterEngine)
30+
PictureInPictureHelper.cleanup()
31+
}
32+
33+
override fun onUserLeaveHint() {
34+
super.onUserLeaveHint()
35+
// Trigger PiP when user leaves the app (e.g., presses home button)
36+
PictureInPictureHelper.handlePipTrigger(this)
37+
}
38+
39+
override fun onPause() {
40+
super.onPause()
41+
// Trigger PiP when app is backgrounded (but not finishing)
42+
if (!isFinishing) {
43+
PictureInPictureHelper.handlePipTrigger(this)
44+
}
45+
}
46+
47+
override fun onPictureInPictureModeChanged(
48+
isInPictureInPictureMode: Boolean,
49+
newConfig: Configuration
50+
) {
51+
super.onPictureInPictureModeChanged(isInPictureInPictureMode, newConfig)
52+
// Notify Flutter about PiP mode changes
53+
PictureInPictureHelper.notifyPictureInPictureModeChanged(this, isInPictureInPictureMode)
54+
}
55+
}

packages/stream_video_flutter/android/src/main/kotlin/io/getstream/video/flutter/stream_video_flutter/service/PictureInPictureHelper.kt

Lines changed: 87 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -4,14 +4,55 @@ import android.app.Activity
44
import android.app.PictureInPictureParams
55
import android.content.pm.ActivityInfo
66
import android.content.pm.PackageManager
7+
import android.graphics.Rect
78
import android.os.Build
9+
import android.util.Log
810
import android.util.Rational
9-
import io.getstream.video.flutter.stream_video_flutter.service.utils.getBoolean
1011
import android.app.PictureInPictureUiState
12+
import io.flutter.embedding.engine.FlutterEngine
13+
import io.flutter.plugin.common.MethodCall
14+
import io.flutter.plugin.common.MethodChannel
1115

1216
class PictureInPictureHelper {
1317
companion object {
14-
const val PIP_ENABLED_PREF_KEY = "pip_enabled"
18+
private const val PIP_CHANNEL = "stream_video_flutter_android_pip"
19+
20+
private var methodChannel: MethodChannel? = null
21+
private var getActivity: (() -> Activity?)? = null
22+
private var isPictureInPictureAllowed: Boolean = false
23+
24+
fun initializeWithFlutterEngine(engine: FlutterEngine, activityGetter: () -> Activity?) {
25+
methodChannel = MethodChannel(engine.dartExecutor.binaryMessenger, PIP_CHANNEL)
26+
getActivity = activityGetter
27+
methodChannel?.setMethodCallHandler { call, result ->
28+
handleMethodCall(call, result)
29+
}
30+
}
31+
32+
private fun handleMethodCall(call: MethodCall, result: MethodChannel.Result) {
33+
when (call.method) {
34+
"setPictureInPictureAllowed" -> {
35+
val isAllowed = call.arguments as Boolean
36+
setPictureInPictureAllowed(isAllowed)
37+
result.success(null)
38+
}
39+
else -> {
40+
result.notImplemented()
41+
}
42+
}
43+
}
44+
45+
private fun setPictureInPictureAllowed(isAllowed: Boolean) {
46+
isPictureInPictureAllowed = isAllowed
47+
48+
// If PiP is being disabled and we're currently in PiP mode, exit it
49+
if (!isAllowed) {
50+
val activity = getActivity?.invoke()
51+
if (activity != null) {
52+
disablePictureInPicture(activity)
53+
}
54+
}
55+
}
1556

1657
fun disablePictureInPicture(activity: Activity) {
1758
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
@@ -25,33 +66,56 @@ class PictureInPictureHelper {
2566
}
2667
}
2768

28-
fun enterPictureInPictureIfInCall(activity: Activity) {
29-
val pipEnabled = getBoolean(activity, PIP_ENABLED_PREF_KEY)
30-
if (!pipEnabled) return
69+
fun handlePipTrigger(activity: Activity) {
70+
// Check if already in PiP mode to avoid redundant calls
71+
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.N && activity.isInPictureInPictureMode) {
72+
return
73+
}
74+
75+
if (isPictureInPictureAllowed && canEnterPictureInPicture(activity)) {
76+
enterPictureInPictureMode(activity)
77+
}
78+
}
79+
80+
fun notifyPictureInPictureModeChanged(activity: Activity, isInPictureInPictureMode: Boolean) {
81+
activity.runOnUiThread {
82+
methodChannel?.invokeMethod("onPictureInPictureModeChanged", isInPictureInPictureMode)
83+
}
84+
}
3185

32-
if (activity.packageManager.hasSystemFeature(PackageManager.FEATURE_PICTURE_IN_PICTURE)) {
33-
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
34-
val currentOrientation = activity.resources.configuration.orientation
86+
fun enterPictureInPictureMode(activity: Activity) {
87+
if (!canEnterPictureInPicture(activity)) return
3588

36-
val aspect =
37-
if (currentOrientation == ActivityInfo.SCREEN_ORIENTATION_PORTRAIT) {
38-
Rational(9, 16)
39-
} else {
40-
Rational(16, 9)
41-
}
89+
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
90+
val currentOrientation = activity.resources.configuration.orientation
4291

43-
val params = PictureInPictureParams.Builder()
44-
params.setAspectRatio(aspect).apply {
45-
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
46-
setSeamlessResizeEnabled(true)
47-
}
48-
}
92+
val aspect = if (currentOrientation == ActivityInfo.SCREEN_ORIENTATION_PORTRAIT) {
93+
Rational(9, 16)
94+
} else {
95+
Rational(16, 9)
96+
}
4997

50-
activity.enterPictureInPictureMode(params.build())
51-
} else if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.N) {
52-
activity.enterPictureInPictureMode()
98+
val params = PictureInPictureParams.Builder()
99+
params.setAspectRatio(aspect).apply {
100+
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
101+
setSeamlessResizeEnabled(true)
102+
}
53103
}
104+
105+
activity.enterPictureInPictureMode(params.build())
106+
} else if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.N) {
107+
activity.enterPictureInPictureMode()
54108
}
55109
}
110+
111+
private fun canEnterPictureInPicture(activity: Activity): Boolean {
112+
return activity.packageManager.hasSystemFeature(PackageManager.FEATURE_PICTURE_IN_PICTURE)
113+
}
114+
115+
fun cleanup() {
116+
methodChannel?.setMethodCallHandler(null)
117+
methodChannel = null
118+
getActivity = null
119+
}
56120
}
57121
}

0 commit comments

Comments
 (0)