Skip to content

Commit b41f09f

Browse files
authored
fix: Picture in Picture improvements (#857)
* pip improvements * tweaks
1 parent 3639417 commit b41f09f

File tree

17 files changed

+225
-100
lines changed

17 files changed

+225
-100
lines changed

dogfooding/lib/di/injector.dart

+2-3
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,5 @@
11
// 📦 Package imports:
22
import 'package:flutter/foundation.dart';
3-
import 'package:flutter/material.dart';
43
// 🌎 Project imports:
54
import 'package:flutter_dogfooding/core/repos/app_preferences.dart';
65
import 'package:flutter_dogfooding/core/repos/user_chat_repository.dart';
@@ -155,8 +154,8 @@ StreamVideo _initStreamVideo(
155154
tokenLoader: tokenLoader,
156155
options: const StreamVideoOptions(
157156
logPriority: Priority.verbose,
158-
muteAudioWhenInBackground: true,
159-
muteVideoWhenInBackground: true,
157+
muteAudioWhenInBackground: false,
158+
muteVideoWhenInBackground: false,
160159
keepConnectionsAliveWhenInBackground: true,
161160
),
162161
pushNotificationManagerProvider: StreamVideoPushNotificationManager.create(

packages/stream_video/lib/src/call/call.dart

+25-1
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ import 'dart:typed_data';
66
import 'package:collection/collection.dart';
77
import 'package:internet_connection_checker_plus/internet_connection_checker_plus.dart';
88
import 'package:meta/meta.dart';
9+
import 'package:stream_webrtc_flutter/stream_webrtc_flutter.dart' as rtc;
910
import 'package:stream_webrtc_flutter/stream_webrtc_flutter.dart';
1011
import 'package:synchronized/synchronized.dart';
1112

@@ -1979,6 +1980,24 @@ class Call {
19791980
return result.map((_) => none);
19801981
}
19811982

1983+
Future<Result<bool>> setMultitaskingCameraAccessEnabled(bool enabled) async {
1984+
if (CurrentPlatform.isIos) {
1985+
try {
1986+
final result =
1987+
await rtc.Helper.enableIOSMultitaskingCameraAccess(enabled);
1988+
return Result.success(result);
1989+
} catch (error, stackTrace) {
1990+
_logger.e(() => 'Failed to set multitasking camera access: $error');
1991+
return Result.error(
1992+
'Failed to set multitasking camera access',
1993+
stackTrace,
1994+
);
1995+
}
1996+
}
1997+
1998+
return const Result.success(false);
1999+
}
2000+
19822001
Future<Result<None>> setVideoInputDevice(RtcMediaDevice device) async {
19832002
final result = await _session?.setVideoInputDevice(device) ??
19842003
Result.error('Session is null');
@@ -1998,14 +2017,19 @@ class Call {
19982017
if (enabled && !hasPermission(CallPermission.sendVideo)) {
19992018
return Result.error('Missing permission to send video');
20002019
}
2001-
20022020
final result =
20032021
await _session?.setCameraEnabled(enabled, constraints: constraints) ??
20042022
Result.error('Session is null');
20052023

20062024
if (result.isSuccess) {
2025+
// Set multitasking camera access for iOS
2026+
final multitaskingResult = await setMultitaskingCameraAccessEnabled(
2027+
enabled && !_streamVideo.muteVideoWhenInBackground,
2028+
);
2029+
20072030
_stateManager.participantSetCameraEnabled(
20082031
enabled: enabled,
2032+
iOSMultitaskingCameraAccessEnabled: multitaskingResult.getDataOrNull(),
20092033
);
20102034

20112035
_connectOptions = _connectOptions.copyWith(

packages/stream_video/lib/src/call/state/mixins/state_participant_mixin.dart

+10-3
Original file line numberDiff line numberDiff line change
@@ -245,8 +245,13 @@ mixin StateParticipantMixin on StateNotifier<CallState> {
245245

246246
void participantSetCameraEnabled({
247247
required bool enabled,
248+
bool? iOSMultitaskingCameraAccessEnabled,
248249
}) {
249-
return _toggleTrackType(SfuTrackType.video, enabled);
250+
return _toggleTrackType(
251+
SfuTrackType.video,
252+
enabled,
253+
iOSMultitaskingCameraAccessEnabled: iOSMultitaskingCameraAccessEnabled,
254+
);
250255
}
251256

252257
void participantSetMicrophoneEnabled({
@@ -263,9 +268,11 @@ mixin StateParticipantMixin on StateNotifier<CallState> {
263268

264269
void _toggleTrackType(
265270
SfuTrackType trackType,
266-
bool enabled,
267-
) {
271+
bool enabled, {
272+
bool? iOSMultitaskingCameraAccessEnabled,
273+
}) {
268274
state = state.copyWith(
275+
iOSMultitaskingCameraAccessEnabled: iOSMultitaskingCameraAccessEnabled,
269276
callParticipants: state.callParticipants.map((participant) {
270277
if (participant.isLocal) {
271278
final publishedTracks = participant.publishedTracks;

packages/stream_video/lib/src/call_state.dart

+7
Original file line numberDiff line numberDiff line change
@@ -48,6 +48,7 @@ class CallState extends Equatable {
4848
blockedUserIds: const [],
4949
participantCount: 0,
5050
anonymousParticipantCount: 0,
51+
iOSMultitaskingCameraAccessEnabled: false,
5152
custom: const {},
5253
);
5354
}
@@ -87,6 +88,7 @@ class CallState extends Equatable {
8788
required this.blockedUserIds,
8889
required this.participantCount,
8990
required this.anonymousParticipantCount,
91+
required this.iOSMultitaskingCameraAccessEnabled,
9092
required this.custom,
9193
});
9294

@@ -124,6 +126,7 @@ class CallState extends Equatable {
124126
final List<String> blockedUserIds;
125127
final int participantCount;
126128
final int anonymousParticipantCount;
129+
final bool iOSMultitaskingCameraAccessEnabled;
127130
final Map<String, Object> custom;
128131

129132
String get callId => callCid.id;
@@ -175,6 +178,7 @@ class CallState extends Equatable {
175178
List<String>? blockedUserIds,
176179
int? participantCount,
177180
int? anonymousParticipantCount,
181+
bool? iOSMultitaskingCameraAccessEnabled,
178182
Map<String, Object>? custom,
179183
}) {
180184
return CallState._(
@@ -213,6 +217,8 @@ class CallState extends Equatable {
213217
participantCount: participantCount ?? this.participantCount,
214218
anonymousParticipantCount:
215219
anonymousParticipantCount ?? this.anonymousParticipantCount,
220+
iOSMultitaskingCameraAccessEnabled: iOSMultitaskingCameraAccessEnabled ??
221+
this.iOSMultitaskingCameraAccessEnabled,
216222
custom: custom ?? this.custom,
217223
);
218224
}
@@ -282,6 +288,7 @@ class CallState extends Equatable {
282288
blockedUserIds,
283289
participantCount,
284290
anonymousParticipantCount,
291+
iOSMultitaskingCameraAccessEnabled,
285292
custom,
286293
];
287294

packages/stream_video/lib/src/stream_video.dart

+43-34
Original file line numberDiff line numberDiff line change
@@ -221,6 +221,9 @@ class StreamVideo extends Disposable {
221221
final StreamVideoOptions _options;
222222
final MutableClientState _state;
223223

224+
bool get muteVideoWhenInBackground => _options.muteVideoWhenInBackground;
225+
bool get muteAudioWhenInBackground => _options.muteAudioWhenInBackground;
226+
224227
final _tokenManager = TokenManager();
225228
final _subscriptions = Subscriptions();
226229
late final CoordinatorClient _client;
@@ -429,30 +432,33 @@ class StreamVideo extends Disposable {
429432
try {
430433
final activeCallCid = _state.activeCall.valueOrNull?.callCid;
431434

432-
if (state.isPaused &&
433-
activeCallCid == null &&
434-
!_options.keepConnectionsAliveWhenInBackground) {
435-
_logger.i(() => '[onAppState] close connection');
436-
_subscriptions.cancel(_idEvents);
437-
await _client.closeConnection();
438-
} else if (state.isPaused && activeCallCid != null) {
439-
final callState = activeCall?.state.value;
440-
final isVideoEnabled =
441-
callState?.localParticipant?.isVideoEnabled ?? false;
442-
final isAudioEnabled =
443-
callState?.localParticipant?.isAudioEnabled ?? false;
444-
445-
if (_options.muteVideoWhenInBackground && isVideoEnabled) {
446-
await activeCall?.setCameraEnabled(enabled: false);
447-
_mutedCameraByStateChange = true;
448-
_logger.v(() => 'Muted camera track since app was paused.');
449-
}
450-
if (_options.muteAudioWhenInBackground && isAudioEnabled) {
451-
await activeCall?.setMicrophoneEnabled(enabled: false);
452-
_mutedAudioByStateChange = true;
453-
_logger.v(() => 'Muted audio track since app was paused.');
435+
if (state.isPaused) {
436+
// Handle app paused state
437+
if (activeCallCid == null &&
438+
!_options.keepConnectionsAliveWhenInBackground) {
439+
_logger.i(() => '[onAppState] close connection');
440+
_subscriptions.cancel(_idEvents);
441+
await _client.closeConnection();
442+
} else if (activeCallCid != null) {
443+
final callState = activeCall?.state.value;
444+
final isVideoEnabled =
445+
callState?.localParticipant?.isVideoEnabled ?? false;
446+
final isAudioEnabled =
447+
callState?.localParticipant?.isAudioEnabled ?? false;
448+
449+
if (_options.muteVideoWhenInBackground && isVideoEnabled) {
450+
await activeCall?.setCameraEnabled(enabled: false);
451+
_mutedCameraByStateChange = true;
452+
_logger.v(() => 'Muted camera track since app was paused.');
453+
}
454+
if (_options.muteAudioWhenInBackground && isAudioEnabled) {
455+
await activeCall?.setMicrophoneEnabled(enabled: false);
456+
_mutedAudioByStateChange = true;
457+
_logger.v(() => 'Muted audio track since app was paused.');
458+
}
454459
}
455460
} else if (state.isResumed) {
461+
// Handle app resumed state
456462
_logger.i(() => '[onAppState] open connection');
457463
await _client.openConnection();
458464
_subscriptions.add(_idEvents, _client.events.listen(_onEvent));
@@ -610,20 +616,23 @@ class StreamVideo extends Disposable {
610616
cid: calls.first.callCid!,
611617
);
612618

613-
callResult.fold(success: (result) async {
614-
final call = result.data;
615-
await call.accept();
619+
callResult.fold(
620+
success: (result) async {
621+
final call = result.data;
622+
await call.accept();
616623

617-
onCallAccepted?.call(call);
624+
onCallAccepted?.call(call);
618625

619-
return true;
620-
}, failure: (error) {
621-
_logger.d(
622-
() =>
623-
'[consumeAndAcceptActiveCall] error consuming incoming call: $error',
624-
);
625-
return false;
626-
});
626+
return true;
627+
},
628+
failure: (error) {
629+
_logger.d(
630+
() =>
631+
'[consumeAndAcceptActiveCall] error consuming incoming call: $error',
632+
);
633+
return false;
634+
},
635+
);
627636

628637
return false;
629638
}

packages/stream_video/lib/src/webrtc/rtc_track/rtc_local_track.dart

+1
Original file line numberDiff line numberDiff line change
@@ -158,6 +158,7 @@ class RtcLocalTrack<T extends MediaConstraints> extends RtcTrack {
158158
streamLog.i(_tag, () => 'Enabling track $trackId');
159159
try {
160160
mediaTrack.enabled = true;
161+
161162
for (final track in clonedTracks) {
162163
track.enabled = true;
163164
}

packages/stream_video/pubspec.yaml

+1-1
Original file line numberDiff line numberDiff line change
@@ -30,7 +30,7 @@ dependencies:
3030
rxdart: ^0.28.0
3131
sdp_transform: ^0.3.2
3232
state_notifier: ^1.0.0
33-
stream_webrtc_flutter: ^0.12.9
33+
stream_webrtc_flutter: ^0.12.9+1
3434
synchronized: ^3.1.0
3535
system_info2: ^4.0.0
3636
tart: ^0.5.1

packages/stream_video_flutter/CHANGELOG.md

+10-2
Original file line numberDiff line numberDiff line change
@@ -1,15 +1,23 @@
11
## Upcoming
22

33
✅ Added
4-
- Introduced `disposeAfterResolvingRinging()` and `consumeAndAcceptActiveCall()` methods in `StreamVideo` to simplify the ringing flow implementation.
4+
* Introduced `disposeAfterResolvingRinging()` and `consumeAndAcceptActiveCall()` methods in `StreamVideo` to simplify the ringing flow implementation.
55
- Refer to the updated [Incoming Call Documentation](https://getstream.io/video/docs/flutter/incoming-calls/overview/) or the [Ringing Tutorial](https://getstream.io/video/sdk/flutter/tutorial/ringing/) for more details.
66

77
🔄 Changed
8-
- Deprecated the `backgroundVoipCallHandler` parameter in `StreamVideoPushNotificationManager`, as it is no longer required for iOS ringing to function in a terminated state.
8+
* Deprecated the `backgroundVoipCallHandler` parameter in `StreamVideoPushNotificationManager`, as it is no longer required for iOS ringing to function in a terminated state.
99

1010
🐞 Fixed
1111
* Center alignment of buttons in `StreamLobbyVideo` to support more screen sizes.
1212

13+
🚧 (Breaking) Picture-in-Picture (PiP) Improvements & Fixes
14+
* **Fixed:** PiP not working on Android 15.
15+
* **Fixed:** PiP not displaying other participants' screen sharing.
16+
* **Added support for iOS 18 Multitasking Camera Access changes.** From **iOS 18**, you can easily enable camera usage while the app is in the background (e.g., for PiP). Refer to [Picture in Picture documentation](https://getstream.io/video/docs/flutter/advanced/picture_in_picture/) for details.
17+
* Added `disablePictureInPictureWhenScreenSharing` configuration option to `PictureInPictureConfiguration`. When **true** (default), PiP is disabled if the local device is screen sharing.
18+
* ❗ Breaking Change: `ignoreLocalParticipantVideo` parameter in `IOSPictureInPictureConfiguration` is replaced by `includeLocalParticipantVideo`. By default, local video **is enabled** and will appear in PiP mode if the iOS device supports **Multitasking Camera Access**.
19+
* ❗ Breaking Change: `ignoreLocalParticipantVideo` parameter in `StreamPictureInPictureUiKitView` is also replaced by `includeLocalParticipantVideo`.
20+
1321
## 0.7.2
1422

1523
🐞 Fixed

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

+1-4
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@ import android.content.pm.PackageManager
77
import android.os.Build
88
import android.util.Rational
99
import io.getstream.video.flutter.stream_video_flutter.service.utils.getBoolean
10+
import android.app.PictureInPictureUiState
1011

1112
class PictureInPictureHelper {
1213
companion object {
@@ -41,11 +42,7 @@ class PictureInPictureHelper {
4142

4243
val params = PictureInPictureParams.Builder()
4344
params.setAspectRatio(aspect).apply {
44-
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) {
45-
setAutoEnterEnabled(true)
46-
}
4745
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
48-
setTitle("Video Player")
4946
setSeamlessResizeEnabled(true)
5047
}
5148
}

packages/stream_video_flutter/example/pubspec.yaml

+1-1
Original file line numberDiff line numberDiff line change
@@ -30,7 +30,7 @@ dependencies:
3030
stream_video: ^0.7.2
3131
stream_video_flutter: ^0.7.2
3232
stream_video_push_notification: ^0.7.2
33-
stream_webrtc_flutter: ^0.12.9
33+
stream_webrtc_flutter: ^0.12.9+1
3434

3535
dependency_overrides:
3636
stream_video:

packages/stream_video_flutter/lib/src/call_participants/call_participants.dart

+10
Original file line numberDiff line numberDiff line change
@@ -150,6 +150,16 @@ class _StreamCallParticipantsState extends State<StreamCallParticipants> {
150150
Widget build(BuildContext context) {
151151
if (_participants.isNotEmpty &&
152152
widget.layoutMode == ParticipantLayoutMode.pictureInPicture) {
153+
if (_screenShareParticipant != null) {
154+
return ScreenShareContent(
155+
key: ValueKey(
156+
'${_screenShareParticipant!.userId} - screenShareContent',
157+
),
158+
call: widget.call,
159+
participant: _screenShareParticipant!,
160+
);
161+
}
162+
153163
return widget.callParticipantBuilder(
154164
context,
155165
widget.call,

packages/stream_video_flutter/lib/src/call_screen/call_content/call_content.dart

+23-5
Original file line numberDiff line numberDiff line change
@@ -113,6 +113,20 @@ class _StreamCallContentState extends State<StreamCallContent>
113113
}
114114
}
115115

116+
// Disable PiP when screen sharing is enabled
117+
if (widget.callState.localParticipant?.isScreenShareEnabled !=
118+
oldWidget.callState.localParticipant?.isScreenShareEnabled) {
119+
if (widget.pictureInPictureConfiguration
120+
.disablePictureInPictureWhenScreenSharing) {
121+
StreamVideoFlutterBackground.setPictureInPictureEnabled(
122+
enable: widget.pictureInPictureConfiguration.enablePictureInPicture &&
123+
!(widget.callState.localParticipant?.isScreenShareEnabled ??
124+
false),
125+
);
126+
}
127+
}
128+
129+
// Disable PiP when call is disconnected
116130
if (widget.callState.status != oldWidget.callState.status) {
117131
if (widget.callState.status.isDisconnected) {
118132
StreamVideoFlutterBackground.setPictureInPictureEnabled(enable: false);
@@ -146,8 +160,13 @@ class _StreamCallContentState extends State<StreamCallContent>
146160
@override
147161
Widget build(BuildContext context) {
148162
final theme = StreamVideoTheme.of(context);
163+
final pipEnabled =
164+
widget.pictureInPictureConfiguration.enablePictureInPicture &&
165+
(!widget.pictureInPictureConfiguration
166+
.disablePictureInPictureWhenScreenSharing ||
167+
!(callState.localParticipant?.isScreenShareEnabled ?? false));
149168

150-
if (_isPictureInPictureModeOn && CurrentPlatform.isAndroid) {
169+
if (pipEnabled && _isPictureInPictureModeOn && CurrentPlatform.isAndroid) {
151170
return widget.pictureInPictureConfiguration.androidPiPConfiguration
152171
.callPictureInPictureBuilder
153172
?.call(context, call, callState) ??
@@ -164,17 +183,16 @@ class _StreamCallContentState extends State<StreamCallContent>
164183
callState.status.isMigrating) {
165184
bodyWidget = Stack(
166185
children: [
167-
if (CurrentPlatform.isIos &&
168-
widget.pictureInPictureConfiguration.enablePictureInPicture)
186+
if (CurrentPlatform.isIos && pipEnabled)
169187
SizedBox(
170188
height: 600,
171189
width: 300,
172190
child: StreamPictureInPictureUiKitView(
173191
call: call,
174-
ignoreLocalParticipantVideo: widget
192+
includeLocalParticipantVideo: widget
175193
.pictureInPictureConfiguration
176194
.iOSPiPConfiguration
177-
.ignoreLocalParticipantVideo,
195+
.includeLocalParticipantVideo,
178196
),
179197
),
180198
widget.callParticipantsBuilder?.call(

0 commit comments

Comments
 (0)