Skip to content
Merged
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
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down Expand Up @@ -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()
Expand All @@ -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}")
}
Expand Down
27 changes: 26 additions & 1 deletion src-tauri/src/ios.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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::{
Expand Down Expand Up @@ -332,10 +334,18 @@ fn load_system_sound(caf_bytes: &[u8], temp_name: &str) -> Result<u32, String> {
}
}

static NOTIFICATION_SOUND_PLAYING: AtomicBool = AtomicBool::new(false);

pub(crate) fn play_notification_sound(kind: String) -> Result<(), String> {
static NOTIFICATION_SOUND: OnceLock<u32> = OnceLock::new();
static INVITE_SOUND: OnceLock<u32> = 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 {
Expand All @@ -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(())
}

Expand Down
65 changes: 65 additions & 0 deletions src/app/utils/notificationSound.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,65 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { playNotificationSound } from './notificationSound';

type MockSource = {
buffer: AudioBuffer | null;
connect: ReturnType<typeof vi.fn>;
start: ReturnType<typeof vi.fn>;
addEventListener: ReturnType<typeof vi.fn>;
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<AudioBuffer>>().mockResolvedValue({} as AudioBuffer);
public resume = vi.fn<() => Promise<void>>().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<Response>>().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?.();
});
});
10 changes: 10 additions & 0 deletions src/app/utils/notificationSound.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@
// buffer source does neither.

let context: AudioContext | undefined;
let playingSource: AudioBufferSourceNode | undefined;
const buffers = new Map<string, Promise<AudioBuffer>>();

const decode = (ctx: AudioContext, url: string): Promise<AudioBuffer> => {
Expand All @@ -27,9 +28,18 @@ export const playNotificationSound = async (url: string): Promise<void> => {
// 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();
};
Loading