Skip to content

Commit 1815cb0

Browse files
authored
fix(ui): fixes for PiP mode on iOS (#1101)
* iOS pip fixes * changelog * linter fix
1 parent 52978ef commit 1815cb0

File tree

4 files changed

+104
-16
lines changed

4 files changed

+104
-16
lines changed

packages/stream_video_flutter/CHANGELOG.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,11 @@ In this release, we removed the dependency on `flutter_callkit_incoming`, which
1919
- `observeCallEndedCallKitEvent``observeCallEndedRingingEvent`
2020
- `CallKitEvent` (type) → `RingingEvent`
2121

22+
🐞 Fixed
23+
* [iOS] Resolved an issue in Picture in Picture where video tracks might remain disabled after returning the app to the foreground.
24+
* [iOS] Addressed a problem where Picture in Picture was not exited properly if the call ended during PiP mode.
25+
* [iOS] Fixed a bug where quickly backgrounding the app right after ending a call could still activate PiP mode.
26+
2227
## 0.11.2
2328

2429
🐞 Fixed

packages/stream_video_flutter/ios/stream_video_flutter/Sources/stream_video_flutter/PictureInPicture/StreamPictureInPictureController.swift

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -54,6 +54,8 @@ final class StreamPictureInPictureController: NSObject, AVPictureInPictureContro
5454
/// active track.
5555
private let trackStateAdapter: StreamPictureInPictureTrackStateAdapter = .init()
5656

57+
private var shouldCleanupAfterStop: Bool = false
58+
5759
// MARK: - Lifecycle
5860

5961
/// Initializes the controller and creates the content view
@@ -121,10 +123,31 @@ final class StreamPictureInPictureController: NSObject, AVPictureInPictureContro
121123
public func pictureInPictureControllerDidStopPictureInPicture(
122124
_ pictureInPictureController: AVPictureInPictureController
123125
) {
126+
if shouldCleanupAfterStop {
127+
shouldCleanupAfterStop = false
128+
track = nil
129+
sourceView = nil
130+
}
131+
124132
}
125133

126134
// MARK: - Public API
127135

136+
public func stopPictureInPictureAndCleanup() {
137+
let isActive = pictureInPictureController?.isPictureInPictureActive == true
138+
139+
if isActive && !shouldCleanupAfterStop {
140+
shouldCleanupAfterStop = true
141+
pictureInPictureController?.stopPictureInPicture()
142+
} else {
143+
if track != nil || sourceView != nil {
144+
shouldCleanupAfterStop = false
145+
track = nil
146+
sourceView = nil
147+
}
148+
}
149+
}
150+
128151
/// Updates participant information and refreshes overlay
129152
/// - Note: Only available on iOS 15.0+. Earlier versions will ignore this call.
130153
public func updateParticipant(

packages/stream_video_flutter/ios/stream_video_flutter/Sources/stream_video_flutter/StreamVideoFlutterPlugin.swift

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -175,8 +175,9 @@ class StreamPictureInPictureNativeView: NSObject, FlutterPlatformView {
175175
}
176176
result(nil)
177177
case "callEnded":
178-
self.pictureInPictureController?.track = nil
179-
self.pictureInPictureController?.sourceView = nil
178+
DispatchQueue.main.async {
179+
self.pictureInPictureController?.stopPictureInPictureAndCleanup()
180+
}
180181
result(nil)
181182
default:
182183
result(FlutterMethodNotImplemented)

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

Lines changed: 73 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -56,20 +56,21 @@ class _StreamPictureInPictureUiKitViewState
5656
extends State<StreamPictureInPictureUiKitView>
5757
with WidgetsBindingObserver {
5858
static const _idCallState = 2;
59+
static const _idCallEnded = 3;
5960

6061
static const MethodChannel _channel = MethodChannel(
6162
'stream_video_flutter_pip',
6263
);
6364

6465
final Subscriptions _subscriptions = Subscriptions();
6566

66-
Future<void> _handleCallState(
67-
CallState callState,
67+
Future<void> _handleParticipantsChange(
68+
List<CallParticipantState> callParticipants,
6869
bool includeLocalParticipantVideo,
6970
) async {
7071
final participants = includeLocalParticipantVideo
71-
? callState.callParticipants
72-
: callState.otherParticipants;
72+
? callParticipants
73+
: callParticipants.where((element) => !element.isLocal).toList();
7374

7475
final sorted = List<CallParticipantState>.from(participants);
7576
mergeSort(
@@ -125,21 +126,23 @@ class _StreamPictureInPictureUiKitViewState
125126
},
126127
);
127128
}
128-
129-
if (callState.status is CallStatusDisconnected) {
130-
await _channel.invokeMethod(
131-
'callEnded',
132-
);
133-
}
134129
}
135130

136131
void _subscribeToCallEvents() {
132+
if (widget.call.state.value.status is CallStatusDisconnected) {
133+
return;
134+
}
135+
137136
_subscriptions.add(
138137
_idCallState,
139138
widget.call.state.listen(
140-
(callState) {
141-
_handleCallState(
142-
callState,
139+
(state) {
140+
if (state.status is CallStatusDisconnected) {
141+
return;
142+
}
143+
144+
_handleParticipantsChange(
145+
state.callParticipants,
143146
widget.configuration.includeLocalParticipantVideo &&
144147
widget.call.state.value.iOSMultitaskingCameraAccessEnabled,
145148
);
@@ -152,6 +155,19 @@ class _StreamPictureInPictureUiKitViewState
152155
void initState() {
153156
WidgetsBinding.instance.addObserver(this);
154157

158+
_subscriptions.add(
159+
_idCallEnded,
160+
widget.call.state.listen(
161+
(state) async {
162+
if (state.status is CallStatusDisconnected) {
163+
await _channel.invokeMethod(
164+
'callEnded',
165+
);
166+
}
167+
},
168+
),
169+
);
170+
155171
super.initState();
156172
}
157173

@@ -161,16 +177,59 @@ class _StreamPictureInPictureUiKitViewState
161177

162178
switch (state) {
163179
case AppLifecycleState.resumed:
164-
_subscriptions.cancelAll();
180+
_subscriptions.cancel(_idCallState);
181+
_toggleSubscribedTracks(true);
165182
break;
166183
case AppLifecycleState.paused:
184+
_toggleSubscribedTracks(false);
167185
_subscribeToCallEvents();
186+
187+
// Check status with a small delay to catch the race condition where
188+
// the call disconnects at nearly the same time as backgrounding
189+
Future.delayed(const Duration(milliseconds: 100), () {
190+
if (!mounted) return;
191+
if (widget.call.state.value.status is CallStatusDisconnected) {
192+
try {
193+
_channel.invokeMethod('callEnded');
194+
} catch (e) {
195+
// Silent catch
196+
}
197+
}
198+
});
168199
break;
169200
default:
170201
break;
171202
}
172203
}
173204

205+
// When app goes to background disable video tracks of all participants except
206+
// the one shown in PiP to save bandwidth and resources.
207+
// Re-enable them when app comes back to foreground.
208+
void _toggleSubscribedTracks(bool enable) {
209+
final participants = widget.call.state.value.callParticipants;
210+
211+
for (final participant in participants.where(
212+
(p) => p.isVideoEnabled,
213+
)) {
214+
if (!enable && participant.isSpeaking) {
215+
// Do not disable video track of speaking participant
216+
continue;
217+
}
218+
219+
final videoTrackState = participant.publishedTracks[SfuTrackType.video];
220+
if ((videoTrackState is RemoteTrackState && videoTrackState.subscribed) ||
221+
participant.isLocal) {
222+
final track = widget.call.getTrack(
223+
participant.trackIdPrefix,
224+
SfuTrackType.video,
225+
);
226+
if (track != null) {
227+
track.mediaTrack.enabled = enable;
228+
}
229+
}
230+
}
231+
}
232+
174233
@override
175234
void dispose() {
176235
WidgetsBinding.instance.removeObserver(this);

0 commit comments

Comments
 (0)