Skip to content
Open
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
165 changes: 165 additions & 0 deletions react/features/base/sounds/SoundManager.web.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,165 @@
/*
* A minimal sound manager for Web that avoids creating one <audio> element
* per registered sound. It uses a small pool for one-shot sounds and
* on-demand elements for looping sounds.
*/

type OptionalHTMLAudioElement = HTMLAudioElement | undefined | null;

class SoundManager {
private static instance: SoundManager | undefined;

// Small pool to allow limited overlap of short notification sounds
private oneShotPool: Array<OptionalHTMLAudioElement> = [];
private poolSize: number = 3;
private nextPoolIndex: number = 0;

// Looping sounds tracked by sound id
private loopElementsById: Map<string, HTMLAudioElement> = new Map();

private currentSinkId: string | undefined;

static getInstance(): SoundManager {
if (!SoundManager.instance) {
SoundManager.instance = new SoundManager();
}

return SoundManager.instance;
}

play(soundId: string, src: string, loop: boolean): void {
if (loop) {
this.playLoop(soundId, src);

return;
}
this.playOneShot(src);
}

stop(soundId: string): void {
const el = this.loopElementsById.get(soundId);

if (el) {
try {
el.pause();
el.currentTime = 0;
} catch {
// ignore
}
this.loopElementsById.delete(soundId);
}
}

setSinkId(deviceId: string): void {
this.currentSinkId = deviceId;

// Update all managed elements where supported
for (const el of this.oneShotPool) {
this.applySinkId(el);
}

for (const el of this.loopElementsById.values()) {
this.applySinkId(el);
}
}

private playOneShot(src: string): void {
const el = this.getOrCreateOneShotElement();

if (!el) {
return;
}

try {
// Reset and set new source
el.loop = false;
el.src = src;
// Some browsers require calling load() after changing src for quick reuse
// but it is usually optional. Call to be safe and consistent.
el.load();
void el.play().catch(() => { /* ignore play rejections */ });
} catch {
// ignore
}
}

private playLoop(soundId: string, src: string): void {
let el = this.loopElementsById.get(soundId);

if (!el) {
el = this.createAudioElement();

Check failure on line 90 in react/features/base/sounds/SoundManager.web.ts

View workflow job for this annotation

GitHub Actions / Lint

Type 'OptionalHTMLAudioElement' is not assignable to type 'HTMLAudioElement | undefined'.
if (!el) {
return;
}
el.loop = true;
this.loopElementsById.set(soundId, el);
}

try {
el.src = src;
el.load();
void el.play().catch(() => { /* ignore play rejections */ });
} catch {
// ignore
}
}

private getOrCreateOneShotElement(): OptionalHTMLAudioElement {
if (this.oneShotPool.length < this.poolSize) {
const el = this.createAudioElement();

if (!el) {
return null;
}
this.oneShotPool.push(el);

return el;
}

const el = this.oneShotPool[this.nextPoolIndex];

this.nextPoolIndex = (this.nextPoolIndex + 1) % this.oneShotPool.length;

try {
// Interrupt any current playback for the chosen element
el?.pause();
if (el) {
el.currentTime = 0;
}
} catch {
// ignore
}

return el;
}

private createAudioElement(): OptionalHTMLAudioElement {
try {
const el = new Audio();

// Minimize background work when idle
el.preload = 'none';
el.autoplay = false;
// Ensure it is not visible and not attached to DOM; playback works fine off-DOM
this.applySinkId(el);

return el;
} catch {
return null;
}
}

private applySinkId(el?: HTMLAudioElement | null): void {
if (!el || !this.currentSinkId) {
return;
}
// @ts-ignore - setSinkId is not in the standard DOM typings everywhere
if (typeof el.setSinkId === 'function') {
// @ts-ignore
el.setSinkId(this.currentSinkId).catch(() => { /* ignore */ });
}
}
}

export default SoundManager.getInstance();

17 changes: 17 additions & 0 deletions react/features/base/sounds/components/SoundCollection.web.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
import { Component } from 'react';
import { connect } from 'react-redux';

import { IReduxState } from '../../../app/types';

/**
* Web override: do not render one <audio> per sound. Playback is handled by
* SoundManager via middleware.
*/
class SoundCollection extends Component {
override render() {
return null;
}
}

export default connect((_: IReduxState) => ({}))(SoundCollection);

11 changes: 5 additions & 6 deletions react/features/base/sounds/functions.web.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,11 +17,10 @@ export function getSoundsPath() {
* @returns {Function}
*/
export function setNewAudioOutputDevice(deviceId: string) {
return function(_dispatch: IStore['dispatch'], getState: IStore['getState']) {
const sounds = getState()['features/base/sounds'];

for (const [ , sound ] of sounds) {
sound.audioElement?.setSinkId?.(deviceId);
}
return function(_dispatch: IStore['dispatch'], _getState: IStore['getState']) {
// Route through SoundManager to apply sink to managed pool
import('./SoundManager.web').then(({ default: SoundManager }) => {
SoundManager.setSinkId(deviceId);
});
};
}
38 changes: 33 additions & 5 deletions react/features/base/sounds/middleware.web.ts
Original file line number Diff line number Diff line change
@@ -1,23 +1,51 @@
import { AnyAction } from 'redux';

import { IStore } from '../../app/types';
import { getAudioOutputDeviceId } from '../devices/functions.web';
import MiddlewareRegistry from '../redux/MiddlewareRegistry';

import { _ADD_AUDIO_ELEMENT } from './actionTypes';

import './middleware.any';

import SoundManager from './SoundManager.web';
import { PLAY_SOUND, REGISTER_SOUND, STOP_SOUND, _ADD_AUDIO_ELEMENT } from './actionTypes';

/**
* Implements the entry point of the middleware of the feature base/sounds.
* Web-only middleware that routes sound playback through SoundManager to
* avoid keeping many paused <audio> elements in the DOM.
*
* @param {Store} store - The redux store.
* @returns {Function}
*/
MiddlewareRegistry.register(_store => next => action => {
MiddlewareRegistry.register((store: IStore) => next => (action: AnyAction) => {
const result = next(action);

switch (action.type) {
case PLAY_SOUND: {
const { soundId } = action;
const sounds = store.getState()['features/base/sounds'];
const sound = sounds.get(soundId);

if (sound?.src) {
const loop = Boolean(sound.options?.loop);

SoundManager.play(soundId, String(sound.src), loop);
}
break;
}
case STOP_SOUND: {
const { soundId } = action;

SoundManager.stop(soundId);
break;
}
case REGISTER_SOUND: {
// No-op; elements are created on demand by SoundManager
break;
}
case _ADD_AUDIO_ELEMENT:
action.audioElement?.setSinkId?.(getAudioOutputDeviceId());
break;
}

return next(action);
return result;
});
Loading