Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 7 additions & 0 deletions packages/client/src/Call.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 <img> element to this call's thumbnail (if enabled in settings).
*
Expand Down
73 changes: 72 additions & 1 deletion packages/client/src/helpers/DynascaleManager.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<HTMLAudioElement>
>(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<VideoTrackSubscriptionOverrides>({});

Expand Down Expand Up @@ -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);
Expand Down Expand Up @@ -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.
Expand All @@ -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);
});
}
Expand Down Expand Up @@ -628,6 +670,7 @@ export class DynascaleManager {

return () => {
this.audioBindingsWatchdog?.unregister(sessionId, trackType);
this.removeBlockedAudioElement(audioElement);
sinkIdSubscription?.unsubscribe();
volumeSubscription.unsubscribe();
updateMediaStreamSubscription.unsubscribe();
Expand All @@ -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<HTMLAudioElement>();
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;
Expand Down
120 changes: 120 additions & 0 deletions packages/client/src/helpers/__tests__/DynascaleManager.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -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
Expand Down
12 changes: 12 additions & 0 deletions packages/react-bindings/src/hooks/callStateHooks.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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.
*/
Expand Down
Loading