mirror of
https://github.com/fluxerapp/fluxer
synced 2026-10-17 21:15:32 +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,
|
||||
volume: number,
|
||||
onAutoplayBlocked?: () => void,
|
||||
signal?: AbortSignal,
|
||||
): Promise<HTMLAudioElement | null> => {
|
||||
if (signal?.aborted) return null;
|
||||
const soundUrl = await getSoundUrl(type);
|
||||
if (signal?.aborted) return null;
|
||||
const audio = createAudioElement(soundUrl);
|
||||
audio.currentTime = 0;
|
||||
audio.loop = false;
|
||||
@@ -361,6 +364,7 @@ const playOneShotSound = async (
|
||||
const routeToCaptureBus = shouldRouteSoundToCaptureBus(type);
|
||||
try {
|
||||
const ctx = await resumeAudioContextIfNeeded();
|
||||
if (signal?.aborted) return null;
|
||||
if (ctx.state !== 'suspended') {
|
||||
sourceNode = ctx.createMediaElementSource(audio);
|
||||
gainNode = ctx.createGain();
|
||||
@@ -377,26 +381,49 @@ const playOneShotSound = async (
|
||||
audio.volume = effectiveVolume;
|
||||
applyOutputDeviceToAudioElement(audio);
|
||||
}
|
||||
let cleanedUp = false;
|
||||
let abortPlayback: (() => void) | null = null;
|
||||
const instance: OneShotAudioInstance = {
|
||||
audio,
|
||||
gainNode,
|
||||
sourceNode,
|
||||
cleanup: () => {
|
||||
if (cleanedUp) return;
|
||||
cleanedUp = true;
|
||||
activeOneShotSounds.delete(instance);
|
||||
audio.removeEventListener('ended', instance.cleanup);
|
||||
audio.removeEventListener('error', instance.cleanup);
|
||||
if (abortPlayback) signal?.removeEventListener('abort', abortPlayback);
|
||||
disconnectNodes(sourceNode, gainNode);
|
||||
},
|
||||
};
|
||||
abortPlayback = () => {
|
||||
audio.pause();
|
||||
audio.removeAttribute('src');
|
||||
audio.load();
|
||||
instance.cleanup();
|
||||
};
|
||||
audio.addEventListener('ended', instance.cleanup);
|
||||
audio.addEventListener('error', instance.cleanup);
|
||||
if (signal?.aborted) {
|
||||
abortPlayback();
|
||||
return null;
|
||||
}
|
||||
signal?.addEventListener('abort', abortPlayback, {once: true});
|
||||
trimActiveOneShotSounds();
|
||||
activeOneShotSounds.add(instance);
|
||||
try {
|
||||
await audio.play();
|
||||
if (signal?.aborted) {
|
||||
abortPlayback();
|
||||
return null;
|
||||
}
|
||||
return audio;
|
||||
} catch (error) {
|
||||
instance.cleanup();
|
||||
if (signal?.aborted) {
|
||||
return null;
|
||||
}
|
||||
if (isAutoplayBlockedError(error)) {
|
||||
logger.debug('Autoplay blocked; dropping sound', {type});
|
||||
onAutoplayBlocked?.();
|
||||
@@ -412,6 +439,7 @@ export async function playSound(
|
||||
loop = false,
|
||||
volume = 0.4,
|
||||
onAutoplayBlocked?: () => void,
|
||||
signal?: AbortSignal,
|
||||
): Promise<HTMLAudioElement | null> {
|
||||
const activeSound = activeSounds.get(type);
|
||||
if (loop && activeSound && !activeSound.audio.paused) {
|
||||
@@ -419,7 +447,7 @@ export async function playSound(
|
||||
}
|
||||
try {
|
||||
if (!loop) {
|
||||
return await playOneShotSound(type, volume, onAutoplayBlocked);
|
||||
return await playOneShotSound(type, volume, onAutoplayBlocked, signal);
|
||||
}
|
||||
const ctx = await resumeAudioContextIfNeeded();
|
||||
if (ctx.state === 'suspended') {
|
||||
|
||||
@@ -57,6 +57,18 @@ export function playSoundBypassingSelfDeafened(sound: SoundType, loop?: boolean)
|
||||
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 {
|
||||
dispatchSoundIntent({kind: 'preview', sound});
|
||||
}
|
||||
|
||||
@@ -17,6 +17,7 @@ export interface SoundSettings {
|
||||
|
||||
export interface SoundPlayOptions {
|
||||
bypassSelfDeafened?: boolean;
|
||||
ignoreGroupCooldown?: boolean;
|
||||
}
|
||||
|
||||
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 {
|
||||
if (StreamerMode.shouldDisableSounds) {
|
||||
return;
|
||||
@@ -553,7 +584,7 @@ class Sound {
|
||||
) {
|
||||
this.lastOneShotPlayedAt = 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();
|
||||
}
|
||||
@@ -579,23 +610,38 @@ class Sound {
|
||||
if (Date.now() > pending.expiresAt) return;
|
||||
if (!this.isSoundEnabled(pending.sound)) 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 {
|
||||
SoundUtils.playSound(sound, false, volume, () =>
|
||||
this.handleOneShotAutoplayBlocked(sound, volume, bypassSelfDeafened),
|
||||
)
|
||||
.then((result) => {
|
||||
if (!result) return;
|
||||
this.addCurrentlyPlayingSound(sound);
|
||||
const remove = () => this.removeCurrentlyPlayingSound(sound);
|
||||
result.addEventListener('ended', remove, {once: true});
|
||||
result.addEventListener('error', remove, {once: true});
|
||||
})
|
||||
.catch((error) => {
|
||||
this.logger.warn('Failed to play sound:', error);
|
||||
});
|
||||
private async playOneShotSoundNow(
|
||||
sound: SoundType,
|
||||
volume: number,
|
||||
bypassSelfDeafened: boolean,
|
||||
signal?: AbortSignal,
|
||||
retryOnAutoplayBlocked = true,
|
||||
): Promise<boolean> {
|
||||
try {
|
||||
const result = await SoundUtils.playSound(
|
||||
sound,
|
||||
false,
|
||||
volume,
|
||||
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 {
|
||||
|
||||
Reference in new issue
Block a user