Skip to content

Commit 6265ca6

Browse files
authored
chore: allow toggling video/audio of remove video file (#2022)
Adds buttons for toggling the audio/video of the remote file. <img width="1986" height="586" alt="Screenshot 2025-11-27 at 15 28 35" src="https://github.com/user-attachments/assets/be41ffdd-32bf-473e-ab1b-5ae07fbed572" />
1 parent 91f1668 commit 6265ca6

File tree

5 files changed

+136
-30
lines changed

5 files changed

+136
-30
lines changed

sample-apps/react/react-dogfood/components/ActiveCall.tsx

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -59,6 +59,10 @@ import { StepNames, useTourContext } from '../context/TourContext';
5959
import { useNotificationSounds } from '../hooks/useNotificationSounds';
6060
import { usePipWindow } from '../hooks/usePipWindow';
6161
import { StagePip } from './StagePip';
62+
import {
63+
RemoteVideoControls,
64+
useRemoteFilePublisher,
65+
} from './RemoteFilePublisher';
6266

6367
export type ActiveCallProps = {
6468
chatClient?: StreamChat | null;
@@ -152,6 +156,8 @@ export const ActiveCall = (props: ActiveCallProps) => {
152156
} = usePipWindow('@pronto/pip');
153157
useNotificationSounds();
154158

159+
const remoteFilePublisherAPI = useRemoteFilePublisher();
160+
155161
return (
156162
<div className="rd__call">
157163
{isDemoEnvironment && <TourPanel highlightClass="rd__highlight" />}
@@ -273,6 +279,10 @@ export const ActiveCall = (props: ActiveCallProps) => {
273279
</div>
274280
</div>
275281
<div className="str-video__call-controls--group str-video__call-controls--media">
282+
{remoteFilePublisherAPI && (
283+
<RemoteVideoControls api={remoteFilePublisherAPI} />
284+
)}
285+
276286
<ToggleDualMicButton />
277287
<ToggleDualCameraButton />
278288
<div className="str-video__call-controls__desktop">

sample-apps/react/react-dogfood/components/MeetingUI.tsx

Lines changed: 18 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -25,7 +25,11 @@ import { DefaultAppHeader } from './DefaultAppHeader';
2525
import { Feedback } from './Feedback/Feedback';
2626
import { Lobby, UserMode } from './Lobby';
2727
import { getRandomName, sanitizeUserId } from '../lib/names';
28-
import { publishRemoteFile } from '../lib/remoteFilePublisher';
28+
import {
29+
publishRemoteFile,
30+
RemoteFilePublisher,
31+
RemoteFilePublisherContext,
32+
} from './RemoteFilePublisher';
2933
import { applyQueryConfigParams } from '../lib/queryConfigParams';
3034

3135
const contents = {
@@ -41,6 +45,7 @@ type MeetingUIProps = {
4145
chatClient?: StreamChat | null;
4246
mode?: UserMode;
4347
};
48+
4449
export const MeetingUI = ({ chatClient, mode }: MeetingUIProps) => {
4550
const [show, setShow] = useState<
4651
'lobby' | 'error-join' | 'error-leave' | 'loading' | 'active-call' | 'left'
@@ -54,6 +59,8 @@ export const MeetingUI = ({ chatClient, mode }: MeetingUIProps) => {
5459
settings: { deviceSelectionPreference },
5560
} = useSettings();
5661
const isRestricted = useIsRestrictedEnvironment();
62+
const [remoteFilePublisherAPI, setRemoteFilePublisherAPI] =
63+
useState<RemoteFilePublisher>();
5764

5865
const onJoin = useCallback(
5966
async (options: { fastJoin?: boolean; displayName?: string } = {}) => {
@@ -73,7 +80,8 @@ export const MeetingUI = ({ chatClient, mode }: MeetingUIProps) => {
7380
}
7481

7582
if (videoFile) {
76-
await publishRemoteFile(call, videoFile);
83+
const api = await publishRemoteFile(call, videoFile);
84+
setRemoteFilePublisherAPI(api);
7785
} else {
7886
await call.join({ create: !isRestricted });
7987
}
@@ -184,12 +192,14 @@ export const MeetingUI = ({ chatClient, mode }: MeetingUIProps) => {
184192
);
185193
} else {
186194
childrenToRender = (
187-
<ActiveCall
188-
activeCall={call}
189-
chatClient={chatClient}
190-
onLeave={onLeave}
191-
onJoin={() => onJoin()}
192-
/>
195+
<RemoteFilePublisherContext.Provider value={remoteFilePublisherAPI}>
196+
<ActiveCall
197+
activeCall={call}
198+
chatClient={chatClient}
199+
onLeave={onLeave}
200+
onJoin={() => onJoin()}
201+
/>
202+
</RemoteFilePublisherContext.Provider>
193203
);
194204
}
195205

Lines changed: 107 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,107 @@
1+
import { createContext, useContext } from 'react';
2+
import clsx from 'clsx';
3+
import {
4+
Call,
5+
hasAudio,
6+
hasVideo,
7+
SfuModels,
8+
useCallStateHooks,
9+
} from '@stream-io/video-react-sdk';
10+
11+
export type RemoteFilePublisher = {
12+
publish: (...trackTypes: SfuModels.TrackType[]) => Promise<void>;
13+
unpublish: (...trackTypes: SfuModels.TrackType[]) => Promise<void>;
14+
videoElement: HTMLVideoElement;
15+
};
16+
17+
export const RemoteFilePublisherContext = createContext<
18+
RemoteFilePublisher | undefined
19+
>(undefined);
20+
21+
export const useRemoteFilePublisher = () => {
22+
return useContext(RemoteFilePublisherContext);
23+
};
24+
25+
export const publishRemoteFile = async (
26+
call: Call,
27+
videoFileUrl: string,
28+
): Promise<RemoteFilePublisher> => {
29+
const videoElement = document.createElement('video');
30+
videoElement.crossOrigin = 'anonymous';
31+
videoElement.muted = true;
32+
videoElement.autoplay = true;
33+
videoElement.loop = true;
34+
videoElement.src = videoFileUrl;
35+
36+
await videoElement.play();
37+
38+
await call.microphone.disable();
39+
await call.camera.disable();
40+
41+
// @ts-expect-error - captureStream is not in the type definitions yet
42+
const stream: MediaStream = videoElement.captureStream();
43+
44+
const publish = async (...trackTypes: SfuModels.TrackType[]) => {
45+
if (trackTypes.includes(SfuModels.TrackType.AUDIO)) {
46+
await call.publish(stream, SfuModels.TrackType.AUDIO);
47+
}
48+
if (trackTypes.includes(SfuModels.TrackType.VIDEO)) {
49+
await call.publish(stream, SfuModels.TrackType.VIDEO);
50+
}
51+
};
52+
53+
const unpublish = async (...trackTypes: SfuModels.TrackType[]) => {
54+
await call.stopPublish(...trackTypes);
55+
};
56+
57+
await call.join({ create: true });
58+
await publish(SfuModels.TrackType.AUDIO, SfuModels.TrackType.VIDEO);
59+
60+
return { publish, unpublish, videoElement };
61+
};
62+
63+
export const RemoteVideoControls = (props: { api: RemoteFilePublisher }) => {
64+
const { api } = props;
65+
const { useLocalParticipant } = useCallStateHooks();
66+
const localParticipant = useLocalParticipant();
67+
const isPublishingVideo = localParticipant && hasVideo(localParticipant);
68+
const isPublishingAudio = localParticipant && hasAudio(localParticipant);
69+
return (
70+
<>
71+
<button
72+
data-testid="vf-video-toggle"
73+
type="button"
74+
className={clsx('rd__button', {
75+
'rd__button--primary': isPublishingVideo,
76+
'rd__button--secondary': !isPublishingVideo,
77+
})}
78+
onClick={async () => {
79+
if (isPublishingVideo) {
80+
await api.unpublish(SfuModels.TrackType.VIDEO);
81+
} else {
82+
await api.publish(SfuModels.TrackType.VIDEO);
83+
}
84+
}}
85+
>
86+
Video
87+
</button>
88+
<button
89+
data-testid="vf-audio-toggle"
90+
type="button"
91+
className={clsx('rd__button', {
92+
'rd__button--primary': isPublishingAudio,
93+
'rd__button--secondary': !isPublishingAudio,
94+
})}
95+
onClick={async () => {
96+
if (isPublishingAudio) {
97+
await api.unpublish(SfuModels.TrackType.AUDIO);
98+
} else {
99+
await api.publish(SfuModels.TrackType.AUDIO);
100+
}
101+
}}
102+
>
103+
Audio
104+
</button>
105+
</>
106+
);
107+
};

sample-apps/react/react-dogfood/lib/remoteFilePublisher.ts

Lines changed: 0 additions & 21 deletions
This file was deleted.

sample-apps/react/react-dogfood/pages/bare/join/[callId].tsx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -29,7 +29,7 @@ import {
2929
} from '../../../lib/getServerSideCredentialsProps';
3030
import { IncomingVideoSettingsButton } from '../../../components/IncomingVideoSettings';
3131
import appTranslations from '../../../translations';
32-
import { publishRemoteFile } from '../../../lib/remoteFilePublisher';
32+
import { publishRemoteFile } from '../../../components/RemoteFilePublisher';
3333
import { applyQueryConfigParams } from '../../../lib/queryConfigParams';
3434

3535
export default function BareCallRoom(props: ServerSideCredentialsProps) {

0 commit comments

Comments
 (0)