Compare commits

...

8 Commits

Author SHA1 Message Date
Timo K.
10296f98c1 Update CallViewModel.ts 2026-07-08 18:29:09 +02:00
Timo K.
22e18e615d Merge branch 'livekit' into fix/publish-before-tracks-race 2026-07-08 18:19:44 +02:00
Timo K.
c3f61e2d83 Merge branch 'livekit' into fix/publish-before-tracks-race 2026-07-08 15:58:08 +02:00
Timo K.
99e792e6fd fmt 2026-07-08 13:27:23 +02:00
Timo K.
63f5af80da no child logger for Publsiher 2026-07-07 18:32:41 +02:00
Timo K.
ba2aa6ff5a add logs 2026-07-07 17:18:44 +02:00
Timo K.
3c1d7a0119 Update Publisher.ts 2026-07-07 17:17:54 +02:00
Timo K.
2e1a8bb039 Fix tracks not being resumed in case of a startPublishing vs track
creating race.
2026-07-07 14:02:37 +02:00
2 changed files with 65 additions and 4 deletions

View File

@@ -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<void>();
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();

View File

@@ -80,11 +80,15 @@ export class Publisher {
this.connection.livekitRoom.localParticipant.on(
ParticipantEvent.LocalTrackPublished,
this.onLocalTrackPublished.bind(this),
this.onLocalTrackPublished,
);
}
public async destroy(): Promise<void> {
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,16 @@ export class Publisher {
this.pauseUpstreams(lkRoom, [localTrackPublication.source]).catch((e) => {
this.logger.error(`Failed to pause upstreams`, e);
});
} else {
this.logger.info(`resumeUpstream onLocalTrackPublished`);
// 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.
localTrackPublication.resumeUpstream().catch((e) => {
this.logger.error(`Failed to resume upstreams`, e);
});
}
if (localTrackPublication.source === Track.Source.Microphone) {
const muteState = this.muteStates.audio;
@@ -156,7 +170,7 @@ export class Publisher {
}
}
}
}
};
/**
* Create and setup local audio and video tracks based on the current mute states.
* It creates the tracks only if audio and/or video is enabled, to avoid unnecessary