mirror of
https://github.com/fluxerapp/fluxer
synced 2026-10-18 21:45:31 +09:00
feat(sound): play one-shot sounds immediately and abortably (#2372)
This commit is contained in:
1 parent
453abfa145
commit
dfe42ae3e3
3 files changed
+103
-17
No files matched your search
@@ -350,8 +350,11 @@ const playOneShotSound = async (
|
|||||||
type: SoundType,
|
type: SoundType,
|
||||||
volume: number,
|
volume: number,
|
||||||
onAutoplayBlocked?: () => void,
|
onAutoplayBlocked?: () => void,
|
||||||
|
signal?: AbortSignal,
|
||||||
): Promise<HTMLAudioElement | null> => {
|
): Promise<HTMLAudioElement | null> => {
|
||||||
|
if (signal?.aborted) return null;
|
||||||
const soundUrl = await getSoundUrl(type);
|
const soundUrl = await getSoundUrl(type);
|
||||||
|
if (signal?.aborted) return null;
|
||||||
const audio = createAudioElement(soundUrl);
|
const audio = createAudioElement(soundUrl);
|
||||||
audio.currentTime = 0;
|
audio.currentTime = 0;
|
||||||
audio.loop = false;
|
audio.loop = false;
|
||||||
@@ -361,6 +364,7 @@ const playOneShotSound = async (
|
|||||||
const routeToCaptureBus = shouldRouteSoundToCaptureBus(type);
|
const routeToCaptureBus = shouldRouteSoundToCaptureBus(type);
|
||||||
try {
|
try {
|
||||||
const ctx = await resumeAudioContextIfNeeded();
|
const ctx = await resumeAudioContextIfNeeded();
|
||||||
|
if (signal?.aborted) return null;
|
||||||
if (ctx.state !== 'suspended') {
|
if (ctx.state !== 'suspended') {
|
||||||
sourceNode = ctx.createMediaElementSource(audio);
|
sourceNode = ctx.createMediaElementSource(audio);
|
||||||
gainNode = ctx.createGain();
|
gainNode = ctx.createGain();
|
||||||
@@ -377,26 +381,49 @@ const playOneShotSound = async (
|
|||||||
audio.volume = effectiveVolume;
|
audio.volume = effectiveVolume;
|
||||||
applyOutputDeviceToAudioElement(audio);
|
applyOutputDeviceToAudioElement(audio);
|
||||||
}
|
}
|
||||||
|
let cleanedUp = false;
|
||||||
|
let abortPlayback: (() => void) | null = null;
|
||||||
const instance: OneShotAudioInstance = {
|
const instance: OneShotAudioInstance = {
|
||||||
audio,
|
audio,
|
||||||
gainNode,
|
gainNode,
|
||||||
sourceNode,
|
sourceNode,
|
||||||
cleanup: () => {
|
cleanup: () => {
|
||||||
|
if (cleanedUp) return;
|
||||||
|
cleanedUp = true;
|
||||||
activeOneShotSounds.delete(instance);
|
activeOneShotSounds.delete(instance);
|
||||||
audio.removeEventListener('ended', instance.cleanup);
|
audio.removeEventListener('ended', instance.cleanup);
|
||||||
audio.removeEventListener('error', instance.cleanup);
|
audio.removeEventListener('error', instance.cleanup);
|
||||||
|
if (abortPlayback) signal?.removeEventListener('abort', abortPlayback);
|
||||||
disconnectNodes(sourceNode, gainNode);
|
disconnectNodes(sourceNode, gainNode);
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
abortPlayback = () => {
|
||||||
|
audio.pause();
|
||||||
|
audio.removeAttribute('src');
|
||||||
|
audio.load();
|
||||||
|
instance.cleanup();
|
||||||
|
};
|
||||||
audio.addEventListener('ended', instance.cleanup);
|
audio.addEventListener('ended', instance.cleanup);
|
||||||
audio.addEventListener('error', instance.cleanup);
|
audio.addEventListener('error', instance.cleanup);
|
||||||
|
if (signal?.aborted) {
|
||||||
|
abortPlayback();
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
signal?.addEventListener('abort', abortPlayback, {once: true});
|
||||||
trimActiveOneShotSounds();
|
trimActiveOneShotSounds();
|
||||||
activeOneShotSounds.add(instance);
|
activeOneShotSounds.add(instance);
|
||||||
try {
|
try {
|
||||||
await audio.play();
|
await audio.play();
|
||||||
|
if (signal?.aborted) {
|
||||||
|
abortPlayback();
|
||||||
|
return null;
|
||||||
|
}
|
||||||
return audio;
|
return audio;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
instance.cleanup();
|
instance.cleanup();
|
||||||
|
if (signal?.aborted) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
if (isAutoplayBlockedError(error)) {
|
if (isAutoplayBlockedError(error)) {
|
||||||
logger.debug('Autoplay blocked; dropping sound', {type});
|
logger.debug('Autoplay blocked; dropping sound', {type});
|
||||||
onAutoplayBlocked?.();
|
onAutoplayBlocked?.();
|
||||||
@@ -412,6 +439,7 @@ export async function playSound(
|
|||||||
loop = false,
|
loop = false,
|
||||||
volume = 0.4,
|
volume = 0.4,
|
||||||
onAutoplayBlocked?: () => void,
|
onAutoplayBlocked?: () => void,
|
||||||
|
signal?: AbortSignal,
|
||||||
): Promise<HTMLAudioElement | null> {
|
): Promise<HTMLAudioElement | null> {
|
||||||
const activeSound = activeSounds.get(type);
|
const activeSound = activeSounds.get(type);
|
||||||
if (loop && activeSound && !activeSound.audio.paused) {
|
if (loop && activeSound && !activeSound.audio.paused) {
|
||||||
@@ -419,7 +447,7 @@ export async function playSound(
|
|||||||
}
|
}
|
||||||
try {
|
try {
|
||||||
if (!loop) {
|
if (!loop) {
|
||||||
return await playOneShotSound(type, volume, onAutoplayBlocked);
|
return await playOneShotSound(type, volume, onAutoplayBlocked, signal);
|
||||||
}
|
}
|
||||||
const ctx = await resumeAudioContextIfNeeded();
|
const ctx = await resumeAudioContextIfNeeded();
|
||||||
if (ctx.state === 'suspended') {
|
if (ctx.state === 'suspended') {
|
||||||
|
|||||||
@@ -57,6 +57,18 @@ export function playSoundBypassingSelfDeafened(sound: SoundType, loop?: boolean)
|
|||||||
dispatchSoundIntent({kind: 'play', sound, loop, options: {bypassSelfDeafened: true}});
|
dispatchSoundIntent({kind: 'play', sound, loop, options: {bypassSelfDeafened: true}});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function playOneShotSoundImmediatelyBypassingSelfDeafened(
|
||||||
|
sound: SoundType,
|
||||||
|
signal?: AbortSignal,
|
||||||
|
options: {ignoreGroupCooldown?: boolean} = {},
|
||||||
|
): Promise<boolean> {
|
||||||
|
return Sound.playOneShotSoundImmediately(
|
||||||
|
sound,
|
||||||
|
{bypassSelfDeafened: true, ignoreGroupCooldown: options.ignoreGroupCooldown === true},
|
||||||
|
signal,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
export function previewSound(sound: SoundType): void {
|
export function previewSound(sound: SoundType): void {
|
||||||
dispatchSoundIntent({kind: 'preview', sound});
|
dispatchSoundIntent({kind: 'preview', sound});
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -17,6 +17,7 @@ export interface SoundSettings {
|
|||||||
|
|
||||||
export interface SoundPlayOptions {
|
export interface SoundPlayOptions {
|
||||||
bypassSelfDeafened?: boolean;
|
bypassSelfDeafened?: boolean;
|
||||||
|
ignoreGroupCooldown?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface OneShotSoundPolicy {
|
interface OneShotSoundPolicy {
|
||||||
@@ -294,6 +295,36 @@ class Sound {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async playOneShotSoundImmediately(
|
||||||
|
sound: SoundType,
|
||||||
|
options: SoundPlayOptions = {},
|
||||||
|
signal?: AbortSignal,
|
||||||
|
): Promise<boolean> {
|
||||||
|
if (StreamerMode.shouldDisableSounds) return false;
|
||||||
|
if (this.isSuppressedWhileSelfDeafened(sound, options)) return false;
|
||||||
|
if (!this.isSoundEnabled(sound)) return false;
|
||||||
|
const policy = getOneShotSoundPolicy(sound);
|
||||||
|
if (options.ignoreGroupCooldown !== true) {
|
||||||
|
const lastGroupPlayedAt = this.lastOneShotPlayedAtByGroup.get(policy.group);
|
||||||
|
if (lastGroupPlayedAt !== undefined && Date.now() < lastGroupPlayedAt + policy.cooldownMs) return false;
|
||||||
|
}
|
||||||
|
this.clearPendingOneShotSound(sound);
|
||||||
|
const effectiveVolume = this.volume * this.getEffectiveMultiplier(sound);
|
||||||
|
const played = await this.playOneShotSoundNow(
|
||||||
|
sound,
|
||||||
|
effectiveVolume,
|
||||||
|
options.bypassSelfDeafened === true,
|
||||||
|
signal,
|
||||||
|
false,
|
||||||
|
);
|
||||||
|
if (played) {
|
||||||
|
const playedAt = Date.now();
|
||||||
|
this.lastOneShotPlayedAt = playedAt;
|
||||||
|
this.lastOneShotPlayedAtByGroup.set(policy.group, playedAt);
|
||||||
|
}
|
||||||
|
return played;
|
||||||
|
}
|
||||||
|
|
||||||
previewSound(sound: SoundType): void {
|
previewSound(sound: SoundType): void {
|
||||||
if (StreamerMode.shouldDisableSounds) {
|
if (StreamerMode.shouldDisableSounds) {
|
||||||
return;
|
return;
|
||||||
@@ -553,7 +584,7 @@ class Sound {
|
|||||||
) {
|
) {
|
||||||
this.lastOneShotPlayedAt = now;
|
this.lastOneShotPlayedAt = now;
|
||||||
this.lastOneShotPlayedAtByGroup.set(selectedGroup, now);
|
this.lastOneShotPlayedAtByGroup.set(selectedGroup, now);
|
||||||
this.playOneShotSoundNow(selectedSound.sound, selectedSound.volume, selectedSound.bypassSelfDeafened);
|
void this.playOneShotSoundNow(selectedSound.sound, selectedSound.volume, selectedSound.bypassSelfDeafened);
|
||||||
}
|
}
|
||||||
this.scheduleOneShotDrain();
|
this.scheduleOneShotDrain();
|
||||||
}
|
}
|
||||||
@@ -579,23 +610,38 @@ class Sound {
|
|||||||
if (Date.now() > pending.expiresAt) return;
|
if (Date.now() > pending.expiresAt) return;
|
||||||
if (!this.isSoundEnabled(pending.sound)) return;
|
if (!this.isSoundEnabled(pending.sound)) return;
|
||||||
if (this.isSuppressedWhileSelfDeafened(pending.sound, pending)) return;
|
if (this.isSuppressedWhileSelfDeafened(pending.sound, pending)) return;
|
||||||
this.playOneShotSoundNow(pending.sound, pending.volume, pending.bypassSelfDeafened);
|
void this.playOneShotSoundNow(pending.sound, pending.volume, pending.bypassSelfDeafened);
|
||||||
}
|
}
|
||||||
|
|
||||||
private playOneShotSoundNow(sound: SoundType, volume: number, bypassSelfDeafened: boolean): void {
|
private async playOneShotSoundNow(
|
||||||
SoundUtils.playSound(sound, false, volume, () =>
|
sound: SoundType,
|
||||||
this.handleOneShotAutoplayBlocked(sound, volume, bypassSelfDeafened),
|
volume: number,
|
||||||
)
|
bypassSelfDeafened: boolean,
|
||||||
.then((result) => {
|
signal?: AbortSignal,
|
||||||
if (!result) return;
|
retryOnAutoplayBlocked = true,
|
||||||
this.addCurrentlyPlayingSound(sound);
|
): Promise<boolean> {
|
||||||
const remove = () => this.removeCurrentlyPlayingSound(sound);
|
try {
|
||||||
result.addEventListener('ended', remove, {once: true});
|
const result = await SoundUtils.playSound(
|
||||||
result.addEventListener('error', remove, {once: true});
|
sound,
|
||||||
})
|
false,
|
||||||
.catch((error) => {
|
volume,
|
||||||
this.logger.warn('Failed to play sound:', error);
|
retryOnAutoplayBlocked ? () => this.handleOneShotAutoplayBlocked(sound, volume, bypassSelfDeafened) : undefined,
|
||||||
});
|
signal,
|
||||||
|
);
|
||||||
|
if (!result) return false;
|
||||||
|
this.addCurrentlyPlayingSound(sound);
|
||||||
|
const remove = (): void => {
|
||||||
|
signal?.removeEventListener('abort', remove);
|
||||||
|
this.removeCurrentlyPlayingSound(sound);
|
||||||
|
};
|
||||||
|
result.addEventListener('ended', remove, {once: true});
|
||||||
|
result.addEventListener('error', remove, {once: true});
|
||||||
|
signal?.addEventListener('abort', remove, {once: true});
|
||||||
|
return true;
|
||||||
|
} catch (error) {
|
||||||
|
this.logger.warn('Failed to play sound:', error);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private addCurrentlyPlayingSound(sound: SoundType): void {
|
private addCurrentlyPlayingSound(sound: SoundType): void {
|
||||||
|
|||||||
Reference in new issue
Block a user