@@ -9,6 +9,7 @@ import ProfileSettings from './ProfileSettings';
99import Player from '../player/Player' ;
1010import { LocaleContext } from '../../providers/LocaleProvider' ;
1111import toBase64Utf8 from '../../utilities/base64' ;
12+ import { useReconnectController } from '../../hooks/useReconnectController' ;
1213
1314const 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