diff --git a/src-tauri/gen/android/app/src/main/java/moe/sable/client/MainActivity.kt b/src-tauri/gen/android/app/src/main/java/moe/sable/client/MainActivity.kt index 0d635fc3b1..8d329af0b0 100644 --- a/src-tauri/gen/android/app/src/main/java/moe/sable/client/MainActivity.kt +++ b/src-tauri/gen/android/app/src/main/java/moe/sable/client/MainActivity.kt @@ -156,6 +156,7 @@ class MainActivity : TauriActivity() { companion object { private var instance: MainActivity? = null + private var notificationPlayer: MediaPlayer? = null private var immersiveSystemBarsBehavior: Int? = null private var immersiveDepth = 0 @@ -233,6 +234,9 @@ class MainActivity : TauriActivity() { val activity = instance ?: return val resId = if (code == 1) R.raw.invite else R.raw.notification activity.runOnUiThread { + // A message burst should produce one alert, not overlapping players. + if (notificationPlayer != null) return@runOnUiThread + val mp = MediaPlayer() try { val attrs = AudioAttributes.Builder() @@ -243,14 +247,20 @@ class MainActivity : TauriActivity() { activity.resources.openRawResourceFd(resId).use { afd -> mp.setDataSource(afd.fileDescriptor, afd.startOffset, afd.length) } - mp.setOnCompletionListener { it.release() } + notificationPlayer = mp + mp.setOnCompletionListener { + if (notificationPlayer === it) notificationPlayer = null + it.release() + } mp.setOnErrorListener { player, _, _ -> + if (notificationPlayer === player) notificationPlayer = null player.release() true } mp.prepare() mp.start() } catch (e: Exception) { + if (notificationPlayer === mp) notificationPlayer = null mp.release() android.util.Log.w("NotificationSound", "play failed: ${e.message}") } diff --git a/src-tauri/src/ios.rs b/src-tauri/src/ios.rs index ac88bc6295..f2309155b3 100644 --- a/src-tauri/src/ios.rs +++ b/src-tauri/src/ios.rs @@ -4,7 +4,9 @@ // the same approach as Capacitor's hideFormAccessoryBar. use std::ffi::CString; +use std::sync::atomic::{AtomicBool, Ordering}; use std::sync::OnceLock; +use std::time::Duration; use objc2::rc::{Allocated, Retained}; use objc2::runtime::{ @@ -332,10 +334,18 @@ fn load_system_sound(caf_bytes: &[u8], temp_name: &str) -> Result { } } +static NOTIFICATION_SOUND_PLAYING: AtomicBool = AtomicBool::new(false); + pub(crate) fn play_notification_sound(kind: String) -> Result<(), String> { static NOTIFICATION_SOUND: OnceLock = OnceLock::new(); static INVITE_SOUND: OnceLock = OnceLock::new(); + // AudioServices plays asynchronously and cannot stop an active sound. Drop + // bursts until this clip finishes instead of overlapping notification audio. + if NOTIFICATION_SOUND_PLAYING.swap(true, Ordering::Relaxed) { + return Ok(()); + } + let cache = if kind == "invite" { &INVITE_SOUND } else { @@ -356,12 +366,27 @@ pub(crate) fn play_notification_sound(kind: String) -> Result<(), String> { let sound_id = match cache.get() { Some(id) => *id, None => { - let id = load_system_sound(caf, name)?; + let id = match load_system_sound(caf, name) { + Ok(id) => id, + Err(error) => { + NOTIFICATION_SOUND_PLAYING.store(false, Ordering::Relaxed); + return Err(error); + } + }; let _ = cache.set(id); id } }; unsafe { AudioServicesPlaySystemSound(sound_id) }; + let duration = if kind == "invite" { + Duration::from_millis(1905) + } else { + Duration::from_millis(817) + }; + std::thread::spawn(move || { + std::thread::sleep(duration); + NOTIFICATION_SOUND_PLAYING.store(false, Ordering::Relaxed); + }); Ok(()) } diff --git a/src/app/utils/notificationSound.test.ts b/src/app/utils/notificationSound.test.ts new file mode 100644 index 0000000000..441ff9420d --- /dev/null +++ b/src/app/utils/notificationSound.test.ts @@ -0,0 +1,65 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { playNotificationSound } from './notificationSound'; + +type MockSource = { + buffer: AudioBuffer | null; + connect: ReturnType; + start: ReturnType; + addEventListener: ReturnType; + ended: (() => void) | undefined; +}; + +const nativeAudioContext = globalThis.AudioContext; +const nativeFetch = globalThis.fetch; +let sources: MockSource[]; + +class MockAudioContext { + public state: AudioContextState = 'running'; + public destination = {} as AudioDestinationNode; + + public decodeAudioData = vi.fn<() => Promise>().mockResolvedValue({} as AudioBuffer); + public resume = vi.fn<() => Promise>().mockResolvedValue(undefined); + public createBufferSource = vi.fn<() => AudioBufferSourceNode>(() => { + const source: MockSource = { + buffer: null, + connect: vi.fn<() => void>(), + start: vi.fn<() => void>(), + addEventListener: vi.fn<(type: string, listener: () => void) => void>((type, listener) => { + if (type === 'ended') source.ended = listener; + }), + ended: undefined, + }; + sources.push(source); + return source as unknown as AudioBufferSourceNode; + }); +} + +beforeEach(() => { + sources = []; + globalThis.AudioContext = MockAudioContext as unknown as typeof AudioContext; + globalThis.fetch = vi.fn<() => Promise>().mockResolvedValue({ + arrayBuffer: () => Promise.resolve(new ArrayBuffer(0)), + } as Response); +}); + +afterEach(() => { + globalThis.AudioContext = nativeAudioContext; + globalThis.fetch = nativeFetch; +}); + +describe('playNotificationSound', () => { + it('does not overlap sounds requested while one is playing', async () => { + await Promise.all([ + playNotificationSound('/sound/notification.ogg'), + playNotificationSound('/sound/notification.ogg'), + ]); + + expect(sources).toHaveLength(1); + + sources[0]!.ended?.(); + await playNotificationSound('/sound/notification.ogg'); + + expect(sources).toHaveLength(2); + sources[1]!.ended?.(); + }); +}); diff --git a/src/app/utils/notificationSound.ts b/src/app/utils/notificationSound.ts index fc7aa73c31..6f702e6c94 100644 --- a/src/app/utils/notificationSound.ts +++ b/src/app/utils/notificationSound.ts @@ -4,6 +4,7 @@ // buffer source does neither. let context: AudioContext | undefined; +let playingSource: AudioBufferSourceNode | undefined; const buffers = new Map>(); const decode = (ctx: AudioContext, url: string): Promise => { @@ -27,9 +28,18 @@ export const playNotificationSound = async (url: string): Promise => { // A context constructed outside a user gesture starts suspended, and Safari // moves it to 'interrupted' after a phone call or a route change. if (context.state !== 'running') await context.resume(); + if (playingSource) return; const source = context.createBufferSource(); source.buffer = buffer; source.connect(context.destination); + playingSource = source; + source.addEventListener( + 'ended', + () => { + if (playingSource === source) playingSource = undefined; + }, + { once: true } + ); source.start(); };