Skip to content

Commit 8cdb13b

Browse files
authored
feat(ui): added option to prioritise track to display in Picture in Picture mode (#1127)
* added option to prioritise pip track to display * cleanup of pip implementation * tweaks * tweaks * tweaks
1 parent 0126924 commit 8cdb13b

File tree

10 files changed

+318
-124
lines changed

10 files changed

+318
-124
lines changed

packages/stream_video_flutter/CHANGELOG.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,8 @@
1+
## Upcoming
2+
3+
### ✅ Added
4+
* Added `prioritiseScreenSharingTrack` parameter to `PictureInPictureConfiguration` to control whether screen sharing or camera track is preferred in PiP mode. When set to `false`, the camera track is preferred, but screen share will still be shown as a fallback if the camera is disabled.
5+
16
## 1.0.2
27

38
🐞 Fixed

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

Lines changed: 23 additions & 75 deletions
Original file line numberDiff line numberDiff line change
@@ -109,27 +109,36 @@ class StreamCallParticipants extends StatefulWidget {
109109
State<StreamCallParticipants> createState() => _StreamCallParticipantsState();
110110
}
111111

112-
class _StreamCallParticipantsState extends State<StreamCallParticipants> {
113-
List<CallParticipantState> _participants = [];
114-
CallParticipantState? _screenShareParticipant;
112+
class _StreamCallParticipantsState extends State<StreamCallParticipants>
113+
with CallParticipantsSortingMixin {
114+
StreamSubscription<List<CallParticipantState>?>? _participantsSubscription;
115115

116-
List<String> _sortedParticipantKeys = [];
116+
@override
117+
Filter<CallParticipantState> get participantFilter => widget.filter;
117118

118-
StreamSubscription<List<CallParticipantState>?>? _participantsSubscription;
119+
@override
120+
Sort<CallParticipantState> get participantSort => widget.sort;
119121

120122
@override
121123
void initState() {
122124
super.initState();
123-
_recalculateParticipants(
125+
recalculateParticipants(
124126
widget.participants ?? widget.call.state.value.callParticipants,
125127
);
128+
126129
if (widget.participants == null) {
127130
_participantsSubscription = widget.call
128131
.partialState((state) => state.callParticipants)
129-
.listen(_recalculateParticipants);
132+
.listen(recalculateParticipants);
130133
}
131134
}
132135

136+
@override
137+
void dispose() {
138+
_participantsSubscription?.cancel();
139+
super.dispose();
140+
}
141+
133142
@override
134143
void didUpdateWidget(covariant StreamCallParticipants oldWidget) {
135144
super.didUpdateWidget(oldWidget);
@@ -141,94 +150,33 @@ class _StreamCallParticipantsState extends State<StreamCallParticipants> {
141150
widget.participants!.toList(),
142151
oldWidget.participants?.toList(),
143152
)) {
144-
_recalculateParticipants(widget.participants!);
153+
recalculateParticipants(widget.participants!);
145154
}
146155
} else if (widget.call != oldWidget.call) {
147156
_participantsSubscription?.cancel();
148157
_participantsSubscription = widget.call
149158
.partialState((state) => state.callParticipants)
150-
.listen(_recalculateParticipants);
151-
}
152-
}
153-
154-
void _recalculateParticipants(List<CallParticipantState> newParticipants) {
155-
final participants = [...newParticipants].where(widget.filter).toList();
156-
157-
for (final participant in participants) {
158-
final index = _sortedParticipantKeys.indexOf(
159-
participant.uniqueParticipantKey,
160-
);
161-
if (index == -1) {
162-
_sortedParticipantKeys.add(participant.uniqueParticipantKey);
163-
}
164-
}
165-
166-
// First apply previous sorting on new participants list
167-
participants.sort(
168-
(a, b) => _sortedParticipantKeys
169-
.indexOf(a.uniqueParticipantKey)
170-
.compareTo(_sortedParticipantKeys.indexOf(b.uniqueParticipantKey)),
171-
);
172-
173-
mergeSort(participants, compare: widget.sort);
174-
175-
final screenShareParticipant = participants.firstWhereOrNull(
176-
(it) {
177-
final screenShareTrack = it.screenShareTrack;
178-
final isScreenShareEnabled = it.isScreenShareEnabled;
179-
180-
if (screenShareTrack == null || !isScreenShareEnabled) return false;
181-
182-
return true;
183-
},
184-
);
185-
186-
_sortedParticipantKeys = participants
187-
.map((e) => e.uniqueParticipantKey)
188-
.toList();
159+
.listen(recalculateParticipants);
189160

190-
if (mounted) {
191-
setState(() {
192-
_participants = participants.toList();
193-
_screenShareParticipant = screenShareParticipant;
194-
});
161+
recalculateParticipants(widget.call.state.value.callParticipants);
195162
}
196163
}
197164

198165
@override
199166
Widget build(BuildContext context) {
200-
if (_participants.isNotEmpty &&
201-
widget.layoutMode == ParticipantLayoutMode.pictureInPicture) {
202-
if (_screenShareParticipant != null) {
203-
return ScreenShareContent(
204-
key: ValueKey(
205-
'${_screenShareParticipant!.uniqueParticipantKey} - screenShareContent',
206-
),
207-
call: widget.call,
208-
participant: _screenShareParticipant!,
209-
);
210-
}
211-
212-
return widget.callParticipantBuilder(
213-
context,
214-
widget.call,
215-
_participants.first,
216-
);
217-
}
218-
219-
if (_screenShareParticipant != null) {
167+
if (screenShareParticipant != null) {
220168
return ScreenShareCallParticipantsContent(
221169
call: widget.call,
222-
participants: _participants,
223-
screenSharingParticipant: _screenShareParticipant!,
170+
participants: sortedParticipants,
171+
screenSharingParticipant: screenShareParticipant!,
224172
screenShareContentBuilder: widget.screenShareContentBuilder,
225173
screenShareParticipantBuilder: widget.screenShareParticipantBuilder,
226174
);
227175
}
228176

229177
return RegularCallParticipantsContent(
230178
call: widget.call,
231-
participants: _participants,
179+
participants: sortedParticipants,
232180
layoutMode: widget.layoutMode,
233181
enableLocalVideo: widget.enableLocalVideo,
234182
callParticipantBuilder: widget.callParticipantBuilder,
Lines changed: 81 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,81 @@
1+
import 'package:collection/collection.dart';
2+
import 'package:flutter/widgets.dart';
3+
import 'package:stream_video/stream_video.dart';
4+
5+
import 'call_participants.dart';
6+
7+
/// A mixin that provides participant sorting and filtering logic.
8+
mixin CallParticipantsSortingMixin<T extends StatefulWidget> on State<T> {
9+
List<CallParticipantState> get sortedParticipants => _participants;
10+
List<CallParticipantState> _participants = [];
11+
12+
CallParticipantState? get screenShareParticipant => _screenShareParticipant;
13+
CallParticipantState? _screenShareParticipant;
14+
15+
/// Keys used to maintain stable participant ordering across updates.
16+
List<String> _sortedParticipantKeys = [];
17+
18+
/// The filter function to apply to participants.
19+
///
20+
/// Override this getter to provide the filter from your widget.
21+
Filter<CallParticipantState>? get participantFilter;
22+
23+
/// The sort comparator to apply to participants.
24+
///
25+
/// Override this getter to provide the sort from your widget.
26+
Sort<CallParticipantState>? get participantSort;
27+
28+
/// Call this method whenever the participant list changes, typically from
29+
/// a stream subscription or in [didUpdateWidget].
30+
void recalculateParticipants(List<CallParticipantState> newParticipants) {
31+
final participants = [
32+
...newParticipants,
33+
].where(participantFilter ?? (_) => true).toList();
34+
35+
for (final participant in participants) {
36+
final index = _sortedParticipantKeys.indexOf(
37+
participant.uniqueParticipantKey,
38+
);
39+
if (index == -1) {
40+
_sortedParticipantKeys.add(participant.uniqueParticipantKey);
41+
}
42+
}
43+
44+
// First apply previous sorting on new participants list
45+
participants.sort(
46+
(a, b) => _sortedParticipantKeys
47+
.indexOf(a.uniqueParticipantKey)
48+
.compareTo(_sortedParticipantKeys.indexOf(b.uniqueParticipantKey)),
49+
);
50+
51+
if (participantSort != null) {
52+
mergeSort(participants, compare: participantSort);
53+
}
54+
55+
final screenShareParticipant = participants.firstWhereOrNull(
56+
(it) {
57+
final screenShareTrack = it.screenShareTrack;
58+
final isScreenShareEnabled = it.isScreenShareEnabled;
59+
60+
if (screenShareTrack == null || !isScreenShareEnabled) return false;
61+
62+
return true;
63+
},
64+
);
65+
66+
_sortedParticipantKeys = participants
67+
.map((e) => e.uniqueParticipantKey)
68+
.toList();
69+
70+
if (mounted) {
71+
setState(() {
72+
_participants = participants.toList();
73+
_screenShareParticipant = screenShareParticipant;
74+
});
75+
}
76+
}
77+
78+
void clearParticipantSortingCache() {
79+
_sortedParticipantKeys = [];
80+
}
81+
}

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

Lines changed: 2 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -143,9 +143,8 @@ class _StreamCallContentState extends State<StreamCallContent> {
143143
width: 300,
144144
child: StreamPictureInPictureUiKitView(
145145
call: call,
146-
configuration:
147-
widget.pictureInPictureConfiguration.iOSPiPConfiguration,
148-
participantSort: widget.pictureInPictureConfiguration.sort,
146+
pictureInPictureConfiguration:
147+
widget.pictureInPictureConfiguration,
149148
),
150149
),
151150
if (CurrentPlatform.isAndroid && pipEnabled)

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

Lines changed: 89 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,10 @@
1+
// ignore_for_file: deprecated_member_use_from_same_package
2+
3+
import 'dart:async';
4+
15
import 'package:flutter/material.dart';
26
import '../../../../stream_video_flutter.dart';
7+
import '../../../call_participants/screen_share_call_participants_content.dart';
38

49
/// A dedicated overlay widget for Android Picture-in-Picture mode.
510
/// This widget creates a floating overlay that shows only the video content
@@ -8,31 +13,106 @@ class AndroidPipOverlay extends StatefulWidget {
813
const AndroidPipOverlay({
914
super.key,
1015
required this.call,
16+
this.pictureInPictureConfiguration,
17+
@Deprecated(
18+
'Pass PictureInPictureConfiguration [pictureInPictureConfiguration] instead',
19+
)
1120
this.sort,
21+
@Deprecated(
22+
'Pass PictureInPictureConfiguration [pictureInPictureConfiguration] instead',
23+
)
1224
this.customBuilder,
1325
});
1426

1527
final Call call;
16-
final Comparator<CallParticipantState>? sort;
28+
final PictureInPictureConfiguration? pictureInPictureConfiguration;
29+
@Deprecated('Use [pictureInPictureConfiguration.sort] instead')
30+
final Sort<CallParticipantState>? sort;
31+
@Deprecated(
32+
'Use [pictureInPictureConfiguration.androidPiPConfiguration.customBuilder] instead',
33+
)
1734
final CallWidgetBuilder? customBuilder;
1835

1936
@override
2037
State<AndroidPipOverlay> createState() => _AndroidPipOverlayState();
2138
}
2239

23-
class _AndroidPipOverlayState extends State<AndroidPipOverlay> {
40+
class _AndroidPipOverlayState extends State<AndroidPipOverlay>
41+
with CallParticipantsSortingMixin {
42+
StreamSubscription<List<CallParticipantState>?>? _participantsSubscription;
43+
44+
@override
45+
Filter<CallParticipantState>? get participantFilter => null;
46+
47+
@override
48+
Sort<CallParticipantState>? get participantSort =>
49+
widget.pictureInPictureConfiguration?.sort ?? widget.sort;
50+
51+
@override
52+
void initState() {
53+
super.initState();
54+
recalculateParticipants(widget.call.state.value.callParticipants);
55+
56+
_participantsSubscription = widget.call
57+
.partialState((state) => state.callParticipants)
58+
.listen(recalculateParticipants);
59+
}
60+
61+
@override
62+
void dispose() {
63+
_participantsSubscription?.cancel();
64+
super.dispose();
65+
}
66+
2467
@override
2568
Widget build(BuildContext context) {
69+
Widget pipBody = const SizedBox.shrink();
70+
71+
final customBuilder =
72+
widget
73+
.pictureInPictureConfiguration
74+
?.androidPiPConfiguration
75+
.callPictureInPictureWidgetBuilder ??
76+
widget.customBuilder;
77+
78+
if (customBuilder == null && sortedParticipants.isNotEmpty) {
79+
final pipParticipant = sortedParticipants.first;
80+
81+
final hasScreenShare =
82+
pipParticipant.isScreenShareEnabled &&
83+
pipParticipant.screenShareTrack != null;
84+
85+
// Show screen share if:
86+
// 1. prioritise is true and screen share is available, OR
87+
// 2. video is disabled but screen share is available (fallback)
88+
final shouldShowScreenShare =
89+
hasScreenShare &&
90+
(widget.pictureInPictureConfiguration?.pipTrackPriority !=
91+
PipTrackPriority.camera ||
92+
!pipParticipant.isVideoEnabled);
93+
94+
if (shouldShowScreenShare) {
95+
pipBody = ScreenShareContent(
96+
key: ValueKey(
97+
'${pipParticipant.uniqueParticipantKey} - screenShareContent',
98+
),
99+
call: widget.call,
100+
participant: pipParticipant,
101+
);
102+
} else {
103+
pipBody = StreamCallParticipant(
104+
// We use the sessionId as the key to map the state to the participant.
105+
key: Key(pipParticipant.uniqueParticipantKey),
106+
call: widget.call,
107+
participant: pipParticipant,
108+
);
109+
}
110+
}
111+
26112
return Material(
27113
color: Colors.black,
28114
child: SizedBox.expand(
29-
child:
30-
widget.customBuilder?.call(context, widget.call) ??
31-
StreamCallParticipants(
32-
call: widget.call,
33-
layoutMode: ParticipantLayoutMode.pictureInPicture,
34-
sort: widget.sort,
35-
),
115+
child: customBuilder?.call(context, widget.call) ?? pipBody,
36116
),
37117
);
38118
}

0 commit comments

Comments
 (0)