Skip to content

Commit af080e8

Browse files
committed
Allow switching between Webcam + Screenshare while live
Resolves #504
1 parent 010af8b commit af080e8

1 file changed

Lines changed: 105 additions & 51 deletions

File tree

web/src/components/broadcast/Broadcast.tsx

Lines changed: 105 additions & 51 deletions
Original file line numberDiff line numberDiff line change
@@ -34,92 +34,149 @@ function BrowserBroadcaster() {
3434
const [profileStreamKey, setProfileStreamKey] = useState<string>("")
3535

3636
const peerConnectionRef = useRef<RTCPeerConnection | null>(null);
37+
const localMediaStreamRef = useRef<MediaStream | null>(null)
38+
const eventSourceRef = useRef<EventSource | null>(null)
3739
const videoRef = useRef<HTMLVideoElement>(null)
3840
const hasSignalRef = useRef<boolean>(false);
3941
const badSignalCountRef = useRef<number>(10);
4042

4143
const endStream = () => navigate('/')
4244

43-
useEffect(() => {
44-
peerConnectionRef.current = new RTCPeerConnection();
45+
const stopLocalMediaStream = (localMediaStream: MediaStream | null) => {
46+
if (!localMediaStream) {
47+
return
48+
}
49+
50+
localMediaStream
51+
.getTracks()
52+
.forEach((streamTrack: MediaStreamTrack) => streamTrack.stop())
53+
}
4554

46-
return () => peerConnectionRef.current?.close()
55+
const getSenderByKind = (peerConnection: RTCPeerConnection, kind: "audio" | "video") => {
56+
return peerConnection.getTransceivers().find(transceiver => transceiver.sender.track?.kind === kind)?.sender ??
57+
peerConnection.getTransceivers().find(transceiver => transceiver.receiver.track.kind === kind)?.sender ??
58+
null
59+
}
60+
61+
useEffect(() => {
62+
return () => {
63+
eventSourceRef.current?.close()
64+
stopLocalMediaStream(localMediaStreamRef.current)
65+
localMediaStreamRef.current = null
66+
peerConnectionRef.current?.close()
67+
peerConnectionRef.current = null
68+
}
4769
}, [])
4870

4971
useEffect(() => {
50-
if (useDisplayMedia === "None" || !peerConnectionRef.current) {
72+
if (useDisplayMedia === "None") {
5173
return;
5274
}
5375

54-
let stream: MediaStream | undefined = undefined;
55-
5676
if (!navigator.mediaDevices) {
5777
setMediaAccessError(() => ErrorMessageEnum.NoMediaDevices);
5878
setUseDisplayMedia(() => "None")
5979
return
6080
}
6181

82+
let cancelled = false
83+
6284
const mediaPromise = useDisplayMedia == "Screen" ?
6385
navigator.mediaDevices.getDisplayMedia(mediaOptions) :
6486
navigator.mediaDevices.getUserMedia(mediaOptions)
6587

66-
mediaPromise.then(mediaStream => {
67-
if (peerConnectionRef.current!.connectionState === "closed") {
68-
mediaStream
69-
.getTracks()
70-
.forEach(mediaStreamTrack => mediaStreamTrack.stop())
88+
mediaPromise.then(async mediaStream => {
89+
const nextLocalMediaStream = mediaStream
7190

91+
if (cancelled) {
92+
stopLocalMediaStream(nextLocalMediaStream)
7293
return;
7394
}
7495

75-
stream = mediaStream
96+
const videoTrack = mediaStream.getVideoTracks()[0] ?? null
97+
const audioTrack = mediaStream.getAudioTracks()[0] ?? null
98+
99+
const existingPeerConnection = peerConnectionRef.current
100+
if (existingPeerConnection) {
101+
const videoSender = getSenderByKind(existingPeerConnection, "video")
102+
const audioSender = getSenderByKind(existingPeerConnection, "audio")
103+
104+
await Promise.all([
105+
videoSender?.replaceTrack(videoTrack) ?? Promise.resolve(),
106+
audioSender?.replaceTrack(audioTrack) ?? Promise.resolve(),
107+
])
108+
109+
if (
110+
cancelled ||
111+
peerConnectionRef.current !== existingPeerConnection
112+
) {
113+
stopLocalMediaStream(nextLocalMediaStream)
114+
return;
115+
}
116+
117+
videoRef.current!.srcObject = mediaStream
118+
const previousLocalMediaStream = localMediaStreamRef.current
119+
localMediaStreamRef.current = nextLocalMediaStream
120+
stopLocalMediaStream(previousLocalMediaStream)
121+
return
122+
}
123+
124+
const peerConnection = new RTCPeerConnection();
125+
peerConnectionRef.current = peerConnection
126+
127+
if (
128+
cancelled ||
129+
peerConnectionRef.current !== peerConnection
130+
) {
131+
if (peerConnectionRef.current === peerConnection) {
132+
peerConnectionRef.current = null
133+
}
134+
peerConnection.close()
135+
stopLocalMediaStream(nextLocalMediaStream)
136+
return
137+
}
138+
76139
videoRef.current!.srcObject = mediaStream
77-
const isFirefox = navigator.userAgent.toLowerCase().includes('firefox')
140+
const previousLocalMediaStream = localMediaStreamRef.current
141+
localMediaStreamRef.current = nextLocalMediaStream
142+
stopLocalMediaStream(previousLocalMediaStream)
143+
144+
peerConnection.addTransceiver(audioTrack ? audioTrack : "audio", { direction: 'sendonly' })
78145

146+
const isFirefox = navigator.userAgent.toLowerCase().includes('firefox')
79147
const encodingPrefix = "Web"
80-
mediaStream
81-
.getTracks()
82-
.forEach(mediaStreamTrack => {
83-
if (mediaStreamTrack.kind === 'audio') {
84-
peerConnectionRef.current!.addTransceiver(mediaStreamTrack, {
85-
direction: 'sendonly',
86-
})
87-
} else {
88-
peerConnectionRef.current!.addTransceiver(mediaStreamTrack, {
89-
direction: 'sendonly',
90-
sendEncodings: isFirefox ? undefined : [
91-
{
92-
rid: encodingPrefix + 'High',
93-
},
94-
{
95-
rid: encodingPrefix + 'Mid',
96-
scaleResolutionDownBy: 2.0
97-
},
98-
{
99-
rid: encodingPrefix + 'Low',
100-
scaleResolutionDownBy: 4.0
101-
}
102-
]
103-
})
148+
peerConnection.addTransceiver(videoTrack ? videoTrack : "video", {
149+
direction: 'sendonly',
150+
sendEncodings: isFirefox ? undefined : [
151+
{
152+
rid: encodingPrefix + 'High',
153+
},
154+
{
155+
rid: encodingPrefix + 'Mid',
156+
scaleResolutionDownBy: 2.0
157+
},
158+
{
159+
rid: encodingPrefix + 'Low',
160+
scaleResolutionDownBy: 4.0
104161
}
105-
})
162+
],
163+
})
106164

107-
peerConnectionRef.current!.oniceconnectionstatechange = () => {
108-
if (peerConnectionRef.current!.iceConnectionState === 'connected' || peerConnectionRef.current!.iceConnectionState === 'completed') {
165+
peerConnection.oniceconnectionstatechange = () => {
166+
if (peerConnection.iceConnectionState === 'connected' || peerConnection.iceConnectionState === 'completed') {
109167
setPublishSuccess(() => true)
110168
setMediaAccessError(() => null)
111169
setPeerConnectionDisconnected(() => false)
112-
} else if (peerConnectionRef.current!.iceConnectionState === 'disconnected' || peerConnectionRef.current!.iceConnectionState === 'failed') {
170+
} else if (peerConnection.iceConnectionState === 'disconnected' || peerConnection.iceConnectionState === 'failed') {
113171
setPublishSuccess(() => false)
114172
setPeerConnectionDisconnected(() => true)
115173
}
116174
}
117175

118-
peerConnectionRef
119-
.current!
176+
peerConnection
120177
.createOffer()
121178
.then(offer => {
122-
peerConnectionRef.current!.setLocalDescription(offer)
179+
peerConnection.setLocalDescription(offer)
123180
.catch((err) => console.error("SetLocalDescription", err));
124181

125182
fetch(`/api/whip`, {
@@ -141,7 +198,9 @@ function BrowserBroadcaster() {
141198
throw new DOMException("Missing link header");
142199
}
143200

201+
eventSourceRef.current?.close()
144202
const evtSource = new EventSource(`${parsedLinkHeader['urn:ietf:params:whep:ext:core:server-sent-events'].url}`)
203+
eventSourceRef.current = evtSource
145204

146205
evtSource.onerror = () => evtSource.close();
147206

@@ -150,7 +209,7 @@ function BrowserBroadcaster() {
150209

151210
return r.text()
152211
}).then(answer => {
153-
peerConnectionRef.current!.setRemoteDescription({
212+
peerConnection.setRemoteDescription({
154213
sdp: answer,
155214
type: 'answer'
156215
}).catch((err) => console.error("SetRemoteDescription", err))
@@ -162,12 +221,7 @@ function BrowserBroadcaster() {
162221
})
163222

164223
return () => {
165-
peerConnectionRef.current?.close()
166-
if (stream) {
167-
stream
168-
.getTracks()
169-
.forEach((streamTrack: MediaStreamTrack) => streamTrack.stop())
170-
}
224+
cancelled = true
171225
}
172226
// eslint-disable-next-line react-hooks/exhaustive-deps
173227
}, [videoRef, useDisplayMedia, location.pathname])

0 commit comments

Comments
 (0)