diff --git a/src/state/CallViewModel/localMember/Publisher.test.ts b/src/state/CallViewModel/localMember/Publisher.test.ts index 21775c58d..fe44fdd79 100644 --- a/src/state/CallViewModel/localMember/Publisher.test.ts +++ b/src/state/CallViewModel/localMember/Publisher.test.ts @@ -286,6 +286,53 @@ describe("Publisher", () => { expect(track!.isUpstreamPaused).toBe(true); }); + it("Resumes upstream for tracks published after startPublishing was called (slow camera)", async () => { + videoEnabled$.next(true); + + // Simulate that the upstream ended up paused by the time the track gets + // published (e.g. paused while it was still unpublished). + const originalPublishTrack = localParticipant.publishTrack; + vi.mocked(localParticipant).publishTrack = vi + .fn() + .mockImplementation(async (track: LocalTrack) => { + await track.pauseUpstream(); + return originalPublishTrack(track); + }); + + const resolvers = Promise.withResolvers(); + createTrackLock = resolvers.promise; + + // Track creation is slow (e.g. camera hardware takes time to open) + await publisher.createAndSetupTracks(); + // startPublishing runs before the camera track exists, so its + // resumeUpstreams call finds nothing to resume + await publisher.startPublishing(); + expect( + localParticipant.getTrackPublication(Track.Source.Camera), + ).toBeUndefined(); + + // The camera opens and the track gets published + resolvers.resolve(); + await flushPromises(); + + const track = localParticipant.getTrackPublication( + Track.Source.Camera, + )?.track; + expect(track).toBeDefined(); + expect(track!.resumeUpstream).toHaveBeenCalled(); + expect(track!.isUpstreamPaused).toBe(false); + }); + + it("Does not pause tracks published after the publisher was destroyed", async () => { + await publisher.destroy(); + + const track = createMockLocalTrack(Track.Source.Camera); + await localParticipant.publishTrack(track); + await flushPromises(); + + expect(track.pauseUpstream).not.toHaveBeenCalled(); + }); + it("Ensure resume upstream when published is called", async () => { videoEnabled$.next(true); await publisher.createAndSetupTracks(); diff --git a/src/state/CallViewModel/localMember/Publisher.ts b/src/state/CallViewModel/localMember/Publisher.ts index 0d5f263a6..cf6fb9fe8 100644 --- a/src/state/CallViewModel/localMember/Publisher.ts +++ b/src/state/CallViewModel/localMember/Publisher.ts @@ -80,11 +80,15 @@ export class Publisher { this.connection.livekitRoom.localParticipant.on( ParticipantEvent.LocalTrackPublished, - this.onLocalTrackPublished.bind(this), + this.onLocalTrackPublished, ); } public async destroy(): Promise { + this.connection.livekitRoom.localParticipant.off( + ParticipantEvent.LocalTrackPublished, + this.onLocalTrackPublished, + ); this.scope.end(); this.logger.info("Scope ended -> unset handler"); this.muteStates.audio.unsetHandler(); @@ -106,9 +110,9 @@ export class Publisher { // So for that we use pauseUpStream(): Stops sending media to the server by replacing // the sender track with null, but keeps the local MediaStreamTrack active. // The user can still see/hear themselves locally, but remote participants see nothing. - private onLocalTrackPublished( + private onLocalTrackPublished = ( localTrackPublication: LocalTrackPublication, - ): void { + ): void => { this.logger.info("Local track published", localTrackPublication); const lkRoom = this.connection.livekitRoom; if (!this.shouldPublish) { @@ -116,6 +120,17 @@ export class Publisher { this.pauseUpstreams(lkRoom, [localTrackPublication.source]).catch((e) => { this.logger.error(`Failed to pause upstreams`, e); }); + } else { + // If startPublishing() ran before this track existed (track creation is + // not awaited and e.g. camera hardware can take a while to open), its + // resumeUpstreams() call found no track and did nothing. Resume here so + // that a track published after startPublishing() actually sends media. + // This is a no-op if the upstream is not paused. + this.resumeUpstreams(lkRoom, [localTrackPublication.source]).catch( + (e) => { + this.logger.error(`Failed to resume upstreams`, e); + }, + ); } if (localTrackPublication.source === Track.Source.Microphone) { const muteState = this.muteStates.audio;