Skip to content

Commit adbec63

Browse files
authored
feat(client): expose blocked autoplay audio state and explicit resume API (#2187)
### 💡 Overview This PR introduces a way to recover when incoming audio is blocked by browser autoplay rules. It tracks blocked audio, exposes that state to consumers, and adds `call.resumeAudio()` so they can retry playback after a user interaction. It also includes a test for the case where the audio stream is removed before playback resumes. ### 📝 Implementation notes - invoke `call.resumeAudio()` for explicit retry from a user gesture - use `useIsAutoplayBlocked()` from call state hooks to detect blocked incoming audio 🎫 Ticket: https://linear.app/stream/issue/REACT-941/audio-autoplay-policy-observable 📑 Docs: GetStream/docs-content#1155 <!-- This is an auto-generated comment: release notes by coderabbit.ai --> ## Summary by CodeRabbit * **New Features** * Detects browser autoplay-blocked audio and exposes a boolean observable to track it. * Provides a public resume operation to retry playback for blocked audio elements. * Adds a React hook to surface autoplay-blocked state for UI integration. * **Tests** * Adds tests covering detection, recovery, and cleanup of autoplay-blocked audio flows. <!-- end of auto-generated comment: release notes by coderabbit.ai -->
1 parent 5c642ce commit adbec63

4 files changed

Lines changed: 211 additions & 1 deletion

File tree

packages/client/src/Call.ts

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2922,6 +2922,13 @@ export class Call {
29222922
};
29232923
};
29242924

2925+
/**
2926+
* Plays all audio elements blocked by the browser's autoplay policy.
2927+
*/
2928+
resumeAudio = () => {
2929+
return this.dynascaleManager.resumeAudio();
2930+
};
2931+
29252932
/**
29262933
* Binds a DOM <img> element to this call's thumbnail (if enabled in settings).
29272934
*

packages/client/src/helpers/DynascaleManager.ts

Lines changed: 72 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -79,6 +79,40 @@ export class DynascaleManager {
7979
private pendingSubscriptionsUpdate: NodeJS.Timeout | null = null;
8080
readonly audioBindingsWatchdog: AudioBindingsWatchdog | undefined;
8181

82+
/**
83+
* Audio elements that were blocked by the browser's autoplay policy.
84+
* These can be retried by calling `resumeAudio()` from a user gesture.
85+
*/
86+
private blockedAudioElementsSubject = new BehaviorSubject<
87+
Set<HTMLAudioElement>
88+
>(new Set());
89+
90+
/**
91+
* Whether the browser's autoplay policy is blocking audio playback.
92+
* Will be `true` when the browser blocks autoplay (e.g., no prior user interaction).
93+
* Use `resumeAudio()` within a user gesture to unblock.
94+
*/
95+
autoplayBlocked$ = this.blockedAudioElementsSubject.pipe(
96+
map((elements) => elements.size > 0),
97+
distinctUntilChanged(),
98+
);
99+
100+
private addBlockedAudioElement = (audioElement: HTMLAudioElement) => {
101+
setCurrentValue(this.blockedAudioElementsSubject, (elements) => {
102+
const next = new Set(elements);
103+
next.add(audioElement);
104+
return next;
105+
});
106+
};
107+
108+
private removeBlockedAudioElement = (audioElement: HTMLAudioElement) => {
109+
setCurrentValue(this.blockedAudioElementsSubject, (elements) => {
110+
const nextElements = new Set(elements);
111+
nextElements.delete(audioElement);
112+
return nextElements;
113+
});
114+
};
115+
82116
private videoTrackSubscriptionOverridesSubject =
83117
new BehaviorSubject<VideoTrackSubscriptionOverrides>({});
84118

@@ -136,6 +170,7 @@ export class DynascaleManager {
136170
clearTimeout(this.pendingSubscriptionsUpdate);
137171
}
138172
this.audioBindingsWatchdog?.dispose();
173+
setCurrentValue(this.blockedAudioElementsSubject, new Set());
139174
const context = this.audioContext;
140175
if (context && context.state !== 'closed') {
141176
document.removeEventListener('click', this.resumeAudioContext);
@@ -575,7 +610,10 @@ export class DynascaleManager {
575610

576611
setTimeout(() => {
577612
audioElement.srcObject = source ?? null;
578-
if (!source) return;
613+
if (!source) {
614+
this.removeBlockedAudioElement(audioElement);
615+
return;
616+
}
579617

580618
// Safari has a special quirk that prevents playing audio until the user
581619
// interacts with the page or focuses on the tab where the call happens.
@@ -599,6 +637,10 @@ export class DynascaleManager {
599637
audioElement.muted = false;
600638
audioElement.play().catch((e) => {
601639
this.tracer.trace('audioPlaybackError', e.message);
640+
if (e.name === 'NotAllowedError') {
641+
this.tracer.trace('audioPlaybackBlocked', null);
642+
this.addBlockedAudioElement(audioElement);
643+
}
602644
this.logger.warn(`Failed to play audio stream`, e);
603645
});
604646
}
@@ -628,6 +670,7 @@ export class DynascaleManager {
628670

629671
return () => {
630672
this.audioBindingsWatchdog?.unregister(sessionId, trackType);
673+
this.removeBlockedAudioElement(audioElement);
631674
sinkIdSubscription?.unsubscribe();
632675
volumeSubscription.unsubscribe();
633676
updateMediaStreamSubscription.unsubscribe();
@@ -637,6 +680,34 @@ export class DynascaleManager {
637680
};
638681
};
639682

683+
/**
684+
* Plays all audio elements blocked by the browser's autoplay policy.
685+
* Must be called from within a user gesture (e.g., click handler).
686+
*
687+
* @returns a promise that resolves when all blocked elements have been retried.
688+
*/
689+
resumeAudio = async () => {
690+
this.tracer.trace('resumeAudio', null);
691+
const blocked = new Set<HTMLAudioElement>();
692+
await Promise.all(
693+
Array.from(
694+
getCurrentValue(this.blockedAudioElementsSubject),
695+
async (el) => {
696+
try {
697+
if (el.srcObject) {
698+
await el.play();
699+
}
700+
} catch {
701+
this.logger.warn(`Can't resume audio for element: `, el);
702+
blocked.add(el);
703+
}
704+
},
705+
),
706+
);
707+
708+
setCurrentValue(this.blockedAudioElementsSubject, blocked);
709+
};
710+
640711
private getOrCreateAudioContext = (): AudioContext | undefined => {
641712
if (!this.useWebAudio) return;
642713
if (this.audioContext) return this.audioContext;

packages/client/src/helpers/__tests__/DynascaleManager.test.ts

Lines changed: 120 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,7 @@ import { DynascaleManager } from '../DynascaleManager';
1818
import { Call } from '../../Call';
1919
import { StreamClient } from '../../coordinator/connection/client';
2020
import { StreamVideoWriteableStateStore } from '../../store';
21+
import { getCurrentValue } from '../../store/rxUtils';
2122
import { VisibilityState } from '../../types';
2223
import { noopComparator } from '../../sorting';
2324
import { TrackType } from '../../gen/video/sfu/models/models';
@@ -647,6 +648,125 @@ describe('DynascaleManager', () => {
647648
expect(unregisterSpy).toHaveBeenCalledWith('session-id', 'audioTrack');
648649
});
649650

651+
it('audio: should track blocked audio elements on NotAllowedError', async () => {
652+
vi.useFakeTimers();
653+
const audioElement = document.createElement('audio');
654+
Object.defineProperty(audioElement, 'srcObject', { writable: true });
655+
const notAllowedError = new DOMException('', 'NotAllowedError');
656+
vi.spyOn(audioElement, 'play').mockRejectedValue(notAllowedError);
657+
658+
// @ts-expect-error incomplete data
659+
call.state.updateOrAddParticipant('session-id', {
660+
userId: 'user-id',
661+
sessionId: 'session-id',
662+
publishedTracks: [],
663+
});
664+
665+
const cleanup = dynascaleManager.bindAudioElement(
666+
audioElement,
667+
'session-id',
668+
'audioTrack',
669+
);
670+
671+
const mediaStream = new MediaStream();
672+
call.state.updateParticipant('session-id', {
673+
audioStream: mediaStream,
674+
});
675+
676+
vi.runAllTimers();
677+
await vi.advanceTimersByTimeAsync(0);
678+
679+
expect(getCurrentValue(dynascaleManager.autoplayBlocked$)).toBe(true);
680+
681+
cleanup?.();
682+
expect(getCurrentValue(dynascaleManager.autoplayBlocked$)).toBe(false);
683+
});
684+
685+
it('audio: should unblock audio elements on explicit resumeAudio call', async () => {
686+
vi.useFakeTimers();
687+
const audioElement = document.createElement('audio');
688+
Object.defineProperty(audioElement, 'srcObject', { writable: true });
689+
const playSpy = vi
690+
.spyOn(audioElement, 'play')
691+
.mockRejectedValueOnce(new DOMException('', 'NotAllowedError'))
692+
.mockResolvedValue(undefined);
693+
694+
// @ts-expect-error incomplete data
695+
call.state.updateOrAddParticipant('session-id', {
696+
userId: 'user-id',
697+
sessionId: 'session-id',
698+
publishedTracks: [],
699+
});
700+
701+
const cleanup = dynascaleManager.bindAudioElement(
702+
audioElement,
703+
'session-id',
704+
'audioTrack',
705+
);
706+
707+
const mediaStream = new MediaStream();
708+
call.state.updateParticipant('session-id', {
709+
audioStream: mediaStream,
710+
});
711+
712+
vi.runAllTimers();
713+
await vi.advanceTimersByTimeAsync(0);
714+
715+
expect(getCurrentValue(dynascaleManager.autoplayBlocked$)).toBe(true);
716+
717+
await dynascaleManager.resumeAudio();
718+
await vi.advanceTimersByTimeAsync(0);
719+
720+
expect(playSpy).toHaveBeenCalledTimes(2);
721+
expect(getCurrentValue(dynascaleManager.autoplayBlocked$)).toBe(false);
722+
723+
cleanup?.();
724+
});
725+
726+
it('audio: should clear blocked state when the audio stream is removed', async () => {
727+
vi.useFakeTimers();
728+
const audioElement = document.createElement('audio');
729+
Object.defineProperty(audioElement, 'srcObject', { writable: true });
730+
vi.spyOn(audioElement, 'play').mockRejectedValue(
731+
new DOMException('', 'NotAllowedError'),
732+
);
733+
734+
// @ts-expect-error incomplete data
735+
call.state.updateOrAddParticipant('session-id', {
736+
userId: 'user-id',
737+
sessionId: 'session-id',
738+
publishedTracks: [],
739+
});
740+
741+
const cleanup = dynascaleManager.bindAudioElement(
742+
audioElement,
743+
'session-id',
744+
'audioTrack',
745+
);
746+
747+
const mediaStream = new MediaStream();
748+
call.state.updateParticipant('session-id', {
749+
audioStream: mediaStream,
750+
});
751+
752+
vi.runAllTimers();
753+
await vi.advanceTimersByTimeAsync(0);
754+
755+
expect(getCurrentValue(dynascaleManager.autoplayBlocked$)).toBe(true);
756+
757+
call.state.updateParticipant('session-id', {
758+
audioStream: undefined,
759+
});
760+
761+
vi.runAllTimers();
762+
await vi.advanceTimersByTimeAsync(0);
763+
764+
expect(audioElement.srcObject).toBeNull();
765+
expect(getCurrentValue(dynascaleManager.autoplayBlocked$)).toBe(false);
766+
767+
cleanup?.();
768+
});
769+
650770
it('audio: should warn when binding an already-bound session', () => {
651771
const watchdog = dynascaleManager.audioBindingsWatchdog!;
652772
// @ts-expect-error private property

packages/react-bindings/src/hooks/callStateHooks.ts

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -496,6 +496,18 @@ export const useIncomingVideoSettings = () => {
496496
return useObservableValue(call.dynascaleManager.incomingVideoSettings$);
497497
};
498498

499+
/**
500+
* Returns whether the browser's autoplay policy is blocking audio playback.
501+
*
502+
* When the browser blocks audio autoplay (e.g., no prior user interaction),
503+
* this hook returns `true`. Use `call.resumeAudio()` inside a click handler
504+
* to unblock audio playback.
505+
*/
506+
export const useIsAutoplayBlocked = (): boolean => {
507+
const call = useCall() as Call;
508+
return useObservableValue(call.dynascaleManager.autoplayBlocked$);
509+
};
510+
499511
/**
500512
* Returns the current call's closed captions queue.
501513
*/

0 commit comments

Comments
 (0)