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
5 changes: 5 additions & 0 deletions src-tauri/build.rs
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,11 @@ fn main() {
println!("cargo:rustc-link-arg-bins=-Wl,-rpath,/usr/lib/swift");
}

// AudioServicesPlaySystemSound (ios.rs). System framework.
if std::env::var("CARGO_CFG_TARGET_OS").as_deref() == Ok("ios") {
println!("cargo:rustc-link-lib=framework=AudioToolbox");
}

tauri_typegen::BuildSystem::generate_at_build_time()
.expect("Failed to generate TypeScript bindings");

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,8 @@ package moe.sable.client

import android.content.Intent
import android.graphics.Color
import android.media.AudioAttributes
import android.media.MediaPlayer
import android.net.Uri
import android.os.Bundle
import android.provider.OpenableColumns
Expand Down Expand Up @@ -177,5 +179,31 @@ class MainActivity : TauriActivity() {
(0.299 * Color.red(color) + 0.587 * Color.green(color) + 0.114 * Color.blue(color)) / 255.0
return luminance > 0.5
}

@JvmStatic
fun playNotificationSoundNative(code: Int) {
val activity = instance ?: return
val resId = if (code == 1) R.raw.invite else R.raw.notification
activity.runOnUiThread {
val mp = MediaPlayer()
try {
val attrs = AudioAttributes.Builder()
.setUsage(AudioAttributes.USAGE_NOTIFICATION)
.setContentType(AudioAttributes.CONTENT_TYPE_SONIFICATION)
.build()
mp.setAudioAttributes(attrs)
activity.resources.openRawResourceFd(resId).use { afd ->
mp.setDataSource(afd.fileDescriptor, afd.startOffset, afd.length)
}
mp.setOnCompletionListener { it.release() }
mp.setOnErrorListener { err -> err.release(); true }
mp.prepare()
mp.start()
} catch (e: Exception) {
mp.release()
android.util.Log.w("NotificationSound", "play failed: ${e.message}")
}
}
}
}
}
Binary file not shown.
Binary file not shown.
Binary file added src-tauri/resources/invite.caf
Binary file not shown.
Binary file added src-tauri/resources/notification.caf
Binary file not shown.
70 changes: 70 additions & 0 deletions src-tauri/src/ios.rs
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@
// the same approach as Capacitor's hideFormAccessoryBar.

use std::ffi::CString;
use std::sync::OnceLock;

use objc2::rc::{Allocated, Retained};
use objc2::runtime::{AnyClass, AnyObject, ClassBuilder, Sel};
Expand Down Expand Up @@ -85,3 +86,72 @@ pub fn hide_form_accessory_bar(window: &WebviewWindow<crate::BrowserEngine>) {
}
});
}

// WKWebView plays HTMLAudioElement on .playback, ignoring the silent switch.
// AudioServicesPlaySystemSound respects the switch and uses ringer volume.

use objc2_foundation::{NSString, NSURL};

extern "C" {
fn AudioServicesCreateSystemSoundID(
in_file_url: *mut objc2::runtime::AnyObject,
out_sound_id: *mut u32,
) -> i32;
fn AudioServicesPlaySystemSound(sound_id: u32);
fn AudioServicesDisposeSystemSoundID(sound_id: u32);
}

fn load_system_sound(caf_bytes: &[u8], temp_name: &str) -> Option<u32> {
// AudioServicesCreateSystemSoundID needs a file URL, so write the
// embedded .caf to the app's temp directory on first use.
let mut path = std::env::temp_dir();
path.push(temp_name);
if !path.exists() {
if let Err(_) = std::fs::write(&path, caf_bytes) {
return None;
}
}
unsafe {
let path_str = NSString::from_str(&path.to_string_lossy());
let url: Option<Retained<NSURL>> = msg_send![
NSURL, fileURLWithPath: &*path_str
];
let url = url?;
let mut sound_id: u32 = 0;
let status = AudioServicesCreateSystemSoundID(
&*url as *mut _ as *mut objc2::runtime::AnyObject,
&mut sound_id,
);
if status != 0 || sound_id == 0 {
return None;
}
Some(sound_id)
}
}

pub(crate) fn play_notification_sound(kind: String) -> Result<(), String> {
static NOTIFICATION_SOUND: OnceLock<Option<u32>> = OnceLock::new();
static INVITE_SOUND: OnceLock<Option<u32>> = OnceLock::new();

let cache = if kind == "invite" {
&INVITE_SOUND
} else {
&NOTIFICATION_SOUND
};
let caf = if kind == "invite" {
include_bytes!("../resources/invite.caf")
} else {
include_bytes!("../resources/notification.caf")
};
let name = if kind == "invite" {
"sable_invite.caf"
} else {
"sable_notification.caf"
};

let sound_id = cache.get_or_init(|| load_system_sound(caf, name));
if let Some(id) = sound_id {
unsafe { AudioServicesPlaySystemSound(*id) };
}
Ok(())
}
17 changes: 17 additions & 0 deletions src-tauri/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -118,6 +118,21 @@ fn setup_cef_resize_workaround(
Ok(())
}

/// Routes in-app notification sounds to the native volume stream on mobile.
/// `kind` is "notification" or "invite".
#[cfg(any(target_os = "android", target_os = "ios"))]
#[tauri::command]
fn play_notification_sound(kind: String) -> Result<(), String> {
#[cfg(target_os = "android")]
{
mobile::play_notification_sound(kind)
}
#[cfg(target_os = "ios")]
{
ios::play_notification_sound(kind)
}
}

pub fn show_or_create_main_window(app: &AppHandle<crate::BrowserEngine>) -> tauri::Result<()> {
if let Some(_window) = app.get_webview_window(MAIN_WINDOW_LABEL) {
#[cfg(desktop)]
Expand Down Expand Up @@ -393,6 +408,8 @@ pub fn run() {
mobile::set_navigation_bar_color,
#[cfg(target_os = "ios")]
ios::haptic_feedback,
#[cfg(any(target_os = "android", target_os = "ios"))]
play_notification_sound,
#[cfg(desktop)]
desktop::download::save_download,
#[cfg(desktop)]
Expand Down
24 changes: 24 additions & 0 deletions src-tauri/src/mobile.rs
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,30 @@ pub fn set_navigation_bar_color(color: u32) -> Result<(), String> {
call_bar_color("setNavigationBarColorNative", color)
}

/// `kind` is "notification" or "invite"; mapped to an int to avoid JNI string
/// marshalling (mirrors set_*_bar_color).
pub(crate) fn play_notification_sound(kind: String) -> Result<(), String> {
let code = match kind.as_str() {
"invite" => 1,
_ => 0,
};
let vm = JAVA_VM.get().ok_or("java vm not initialized")?;
let mut env = vm.attach_current_thread().map_err(|e| e.to_string())?;

let result = env.call_static_method(
"moe/sable/client/MainActivity",
"playNotificationSoundNative",
"(I)V",
&[JValue::Int(code)],
);
if result.is_err() {
let _ = env.exception_clear();
}
result.map_err(|e| e.to_string())?;

Ok(())
}

fn call_bar_color(method: &str, color: u32) -> Result<(), String> {
let vm = JAVA_VM.get().ok_or("java vm not initialized")?;
let mut env = vm.attach_current_thread().map_err(|e| e.to_string())?;
Expand Down
6 changes: 5 additions & 1 deletion src/app/generated/tauri/commands.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
/**
* Auto-generated TypeScript bindings for Tauri commands
* Generated by tauri-typegen v0.5.0
* Generated at: 2026-07-24T06:02:36.533786106+00:00
* Generated at: 2026-07-24T06:26:22.623670714+00:00
* Generator: none
*
* Do not edit manually - regenerate using: cargo tauri-typegen generate
Expand Down Expand Up @@ -46,6 +46,10 @@ export async function nativeUpload(params: types.NativeUploadParams): Promise<ty
return invoke('native_upload', params);
}

export async function playNotificationSound(params: types.PlayNotificationSoundParams): Promise<void> {
return invoke('play_notification_sound', params);
}

export async function saveDownload(params: types.SaveDownloadParams): Promise<boolean> {
return invoke('save_download', params);
}
Expand Down
2 changes: 1 addition & 1 deletion src/app/generated/tauri/events.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
/**
* Auto-generated TypeScript bindings for Tauri commands
* Generated by tauri-typegen v0.5.0
* Generated at: 2026-07-24T06:02:36.534222913+00:00
* Generated at: 2026-07-24T06:26:22.624090128+00:00
* Generator: none
*
* Do not edit manually - regenerate using: cargo tauri-typegen generate
Expand Down
2 changes: 1 addition & 1 deletion src/app/generated/tauri/index.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
/**
* Auto-generated TypeScript bindings for Tauri commands
* Generated by tauri-typegen v0.5.0
* Generated at: 2026-07-24T06:02:36.534763026+00:00
* Generated at: 2026-07-24T06:26:22.624228112+00:00
* Generator: none
*
* Do not edit manually - regenerate using: cargo tauri-typegen generate
Expand Down
7 changes: 6 additions & 1 deletion src/app/generated/tauri/types.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
/**
* Auto-generated TypeScript bindings for Tauri commands
* Generated by tauri-typegen v0.5.0
* Generated at: 2026-07-24T06:02:36.533095833+00:00
* Generated at: 2026-07-24T06:26:22.622997273+00:00
* Generator: none
*
* Do not edit manually - regenerate using: cargo tauri-typegen generate
Expand Down Expand Up @@ -92,6 +92,11 @@ export interface NativeUploadParams {
[key: string]: unknown;
}

export interface PlayNotificationSoundParams {
kind: string;
[key: string]: unknown;
}

export interface SaveDownloadParams {
filename: string;
bytes: number[];
Expand Down
9 changes: 9 additions & 0 deletions src/app/pages/client/ClientNonUIFeatures.tsx
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import { useAtom, useAtomValue, useSetAtom } from 'jotai';
import * as Sentry from '@sentry/react';
import { type as osType } from '@tauri-apps/plugin-os';
import { invoke } from '@tauri-apps/api/core';
import { setTrayBadge } from '$generated/tauri/commands';
import type { ReactNode } from 'react';
import { useCallback, useEffect, useRef, useState } from 'react';
Expand Down Expand Up @@ -271,6 +272,10 @@ function InviteNotifications() {
);

const playSound = useCallback(() => {
if (isAndroidTauri() || isIosTauri()) {
invoke('play_notification_sound', { kind: 'invite' }).catch(() => {});
return;
}
const audioElement = audioRef.current;
audioElement?.play()?.catch(() => {});
clearMediaSessionQuickly();
Expand Down Expand Up @@ -350,6 +355,10 @@ function MessageNotifications() {
const notificationSelected = useInboxNotificationsSelected();

const playSound = useCallback(() => {
if (isAndroidTauri() || isIosTauri()) {
invoke('play_notification_sound', { kind: 'notification' }).catch(() => {});
return;
}
const audioElement = audioRef.current;
audioElement?.play()?.catch(() => {});
clearMediaSessionQuickly();
Expand Down
Loading