Skip to content

Commit fc219c4

Browse files
authored
feat(llc): added mirror participant video method (#993)
* added mirror participant video method * tweak * tweak
1 parent 4424d21 commit fc219c4

File tree

6 files changed

+253
-1
lines changed

6 files changed

+253
-1
lines changed

packages/stream_video/CHANGELOG.md

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

33
✅ Added
4+
* Added `setMirrorVideo` method to `Call` class to control video mirroring for participants.
45
* Added `call.partialState` for more specific and efficient state updates.
56

67
## 0.9.6

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

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2523,6 +2523,25 @@ class Call {
25232523
return result;
25242524
}
25252525

2526+
/// Sets the mirror state for a remote participant's video track.
2527+
///
2528+
/// - [sessionId]: The session ID of the participant.
2529+
/// - [userId]: The user ID of the participant.
2530+
/// - [mirrorVideo]: Whether to mirror the participant's video.
2531+
Result<None> setMirrorVideo({
2532+
required String sessionId,
2533+
required String userId,
2534+
required bool mirrorVideo,
2535+
}) {
2536+
_stateManager.participantMirrorVideo(
2537+
sessionId: sessionId,
2538+
userId: userId,
2539+
mirrorVideo: mirrorVideo,
2540+
);
2541+
2542+
return const Result.success(none);
2543+
}
2544+
25262545
@Deprecated('Use setParticipantPinnedLocally instead')
25272546
Future<Result<None>> setParticipantPinned({
25282547
required String sessionId,

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

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -143,6 +143,32 @@ mixin StateParticipantMixin on StateNotifier<CallState> {
143143
);
144144
}
145145

146+
void participantMirrorVideo({
147+
required String sessionId,
148+
required String userId,
149+
required bool mirrorVideo,
150+
}) {
151+
state = state.copyWith(
152+
callParticipants: state.callParticipants.map((participant) {
153+
if (participant.sessionId == sessionId &&
154+
participant.userId == userId) {
155+
final trackState = participant.publishedTracks[SfuTrackType.video];
156+
if (trackState is RemoteTrackState) {
157+
return participant.copyWith(
158+
publishedTracks: {
159+
...participant.publishedTracks,
160+
SfuTrackType.video: trackState.copyWith(
161+
mirrorVideo: mirrorVideo,
162+
),
163+
},
164+
);
165+
}
166+
}
167+
return participant;
168+
}).toList(),
169+
);
170+
}
171+
146172
void participantUpdateCameraPosition({
147173
required CameraPosition cameraPosition,
148174
}) {

packages/stream_video/lib/src/models/call_track_state.dart

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -113,6 +113,7 @@ class RemoteTrackState extends TrackState {
113113
required super.muted,
114114
this.subscribed = false,
115115
this.received = false,
116+
this.mirrorVideo = false,
116117
this.audioSinkDevice,
117118
this.videoDimension,
118119
});
@@ -121,6 +122,9 @@ class RemoteTrackState extends TrackState {
121122
final bool received;
122123
final RtcVideoDimension? videoDimension;
123124

125+
/// Whether the video should be mirrored.
126+
final bool mirrorVideo;
127+
124128
/// The sinkId of the audio output device to use
125129
/// in case it is an audio track.
126130
final RtcMediaDevice? audioSinkDevice;
@@ -130,6 +134,7 @@ class RemoteTrackState extends TrackState {
130134
subscribed,
131135
received,
132136
muted,
137+
mirrorVideo,
133138
audioSinkDevice,
134139
videoDimension,
135140
];
@@ -140,6 +145,7 @@ class RemoteTrackState extends TrackState {
140145
if (subscribed) 'subscribed',
141146
if (received) 'received',
142147
if (muted) 'muted',
148+
if (mirrorVideo) 'mirrorVideo',
143149
if (audioSinkDevice != null) 'audioSinkDevice($audioSinkDevice)',
144150
if (videoDimension != null)
145151
'videoDimension(width: ${videoDimension!.width}, height: ${videoDimension!.height})',
@@ -159,13 +165,15 @@ class RemoteTrackState extends TrackState {
159165
bool? subscribed,
160166
bool? received,
161167
bool? muted,
168+
bool? mirrorVideo,
162169
RtcMediaDevice? audioSinkDevice,
163170
RtcVideoDimension? videoDimension,
164171
}) {
165172
return RemoteTrackState._(
166173
subscribed: subscribed ?? this.subscribed,
167174
received: received ?? this.received,
168175
muted: muted ?? this.muted,
176+
mirrorVideo: mirrorVideo ?? this.mirrorVideo,
169177
audioSinkDevice: audioSinkDevice ?? this.audioSinkDevice,
170178
videoDimension: videoDimension ?? this.videoDimension,
171179
);
Lines changed: 197 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,197 @@
1+
import 'package:flutter_test/flutter_test.dart';
2+
import 'package:stream_video/src/call/state/call_state_notifier.dart';
3+
import 'package:stream_video/stream_video.dart';
4+
5+
void main() {
6+
group('CallStateNotifier participantMirrorVideo tests', () {
7+
late CallStateNotifier stateNotifier;
8+
late CallState initialState;
9+
10+
setUp(() {
11+
final targetParticipant = CallParticipantState(
12+
userId: 'target-user',
13+
sessionId: 'target-session',
14+
name: 'Target Participant',
15+
roles: const [],
16+
custom: const {},
17+
trackIdPrefix: 'target-track',
18+
publishedTracks: {
19+
SfuTrackType.video: TrackState.remote(
20+
subscribed: true,
21+
received: true,
22+
),
23+
},
24+
);
25+
26+
final otherParticipant = CallParticipantState(
27+
userId: 'other-user',
28+
sessionId: 'other-session',
29+
name: 'Other Participant',
30+
image: '',
31+
roles: const [],
32+
custom: const {},
33+
trackIdPrefix: 'other-track',
34+
publishedTracks: {
35+
SfuTrackType.video: TrackState.remote(
36+
subscribed: true,
37+
received: true,
38+
),
39+
},
40+
);
41+
42+
initialState = CallState(
43+
callCid: StreamCallCid.from(
44+
type: StreamCallType.defaultType(),
45+
id: 'test-call',
46+
),
47+
currentUserId: 'current-user',
48+
preferences: DefaultCallPreferences(),
49+
).copyWith(
50+
callParticipants: [targetParticipant, otherParticipant],
51+
);
52+
53+
stateNotifier = CallStateNotifier(initialState);
54+
});
55+
56+
tearDown(() {
57+
stateNotifier.dispose();
58+
});
59+
60+
test('participantMirrorVideo updates target participant video track', () {
61+
final stateBefore = stateNotifier.state;
62+
expect(stateBefore.callParticipants.length, 2);
63+
64+
final targetParticipantBefore = stateBefore.callParticipants.firstWhere(
65+
(p) => p.sessionId == 'target-session',
66+
);
67+
final targetVideoTrackBefore = targetParticipantBefore
68+
.publishedTracks[SfuTrackType.video]! as RemoteTrackState;
69+
expect(targetVideoTrackBefore.mirrorVideo, false);
70+
71+
final otherParticipantBefore = stateBefore.callParticipants.firstWhere(
72+
(p) => p.sessionId == 'other-session',
73+
);
74+
final otherVideoTrackBefore = otherParticipantBefore
75+
.publishedTracks[SfuTrackType.video]! as RemoteTrackState;
76+
expect(otherVideoTrackBefore.mirrorVideo, false);
77+
78+
stateNotifier.participantMirrorVideo(
79+
sessionId: 'target-session',
80+
userId: 'target-user',
81+
mirrorVideo: true,
82+
);
83+
84+
final stateAfter = stateNotifier.state;
85+
expect(stateAfter.callParticipants.length, 2);
86+
87+
final targetParticipantAfter = stateAfter.callParticipants.firstWhere(
88+
(p) => p.sessionId == 'target-session',
89+
);
90+
final targetVideoTrackAfter = targetParticipantAfter
91+
.publishedTracks[SfuTrackType.video]! as RemoteTrackState;
92+
expect(targetVideoTrackAfter.mirrorVideo, true);
93+
94+
// Other participant should remain unchanged
95+
final otherParticipantAfter = stateAfter.callParticipants.firstWhere(
96+
(p) => p.sessionId == 'other-session',
97+
);
98+
final otherVideoTrackAfter = otherParticipantAfter
99+
.publishedTracks[SfuTrackType.video]! as RemoteTrackState;
100+
expect(otherVideoTrackAfter.mirrorVideo, false);
101+
102+
// Verify other properties remain unchanged
103+
expect(targetVideoTrackAfter.muted, targetVideoTrackBefore.muted);
104+
expect(
105+
targetVideoTrackAfter.subscribed,
106+
targetVideoTrackBefore.subscribed,
107+
);
108+
expect(targetVideoTrackAfter.received, targetVideoTrackBefore.received);
109+
});
110+
111+
test('participantMirrorVideo ignores participants without video tracks',
112+
() {
113+
// Arrange - Create state with participant that has no video track
114+
final participantWithoutVideo = CallParticipantState(
115+
userId: 'no-video-user',
116+
sessionId: 'no-video-session',
117+
name: 'Audio Only Participant',
118+
image: '',
119+
roles: const [],
120+
custom: const {},
121+
trackIdPrefix: 'no-video-track',
122+
publishedTracks: {
123+
SfuTrackType.audio: TrackState.remote(), // Only audio track
124+
},
125+
);
126+
127+
final stateWithAudioOnly = initialState.copyWith(
128+
callParticipants: [
129+
...initialState.callParticipants,
130+
participantWithoutVideo
131+
],
132+
);
133+
stateNotifier.state = stateWithAudioOnly;
134+
135+
stateNotifier.participantMirrorVideo(
136+
sessionId: 'no-video-session',
137+
userId: 'no-video-user',
138+
mirrorVideo: true,
139+
);
140+
141+
final stateAfter = stateNotifier.state;
142+
final participantAfter = stateAfter.callParticipants.firstWhere(
143+
(p) => p.sessionId == 'no-video-session',
144+
);
145+
146+
expect(
147+
participantAfter.publishedTracks.containsKey(SfuTrackType.video),
148+
false,
149+
);
150+
expect(
151+
participantAfter.publishedTracks[SfuTrackType.audio],
152+
isA<RemoteTrackState>(),
153+
);
154+
});
155+
156+
test('participantMirrorVideo only affects RemoteTrackState', () {
157+
// Arrange - Create participant with LocalTrackState
158+
final localParticipant = CallParticipantState(
159+
userId: 'local-user',
160+
sessionId: 'local-session',
161+
name: 'Local Participant',
162+
roles: const [],
163+
custom: const {},
164+
isLocal: true,
165+
trackIdPrefix: 'local-track',
166+
publishedTracks: {
167+
SfuTrackType.video: TrackState.local(), // Local track
168+
},
169+
);
170+
171+
final stateWithLocal = initialState.copyWith(
172+
callParticipants: [...initialState.callParticipants, localParticipant],
173+
);
174+
stateNotifier.state = stateWithLocal;
175+
176+
final localTrackBefore = stateWithLocal.callParticipants
177+
.firstWhere((p) => p.isLocal)
178+
.publishedTracks[SfuTrackType.video]! as LocalTrackState;
179+
180+
stateNotifier.participantMirrorVideo(
181+
sessionId: 'local-session',
182+
userId: 'local-user',
183+
mirrorVideo: true,
184+
);
185+
186+
// Assert - Local track should remain unchanged (no mirrorVideo property)
187+
final stateAfter = stateNotifier.state;
188+
final localTrackAfter = stateAfter.callParticipants
189+
.firstWhere((p) => p.sessionId == 'local-session')
190+
.publishedTracks[SfuTrackType.video]! as LocalTrackState;
191+
192+
expect(localTrackAfter.muted, localTrackBefore.muted);
193+
expect(localTrackAfter.sourceDevice, localTrackBefore.sourceDevice);
194+
expect(localTrackAfter.cameraPosition, localTrackBefore.cameraPosition);
195+
});
196+
});
197+
}

packages/stream_video_flutter/lib/src/renderer/video_renderer.dart

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -124,7 +124,8 @@ class _StreamVideoRendererState extends State<StreamVideoRenderer> {
124124
return widget.placeholderBuilder.call(context);
125125
}
126126

127-
var mirror = widget.participant.isLocal;
127+
var mirror = (trackState is RemoteTrackState && trackState.mirrorVideo) ||
128+
widget.participant.isLocal;
128129

129130
if (videoTrack is RtcLocalScreenShareTrack) {
130131
mirror = false;

0 commit comments

Comments
 (0)