softphone: dock panel, incoming dialog, hold/mute/DTMF, transfers, one tab

The dock opens a panel with a 12-key dialpad (typing, paste normalization,
Enter) and microphone/speaker selection. Call cards have mute, hold,
keypad (DTMF) and transfer: "Transfer now" (blind) or "Talk first"
(attended, with Complete/Cancel). An incoming call opens a dialog when
idle, or a card during a call (call waiting holds the first call). A
synthesized WebAudio ringtone and desktop notifications, auto-answer for
Call-Info answer-after=0, and a poor-connection warning from getStats.
One tab registers: a Web Lock plus a BroadcastChannel "Use here".

Found by e2e: Chrome can report canInsertDTMF=false, so DTMF falls back to
SIP INFO; after a REFER's 2xx NOTIFY the browser sends BYE itself (RFC
5589), otherwise SIP.js keeps the call up. apps/web/TESTING.md is the
12-step manual run for Chrome and Firefox (still open, roadmap note);
e2e m5-t2 automates most of it in headless Edge.

Task: M5-T2
This commit is contained in:
iamdoubz committed 2026-10-18 09:43:41 -05:00
1 parent ae3a723a3c
commit c7fd4df77c
12 files changed
+1035 -94

No files matched your search

+23
View File
@@ -0,0 +1,23 @@
# Browser softphone — manual test (M5-T2)
Run all 12 steps in **Chrome** and again in **Firefox**, on the real server behind the reverse
proxy (`https://<your host>/me`). You need: a user with an extension that has the browser
softphone on (say 101), a second phone (desk phone or phone app, say 102), and a third extension
to transfer to (say 103, any phone). Headphones help with echo.
| # | Do this | Pass when |
| --- | --------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
| 1 | Sign in as the user, click **Enable softphone**, allow the microphone. | Status turns **Ready**; Admin → Dashboard shows 101 online. |
| 2 | Reload the page. | It comes back **Ready** without asking again. |
| 3 | Open `/me` in a second tab. | The second tab says **Active in another tab**. Click **Use here**: it becomes **Ready** and the first tab says **Active in another tab**. Close the second tab and click **Use here** in the first. |
| 4 | Open **Keypad**, type `102` (also try typing on the keyboard and pressing Enter), call. | 102 rings; when answered both sides hear each other; the card shows a running timer. |
| 5 | Press **Mute**, talk, then **Unmute**. | 102 hears nothing while muted, then hears you again. |
| 6 | Press **Hold**, then **Resume**. | 102 hears hold music, then you again; the card says **On hold** meanwhile. |
| 7 | During the call open the call's **Keypad** and press `1 2 3`. If 102 is a voicemail menu or an IVR, it reacts; otherwise call `*97` instead and enter your PIN. | The tones are recognized. |
| 8 | Hang up. From 102 call 101. | The **Incoming call** dialog shows 102's name, the ringtone plays; **Answer** connects with two-way audio. Hang up from 102: the card goes away. |
| 9 | Switch to another tab or app, then have 102 call 101 again (first allow notifications with **Notify me of calls**). | A desktop notification appears; clicking it brings the tab back. **Decline** sends 102 to busy/voicemail. |
| 10 | While on a call with 102, have 103 call 101. | A second, smaller **Incoming call** card appears (no dialog). **Answer** puts 102 on hold (hears music) and connects 103. Hang up both. |
| 11 | Call 102, **Transfer** → type `103` → **Transfer now**. | 102 is connected to 103; the browser call ends. |
| 12 | Call 102, **Transfer** → `103` → **Talk first**; talk to 103; **Complete transfer**. Repeat with **Cancel** instead. | Complete: 102 and 103 are connected, the browser is out. Cancel: 103's call ends and you're back with 102. Also check **Microphone/Speaker** selects list your devices and a change takes effect on the next call. |
Record the result (browser, version, pass/fail per step) in the PR or issue for M5-T2.
+400 -72
View File
@@ -1,11 +1,16 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
// Softphone bar on every /me page (05 §5.3). M5-T1: enable, status, dial, answer, hang up.
// M5-T2 adds the expanding panel, hold/mute/DTMF, transfer, devices and notifications.
import { useEffect, useState, useSyncExternalStore } from 'react';
// Softphone on every /me page (05 §5.3): a bar at the bottom that opens a panel with the
// dialpad and audio devices; call cards with mute/hold/keypad/transfer; the incoming call dialog.
// Not yet: DND toggle (M6), Record via *1 and recent calls (M7).
import { useEffect, useRef, useState, useSyncExternalStore } from 'react';
import { formatNumber } from '@selfphoneng/shared';
import { meApi } from '../api/client.ts';
import { Dialog } from '../components/Dialog.tsx';
import { Button, inputClass } from '../components/ui.tsx';
import { useLive } from '../live/store.ts';
import { onTakeOver, stepDown, takeOver, tryLead } from './leader.ts';
import { softphone, type CallInfo } from './SoftphoneEngine.ts';
import { startRinging, stopRinging } from './sounds.ts';
const KEY = 'softphone.enabled';
const remembered = () => {
@@ -26,23 +31,92 @@ const remember = (on: boolean) => {
export const useSoftphone = () => useSyncExternalStore(softphone.subscribe, () => softphone.state);
const STATUS = { off: 'Off', connecting: 'Connecting…', registered: 'Ready', error: 'Error' };
onTakeOver(async () => {
await softphone.stop();
softphone.setElsewhere();
});
async function startSoftphone() {
const creds = await meApi.webrtc();
await softphone.start(creds);
/** Registers in this tab, unless another tab has the softphone (then "Use here" forces it). */
async function startSoftphone(force = false) {
if (force) await takeOver();
else if (!(await tryLead())) return softphone.setElsewhere();
await softphone.start(await meApi.webrtc());
}
const STATUS = {
off: 'Off',
connecting: 'Connecting…',
registered: 'Ready',
error: 'Error',
elsewhere: 'Active in another tab',
};
const KEYS = ['1', '2', '3', '4', '5', '6', '7', '8', '9', '*', '0', '#'];
const clean = (s: string) => s.replace(/[^0-9*#+]/g, '');
/** Caller name: extensions (live presence) → the name the caller sent → the number. */
function useNameOf() {
const exts = useLive((s) => s.extensions);
return (c: { remote: string; remoteName: string | null }) =>
exts[c.remote]?.name ??
c.remoteName ??
(c.remote.length > 6 ? formatNumber(c.remote) : c.remote || 'Unknown');
}
function useNow(on: boolean) {
const [now, setNow] = useState(() => Date.now());
useEffect(() => {
if (!on) return;
const t = setInterval(() => setNow(Date.now()), 1000);
return () => clearInterval(t);
}, [on]);
return now;
}
const elapsed = (from: number, now: number) => {
const s = Math.max(0, Math.floor((now - from) / 1000));
return `${Math.floor(s / 60)}:${String(s % 60).padStart(2, '0')}`;
};
export function SoftphoneDock() {
const { reg, error, calls } = useSoftphone();
const [number, setNumber] = useState('');
const nameOf = useNameOf();
const [open, setOpen] = useState(false);
const [problem, setProblem] = useState<string | null>(null);
const run = (p: Promise<unknown>) => {
setProblem(null);
p.catch((e: Error) => setProblem(e.message));
};
useEffect(() => {
if (remembered() && softphone.state.reg === 'off')
startSoftphone().catch((e: Error) => setProblem(e.message));
}, []);
// Ring while anything is ringing here; tell a hidden tab with a notification.
const incoming = calls.filter((c) => c.state === 'incoming');
const ringingIds = incoming.map((c) => c.id).join(',');
const notified = useRef(new Set<string>());
useEffect(() => {
if (ringingIds) startRinging();
else stopRinging();
for (const c of softphone.state.calls.filter((x) => x.state === 'incoming')) {
if (notified.current.has(c.id)) continue;
notified.current.add(c.id);
if (
document.hidden &&
typeof Notification !== 'undefined' &&
Notification.permission === 'granted'
) {
const n = new Notification('Incoming call', { body: nameOf(c), tag: c.id });
n.onclick = () => {
window.focus();
n.close();
};
}
}
}, [ringingIds, nameOf]);
useEffect(() => stopRinging, []);
async function enable() {
setProblem(null);
try {
@@ -60,12 +134,9 @@ export function SoftphoneDock() {
}
}
async function dial() {
const n = number.replace(/[^0-9*#+]/g, '');
if (!n) return;
setProblem(null);
await softphone.call(n).catch((e: Error) => setProblem(e.message));
}
const talking = calls.some((c) => c.state !== 'incoming');
// The big dialog for a call that rings while nothing else is going on; otherwise a card.
const dialogCall = !talking ? incoming[0] : undefined;
return (
<section
@@ -76,82 +147,339 @@ export function SoftphoneDock() {
<span className="text-sm font-medium">
Softphone: <span role="status">{STATUS[reg]}</span>
</span>
{reg === 'off' ? (
<span className="flex-1" />
{reg === 'off' && (
<Button variant="primary" onClick={enable}>
Enable softphone
</Button>
) : (
<Button
variant="ghost"
onClick={() => {
remember(false);
void softphone.stop();
}}
>
Turn off
)}
{reg === 'elsewhere' && (
<Button variant="primary" onClick={() => run(startSoftphone(true))}>
Use here
</Button>
)}
{(reg === 'registered' || reg === 'connecting' || reg === 'error') && (
<>
<Button
aria-expanded={open}
aria-controls="sp-panel"
onClick={() => setOpen((o) => !o)}
>
{open ? 'Hide keypad' : 'Keypad'}
</Button>
<Button
variant="ghost"
onClick={() => {
remember(false);
run(softphone.stop().then(stepDown));
}}
>
Turn off
</Button>
</>
)}
</div>
{(problem ?? error) && (
<p className="text-sm text-red-600 dark:text-red-400">{problem ?? error}</p>
<p role="alert" className="text-sm text-red-600 dark:text-red-400">
{problem ?? error}
</p>
)}
{reg === 'registered' && (
<form
className="flex gap-2"
onSubmit={(e) => {
e.preventDefault();
void dial();
}}
>
<label htmlFor="sp-number" className="sr-only">
Number to call
</label>
<input
id="sp-number"
className={inputClass}
inputMode="tel"
autoComplete="off"
placeholder="Number or extension"
value={number}
onChange={(e) => setNumber(e.target.value)}
/>
<Button type="submit" variant="primary">
Call
</Button>
</form>
{calls
.filter((c) => c.id !== dialogCall?.id)
.map((c) => (
<CallCard key={c.id} call={c} name={nameOf(c)} onError={setProblem} />
))}
{open && reg === 'registered' && (
<div id="sp-panel" className="flex flex-col gap-3">
<Dialer disabled={calls.length >= 2} onCall={(n) => run(softphone.call(n))} />
<Devices />
</div>
)}
{calls.map((c) => (
<CallRow key={c.id} call={c} />
))}
<Dialog
open={dialogCall !== undefined}
onClose={() => dialogCall && run(softphone.decline(dialogCall.id))}
title="Incoming call"
footer={
dialogCall && (
<>
<Button onClick={() => run(softphone.decline(dialogCall.id))}>Decline</Button>
<Button variant="primary" onClick={() => run(softphone.answer(dialogCall.id))}>
Answer
</Button>
</>
)
}
>
{dialogCall && (
<p className="text-lg">
<span className="font-semibold">{nameOf(dialogCall)}</span>
{nameOf(dialogCall) !== dialogCall.remote && (
<span className="block text-sm text-zinc-500">
{dialogCall.remote.length > 6 ? formatNumber(dialogCall.remote) : dialogCall.remote}
</span>
)}
</p>
)}
</Dialog>
</section>
);
}
function CallRow({ call }: { call: CallInfo }) {
const who = call.remoteName ?? (call.remote.length > 6 ? formatNumber(call.remote) : call.remote);
const label = { incoming: 'Incoming call', calling: 'Calling…', active: 'On call' }[call.state];
function Keypad({ onKey, label }: { onKey: (k: string) => void; label: string }) {
return (
<div role="group" aria-label={label} className="grid w-56 grid-cols-3 gap-1">
{KEYS.map((k) => (
<Button key={k} onClick={() => onKey(k)} className="text-lg">
{k}
</Button>
))}
</div>
);
}
function Dialer({ onCall, disabled }: { onCall: (n: string) => void; disabled: boolean }) {
const [number, setNumber] = useState('');
return (
<form
className="flex flex-col gap-2"
onSubmit={(e) => {
e.preventDefault();
if (clean(number)) onCall(clean(number));
setNumber('');
}}
>
<div className="flex gap-2">
<label htmlFor="sp-number" className="sr-only">
Number to call
</label>
<input
id="sp-number"
className={inputClass}
inputMode="tel"
autoComplete="off"
placeholder="Number or extension"
value={number}
onChange={(e) => setNumber(e.target.value)}
// Pasted numbers like "(312) 555-1234" become dialable digits.
onPaste={(e) => {
e.preventDefault();
setNumber((n) => n + clean(e.clipboardData.getData('text')));
}}
/>
<Button type="submit" variant="primary" disabled={disabled}>
Call
</Button>
</div>
<Keypad label="Dialpad" onKey={(k) => setNumber((n) => n + k)} />
</form>
);
}
function CallCard({
call,
name,
onError,
}: {
call: CallInfo;
name: string;
onError: (m: string | null) => void;
}) {
const now = useNow(call.state === 'active');
const exts = useLive((s) => s.extensions);
const [keypad, setKeypad] = useState(false);
const [transfer, setTransfer] = useState(false);
const [target, setTarget] = useState('');
const [consultId, setConsultId] = useState<string | null>(null);
const consult = useSoftphone().calls.find((c) => c.id === consultId);
const run = (p: Promise<unknown>) => {
onError(null);
p.catch((e: Error) => onError(e.message));
};
const label =
call.state === 'incoming'
? 'Incoming call'
: call.state === 'calling'
? 'Calling…'
: call.held
? 'On hold'
: 'On call';
const listId = `sp-dir-${call.id}`;
return (
<div
role="group"
aria-label={`${label}: ${who}`}
className="flex flex-wrap items-center gap-2 rounded-md bg-zinc-100 p-2 dark:bg-zinc-900"
aria-label={`${label}: ${name}`}
className="flex flex-col gap-2 rounded-md bg-zinc-100 p-2 dark:bg-zinc-900"
>
<span className="flex-1 text-sm">
<span className="font-medium">{who}</span> · {label}
</span>
{call.state === 'incoming' && (
<>
<Button variant="primary" onClick={() => void softphone.answer(call.id)}>
Answer
</Button>
<Button variant="ghost" onClick={() => void softphone.decline(call.id)}>
Decline
</Button>
</>
<div className="flex flex-wrap items-center gap-2">
<span className="flex-1 text-sm">
<span className="font-medium">{name}</span> · {label}
{call.answeredAt && <> · {elapsed(call.answeredAt, now)}</>}
{call.poorQuality && (
<span className="ml-2 text-amber-700 dark:text-amber-400">Poor connection</span>
)}
</span>
{call.state === 'incoming' ? (
<>
<Button variant="primary" onClick={() => run(softphone.answer(call.id))}>
Answer
</Button>
<Button variant="ghost" onClick={() => run(softphone.decline(call.id))}>
Decline
</Button>
</>
) : (
<>
{call.state === 'active' && (
<>
<Button
aria-pressed={call.muted}
onClick={() => softphone.mute(call.id, !call.muted)}
>
{call.muted ? 'Unmute' : 'Mute'}
</Button>
<Button
aria-pressed={call.held}
onClick={() => run(softphone.hold(call.id, !call.held))}
>
{call.held ? 'Resume' : 'Hold'}
</Button>
<Button aria-pressed={keypad} onClick={() => setKeypad((k) => !k)}>
Keypad
</Button>
<Button aria-pressed={transfer} onClick={() => setTransfer((t) => !t)}>
Transfer
</Button>
</>
)}
<Button variant="danger" onClick={() => run(softphone.hangup(call.id))}>
Hang up
</Button>
</>
)}
</div>
{keypad && call.state === 'active' && (
<Keypad label="Send tones" onKey={(k) => softphone.dtmf(call.id, k)} />
)}
{call.state !== 'incoming' && (
<Button variant="danger" onClick={() => void softphone.hangup(call.id)}>
Hang up
{transfer && call.state === 'active' && (
<div className="flex flex-col gap-2">
{consultId ? (
<div className="flex flex-wrap items-center gap-2 text-sm">
<span className="flex-1">
Talking to {target} first{consult?.state === 'calling' ? ' (ringing)' : ''}.
</span>
<Button
variant="primary"
disabled={consult?.state !== 'active'}
onClick={() => run(softphone.attendedTransfer(call.id, consultId))}
>
Complete transfer
</Button>
<Button
onClick={() =>
run(
softphone.hangup(consultId).then(() => {
setConsultId(null);
return softphone.hold(call.id, false);
}),
)
}
>
Cancel
</Button>
</div>
) : (
<div className="flex flex-wrap gap-2">
<label htmlFor={`${listId}-in`} className="sr-only">
Transfer to
</label>
<input
id={`${listId}-in`}
className={`${inputClass} max-w-56`}
list={listId}
placeholder="Transfer to"
value={target}
onChange={(e) => setTarget(e.target.value)}
/>
<datalist id={listId}>
{Object.values(exts).map((e) => (
<option key={e.number} value={e.number}>
{e.name}
</option>
))}
</datalist>
<Button
disabled={!clean(target)}
onClick={() => run(softphone.blindTransfer(call.id, clean(target)))}
>
Transfer now
</Button>
<Button
disabled={!clean(target)}
onClick={() => run(softphone.call(clean(target)).then(setConsultId))}
>
Talk first
</Button>
</div>
)}
</div>
)}
</div>
);
}
function Devices() {
const { inputDeviceId, outputDeviceId } = useSoftphone();
const [devices, setDevices] = useState<MediaDeviceInfo[]>([]);
const [perm, setPerm] = useState(() =>
typeof Notification === 'undefined' ? 'unsupported' : Notification.permission,
);
useEffect(() => {
const load = () => void navigator.mediaDevices.enumerateDevices().then(setDevices);
load();
navigator.mediaDevices.addEventListener('devicechange', load);
return () => navigator.mediaDevices.removeEventListener('devicechange', load);
}, []);
const mics = devices.filter((d) => d.kind === 'audioinput');
const speakers = devices.filter((d) => d.kind === 'audiooutput');
const canPickSpeaker = 'setSinkId' in HTMLMediaElement.prototype && speakers.length > 0;
return (
<div className="flex flex-wrap items-end gap-3 text-sm">
<label className="flex flex-col gap-1">
Microphone
<select
className={inputClass}
value={inputDeviceId ?? ''}
onChange={(e) => softphone.setInputDevice(e.target.value || null)}
>
<option value="">Default</option>
{mics.map((d) => (
<option key={d.deviceId} value={d.deviceId}>
{d.label || 'Microphone'}
</option>
))}
</select>
</label>
{canPickSpeaker && (
<label className="flex flex-col gap-1">
Speaker
<select
className={inputClass}
value={outputDeviceId ?? ''}
onChange={(e) => void softphone.setOutputDevice(e.target.value || null)}
>
<option value="">Default</option>
{speakers.map((d) => (
<option key={d.deviceId} value={d.deviceId}>
{d.label || 'Speaker'}
</option>
))}
</select>
</label>
)}
{perm === 'default' && (
<Button onClick={() => void Notification.requestPermission().then(setPerm)}>
Notify me of calls
</Button>
)}
</div>
+175 -15
View File
@@ -1,6 +1,6 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
// The browser softphone (05 §5.2): SIP.js over WebSocket to Asterisk, no React in here.
// React reads it through subscribe()/state (useSyncExternalStore in useSoftphone.ts).
// React reads it through subscribe()/state (useSyncExternalStore in SoftphoneDock.tsx).
import {
Invitation,
Inviter,
@@ -9,11 +9,14 @@ import {
SessionState,
UserAgent,
Web,
type Notification as SipNotification,
type Session,
} from 'sip.js';
import type { WebRtcCreds } from '@selfphoneng/shared';
import { beep } from './sounds.ts';
export type RegState = 'off' | 'connecting' | 'registered' | 'error';
/** elsewhere: another tab of this browser has the softphone (one tab only, leader.ts). */
export type RegState = 'off' | 'connecting' | 'registered' | 'error' | 'elsewhere';
export type CallInfo = {
id: string;
@@ -24,17 +27,38 @@ export type CallInfo = {
/** incoming: ringing here; calling: ringing there; active: talking. */
state: 'incoming' | 'calling' | 'active';
answeredAt: number | null;
muted: boolean;
held: boolean;
/** Packet loss > 5 % or jitter > 50 ms over the last 5 s. */
poorQuality: boolean;
};
export type EngineState = { reg: RegState; error: string | null; calls: CallInfo[] };
export type EngineState = {
reg: RegState;
error: string | null;
calls: CallInfo[];
inputDeviceId: string | null;
outputDeviceId: string | null;
};
const BACKOFF_S = [2, 4, 8, 16, 30];
const MAX_CALLS = 2;
type Tracked = { session: Session; info: CallInfo; audio: HTMLAudioElement };
type Tracked = {
session: Session;
info: CallInfo;
audio: HTMLAudioElement;
stats?: ReturnType<typeof setInterval>;
};
export class SoftphoneEngine {
state: EngineState = { reg: 'off', error: null, calls: [] };
state: EngineState = {
reg: 'off',
error: null,
calls: [],
inputDeviceId: null,
outputDeviceId: null,
};
private listeners = new Set<() => void>();
private ua: UserAgent | null = null;
private registerer: Registerer | null = null;
@@ -58,6 +82,10 @@ export class SoftphoneEngine {
this.set({ calls: [...this.sessions.values()].map((t) => ({ ...t.info })) });
}
setElsewhere() {
this.set({ reg: 'elsewhere', error: null });
}
async start(creds: WebRtcCreds): Promise<void> {
await this.stop();
this.stopped = false;
@@ -138,15 +166,35 @@ export class SoftphoneEngine {
this.set({ reg: 'off', error: null, calls: [] });
}
async call(number: string): Promise<void> {
const target = UserAgent.makeURI(`sip:${encodeURIComponent(number)}@${this.host}`);
if (!this.ua || !target) throw new Error('Softphone is not started');
private constraints(): MediaStreamConstraints {
const id = this.state.inputDeviceId;
return { audio: id ? { deviceId: { exact: id } } : true, video: false };
}
private uri(number: string) {
const u = UserAgent.makeURI(`sip:${encodeURIComponent(number)}@${this.host}`);
if (!u) throw new Error(`Can't call ${number}`);
return u;
}
/** Puts every other talking call on hold first (call waiting, consult calls). */
private async holdOthers(except?: string) {
for (const t of this.sessions.values())
if (t.info.id !== except && t.info.state === 'active' && !t.info.held)
await this.hold(t.info.id, true);
}
/** Returns the new call's id. */
async call(number: string): Promise<string> {
if (!this.ua) throw new Error('Softphone is not started');
if (this.sessions.size >= MAX_CALLS) throw new Error('Two calls already');
const inviter = new Inviter(this.ua, target, {
sessionDescriptionHandlerOptions: { constraints: { audio: true, video: false } },
await this.holdOthers();
const inviter = new Inviter(this.ua, this.uri(number), {
sessionDescriptionHandlerOptions: { constraints: this.constraints() },
});
this.track(inviter, 'out', number, null);
await inviter.invite();
return inviter.id;
}
private incoming(inv: Invitation) {
@@ -160,13 +208,17 @@ export class SoftphoneEngine {
inv.remoteIdentity.uri.user ?? '',
inv.remoteIdentity.displayName || null,
);
// Paging/intercom (03 sp-sub-autoanswer): answer after a short beep.
if (/answer-after=0/.test(inv.request.getHeader('Call-Info') ?? '') && this.sessions.size === 1)
void beep().then(() => this.answer(inv.id));
}
async answer(id: string): Promise<void> {
const t = this.sessions.get(id);
if (!(t?.session instanceof Invitation)) return;
await this.holdOthers(id);
await t.session.accept({
sessionDescriptionHandlerOptions: { constraints: { audio: true, video: false } },
sessionDescriptionHandlerOptions: { constraints: this.constraints() },
});
}
@@ -184,10 +236,91 @@ export class SoftphoneEngine {
else if (s instanceof Invitation) await s.reject();
}
private sdh(id: string) {
const sdh = this.sessions.get(id)?.session.sessionDescriptionHandler;
return sdh instanceof Web.SessionDescriptionHandler ? sdh : null;
}
/** re-INVITE with a=sendonly (hold) or a=sendrecv (resume). */
async hold(id: string, on: boolean): Promise<void> {
const t = this.sessions.get(id);
if (!t || t.session.state !== SessionState.Established || t.info.held === on) return;
t.session.sessionDescriptionHandlerOptionsReInvite = {
...t.session.sessionDescriptionHandlerOptionsReInvite,
hold: on,
} as Web.SessionDescriptionHandlerOptions;
await t.session.invite();
this.sdh(id)?.enableReceiverTracks(!on);
this.sdh(id)?.enableSenderTracks(!on && !t.info.muted);
t.info.held = on;
this.publishCalls();
}
mute(id: string, on: boolean): void {
const t = this.sessions.get(id);
if (!t) return;
this.sdh(id)?.enableSenderTracks(!on && !t.info.held);
t.info.muted = on;
this.publishCalls();
}
/** RFC 4733 through the RTCDTMFSender; SIP INFO when the browser can't (canInsertDTMF false). */
dtmf(id: string, digit: string): void {
if (this.sdh(id)?.sendDtmf(digit)) return;
void this.sessions
.get(id)
?.session.info({
requestOptions: {
body: {
contentDisposition: 'render',
contentType: 'application/dtmf-relay',
content: `Signal=${digit}\r\nDuration=160`,
},
},
})
.catch(() => undefined);
}
/** RFC 5589: once the NOTIFY says the transfer worked (2xx sipfrag), the transferor hangs up. */
private referOptions(s: Session) {
return {
onNotify: (n: SipNotification) => {
void n.accept();
if (/^SIP\/2\.0 2\d\d/m.test(n.request.body ?? '') && s.state === SessionState.Established)
void s.bye().catch(() => undefined);
},
};
}
async blindTransfer(id: string, target: string): Promise<void> {
const s = this.sessions.get(id)?.session;
if (s) await s.refer(this.uri(target), this.referOptions(s));
}
/** REFER with Replaces: the other side of `id` is connected to the other side of `consultId`. */
async attendedTransfer(id: string, consultId: string): Promise<void> {
const s = this.sessions.get(id)?.session;
const consult = this.sessions.get(consultId)?.session;
if (s && consult) await s.refer(consult, this.referOptions(s));
}
async setOutputDevice(deviceId: string | null): Promise<void> {
this.set({ outputDeviceId: deviceId });
for (const t of this.sessions.values())
await t.audio.setSinkId?.(deviceId ?? '').catch(() => undefined);
}
/** Used for the next call. */
setInputDevice(deviceId: string | null): void {
this.set({ inputDeviceId: deviceId });
}
private track(session: Session, direction: 'in' | 'out', remote: string, name: string | null) {
const id = session.id;
const audio = new Audio();
audio.autoplay = true;
if (this.state.outputDeviceId)
void audio.setSinkId?.(this.state.outputDeviceId).catch(() => undefined);
const info: CallInfo = {
id,
direction,
@@ -195,19 +328,25 @@ export class SoftphoneEngine {
remoteName: name,
state: direction === 'in' ? 'incoming' : 'calling',
answeredAt: null,
muted: false,
held: false,
poorQuality: false,
};
this.sessions.set(id, { session, info, audio });
const tracked: Tracked = { session, info, audio };
this.sessions.set(id, tracked);
session.stateChange.addListener((s) => {
if (s === SessionState.Established) {
const sdh = session.sessionDescriptionHandler;
if (sdh instanceof Web.SessionDescriptionHandler) {
if (s === SessionState.Established && info.state !== 'active') {
const sdh = this.sdh(id);
if (sdh) {
audio.srcObject = sdh.remoteMediaStream;
void audio.play().catch(() => undefined);
tracked.stats = this.watchQuality(sdh.peerConnection, info);
}
info.state = 'active';
info.answeredAt = Date.now();
}
if (s === SessionState.Terminated) {
clearInterval(tracked.stats);
audio.srcObject = null;
this.sessions.delete(id);
}
@@ -215,6 +354,27 @@ export class SoftphoneEngine {
});
this.publishCalls();
}
/** Every 5 s: loss and jitter of what we receive. */
private watchQuality(pc: RTCPeerConnection | undefined, info: CallInfo) {
let last = { lost: 0, received: 0 };
return setInterval(() => {
void pc?.getStats().then((report) => {
report.forEach((s: RTCInboundRtpStreamStats) => {
if (s.type !== 'inbound-rtp' || s.kind !== 'audio') return;
const lost = (s.packetsLost ?? 0) - last.lost;
const received = (s.packetsReceived ?? 0) - last.received;
last = { lost: s.packetsLost ?? 0, received: s.packetsReceived ?? 0 };
const poor =
!info.held && (lost / Math.max(1, lost + received) > 0.05 || (s.jitter ?? 0) > 0.05);
if (poor !== info.poorQuality) {
info.poorQuality = poor;
this.publishCalls();
}
});
});
}, 5000);
}
}
/** One engine per tab. */
+46
View File
@@ -0,0 +1,46 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
// One tab only (05 §5.2): the tab holding the Web Lock registers. "Use here" asks the holder,
// over a BroadcastChannel, to stop and let go; the asking tab then gets the lock.
const NAME = 'selfphoneng-softphone';
const channel = typeof BroadcastChannel === 'undefined' ? null : new BroadcastChannel(NAME);
let release: (() => void) | null = null;
/** Holds the lock until stepDown(); resolves once it's ours. */
function hold(resolve: () => void) {
return () => {
resolve();
return new Promise<void>((r) => (release = r));
};
}
/** True if this tab is (now) the softphone tab; false if another tab has it. */
export async function tryLead(): Promise<boolean> {
if (release) return true;
if (!navigator.locks) return true; // very old browser: no coordination
return new Promise((resolve) => {
void navigator.locks.request(NAME, { ifAvailable: true }, (lock) =>
lock ? hold(() => resolve(true))() : resolve(false),
);
});
}
/** "Use here": the other tab stops, then this one leads. */
export async function takeOver(): Promise<void> {
if (release || !navigator.locks) return;
channel?.postMessage('take');
await new Promise<void>((resolve) => void navigator.locks.request(NAME, hold(resolve)));
}
export function stepDown() {
release?.();
release = null;
}
/** Runs when another tab takes over; the lock is released after `stop` finishes. */
export function onTakeOver(stop: () => Promise<void>) {
channel?.addEventListener('message', (e) => {
if (e.data !== 'take' || !release) return;
void stop().finally(stepDown);
});
}
+36
View File
@@ -0,0 +1,36 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
// Ringtone and beep made with WebAudio (US ring: 440 + 480 Hz, 2 s on, 4 s off).
// ponytail: synthesized instead of the ringtone.mp3 in 05 §5.2, so there's no file or license.
let ctx: AudioContext | null = null;
let ringing: ReturnType<typeof setInterval> | undefined;
function tone(freqs: number[], seconds: number, volume: number) {
ctx ??= new AudioContext();
const gain = ctx.createGain();
gain.gain.value = volume;
gain.connect(ctx.destination);
for (const f of freqs) {
const o = ctx.createOscillator();
o.frequency.value = f;
o.connect(gain);
o.start();
o.stop(ctx.currentTime + seconds);
}
}
export function startRinging() {
if (ringing) return;
tone([440, 480], 2, 0.15);
ringing = setInterval(() => tone([440, 480], 2, 0.15), 6000);
}
export function stopRinging() {
clearInterval(ringing);
ringing = undefined;
}
export function beep(): Promise<void> {
tone([1000], 0.2, 0.2);
return new Promise((r) => setTimeout(r, 300));
}