@@ -324,12 +324,21 @@ export default class RTCEngine extends (EventEmitter as new () => TypedEventEmit
324324 this . joinAttempts += 1 ;
325325
326326 this . setupSignalClientCallbacks ( ) ;
327+ // Whether the initial publisher offer is bundled with the join request. Computed once and
328+ // reused after the join below. Only the (non-Firefox) offer-with-join path does this.
329+ const sendOfferWithJoin = ! useV0Path && isPublisherOfferWithJoinSupported ( ) ;
330+
327331 let offerProto : SessionDescription | undefined ;
328- if ( ! useV0Path && isPublisherOfferWithJoinSupported ( ) ) {
332+ if ( sendOfferWithJoin ) {
329333 if ( ! this . pcManager ) {
334+ // Firefox is excluded from offer-with-join (see isPublisherOfferWithJoinSupported):
335+ // customers reported ICE connectivity problems for FF on this path (#1919) that we were
336+ // never able to reproduce, so out of caution FF stays on the deferred path below. The
337+ // exact cause is unknown — note that ICE gathering does not actually start here, since
338+ // createInitialOffer() defers setLocalDescription (via pendingInitialOffer) until the
339+ // answer is applied, after updateConfiguration() has set the server's TURN servers.
330340 await this . configure ( ) ;
331- this . createDataChannels ( ) ;
332- this . addMediaSections ( initialMediaSectionsAudio , initialMediaSectionsVideo ) ;
341+ this . applyInitialPublisherLayout ( ) ;
333342 }
334343 const offer = await this . pcManager ?. publisher . createInitialOffer ( ) ;
335344 if ( offer ) {
@@ -358,11 +367,20 @@ export default class RTCEngine extends (EventEmitter as new () => TypedEventEmit
358367 this . participantSid = joinResponse . participant ?. sid ;
359368
360369 this . subscriberPrimary = joinResponse . subscriberPrimary ;
361- if ( ! useV0Path && isPublisherOfferWithJoinSupported ( ) ) {
370+ if ( sendOfferWithJoin ) {
362371 this . pcManager ?. updateConfiguration ( this . makeRTCConfiguration ( joinResponse ) ) ;
363372 } else {
364373 if ( ! this . pcManager ) {
374+ // Deferred path (Firefox, and V0): configure with the join response so the PC picks up
375+ // the server's ICE servers and topology, then negotiate separately rather than bundling
376+ // the offer with the join.
365377 await this . configure ( joinResponse , ! useV0Path ) ;
378+ if ( ! useV0Path ) {
379+ // The V1 first offer must carry the media layout so Firefox binds receive decoders for
380+ // subscribed tracks — without it, subscribed audio/video arrive as RTP but
381+ // never decode. V0 (legacy dual-PC) keeps its original lazy behavior.
382+ this . applyInitialPublisherLayout ( ) ;
383+ }
366384 }
367385 // create offer
368386 if ( ! this . subscriberPrimary || joinResponse . fastPublish ) {
@@ -814,6 +832,17 @@ export default class RTCEngine extends (EventEmitter as new () => TypedEventEmit
814832 return rtcConfig ;
815833 }
816834
835+ /**
836+ * Populate the publisher PC so its first offer carries the data channels + recvonly media
837+ * sections. Required for every V1 connection: Firefox only binds receive decoders for media
838+ * present in that first offer, and the offer-with-join path needs the sections to
839+ * build a meaningful initial offer. Must be called on a configured pcManager.
840+ */
841+ private applyInitialPublisherLayout ( ) {
842+ this . createDataChannels ( ) ;
843+ this . addMediaSections ( initialMediaSectionsAudio , initialMediaSectionsVideo ) ;
844+ }
845+
817846 private addMediaSections ( numAudios : number , numVideos : number ) {
818847 const transceiverInit : RTCRtpTransceiverInit = { direction : 'recvonly' } ;
819848 for ( let i : number = 0 ; i < numAudios ; i ++ ) {
0 commit comments