diff --git a/packages/client/src/Call.ts b/packages/client/src/Call.ts
index 92ac8c5a67..225f8cf44b 100644
--- a/packages/client/src/Call.ts
+++ b/packages/client/src/Call.ts
@@ -2922,6 +2922,13 @@ export class Call {
};
};
+ /**
+ * Plays all audio elements blocked by the browser's autoplay policy.
+ */
+ resumeAudio = () => {
+ return this.dynascaleManager.resumeAudio();
+ };
+
/**
* Binds a DOM
element to this call's thumbnail (if enabled in settings).
*
diff --git a/packages/client/src/helpers/DynascaleManager.ts b/packages/client/src/helpers/DynascaleManager.ts
index 1b46fdee94..aa00b9933b 100644
--- a/packages/client/src/helpers/DynascaleManager.ts
+++ b/packages/client/src/helpers/DynascaleManager.ts
@@ -79,6 +79,40 @@ export class DynascaleManager {
private pendingSubscriptionsUpdate: NodeJS.Timeout | null = null;
readonly audioBindingsWatchdog: AudioBindingsWatchdog | undefined;
+ /**
+ * Audio elements that were blocked by the browser's autoplay policy.
+ * These can be retried by calling `resumeAudio()` from a user gesture.
+ */
+ private blockedAudioElementsSubject = new BehaviorSubject<
+ Set
+ >(new Set());
+
+ /**
+ * Whether the browser's autoplay policy is blocking audio playback.
+ * Will be `true` when the browser blocks autoplay (e.g., no prior user interaction).
+ * Use `resumeAudio()` within a user gesture to unblock.
+ */
+ autoplayBlocked$ = this.blockedAudioElementsSubject.pipe(
+ map((elements) => elements.size > 0),
+ distinctUntilChanged(),
+ );
+
+ private addBlockedAudioElement = (audioElement: HTMLAudioElement) => {
+ setCurrentValue(this.blockedAudioElementsSubject, (elements) => {
+ const next = new Set(elements);
+ next.add(audioElement);
+ return next;
+ });
+ };
+
+ private removeBlockedAudioElement = (audioElement: HTMLAudioElement) => {
+ setCurrentValue(this.blockedAudioElementsSubject, (elements) => {
+ const nextElements = new Set(elements);
+ nextElements.delete(audioElement);
+ return nextElements;
+ });
+ };
+
private videoTrackSubscriptionOverridesSubject =
new BehaviorSubject({});
@@ -136,6 +170,7 @@ export class DynascaleManager {
clearTimeout(this.pendingSubscriptionsUpdate);
}
this.audioBindingsWatchdog?.dispose();
+ setCurrentValue(this.blockedAudioElementsSubject, new Set());
const context = this.audioContext;
if (context && context.state !== 'closed') {
document.removeEventListener('click', this.resumeAudioContext);
@@ -575,7 +610,10 @@ export class DynascaleManager {
setTimeout(() => {
audioElement.srcObject = source ?? null;
- if (!source) return;
+ if (!source) {
+ this.removeBlockedAudioElement(audioElement);
+ return;
+ }
// Safari has a special quirk that prevents playing audio until the user
// interacts with the page or focuses on the tab where the call happens.
@@ -599,6 +637,10 @@ export class DynascaleManager {
audioElement.muted = false;
audioElement.play().catch((e) => {
this.tracer.trace('audioPlaybackError', e.message);
+ if (e.name === 'NotAllowedError') {
+ this.tracer.trace('audioPlaybackBlocked', null);
+ this.addBlockedAudioElement(audioElement);
+ }
this.logger.warn(`Failed to play audio stream`, e);
});
}
@@ -628,6 +670,7 @@ export class DynascaleManager {
return () => {
this.audioBindingsWatchdog?.unregister(sessionId, trackType);
+ this.removeBlockedAudioElement(audioElement);
sinkIdSubscription?.unsubscribe();
volumeSubscription.unsubscribe();
updateMediaStreamSubscription.unsubscribe();
@@ -637,6 +680,34 @@ export class DynascaleManager {
};
};
+ /**
+ * Plays all audio elements blocked by the browser's autoplay policy.
+ * Must be called from within a user gesture (e.g., click handler).
+ *
+ * @returns a promise that resolves when all blocked elements have been retried.
+ */
+ resumeAudio = async () => {
+ this.tracer.trace('resumeAudio', null);
+ const blocked = new Set();
+ await Promise.all(
+ Array.from(
+ getCurrentValue(this.blockedAudioElementsSubject),
+ async (el) => {
+ try {
+ if (el.srcObject) {
+ await el.play();
+ }
+ } catch {
+ this.logger.warn(`Can't resume audio for element: `, el);
+ blocked.add(el);
+ }
+ },
+ ),
+ );
+
+ setCurrentValue(this.blockedAudioElementsSubject, blocked);
+ };
+
private getOrCreateAudioContext = (): AudioContext | undefined => {
if (!this.useWebAudio) return;
if (this.audioContext) return this.audioContext;
diff --git a/packages/client/src/helpers/__tests__/DynascaleManager.test.ts b/packages/client/src/helpers/__tests__/DynascaleManager.test.ts
index 0bdeaa6f6d..96c63037d0 100644
--- a/packages/client/src/helpers/__tests__/DynascaleManager.test.ts
+++ b/packages/client/src/helpers/__tests__/DynascaleManager.test.ts
@@ -18,6 +18,7 @@ import { DynascaleManager } from '../DynascaleManager';
import { Call } from '../../Call';
import { StreamClient } from '../../coordinator/connection/client';
import { StreamVideoWriteableStateStore } from '../../store';
+import { getCurrentValue } from '../../store/rxUtils';
import { VisibilityState } from '../../types';
import { noopComparator } from '../../sorting';
import { TrackType } from '../../gen/video/sfu/models/models';
@@ -647,6 +648,125 @@ describe('DynascaleManager', () => {
expect(unregisterSpy).toHaveBeenCalledWith('session-id', 'audioTrack');
});
+ it('audio: should track blocked audio elements on NotAllowedError', async () => {
+ vi.useFakeTimers();
+ const audioElement = document.createElement('audio');
+ Object.defineProperty(audioElement, 'srcObject', { writable: true });
+ const notAllowedError = new DOMException('', 'NotAllowedError');
+ vi.spyOn(audioElement, 'play').mockRejectedValue(notAllowedError);
+
+ // @ts-expect-error incomplete data
+ call.state.updateOrAddParticipant('session-id', {
+ userId: 'user-id',
+ sessionId: 'session-id',
+ publishedTracks: [],
+ });
+
+ const cleanup = dynascaleManager.bindAudioElement(
+ audioElement,
+ 'session-id',
+ 'audioTrack',
+ );
+
+ const mediaStream = new MediaStream();
+ call.state.updateParticipant('session-id', {
+ audioStream: mediaStream,
+ });
+
+ vi.runAllTimers();
+ await vi.advanceTimersByTimeAsync(0);
+
+ expect(getCurrentValue(dynascaleManager.autoplayBlocked$)).toBe(true);
+
+ cleanup?.();
+ expect(getCurrentValue(dynascaleManager.autoplayBlocked$)).toBe(false);
+ });
+
+ it('audio: should unblock audio elements on explicit resumeAudio call', async () => {
+ vi.useFakeTimers();
+ const audioElement = document.createElement('audio');
+ Object.defineProperty(audioElement, 'srcObject', { writable: true });
+ const playSpy = vi
+ .spyOn(audioElement, 'play')
+ .mockRejectedValueOnce(new DOMException('', 'NotAllowedError'))
+ .mockResolvedValue(undefined);
+
+ // @ts-expect-error incomplete data
+ call.state.updateOrAddParticipant('session-id', {
+ userId: 'user-id',
+ sessionId: 'session-id',
+ publishedTracks: [],
+ });
+
+ const cleanup = dynascaleManager.bindAudioElement(
+ audioElement,
+ 'session-id',
+ 'audioTrack',
+ );
+
+ const mediaStream = new MediaStream();
+ call.state.updateParticipant('session-id', {
+ audioStream: mediaStream,
+ });
+
+ vi.runAllTimers();
+ await vi.advanceTimersByTimeAsync(0);
+
+ expect(getCurrentValue(dynascaleManager.autoplayBlocked$)).toBe(true);
+
+ await dynascaleManager.resumeAudio();
+ await vi.advanceTimersByTimeAsync(0);
+
+ expect(playSpy).toHaveBeenCalledTimes(2);
+ expect(getCurrentValue(dynascaleManager.autoplayBlocked$)).toBe(false);
+
+ cleanup?.();
+ });
+
+ it('audio: should clear blocked state when the audio stream is removed', async () => {
+ vi.useFakeTimers();
+ const audioElement = document.createElement('audio');
+ Object.defineProperty(audioElement, 'srcObject', { writable: true });
+ vi.spyOn(audioElement, 'play').mockRejectedValue(
+ new DOMException('', 'NotAllowedError'),
+ );
+
+ // @ts-expect-error incomplete data
+ call.state.updateOrAddParticipant('session-id', {
+ userId: 'user-id',
+ sessionId: 'session-id',
+ publishedTracks: [],
+ });
+
+ const cleanup = dynascaleManager.bindAudioElement(
+ audioElement,
+ 'session-id',
+ 'audioTrack',
+ );
+
+ const mediaStream = new MediaStream();
+ call.state.updateParticipant('session-id', {
+ audioStream: mediaStream,
+ });
+
+ vi.runAllTimers();
+ await vi.advanceTimersByTimeAsync(0);
+
+ expect(getCurrentValue(dynascaleManager.autoplayBlocked$)).toBe(true);
+
+ call.state.updateParticipant('session-id', {
+ audioStream: undefined,
+ });
+
+ vi.runAllTimers();
+ await vi.advanceTimersByTimeAsync(0);
+
+ expect(audioElement.srcObject).toBeNull();
+ expect(getCurrentValue(dynascaleManager.autoplayBlocked$)).toBe(false);
+
+ cleanup?.();
+ });
+
it('audio: should warn when binding an already-bound session', () => {
const watchdog = dynascaleManager.audioBindingsWatchdog!;
// @ts-expect-error private property
diff --git a/packages/react-bindings/src/hooks/callStateHooks.ts b/packages/react-bindings/src/hooks/callStateHooks.ts
index 550c021a5b..c53e4e0548 100644
--- a/packages/react-bindings/src/hooks/callStateHooks.ts
+++ b/packages/react-bindings/src/hooks/callStateHooks.ts
@@ -496,6 +496,18 @@ export const useIncomingVideoSettings = () => {
return useObservableValue(call.dynascaleManager.incomingVideoSettings$);
};
+/**
+ * Returns whether the browser's autoplay policy is blocking audio playback.
+ *
+ * When the browser blocks audio autoplay (e.g., no prior user interaction),
+ * this hook returns `true`. Use `call.resumeAudio()` inside a click handler
+ * to unblock audio playback.
+ */
+export const useIsAutoplayBlocked = (): boolean => {
+ const call = useCall() as Call;
+ return useObservableValue(call.dynascaleManager.autoplayBlocked$);
+};
+
/**
* Returns the current call's closed captions queue.
*/