diff --git a/fluxer_app/src/features/notification/utils/SoundUtils.ts b/fluxer_app/src/features/notification/utils/SoundUtils.ts index abb032f2e..b02afc39a 100644 --- a/fluxer_app/src/features/notification/utils/SoundUtils.ts +++ b/fluxer_app/src/features/notification/utils/SoundUtils.ts @@ -350,8 +350,11 @@ const playOneShotSound = async ( type: SoundType, volume: number, onAutoplayBlocked?: () => void, + signal?: AbortSignal, ): Promise => { + 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 { 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') { diff --git a/fluxer_app/src/features/ui/commands/SoundCommands.ts b/fluxer_app/src/features/ui/commands/SoundCommands.ts index 5ff266275..2995bdb03 100644 --- a/fluxer_app/src/features/ui/commands/SoundCommands.ts +++ b/fluxer_app/src/features/ui/commands/SoundCommands.ts @@ -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 { + return Sound.playOneShotSoundImmediately( + sound, + {bypassSelfDeafened: true, ignoreGroupCooldown: options.ignoreGroupCooldown === true}, + signal, + ); +} + export function previewSound(sound: SoundType): void { dispatchSoundIntent({kind: 'preview', sound}); } diff --git a/fluxer_app/src/features/ui/state/Sound.ts b/fluxer_app/src/features/ui/state/Sound.ts index a93aebc43..d6150cf81 100644 --- a/fluxer_app/src/features/ui/state/Sound.ts +++ b/fluxer_app/src/features/ui/state/Sound.ts @@ -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 { + 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 { + 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 {