Skip to content

Commit c867527

Browse files
committed
Implement automatic reconnection logic
1 parent 2f3de2d commit c867527

4 files changed

Lines changed: 604 additions & 242 deletions

File tree

web/src/components/broadcast/Broadcast.tsx

Lines changed: 174 additions & 118 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@ import ProfileSettings from './ProfileSettings';
99
import Player from '../player/Player';
1010
import { LocaleContext } from '../../providers/LocaleProvider';
1111
import toBase64Utf8 from '../../utilities/base64';
12+
import { useReconnectController } from '../../hooks/useReconnectController';
1213

1314
const mediaOptions = {
1415
audio: true,
@@ -39,9 +40,21 @@ function BrowserBroadcaster() {
3940
const peerConnectionRef = useRef<RTCPeerConnection | null>(null);
4041
const localMediaStreamRef = useRef<MediaStream | null>(null)
4142
const eventSourceRef = useRef<EventSource | null>(null)
43+
const whipResourceUrlRef = useRef<string | null>(null)
44+
const setupInProgressRef = useRef<boolean>(false)
4245
const videoRef = useRef<HTMLVideoElement>(null)
4346
const hasSignalRef = useRef<boolean>(false);
4447
const badSignalCountRef = useRef<number>(10);
48+
const shouldAutoReconnectRef = useRef<boolean>(false)
49+
const {
50+
scheduleReconnect,
51+
reset: resetReconnect,
52+
cancel: cancelReconnect,
53+
} = useReconnectController({
54+
baseDelayMs: 500,
55+
maxDelayMs: 8_000,
56+
maxAttempts: 8,
57+
})
4558

4659
const endStream = () => navigate('/')
4760
const requestMedia = (source: MediaSource) => {
@@ -64,107 +77,99 @@ function BrowserBroadcaster() {
6477
.forEach((streamTrack: MediaStreamTrack) => streamTrack.stop())
6578
}, [])
6679

67-
const getSenderByKind = useCallback((peerConnection: RTCPeerConnection, kind: "audio" | "video") => {
68-
return peerConnection.getTransceivers().find(transceiver => transceiver.sender.track?.kind === kind)?.sender ??
69-
peerConnection.getTransceivers().find(transceiver => transceiver.receiver.track.kind === kind)?.sender ??
70-
null
80+
const closeEventSource = useCallback(() => {
81+
eventSourceRef.current?.close()
82+
eventSourceRef.current = null
7183
}, [])
7284

85+
const deleteWhipSession = useCallback(async () => {
86+
const currentWhipResource = whipResourceUrlRef.current
87+
if (!currentWhipResource) {
88+
return
89+
}
90+
91+
whipResourceUrlRef.current = null
92+
93+
await fetch(currentWhipResource, {
94+
method: 'DELETE'
95+
}).catch((err) => {
96+
console.error("WHIP.DeleteSession", err)
97+
})
98+
}, [])
99+
100+
const closePeerConnectionAndSession = useCallback(async () => {
101+
closeEventSource()
102+
peerConnectionRef.current?.close()
103+
peerConnectionRef.current = null
104+
await deleteWhipSession()
105+
}, [closeEventSource, deleteWhipSession])
106+
107+
const isFatalWhipStatus = useCallback((statusCode: number) => {
108+
return statusCode === 400 || statusCode === 401 || statusCode === 403 || statusCode === 404
109+
}, [])
110+
111+
const triggerReconnect = useCallback((setupPublisherSession: () => Promise<void>) => {
112+
if (!shouldAutoReconnectRef.current) {
113+
return
114+
}
115+
116+
scheduleReconnect(() => {
117+
void setupPublisherSession()
118+
})
119+
}, [scheduleReconnect])
120+
73121
useEffect(() => {
74122
return () => {
75-
eventSourceRef.current?.close()
123+
cancelReconnect()
124+
shouldAutoReconnectRef.current = false
125+
void closePeerConnectionAndSession()
76126
stopLocalMediaStream(localMediaStreamRef.current)
77127
localMediaStreamRef.current = null
78-
peerConnectionRef.current?.close()
79-
peerConnectionRef.current = null
80128
}
81-
}, [stopLocalMediaStream])
129+
}, [cancelReconnect, closePeerConnectionAndSession, stopLocalMediaStream])
82130

83131
useEffect(() => {
84132
if (useDisplayMedia === "None") {
133+
shouldAutoReconnectRef.current = false
134+
cancelReconnect()
85135
return;
86136
}
87137

88-
let cancelled = false
138+
let cancelled = false
139+
shouldAutoReconnectRef.current = true
89140

90-
const mediaPromise = useDisplayMedia == "Screen" ?
91-
navigator.mediaDevices.getDisplayMedia(mediaOptions) :
92-
navigator.mediaDevices.getUserMedia(mediaOptions)
93-
94-
mediaPromise.then(async mediaStream => {
95-
const nextLocalMediaStream = mediaStream
141+
const setupPublisherSession = async () => {
142+
if (setupInProgressRef.current || cancelled) {
143+
return
144+
}
96145

97-
if (cancelled) {
98-
stopLocalMediaStream(nextLocalMediaStream)
99-
return;
146+
const mediaStream = localMediaStreamRef.current
147+
if (!mediaStream) {
148+
return
100149
}
101150

151+
setupInProgressRef.current = true
152+
setPeerConnectionDisconnected(() => false)
153+
setConnectFailed(() => false)
154+
102155
const videoTrack = mediaStream.getVideoTracks()[0] ?? null
103156
const audioTrack = mediaStream.getAudioTracks()[0] ?? null
104157

105-
const existingPeerConnection = peerConnectionRef.current
106-
if (existingPeerConnection) {
107-
const videoSender = getSenderByKind(existingPeerConnection, "video")
108-
const audioSender = getSenderByKind(existingPeerConnection, "audio")
109-
110-
await Promise.all([
111-
videoSender?.replaceTrack(videoTrack) ?? Promise.resolve(),
112-
audioSender?.replaceTrack(audioTrack) ?? Promise.resolve(),
113-
])
114-
115-
if (
116-
cancelled ||
117-
peerConnectionRef.current !== existingPeerConnection
118-
) {
119-
stopLocalMediaStream(nextLocalMediaStream)
120-
return;
121-
}
158+
await closePeerConnectionAndSession()
122159

123-
videoRef.current!.srcObject = mediaStream
124-
const previousLocalMediaStream = localMediaStreamRef.current
125-
localMediaStreamRef.current = nextLocalMediaStream
126-
stopLocalMediaStream(previousLocalMediaStream)
127-
return
128-
}
129-
130-
const peerConnection = new RTCPeerConnection();
160+
const peerConnection = new RTCPeerConnection()
131161
peerConnectionRef.current = peerConnection
132162

133-
if (
134-
cancelled ||
135-
peerConnectionRef.current !== peerConnection
136-
) {
137-
if (peerConnectionRef.current === peerConnection) {
138-
peerConnectionRef.current = null
139-
}
140-
peerConnection.close()
141-
stopLocalMediaStream(nextLocalMediaStream)
142-
return
143-
}
144-
145-
videoRef.current!.srcObject = mediaStream
146-
const previousLocalMediaStream = localMediaStreamRef.current
147-
localMediaStreamRef.current = nextLocalMediaStream
148-
stopLocalMediaStream(previousLocalMediaStream)
149-
150163
peerConnection.addTransceiver(audioTrack ? audioTrack : "audio", { direction: 'sendonly' })
151164

152165
const isFirefox = navigator.userAgent.toLowerCase().includes('firefox')
153166
const encodingPrefix = "Web"
154167
peerConnection.addTransceiver(videoTrack ? videoTrack : "video", {
155168
direction: 'sendonly',
156169
sendEncodings: isFirefox ? undefined : [
157-
{
158-
rid: encodingPrefix + 'High',
159-
},
160-
{
161-
rid: encodingPrefix + 'Mid',
162-
scaleResolutionDownBy: 2.0
163-
},
164-
{
165-
rid: encodingPrefix + 'Low',
166-
scaleResolutionDownBy: 4.0
167-
}
170+
{ rid: encodingPrefix + 'High' },
171+
{ rid: encodingPrefix + 'Mid', scaleResolutionDownBy: 2.0 },
172+
{ rid: encodingPrefix + 'Low', scaleResolutionDownBy: 4.0 },
168173
],
169174
})
170175

@@ -173,63 +178,114 @@ function BrowserBroadcaster() {
173178
setPublishSuccess(() => true)
174179
setMediaAccessError(() => null)
175180
setPeerConnectionDisconnected(() => false)
176-
} else if (peerConnection.iceConnectionState === 'disconnected' || peerConnection.iceConnectionState === 'failed') {
181+
resetReconnect()
182+
return
183+
}
184+
185+
if (peerConnection.iceConnectionState === 'disconnected' || peerConnection.iceConnectionState === 'failed') {
177186
setPublishSuccess(() => false)
178187
setPeerConnectionDisconnected(() => true)
188+
triggerReconnect(setupPublisherSession)
179189
}
180190
}
181191

182-
peerConnection
183-
.createOffer()
184-
.then(offer => {
185-
peerConnection.setLocalDescription(offer)
186-
.catch((err) => console.error("SetLocalDescription", err));
187-
188-
fetch(`/api/whip`, {
189-
method: 'POST',
190-
body: offer.sdp,
191-
headers: {
192-
Authorization: `Bearer ${toBase64Utf8(streamKey)}`,
193-
'Content-Type': 'application/sdp'
194-
}
195-
}).then(r => {
196-
197-
if (r.status !== 201) {
198-
setConnectFailed(() => true)
199-
console.error("WHIP Endpoint did not return 201")
200-
}
201-
const parsedLinkHeader = parseLinkHeader(r.headers.get('Link'))
202-
203-
if (parsedLinkHeader === null || parsedLinkHeader === undefined) {
204-
throw new DOMException("Missing link header");
205-
}
206-
207-
eventSourceRef.current?.close()
208-
const evtSource = new EventSource(`${parsedLinkHeader['urn:ietf:params:whep:ext:core:server-sent-events'].url}`)
209-
eventSourceRef.current = evtSource
210-
211-
evtSource.onerror = () => evtSource.close();
212-
213-
// Receive current status of the stream
214-
// evtSource.addEventListener("status", (event: MessageEvent) => setCurrentStreamStatus(JSON.parse(event.data)))
215-
216-
return r.text()
217-
}).then(answer => {
218-
peerConnection.setRemoteDescription({
219-
sdp: answer,
220-
type: 'answer'
221-
}).catch((err) => console.error("SetRemoteDescription", err))
222-
})
192+
try {
193+
const offer = await peerConnection.createOffer()
194+
await peerConnection.setLocalDescription(offer)
195+
196+
const response = await fetch(`/api/whip`, {
197+
method: 'POST',
198+
body: offer.sdp,
199+
headers: {
200+
Authorization: `Bearer ${toBase64Utf8(streamKey)}`,
201+
'Content-Type': 'application/sdp'
202+
}
223203
})
224-
}, (reason: ErrorMessageEnum) => {
225-
setMediaAccessError(() => reason)
226-
setUseDisplayMedia("None");
227-
})
204+
205+
if (response.status !== 201) {
206+
setConnectFailed(() => true)
207+
setPublishSuccess(() => false)
208+
if (isFatalWhipStatus(response.status)) {
209+
shouldAutoReconnectRef.current = false
210+
cancelReconnect()
211+
return
212+
}
213+
214+
throw new DOMException("WHIP Endpoint did not return 201")
215+
}
216+
217+
whipResourceUrlRef.current = response.headers.get('Location')
218+
219+
const parsedLinkHeader = parseLinkHeader(response.headers.get('Link'))
220+
if (parsedLinkHeader === null || parsedLinkHeader === undefined) {
221+
throw new DOMException("Missing link header")
222+
}
223+
224+
closeEventSource()
225+
const evtSource = new EventSource(`${parsedLinkHeader['urn:ietf:params:whep:ext:core:server-sent-events'].url}`)
226+
eventSourceRef.current = evtSource
227+
228+
evtSource.onerror = () => {
229+
closeEventSource()
230+
setPublishSuccess(() => false)
231+
setPeerConnectionDisconnected(() => true)
232+
triggerReconnect(setupPublisherSession)
233+
}
234+
235+
const answer = await response.text()
236+
await peerConnection.setRemoteDescription({
237+
sdp: answer,
238+
type: 'answer'
239+
})
240+
} catch (err) {
241+
console.error("Broadcast.SetupPublisherSession", err)
242+
setPublishSuccess(() => false)
243+
triggerReconnect(setupPublisherSession)
244+
} finally {
245+
setupInProgressRef.current = false
246+
}
247+
}
248+
249+
const requestAndStartSession = async () => {
250+
const mediaPromise = useDisplayMedia == "Screen"
251+
? navigator.mediaDevices.getDisplayMedia(mediaOptions)
252+
: navigator.mediaDevices.getUserMedia(mediaOptions)
253+
254+
try {
255+
const mediaStream = await mediaPromise
256+
if (cancelled) {
257+
stopLocalMediaStream(mediaStream)
258+
return
259+
}
260+
261+
videoRef.current!.srcObject = mediaStream
262+
const previousLocalMediaStream = localMediaStreamRef.current
263+
localMediaStreamRef.current = mediaStream
264+
stopLocalMediaStream(previousLocalMediaStream)
265+
266+
await setupPublisherSession()
267+
} catch (reason) {
268+
const mediaError = reason as { name?: string }
269+
if (mediaError.name === 'NotAllowedError') {
270+
setMediaAccessError(() => ErrorMessageEnum.NotAllowedError)
271+
} else if (mediaError.name === 'NotFoundError') {
272+
setMediaAccessError(() => ErrorMessageEnum.NotFoundError)
273+
} else {
274+
setMediaAccessError(() => ErrorMessageEnum.NoMediaDevices)
275+
}
276+
277+
shouldAutoReconnectRef.current = false
278+
cancelReconnect()
279+
setUseDisplayMedia("None")
280+
}
281+
}
282+
283+
void requestAndStartSession()
228284

229285
return () => {
230286
cancelled = true
231287
}
232-
}, [getSenderByKind, mediaRequestCount, stopLocalMediaStream, streamKey, useDisplayMedia])
288+
}, [cancelReconnect, closeEventSource, closePeerConnectionAndSession, isFatalWhipStatus, mediaRequestCount, resetReconnect, stopLocalMediaStream, streamKey, triggerReconnect, useDisplayMedia])
233289

234290
useEffect(() => {
235291
hasSignalRef.current = hasSignal;

0 commit comments

Comments
 (0)