Skip to content

Commit cb85bb4

Browse files
authored
fix: videorenderer didnt call update subscriptions on remote reconnect (#1964)
also, moved out the update subscriptions aprt to a seperate file and simplified it greatly by using rx.
1 parent 7c25171 commit cb85bb4

File tree

2 files changed

+143
-107
lines changed

2 files changed

+143
-107
lines changed
Lines changed: 125 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,125 @@
1+
import { forwardRef, useImperativeHandle, useEffect, useMemo } from 'react';
2+
import { LayoutChangeEvent } from 'react-native';
3+
import {
4+
Call,
5+
CallingState,
6+
hasScreenShare,
7+
hasVideo,
8+
SfuModels,
9+
type VideoTrackType,
10+
DebounceType,
11+
} from '@stream-io/video-client';
12+
import {
13+
BehaviorSubject,
14+
combineLatest,
15+
distinctUntilChanged,
16+
distinctUntilKeyChanged,
17+
map,
18+
takeWhile,
19+
} from 'rxjs';
20+
export type TrackSubscriberHandle = {
21+
onLayoutUpdate: (event: LayoutChangeEvent) => void;
22+
};
23+
24+
type TrackSubscriberProps = {
25+
participantSessionId: string;
26+
call: Call;
27+
trackType: VideoTrackType;
28+
isVisible: boolean;
29+
};
30+
31+
/**
32+
* This component is used to subscribe to the video + audio track of the participant in the following cases:
33+
* 1. When the participant starts publishing the video track
34+
* 2. When the participant changes the video track dimensions
35+
* 3. When the participant becomes visible
36+
* 4. On joined callingState, this handles reconnection
37+
38+
* This component is used to unsubscribe to video track and subscribe only to the audio track of the participant (by passing undefined dimensions) in the following cases:
39+
* 1. When the participant stops publishing the video track
40+
* 2. When the participant becomes invisible
41+
*/
42+
const TrackSubscriber = forwardRef<TrackSubscriberHandle, TrackSubscriberProps>(
43+
(props, ref) => {
44+
const { call, participantSessionId, trackType, isVisible } = props;
45+
const dimensions$ = useMemo(() => {
46+
return new BehaviorSubject<SfuModels.VideoDimension | undefined>(
47+
undefined,
48+
);
49+
}, []);
50+
51+
useEffect(() => {
52+
const requestTrackWithDimensions = (
53+
debounceType: DebounceType,
54+
dimension: SfuModels.VideoDimension | undefined,
55+
) => {
56+
if (dimension && (dimension.width === 0 || dimension.height === 0)) {
57+
// ignore 0x0 dimensions. this can happen when the video element
58+
// is not visible (e.g., has display: none).
59+
// we treat this as "unsubscription" as we don't want to keep
60+
// consuming bandwidth for a video that is not visible on the screen.
61+
dimension = undefined;
62+
}
63+
call.state.updateParticipantTracks(trackType, {
64+
[participantSessionId]: { dimension },
65+
});
66+
call.dynascaleManager.applyTrackSubscriptions(debounceType);
67+
};
68+
const isPublishingTrack$ = call.state.participants$.pipe(
69+
map((ps) => ps.find((p) => p.sessionId === participantSessionId)),
70+
takeWhile((p) => !!p),
71+
distinctUntilChanged(),
72+
distinctUntilKeyChanged('publishedTracks'),
73+
map((p) =>
74+
trackType === 'videoTrack' ? hasVideo(p) : hasScreenShare(p),
75+
),
76+
distinctUntilChanged(),
77+
);
78+
const isJoinedState$ = call.state.callingState$.pipe(
79+
map((callingState) => callingState === CallingState.JOINED),
80+
);
81+
82+
const subscription = combineLatest([
83+
dimensions$,
84+
isPublishingTrack$,
85+
isJoinedState$,
86+
]).subscribe(([dimension, isPublishing, isJoined]) => {
87+
if (isJoined && isPublishing && !isVisible) {
88+
// the participant is publishing and we are not visible, so we unsubscribe from the video track
89+
requestTrackWithDimensions(DebounceType.FAST, undefined);
90+
} else if (isJoined && isPublishing && isVisible && dimension) {
91+
// the participant is publishing and we are visible and have valid dimensions, so we subscribe to the video track
92+
requestTrackWithDimensions(DebounceType.IMMEDIATE, dimension);
93+
} else if (isJoined && !isPublishing) {
94+
// the participant stopped publishing a track, so we unsubscribe from the video track
95+
requestTrackWithDimensions(DebounceType.FAST, undefined);
96+
}
97+
// if isPublishing but no dimension yet, we wait for dimensions
98+
});
99+
100+
return () => {
101+
subscription.unsubscribe();
102+
};
103+
}, [call, participantSessionId, trackType, isVisible, dimensions$]);
104+
105+
useImperativeHandle(
106+
ref,
107+
() => ({
108+
onLayoutUpdate: (event) => {
109+
const dimension = {
110+
width: Math.trunc(event.nativeEvent.layout.width),
111+
height: Math.trunc(event.nativeEvent.layout.height),
112+
};
113+
dimensions$.next(dimension);
114+
},
115+
}),
116+
[dimensions$],
117+
);
118+
119+
return null;
120+
},
121+
);
122+
123+
TrackSubscriber.displayName = 'TrackSubscriber';
124+
125+
export default TrackSubscriber;

packages/react-native-sdk/src/components/Participant/ParticipantView/VideoRenderer.tsx renamed to packages/react-native-sdk/src/components/Participant/ParticipantView/VideoRenderer/index.tsx

Lines changed: 18 additions & 107 deletions
Original file line numberDiff line numberDiff line change
@@ -2,21 +2,20 @@ import React, { useEffect, useRef } from 'react';
22
import { Platform, StyleSheet, View } from 'react-native';
33
import type { MediaStream } from '@stream-io/react-native-webrtc';
44
import { RTCView } from '@stream-io/react-native-webrtc';
5-
import type { ParticipantViewProps } from './ParticipantView';
5+
import type { ParticipantViewProps } from '../ParticipantView';
66
import {
7-
CallingState,
87
hasPausedTrack,
98
hasScreenShare,
109
hasVideo,
11-
SfuModels,
1210
type VideoTrackType,
1311
VisibilityState,
1412
} from '@stream-io/video-client';
1513
import { useCall, useCallStateHooks } from '@stream-io/video-react-bindings';
16-
import { ParticipantVideoFallback as DefaultParticipantVideoFallback } from './ParticipantVideoFallback';
17-
import { useTheme } from '../../../contexts/ThemeContext';
18-
import { useTrackDimensions } from '../../../hooks/useTrackDimensions';
19-
import { useScreenshotIosContext } from '../../../contexts/internal/ScreenshotIosContext';
14+
import { ParticipantVideoFallback as DefaultParticipantVideoFallback } from '../ParticipantVideoFallback';
15+
import { useTheme } from '../../../../contexts/ThemeContext';
16+
import { useTrackDimensions } from '../../../../hooks/useTrackDimensions';
17+
import { useScreenshotIosContext } from '../../../../contexts/internal/ScreenshotIosContext';
18+
import TrackSubscriber, { TrackSubscriberHandle } from './TrackSubscriber';
2019

2120
const DEFAULT_VIEWPORT_VISIBILITY_STATE: Record<
2221
VideoTrackType,
@@ -56,16 +55,9 @@ export const VideoRenderer = ({
5655
theme: { videoRenderer },
5756
} = useTheme();
5857
const call = useCall();
59-
const { useCallCallingState, useCameraState, useIncomingVideoSettings } =
60-
useCallStateHooks();
58+
const { useCameraState, useIncomingVideoSettings } = useCallStateHooks();
59+
const trackSubscriberRef = useRef<TrackSubscriberHandle>(null);
6160
const { isParticipantVideoEnabled } = useIncomingVideoSettings();
62-
const callingState = useCallCallingState();
63-
const pendingVideoLayoutRef = useRef<SfuModels.VideoDimension | undefined>(
64-
undefined,
65-
);
66-
const subscribedVideoLayoutRef = useRef<SfuModels.VideoDimension | undefined>(
67-
undefined,
68-
);
6961
const { direction } = useCameraState();
7062
const viewRef = useRef(null);
7163
const {
@@ -91,7 +83,6 @@ export const VideoRenderer = ({
9183
? hasScreenShare(participant)
9284
: hasVideo(participant);
9385

94-
const hasJoinedCall = callingState === CallingState.JOINED;
9586
const videoStreamToRender = (isScreenSharing
9687
? screenShareStream
9788
: videoStream) as unknown as MediaStream | undefined;
@@ -179,11 +170,6 @@ export const VideoRenderer = ({
179170
},
180171
}));
181172
}
182-
if (subscribedVideoLayoutRef.current) {
183-
// when video is enabled again, we want to use the last subscribed dimension to resubscribe
184-
pendingVideoLayoutRef.current = subscribedVideoLayoutRef.current;
185-
subscribedVideoLayoutRef.current = undefined;
186-
}
187173
}
188174
}, [
189175
sessionId,
@@ -194,101 +180,26 @@ export const VideoRenderer = ({
194180
isLocalParticipant,
195181
]);
196182

197-
useEffect(() => {
198-
if (!hasJoinedCall && subscribedVideoLayoutRef.current) {
199-
// when call is joined again, we want to use the last subscribed dimension to resubscribe
200-
pendingVideoLayoutRef.current = subscribedVideoLayoutRef.current;
201-
subscribedVideoLayoutRef.current = undefined;
202-
}
203-
}, [hasJoinedCall]);
204-
205-
/**
206-
* This effect updates the subscription either
207-
* 1. when video tracks are published and was unpublished before
208-
* 2. when the view's visibility changes
209-
* 3. when call was rejoined
210-
*/
211-
useEffect(() => {
212-
if (!call || isLocalParticipant) {
213-
return;
214-
}
215-
// NOTE: We only want to update the subscription if the pendingVideoLayoutRef is set
216-
const updateIsNeeded = pendingVideoLayoutRef.current;
217-
218-
if (!updateIsNeeded || !isPublishingVideoTrack || !hasJoinedCall) {
219-
return;
220-
}
221-
222-
// NOTE: When the view is not visible, we want to subscribe to audio only.
223-
// We unsubscribe their video by setting the dimension to undefined
224-
const dimension = isVisible ? pendingVideoLayoutRef.current : undefined;
225-
call.state.updateParticipantTracks(trackType, {
226-
[sessionId]: { dimension },
227-
});
228-
call.dynascaleManager.applyTrackSubscriptions();
229-
230-
if (dimension) {
231-
subscribedVideoLayoutRef.current = pendingVideoLayoutRef.current;
232-
pendingVideoLayoutRef.current = undefined;
233-
}
234-
}, [
235-
call,
236-
isPublishingVideoTrack,
237-
trackType,
238-
isVisible,
239-
sessionId,
240-
hasJoinedCall,
241-
isLocalParticipant,
242-
]);
243-
244-
useEffect(() => {
245-
return () => {
246-
subscribedVideoLayoutRef.current = undefined;
247-
pendingVideoLayoutRef.current = undefined;
248-
};
249-
}, [trackType, sessionId]);
250-
251183
const onLayout: React.ComponentProps<typeof RTCView>['onLayout'] = (
252184
event,
253185
) => {
254-
if (!call || isLocalParticipant) {
255-
return;
256-
}
257-
const dimension = {
258-
width: Math.trunc(event.nativeEvent.layout.width),
259-
height: Math.trunc(event.nativeEvent.layout.height),
260-
};
261-
262-
// NOTE: If the participant hasn't published a video track yet,
263-
// or the view is not viewable, we store the dimensions and handle it
264-
// when the track is published or the video is enabled.
265-
if (!isPublishingVideoTrack || !isVisible || !hasJoinedCall) {
266-
pendingVideoLayoutRef.current = dimension;
267-
return;
268-
}
269-
270-
// NOTE: We don't want to update the subscription if the dimension hasn't changed
271-
if (
272-
subscribedVideoLayoutRef.current?.width === dimension.width &&
273-
subscribedVideoLayoutRef.current?.height === dimension.height
274-
) {
275-
return;
276-
}
277-
call.state.updateParticipantTracks(trackType, {
278-
[sessionId]: {
279-
dimension,
280-
},
281-
});
282-
call.dynascaleManager.applyTrackSubscriptions();
283-
subscribedVideoLayoutRef.current = dimension;
284-
pendingVideoLayoutRef.current = undefined;
186+
trackSubscriberRef.current?.onLayoutUpdate(event);
285187
};
286188

287189
return (
288190
<View
289191
onLayout={onLayout}
290192
style={[styles.container, videoRenderer.container]}
291193
>
194+
{call && !isLocalParticipant && (
195+
<TrackSubscriber
196+
ref={trackSubscriberRef}
197+
call={call}
198+
participantSessionId={sessionId}
199+
trackType={trackType}
200+
isVisible={isVisible}
201+
/>
202+
)}
292203
{canShowVideo &&
293204
videoStreamToRender &&
294205
(objectFit || isVideoDimensionsValid) ? (

0 commit comments

Comments
 (0)