Skip to content

Commit 51954df

Browse files
committed
fix: Fixed issue with mute toggle states being delayed
Fixed an issue in the Daily Transport and MediaManager where the mic/camEnabled flag was stale in the track-started/stopped events. Previously, the flag was based on daily's localVideo()/Audio(), but those have not yet updated at the moment track-started/stopped are called, so if you rely on those in your handler, the state is wrong.
1 parent 4f25bbc commit 51954df

2 files changed

Lines changed: 83 additions & 8 deletions

File tree

lib/media-mgmt/dailyMediaManager.ts

Lines changed: 33 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -285,11 +285,20 @@ export class DailyMediaManager extends MediaManager {
285285
}
286286
}
287287

288+
// NOTE: deliberately not `this._daily.localVideo()` / `localAudio()`. Daily
289+
// fires 'track-started'/'track-stopped' *before* it commits the new state
290+
// to the participant object those getters read from (see daily-js's
291+
// DAILY_EVENT_PARTICIPANT_UPDATED handling: maybeEventTrackStopped/Started
292+
// run, then `this._participants[id]` is reassigned). Callers that react to
293+
// TrackStarted/TrackStopped by reading isCamEnabled/isMicEnabled — e.g. to
294+
// resync UI state — would otherwise always observe the pre-toggle value.
295+
// _camEnabled/_micEnabled are updated synchronously in
296+
// handleTrackStarted/handleTrackStopped below, before those callbacks fire.
288297
get isCamEnabled(): boolean {
289-
return this._daily.localVideo();
298+
return this._camEnabled;
290299
}
291300
get isMicEnabled(): boolean {
292-
return this._daily.localAudio();
301+
return this._micEnabled;
293302
}
294303
get isSharingScreen(): boolean {
295304
return this._daily.localScreenAudio() || this._daily.localScreenVideo();
@@ -401,7 +410,22 @@ export class DailyMediaManager extends MediaManager {
401410
}
402411
}
403412
};
404-
this._callbacks.onDeviceError?.(generateDeviceError(ev.error));
413+
const deviceError = generateDeviceError(ev.error);
414+
// enableMic()/enableCam() set _micEnabled/_camEnabled optimistically,
415+
// but setLocalAudio()/setLocalVideo() are fire-and-forget — no promise,
416+
// no synchronous failure signal — so a failed request (blocked
417+
// permission, device in use, etc.) leaves that optimistic value wrong
418+
// with nothing to correct it: no track-started/stopped ever fires for
419+
// an operation that didn't actually take effect. This is the only
420+
// signal we get that the request failed, so resync from Daily's actual
421+
// state for whichever device(s) it implicates.
422+
if (deviceError.devices.includes("mic")) {
423+
this._micEnabled = this._daily.localAudio();
424+
}
425+
if (deviceError.devices.includes("cam")) {
426+
this._camEnabled = this._daily.localVideo();
427+
}
428+
this._callbacks.onDeviceError?.(deviceError);
405429
}
406430

407431
private _handleLocalAudioLevel(ev: DailyEventObjectLocalAudioLevel) {
@@ -415,6 +439,7 @@ export class DailyMediaManager extends MediaManager {
415439
protected async handleTrackStarted(event: DailyEventObjectTrack) {
416440
if (!event.participant?.local) return;
417441
if (event.track.kind === "audio") {
442+
this._micEnabled = true;
418443
if (this._mediaStreamRecorder) {
419444
const status = this._mediaStreamRecorder.getStatus();
420445
switch (status) {
@@ -454,6 +479,8 @@ export class DailyMediaManager extends MediaManager {
454479
}
455480
}
456481
this._currentAudioTrack = event.track;
482+
} else if (event.track.kind === "video") {
483+
this._camEnabled = true;
457484
}
458485
this._callbacks.onTrackStarted?.(
459486
event.track,
@@ -467,12 +494,15 @@ export class DailyMediaManager extends MediaManager {
467494
protected handleTrackStopped(event: DailyEventObjectTrack) {
468495
if (!event.participant?.local) return;
469496
if (event.track.kind === "audio") {
497+
this._micEnabled = false;
470498
if (
471499
this._mediaStreamRecorder &&
472500
this._mediaStreamRecorder.getStatus() === "recording"
473501
) {
474502
this._mediaStreamRecorder.pause();
475503
}
504+
} else if (event.track.kind === "video") {
505+
this._camEnabled = false;
476506
}
477507
this._callbacks.onTrackStopped?.(
478508
event.track,

transports/daily/src/transport.ts

Lines changed: 50 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -130,6 +130,20 @@ export class DailyTransport extends Transport {
130130
private _audioQueue: ArrayBuffer[] = [];
131131
declare private _mediaStreamRecorder: MediaStreamRecorder;
132132

133+
// NOTE: deliberately not read live off `this._daily.localAudio()` /
134+
// `localVideo()` in the isMicEnabled/isCamEnabled getters below. Daily
135+
// fires 'track-started'/'track-stopped' *before* it commits the new state
136+
// to the participant object those calls read from (daily-js emits the
137+
// track event from within its participant-updated handling, then
138+
// reassigns its internal participant cache afterwards). Code that reacts
139+
// to TrackStarted/TrackStopped by reading isMicEnabled/isCamEnabled to
140+
// resync UI state — e.g. PipecatClientState's mic/cam sync — would
141+
// otherwise always observe the pre-toggle value. These fields are updated
142+
// synchronously in enableMic/enableCam and in handleTrackStarted/
143+
// handleTrackStopped, before those callbacks fire.
144+
private _micEnabled: boolean = true;
145+
private _camEnabled: boolean = false;
146+
133147
constructor(opts: DailyTransportConstructorOptions = {}) {
134148
super();
135149

@@ -236,6 +250,8 @@ export class DailyTransport extends Transport {
236250
// Default is mic on
237251
this._dailyFactoryOptions.startAudioOff = !(options.enableMic ?? true);
238252
}
253+
this._camEnabled = !this._dailyFactoryOptions.startVideoOff;
254+
this._micEnabled = !this._dailyFactoryOptions.startAudioOff;
239255

240256
this.attachEventListeners();
241257

@@ -330,24 +346,26 @@ export class DailyTransport extends Transport {
330346
// joined — pre-session, daily-js can't act on the request because the
331347
// call object isn't part of a meeting yet.
332348
this._dailyFactoryOptions.startAudioOff = !enable;
349+
this._micEnabled = enable;
333350
if (this._daily.participants()?.local) {
334351
this._daily.setLocalAudio(enable);
335352
}
336353
}
337354

338355
get isMicEnabled() {
339-
return this._daily.localAudio();
356+
return this._micEnabled;
340357
}
341358

342359
enableCam(enable: boolean) {
343360
this._dailyFactoryOptions.startVideoOff = !enable;
361+
this._camEnabled = enable;
344362
if (this._daily.participants()?.local) {
345363
this._daily.setLocalVideo(enable);
346364
}
347365
}
348366

349367
get isCamEnabled() {
350-
return this._daily.localVideo();
368+
return this._camEnabled;
351369
}
352370

353371
public enableScreenShare(enable: boolean) {
@@ -719,7 +737,22 @@ export class DailyTransport extends Transport {
719737
}
720738
}
721739
};
722-
this._callbacks.onDeviceError?.(generateDeviceError(ev.error));
740+
const deviceError = generateDeviceError(ev.error);
741+
// enableMic()/enableCam() set _micEnabled/_camEnabled optimistically,
742+
// but setLocalAudio()/setLocalVideo() are fire-and-forget — no promise,
743+
// no synchronous failure signal — so a failed request (blocked
744+
// permission, device in use, etc.) leaves that optimistic value wrong
745+
// with nothing to correct it: no track-started/stopped ever fires for
746+
// an operation that didn't actually take effect. This is the only
747+
// signal we get that the request failed, so resync from Daily's actual
748+
// state for whichever device(s) it implicates.
749+
if (deviceError.devices.includes("mic")) {
750+
this._micEnabled = this._daily.localAudio();
751+
}
752+
if (deviceError.devices.includes("cam")) {
753+
this._camEnabled = this._daily.localVideo();
754+
}
755+
this._callbacks.onDeviceError?.(deviceError);
723756
}
724757

725758
private async handleLocalAudioTrack(track: MediaStreamTrack) {
@@ -768,8 +801,13 @@ export class DailyTransport extends Transport {
768801
: undefined
769802
);
770803
} else {
771-
if (ev.participant?.local && ev.track.kind === "audio") {
772-
void this.handleLocalAudioTrack(ev.track);
804+
if (ev.participant?.local) {
805+
if (ev.track.kind === "audio") {
806+
this._micEnabled = true;
807+
void this.handleLocalAudioTrack(ev.track);
808+
} else if (ev.track.kind === "video") {
809+
this._camEnabled = true;
810+
}
773811
}
774812
this._callbacks.onTrackStarted?.(
775813
ev.track,
@@ -789,6 +827,13 @@ export class DailyTransport extends Transport {
789827
: undefined
790828
);
791829
} else {
830+
if (ev.participant?.local) {
831+
if (ev.track.kind === "audio") {
832+
this._micEnabled = false;
833+
} else if (ev.track.kind === "video") {
834+
this._camEnabled = false;
835+
}
836+
}
792837
this._callbacks.onTrackStopped?.(
793838
ev.track,
794839
ev.participant

0 commit comments

Comments
 (0)