From 5320ab1ee4428480baf37cf31f0371d90295dc9c Mon Sep 17 00:00:00 2001 From: Anthony Ettinger Date: Wed, 19 Aug 2026 03:02:01 +0000 Subject: [PATCH 1/2] fix(sfu): replace the published screen track instead of publishing a second MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Turning the camera bubble on swaps the raw screen track for a composited one and republishes. publishTrack() left both tracks published, and the viewer — which folds every subscribed video track into one MediaStream and renders only the first — kept showing the pre-camera screen. The camera never reached anyone. Reported from a real pairing session as "I was able to see myself in the preview window, but my pair was unable to see me". publishStream() now replaces the track inside the existing publication when one is already up, which keeps the publication SID stable so viewers switch over without resubscribing. The P2P host has always done this — see the "instead of adding duplicate transceivers on every republish" comment in useWebRTCHostAPI — but SFU is the default mode and never got the same treatment. Also drops the effect that swapped the published video whenever `localStream` changed. That stream is the *raw* capture, not the presentation track, so re-establishing a host connection with the camera on silently replaced the composite with the bare screen and dropped the bubble for every viewer. Co-Authored-By: Claude Opus 5 (1M context) --- .../hooks/useWebRTCHostSFUAPI.test.ts | 104 ++++++++++++++++++ .../src/renderer/hooks/useWebRTCHostSFUAPI.ts | 72 ++++++++---- 2 files changed, 157 insertions(+), 19 deletions(-) diff --git a/apps/desktop/src/renderer/hooks/useWebRTCHostSFUAPI.test.ts b/apps/desktop/src/renderer/hooks/useWebRTCHostSFUAPI.test.ts index bb18dad..945e3f0 100644 --- a/apps/desktop/src/renderer/hooks/useWebRTCHostSFUAPI.test.ts +++ b/apps/desktop/src/renderer/hooks/useWebRTCHostSFUAPI.test.ts @@ -590,4 +590,108 @@ describe('useWebRTCHostSFUAPI', () => { expect(mockConnect).toHaveBeenCalledTimes(2); expect(result.current.isHosting).toBe(true); }); + + /** + * Regression: turning the camera bubble on swaps the raw screen track for a + * composited one and republishes. Publishing again left *both* tracks up, and + * the viewer — which folds every subscribed video track into one MediaStream + * and renders only the first — kept showing the pre-camera screen. The camera + * never reached anyone. Reported from a real session as "I was able to see + * myself in the preview window, but my pair was unable to see me". + */ + describe('republishing the presentation track', () => { + const mockReplaceTrack = vi.fn().mockResolvedValue(undefined); + + /** Mirror LiveKit: a successful publish shows up in trackPublications. */ + function trackPublishingRoom() { + mockReplaceTrack.mockClear(); + mockPublishTrack.mockImplementation((track: { id: string }, options: { source: string }) => { + const publication = { + source: options.source, + track: { mediaStreamTrack: track, replaceTrack: mockReplaceTrack }, + }; + mockTrackPublications.set(options.source, publication); + return Promise.resolve(publication); + }); + } + + async function hostWithScreenTrack() { + trackPublishingRoom(); + const { result } = renderHook(() => + useWebRTCHostSFUAPI({ sessionId: 'session-1', hostId: 'host-1', localStream: null }) + ); + + await act(async () => { + await result.current.startHosting(); + await Promise.resolve(); + }); + + const screenTrack = { kind: 'video', id: 'screen-track' }; + await act(async () => { + await result.current.publishStream( + new MockMediaStream([screenTrack]) as unknown as MediaStream + ); + }); + + return { result, screenTrack }; + } + + it('replaces the published video instead of publishing a second track', async () => { + const { result } = await hostWithScreenTrack(); + + const videoPublishes = () => + mockPublishTrack.mock.calls.filter( + (call) => (call[1] as { source: string }).source === 'screen_share' + ); + + expect(videoPublishes()).toHaveLength(1); + + // Camera on: the composited canvas track replaces the screen track. + const compositeTrack = { kind: 'video', id: 'composite-track' }; + await act(async () => { + await result.current.publishStream( + new MockMediaStream([compositeTrack]) as unknown as MediaStream + ); + }); + + expect(videoPublishes()).toHaveLength(1); + expect(mockReplaceTrack).toHaveBeenCalledTimes(1); + expect(mockReplaceTrack).toHaveBeenCalledWith(compositeTrack); + }); + + it('does not replace when handed the track it is already publishing', async () => { + const { result, screenTrack } = await hostWithScreenTrack(); + + // A re-render republishing the identical track must be a no-op, not a + // needless renegotiation on every state change in the capture view. + await act(async () => { + await result.current.publishStream( + new MockMediaStream([screenTrack]) as unknown as MediaStream + ); + }); + + expect(mockReplaceTrack).not.toHaveBeenCalled(); + expect( + mockPublishTrack.mock.calls.filter( + (call) => (call[1] as { source: string }).source === 'screen_share' + ) + ).toHaveLength(1); + }); + + it('marks the replacement track as detail content for the encoder', async () => { + const { result } = await hostWithScreenTrack(); + + const compositeTrack: { kind: string; id: string; contentHint?: string } = { + kind: 'video', + id: 'composite-track', + }; + await act(async () => { + await result.current.publishStream( + new MockMediaStream([compositeTrack]) as unknown as MediaStream + ); + }); + + expect(compositeTrack.contentHint).toBe('detail'); + }); + }); }); diff --git a/apps/desktop/src/renderer/hooks/useWebRTCHostSFUAPI.ts b/apps/desktop/src/renderer/hooks/useWebRTCHostSFUAPI.ts index ca0d621..daf3775 100644 --- a/apps/desktop/src/renderer/hooks/useWebRTCHostSFUAPI.ts +++ b/apps/desktop/src/renderer/hooks/useWebRTCHostSFUAPI.ts @@ -56,6 +56,11 @@ export interface ViewerConnection { interface UseWebRTCHostSFUAPIOptions { sessionId: string; hostId: string; + /** + * The raw capture stream. Retained so callers keep a single options shape + * across the P2P and SFU hooks; publishing is driven entirely by + * `publishStream()`, which is handed the *presentation* track instead. + */ localStream: MediaStream | null; allowControl?: boolean; onViewerJoined?: (viewerId: string) => void; @@ -94,7 +99,6 @@ interface UseWebRTCHostSFUAPIReturn { export function useWebRTCHostSFUAPI({ sessionId, hostId, - localStream, allowControl = false, onViewerJoined, onViewerLeft, @@ -553,7 +557,20 @@ export function useWebRTCHostSFUAPI({ } }, [sessionId, hostId, addViewer, attachViewerAudio, removeViewer, handleDataReceived]); - // Publish a screen share stream to the LiveKit room + /** + * Publish the presentation stream, replacing whatever is already published. + * + * Republishing is not the same as publishing. Turning the camera bubble on + * swaps the raw screen track for a composited one and calls this again — + * and `publishTrack` would then leave *both* tracks published. Viewers fold + * every subscribed video track into one MediaStream and a