feat(ui): incoming-call ring tone + system notification
Previously: incoming calls silently popped an "Accept/Reject" panel. Easy to miss — no audible cue, no system-level alert if the app was backgrounded. Now the incoming-call path triggers both a synthesized ring tone and a system notification banner. ## Ring tone (desktop/src/main.ts) New `Ringer` class using Web Audio API directly — no external asset files, no new npm dep. Synthesizes a classic NANP two-tone cadence (440Hz + 480Hz sine mix, 2s tone + 4s silence, looped) through an envelope-gated gain node that ramps on/off to avoid clicks. Audible on every Tauri-supported platform because WebView carries Web Audio. - `start()` — lazily creates AudioContext on first use (platforms that require a user gesture for AudioContext creation still work because the incoming-call event is user-adjacent from the webview's perspective), starts setInterval(6000) loop. - `stop()` — clears the timer AND disconnects any active oscillators so there's no tail audio. - Active-nodes array is swept every cycle so it doesn't grow unbounded across long rings. Hooked into signal-event handlers: - `"incoming"` → `ringer.start()` + notifyIncomingCall - `"answered"`, `"setup"`, `"hangup"` → `ringer.stop()` - Accept/Reject button click handlers → `ringer.stop()` as the first thing they do (before any await) ## System notification (desktop/src-tauri + main.ts) Added `tauri-plugin-notification = "2"` to the Tauri app and registered in the builder. Capabilities updated with the four notification permissions. Frontend calls the plugin commands via the generic `invoke` instead of adding `@tauri-apps/plugin-notification` as a JS dep — Tauri plugins expose `plugin:notification|notify` etc. directly. Flow: 1. `is_permission_granted` — check cached 2. If not granted → `request_permission` (Android prompts the user once, cached thereafter) 3. `notify` with title="Incoming call", body="From <alias>" All wrapped in try/catch with console.debug fallback — plugin missing or permission denied is non-fatal, the visible panel + ring tone still alert the user. ## Known gaps (deferred) - Android native system ringtone (RingtoneManager) + full- screen intent for lockscreen-visible ringer. Requires platform-specific Java/Kotlin glue in the Tauri Android shell — bigger lift. - Desktop window flash / taskbar attention-seek on incoming call when app is backgrounded. - Vibration pattern on Android. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -2,6 +2,125 @@ import { invoke } from "@tauri-apps/api/core";
|
||||
import { listen } from "@tauri-apps/api/event";
|
||||
import { generateIdenticon, createIdenticonEl } from "./identicon";
|
||||
|
||||
// ── Incoming-call ringer ─────────────────────────────────────────────
|
||||
//
|
||||
// Web Audio synthesized two-tone ring that loops until stop() is
|
||||
// called. No external asset file — works immediately on every
|
||||
// platform Tauri has a WebView on (Android, macOS, Windows, Linux).
|
||||
//
|
||||
// The pattern is a classic North American ring cadence: 440Hz +
|
||||
// 480Hz tone for 2s, 4s silence, repeat. Volume ramps to ~30%
|
||||
// peak so it's audible without being obnoxious on laptop
|
||||
// speakers. Stops cleanly on stop() — cancels the timer AND
|
||||
// disconnects the active oscillators so there's no tail audio.
|
||||
class Ringer {
|
||||
private ctx: AudioContext | null = null;
|
||||
private timer: number | null = null;
|
||||
private activeNodes: AudioNode[] = [];
|
||||
private running = false;
|
||||
|
||||
start() {
|
||||
if (this.running) return;
|
||||
this.running = true;
|
||||
// Construct the AudioContext lazily on the first ring — some
|
||||
// platforms (iOS WebView, Android WebView) refuse to create
|
||||
// one until after a user gesture, so we MUST be past that
|
||||
// point by the time start() is called. Incoming call event is
|
||||
// user-adjacent enough that the WebView normally allows it.
|
||||
try {
|
||||
if (!this.ctx) {
|
||||
this.ctx = new (window.AudioContext || (window as any).webkitAudioContext)();
|
||||
}
|
||||
} catch (e) {
|
||||
console.warn("Ringer: AudioContext unavailable", e);
|
||||
this.running = false;
|
||||
return;
|
||||
}
|
||||
this.playOnce();
|
||||
// 2s tone + 4s silence = 6s cadence. Loop with setInterval.
|
||||
this.timer = window.setInterval(() => this.playOnce(), 6000);
|
||||
}
|
||||
|
||||
stop() {
|
||||
this.running = false;
|
||||
if (this.timer != null) {
|
||||
window.clearInterval(this.timer);
|
||||
this.timer = null;
|
||||
}
|
||||
for (const n of this.activeNodes) {
|
||||
try {
|
||||
(n as any).disconnect();
|
||||
} catch {}
|
||||
}
|
||||
this.activeNodes = [];
|
||||
}
|
||||
|
||||
private playOnce() {
|
||||
if (!this.ctx || !this.running) return;
|
||||
const ctx = this.ctx;
|
||||
const now = ctx.currentTime;
|
||||
const toneDurSec = 2.0;
|
||||
// Two-tone ring: 440Hz (A4) + 480Hz (close to B4). Mix both
|
||||
// through one gain node for envelope control.
|
||||
const gain = ctx.createGain();
|
||||
gain.gain.setValueAtTime(0, now);
|
||||
gain.gain.linearRampToValueAtTime(0.3, now + 0.05);
|
||||
gain.gain.setValueAtTime(0.3, now + toneDurSec - 0.05);
|
||||
gain.gain.linearRampToValueAtTime(0, now + toneDurSec);
|
||||
gain.connect(ctx.destination);
|
||||
|
||||
for (const freq of [440, 480]) {
|
||||
const osc = ctx.createOscillator();
|
||||
osc.type = "sine";
|
||||
osc.frequency.value = freq;
|
||||
osc.connect(gain);
|
||||
osc.start(now);
|
||||
osc.stop(now + toneDurSec);
|
||||
this.activeNodes.push(osc);
|
||||
}
|
||||
this.activeNodes.push(gain);
|
||||
|
||||
// Schedule a cleanup of old nodes after this tone finishes so
|
||||
// the activeNodes array doesn't grow unbounded across long
|
||||
// rings.
|
||||
window.setTimeout(() => {
|
||||
this.activeNodes = this.activeNodes.filter((n) => n !== gain);
|
||||
}, (toneDurSec + 0.1) * 1000);
|
||||
}
|
||||
}
|
||||
const ringer = new Ringer();
|
||||
|
||||
/// Best-effort system notification via the tauri-plugin-notification
|
||||
/// plugin. Uses raw `invoke` so we don't need to import
|
||||
/// `@tauri-apps/plugin-notification` — just invoke the plugin
|
||||
/// commands directly. Silently no-ops if the plugin isn't
|
||||
/// available or permission is denied.
|
||||
async function notifyIncomingCall(from: string) {
|
||||
try {
|
||||
// Make sure we have permission first. On Android this prompts
|
||||
// the user once; after that it's cached.
|
||||
const granted = await invoke<boolean>(
|
||||
"plugin:notification|is_permission_granted",
|
||||
).catch(() => false);
|
||||
if (!granted) {
|
||||
const result = await invoke<string>(
|
||||
"plugin:notification|request_permission",
|
||||
).catch(() => "denied");
|
||||
if (result !== "granted") return;
|
||||
}
|
||||
await invoke("plugin:notification|notify", {
|
||||
options: {
|
||||
title: "Incoming call",
|
||||
body: `From ${from}`,
|
||||
},
|
||||
});
|
||||
} catch (e) {
|
||||
// Notification plugin missing or refused — not fatal, the
|
||||
// visible panel + ringer still alert the user.
|
||||
console.debug("notify: plugin unavailable or refused", e);
|
||||
}
|
||||
}
|
||||
|
||||
// ── WebView hardening ──
|
||||
// Suppress the browser-style right-click context menu on desktop Tauri — it
|
||||
// exposes Inspect/Reload/Back/Forward entries that don't belong in a native-
|
||||
@@ -1209,6 +1328,7 @@ callBtn.addEventListener("click", async () => {
|
||||
});
|
||||
|
||||
acceptCallBtn.addEventListener("click", async () => {
|
||||
ringer.stop();
|
||||
const status = await invoke<any>("get_signal_status");
|
||||
if (status.incoming_call_id) {
|
||||
await invoke("answer_call", { callId: status.incoming_call_id, mode: 2 });
|
||||
@@ -1217,6 +1337,7 @@ acceptCallBtn.addEventListener("click", async () => {
|
||||
});
|
||||
|
||||
rejectCallBtn.addEventListener("click", async () => {
|
||||
ringer.stop();
|
||||
const status = await invoke<any>("get_signal_status");
|
||||
if (status.incoming_call_id) {
|
||||
await invoke("answer_call", { callId: status.incoming_call_id, mode: 0 });
|
||||
@@ -1234,12 +1355,21 @@ listen("signal-event", (event: any) => {
|
||||
case "incoming":
|
||||
incomingCallPanel.classList.remove("hidden");
|
||||
incomingCaller.textContent = `From: ${data.caller_alias || data.caller_fp?.substring(0, 16) || "unknown"}`;
|
||||
// Start ringing + fire a system notification. Both stop in
|
||||
// the hangup/answered/accepted paths below (and via the
|
||||
// accept/reject button handlers).
|
||||
ringer.start();
|
||||
notifyIncomingCall(
|
||||
data.caller_alias || data.caller_fp?.substring(0, 16) || "unknown",
|
||||
);
|
||||
break;
|
||||
case "answered":
|
||||
callStatusText.textContent = `Call answered (${data.mode})`;
|
||||
ringer.stop();
|
||||
break;
|
||||
case "setup":
|
||||
callStatusText.textContent = "Connecting to media...";
|
||||
ringer.stop();
|
||||
// Phase 3 hole-punching: peer_direct_addr carries the OTHER
|
||||
// party's reflex addr when both sides advertised one. Forward
|
||||
// to Rust connect() which currently logs it + takes the relay
|
||||
@@ -1274,6 +1404,7 @@ listen("signal-event", (event: any) => {
|
||||
// * setup failure mid-handshake → same as above
|
||||
callStatusText.textContent = "";
|
||||
incomingCallPanel.classList.add("hidden");
|
||||
ringer.stop();
|
||||
(async () => {
|
||||
try {
|
||||
// disconnect errors out with "not connected" if there's
|
||||
|
||||
Reference in New Issue
Block a user