@takosjp/yurucommu-core 3.2.1 → 3.3.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/migrations/0020_call_sessions.sql +23 -0
- package/package.json +2 -1
- package/packages/api/package.json +1 -1
- package/packages/api/src/index.ts +1 -0
- package/packages/api/src/lib/rtc-client.ts +542 -0
- package/packages/api/src/types/call.ts +306 -0
- package/packages/api/src/types/index.ts +3 -0
- package/src/backend/index.ts +21 -1
- package/src/backend/lib/rtc/call-store.ts +101 -0
- package/src/backend/lib/rtc/provider.ts +135 -0
- package/src/backend/lib/rtc/signal-transport.ts +125 -0
- package/src/backend/public.ts +3 -0
- package/src/backend/routes/activitypub.ts +5 -1
- package/src/backend/routes/auth.ts +8 -2
- package/src/backend/routes/rtc/index.ts +147 -0
- package/src/backend/runtime/call-hub-core.ts +470 -0
- package/src/backend/runtime/call-hub-port.ts +78 -0
- package/src/backend/runtime/call-signaling-do.ts +242 -0
- package/src/backend/runtime/signaling-hub.ts +187 -0
- package/src/backend/types.ts +28 -0
- package/src/db/schema/calls.ts +46 -0
- package/src/db/schema/index.ts +1 -0
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
CREATE TABLE IF NOT EXISTS call_sessions (
|
|
2
|
+
id TEXT PRIMARY KEY,
|
|
3
|
+
local_actor_ap_id TEXT NOT NULL,
|
|
4
|
+
peer_actor_ap_id TEXT NOT NULL,
|
|
5
|
+
direction TEXT NOT NULL,
|
|
6
|
+
state TEXT NOT NULL DEFAULT 'ringing',
|
|
7
|
+
media_audio INTEGER NOT NULL DEFAULT 1,
|
|
8
|
+
media_video INTEGER NOT NULL DEFAULT 0,
|
|
9
|
+
sfu_focus TEXT,
|
|
10
|
+
peer_signal_endpoint TEXT,
|
|
11
|
+
end_reason TEXT,
|
|
12
|
+
created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ', 'now')),
|
|
13
|
+
updated_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ', 'now')),
|
|
14
|
+
connected_at TEXT,
|
|
15
|
+
ended_at TEXT,
|
|
16
|
+
FOREIGN KEY (local_actor_ap_id) REFERENCES actors(ap_id) ON DELETE CASCADE
|
|
17
|
+
);
|
|
18
|
+
|
|
19
|
+
CREATE INDEX IF NOT EXISTS call_sessions_local_created_idx
|
|
20
|
+
ON call_sessions(local_actor_ap_id, created_at);
|
|
21
|
+
|
|
22
|
+
CREATE INDEX IF NOT EXISTS call_sessions_state_idx
|
|
23
|
+
ON call_sessions(state);
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@takosjp/yurucommu-core",
|
|
3
|
-
"version": "3.
|
|
3
|
+
"version": "3.3.0",
|
|
4
4
|
"license": "AGPL-3.0-only",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"workspaces": [
|
|
@@ -19,6 +19,7 @@
|
|
|
19
19
|
"packages/api/src/types",
|
|
20
20
|
"packages/api/src/lib/api.ts",
|
|
21
21
|
"packages/api/src/lib/transport.ts",
|
|
22
|
+
"packages/api/src/lib/rtc-client.ts",
|
|
22
23
|
"packages/api/src/lib/fetch-with-timeout.ts",
|
|
23
24
|
"packages/api/src/lib/api/account.ts",
|
|
24
25
|
"packages/api/src/lib/api/actors.ts",
|
|
@@ -0,0 +1,542 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* CallClient — framework-agnostic browser WebRTC engine for the call feature.
|
|
3
|
+
*
|
|
4
|
+
* Owns the WebSocket to the per-user signaling hub (`/api/rtc/socket`), the
|
|
5
|
+
* `RTCPeerConnection` lifecycle, `getUserMedia`, half-trickle ICE, and the call
|
|
6
|
+
* state machine as the browser sees it. Both products (yurucommu / yurumeet)
|
|
7
|
+
* wrap this with their own reactive state layer (SolidJS signals / jotai) — no
|
|
8
|
+
* WebRTC logic is duplicated per product, and the wire protocol is single-
|
|
9
|
+
* sourced with the backend via `./types/call.ts`.
|
|
10
|
+
*
|
|
11
|
+
* 1:1 only for now: the caller offers, the callee answers, media is P2P over the
|
|
12
|
+
* server-provided ICE (STUN/TURN) servers. Camera on/off toggles the existing
|
|
13
|
+
* video track (no mid-call renegotiation, so no glare at the PC level).
|
|
14
|
+
*/
|
|
15
|
+
|
|
16
|
+
import type {
|
|
17
|
+
CallMediaKind,
|
|
18
|
+
ClientToHubFrame,
|
|
19
|
+
HubToClientFrame,
|
|
20
|
+
IceServerConfig,
|
|
21
|
+
} from "../types/call.ts";
|
|
22
|
+
|
|
23
|
+
export type CallUiState =
|
|
24
|
+
| "idle"
|
|
25
|
+
| "calling" // outgoing, ringing
|
|
26
|
+
| "incoming" // inbound ring, awaiting accept/decline
|
|
27
|
+
| "connecting"
|
|
28
|
+
| "connected"
|
|
29
|
+
| "ended";
|
|
30
|
+
|
|
31
|
+
export interface IncomingCallInfo {
|
|
32
|
+
callId: string;
|
|
33
|
+
from: string;
|
|
34
|
+
media: CallMediaKind;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
export interface CallClientEvents {
|
|
38
|
+
state: (state: CallUiState) => void;
|
|
39
|
+
incoming: (info: IncomingCallInfo) => void;
|
|
40
|
+
localstream: (stream: MediaStream | null) => void;
|
|
41
|
+
remotestream: (stream: MediaStream | null) => void;
|
|
42
|
+
muted: (muted: boolean) => void;
|
|
43
|
+
cameraoff: (off: boolean) => void;
|
|
44
|
+
error: (code: string, message?: string) => void;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
export interface CallClientOptions {
|
|
48
|
+
/** Origin the app is served from (defaults to the page origin). */
|
|
49
|
+
origin?: string;
|
|
50
|
+
/** WebSocket reconnect backoff ceiling (ms). */
|
|
51
|
+
maxBackoffMs?: number;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
type Listener = (...args: never[]) => void;
|
|
55
|
+
|
|
56
|
+
interface ActiveCall {
|
|
57
|
+
callId: string;
|
|
58
|
+
peer: string;
|
|
59
|
+
media: CallMediaKind;
|
|
60
|
+
role: "caller" | "callee";
|
|
61
|
+
pc: RTCPeerConnection | null;
|
|
62
|
+
iceServers: IceServerConfig[];
|
|
63
|
+
pendingRemoteCandidates: RTCIceCandidateInit[];
|
|
64
|
+
candidateBuffer: RTCIceCandidateInit[];
|
|
65
|
+
flushTimer: ReturnType<typeof setTimeout> | null;
|
|
66
|
+
remoteDescriptionSet: boolean;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
const CANDIDATE_FLUSH_MS = 200;
|
|
70
|
+
|
|
71
|
+
export class CallClient {
|
|
72
|
+
private ws: WebSocket | null = null;
|
|
73
|
+
private wantConnected = false;
|
|
74
|
+
private backoff = 500;
|
|
75
|
+
private reconnectTimer: ReturnType<typeof setTimeout> | null = null;
|
|
76
|
+
private readonly listeners = new Map<keyof CallClientEvents, Set<Listener>>();
|
|
77
|
+
private call: ActiveCall | null = null;
|
|
78
|
+
private localStream: MediaStream | null = null;
|
|
79
|
+
private state: CallUiState = "idle";
|
|
80
|
+
|
|
81
|
+
constructor(private readonly options: CallClientOptions = {}) {}
|
|
82
|
+
|
|
83
|
+
// --- events ---------------------------------------------------------------
|
|
84
|
+
on<K extends keyof CallClientEvents>(
|
|
85
|
+
event: K,
|
|
86
|
+
listener: CallClientEvents[K],
|
|
87
|
+
): () => void {
|
|
88
|
+
let set = this.listeners.get(event);
|
|
89
|
+
if (!set) {
|
|
90
|
+
set = new Set();
|
|
91
|
+
this.listeners.set(event, set);
|
|
92
|
+
}
|
|
93
|
+
set.add(listener as Listener);
|
|
94
|
+
return () => set?.delete(listener as Listener);
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
private emit<K extends keyof CallClientEvents>(
|
|
98
|
+
event: K,
|
|
99
|
+
...args: Parameters<CallClientEvents[K]>
|
|
100
|
+
): void {
|
|
101
|
+
for (const l of this.listeners.get(event) ?? []) {
|
|
102
|
+
(l as (...a: unknown[]) => void)(...args);
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
private setState(state: CallUiState): void {
|
|
107
|
+
if (this.state === state) return;
|
|
108
|
+
this.state = state;
|
|
109
|
+
this.emit("state", state);
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
getState(): CallUiState {
|
|
113
|
+
return this.state;
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
// --- connection -----------------------------------------------------------
|
|
117
|
+
private origin(): string {
|
|
118
|
+
return (
|
|
119
|
+
this.options.origin ??
|
|
120
|
+
(typeof location !== "undefined" ? location.origin : "")
|
|
121
|
+
);
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
private socketUrl(): string {
|
|
125
|
+
const o = this.origin();
|
|
126
|
+
return `${o.replace(/^http/, "ws")}/api/rtc/socket`;
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
connect(): void {
|
|
130
|
+
this.wantConnected = true;
|
|
131
|
+
this.openSocket();
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
disconnect(): void {
|
|
135
|
+
this.wantConnected = false;
|
|
136
|
+
if (this.reconnectTimer) clearTimeout(this.reconnectTimer);
|
|
137
|
+
this.reconnectTimer = null;
|
|
138
|
+
this.ws?.close();
|
|
139
|
+
this.ws = null;
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
private openSocket(): void {
|
|
143
|
+
if (this.ws && this.ws.readyState <= WebSocket.OPEN) return;
|
|
144
|
+
let socket: WebSocket;
|
|
145
|
+
try {
|
|
146
|
+
socket = new WebSocket(this.socketUrl());
|
|
147
|
+
} catch (err) {
|
|
148
|
+
this.emit("error", "socket_open_failed", String(err));
|
|
149
|
+
this.scheduleReconnect();
|
|
150
|
+
return;
|
|
151
|
+
}
|
|
152
|
+
this.ws = socket;
|
|
153
|
+
socket.onopen = () => {
|
|
154
|
+
this.backoff = 500;
|
|
155
|
+
this.send({ t: "hello" });
|
|
156
|
+
if (this.call) this.send({ t: "resume", callId: this.call.callId });
|
|
157
|
+
};
|
|
158
|
+
socket.onmessage = (ev) => {
|
|
159
|
+
void this.onFrame(ev.data);
|
|
160
|
+
};
|
|
161
|
+
socket.onclose = () => {
|
|
162
|
+
if (this.ws === socket) this.ws = null;
|
|
163
|
+
if (this.wantConnected) this.scheduleReconnect();
|
|
164
|
+
};
|
|
165
|
+
socket.onerror = () => {
|
|
166
|
+
socket.close();
|
|
167
|
+
};
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
private scheduleReconnect(): void {
|
|
171
|
+
if (!this.wantConnected || this.reconnectTimer) return;
|
|
172
|
+
const max = this.options.maxBackoffMs ?? 15_000;
|
|
173
|
+
const delay = Math.min(this.backoff, max);
|
|
174
|
+
this.backoff = Math.min(this.backoff * 2, max);
|
|
175
|
+
this.reconnectTimer = setTimeout(() => {
|
|
176
|
+
this.reconnectTimer = null;
|
|
177
|
+
this.openSocket();
|
|
178
|
+
}, delay);
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
private send(frame: ClientToHubFrame): void {
|
|
182
|
+
if (this.ws && this.ws.readyState === WebSocket.OPEN) {
|
|
183
|
+
this.ws.send(JSON.stringify(frame));
|
|
184
|
+
}
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
// --- public call control --------------------------------------------------
|
|
188
|
+
|
|
189
|
+
/** Place an outgoing call. Fetches a callId + ICE, then rings the peer. */
|
|
190
|
+
async startCall(peer: string, media: CallMediaKind): Promise<void> {
|
|
191
|
+
if (this.call) throw new Error("already in a call");
|
|
192
|
+
const res = await fetch(`${this.origin()}/api/rtc/calls`, {
|
|
193
|
+
method: "POST",
|
|
194
|
+
credentials: "include",
|
|
195
|
+
headers: { "Content-Type": "application/json" },
|
|
196
|
+
body: JSON.stringify({ to: peer, media }),
|
|
197
|
+
});
|
|
198
|
+
if (!res.ok) {
|
|
199
|
+
const code = res.status === 403 ? "blocked" : "start_failed";
|
|
200
|
+
this.emit("error", code);
|
|
201
|
+
throw new Error(code);
|
|
202
|
+
}
|
|
203
|
+
const data = (await res.json()) as {
|
|
204
|
+
callId: string;
|
|
205
|
+
iceServers: IceServerConfig[];
|
|
206
|
+
};
|
|
207
|
+
this.call = this.newCall(
|
|
208
|
+
data.callId,
|
|
209
|
+
peer,
|
|
210
|
+
media,
|
|
211
|
+
"caller",
|
|
212
|
+
data.iceServers,
|
|
213
|
+
);
|
|
214
|
+
this.setState("calling");
|
|
215
|
+
this.connect();
|
|
216
|
+
this.send({ t: "invite", callId: data.callId, to: peer, media });
|
|
217
|
+
await this.setupMedia(this.call);
|
|
218
|
+
await this.makeOffer(this.call);
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
/** Accept the current incoming call. */
|
|
222
|
+
async accept(): Promise<void> {
|
|
223
|
+
const call = this.call;
|
|
224
|
+
if (!call || call.role !== "callee") return;
|
|
225
|
+
this.setState("connecting");
|
|
226
|
+
this.send({ t: "accept", callId: call.callId });
|
|
227
|
+
await this.setupMedia(call);
|
|
228
|
+
// The offer was already applied on arrival; create + send the answer.
|
|
229
|
+
if (call.pc && call.remoteDescriptionSet) {
|
|
230
|
+
const answer = await call.pc.createAnswer();
|
|
231
|
+
await call.pc.setLocalDescription(answer);
|
|
232
|
+
this.send({ t: "answer", callId: call.callId, sdp: answer.sdp ?? "" });
|
|
233
|
+
}
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
/** Decline the current incoming call. */
|
|
237
|
+
reject(reason = "declined"): void {
|
|
238
|
+
const call = this.call;
|
|
239
|
+
if (!call) return;
|
|
240
|
+
this.send({ t: "reject", callId: call.callId, reason });
|
|
241
|
+
this.teardown("ended");
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
/** Hang up / cancel the active call. */
|
|
245
|
+
hangup(reason = "hangup"): void {
|
|
246
|
+
const call = this.call;
|
|
247
|
+
if (!call) return;
|
|
248
|
+
this.send({ t: "hangup", callId: call.callId, reason });
|
|
249
|
+
this.teardown("ended");
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
setMuted(muted: boolean): void {
|
|
253
|
+
for (const track of this.localStream?.getAudioTracks() ?? []) {
|
|
254
|
+
track.enabled = !muted;
|
|
255
|
+
}
|
|
256
|
+
this.emit("muted", muted);
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
setCameraEnabled(enabled: boolean): void {
|
|
260
|
+
for (const track of this.localStream?.getVideoTracks() ?? []) {
|
|
261
|
+
track.enabled = enabled;
|
|
262
|
+
}
|
|
263
|
+
this.emit("cameraoff", !enabled);
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
// --- frame handling -------------------------------------------------------
|
|
267
|
+
private async onFrame(raw: unknown): Promise<void> {
|
|
268
|
+
if (typeof raw !== "string") return;
|
|
269
|
+
let frame: HubToClientFrame;
|
|
270
|
+
try {
|
|
271
|
+
frame = JSON.parse(raw) as HubToClientFrame;
|
|
272
|
+
} catch {
|
|
273
|
+
return;
|
|
274
|
+
}
|
|
275
|
+
switch (frame.t) {
|
|
276
|
+
case "ready":
|
|
277
|
+
case "pong":
|
|
278
|
+
return;
|
|
279
|
+
case "ringing":
|
|
280
|
+
this.onRinging(frame);
|
|
281
|
+
return;
|
|
282
|
+
case "ice-servers":
|
|
283
|
+
if (this.call && this.call.callId === frame.callId) {
|
|
284
|
+
this.call.iceServers = frame.iceServers;
|
|
285
|
+
}
|
|
286
|
+
return;
|
|
287
|
+
case "offer":
|
|
288
|
+
await this.onRemoteOffer(frame);
|
|
289
|
+
return;
|
|
290
|
+
case "answer":
|
|
291
|
+
await this.onRemoteAnswer(frame);
|
|
292
|
+
return;
|
|
293
|
+
case "candidates":
|
|
294
|
+
await this.onRemoteCandidates(frame);
|
|
295
|
+
return;
|
|
296
|
+
case "peer-accepted":
|
|
297
|
+
if (this.state === "calling") this.setState("connecting");
|
|
298
|
+
return;
|
|
299
|
+
case "peer-rejected":
|
|
300
|
+
this.emit("error", "declined", frame.reason);
|
|
301
|
+
this.teardown("ended");
|
|
302
|
+
return;
|
|
303
|
+
case "peer-hangup":
|
|
304
|
+
this.teardown("ended");
|
|
305
|
+
return;
|
|
306
|
+
case "call-state":
|
|
307
|
+
this.onCallState(frame);
|
|
308
|
+
return;
|
|
309
|
+
case "error":
|
|
310
|
+
this.emit("error", frame.code, frame.message);
|
|
311
|
+
if (frame.code === "peer_unreachable") this.teardown("ended");
|
|
312
|
+
return;
|
|
313
|
+
}
|
|
314
|
+
}
|
|
315
|
+
|
|
316
|
+
private onRinging(
|
|
317
|
+
frame: Extract<HubToClientFrame, { t: "ringing" }>,
|
|
318
|
+
): void {
|
|
319
|
+
if (this.call) {
|
|
320
|
+
// Already busy — auto-decline the second ring.
|
|
321
|
+
this.send({ t: "reject", callId: frame.callId, reason: "busy" });
|
|
322
|
+
return;
|
|
323
|
+
}
|
|
324
|
+
this.call = this.newCall(
|
|
325
|
+
frame.callId,
|
|
326
|
+
frame.from,
|
|
327
|
+
frame.media,
|
|
328
|
+
"callee",
|
|
329
|
+
[],
|
|
330
|
+
);
|
|
331
|
+
this.setState("incoming");
|
|
332
|
+
this.emit("incoming", {
|
|
333
|
+
callId: frame.callId,
|
|
334
|
+
from: frame.from,
|
|
335
|
+
media: frame.media,
|
|
336
|
+
});
|
|
337
|
+
}
|
|
338
|
+
|
|
339
|
+
private onCallState(
|
|
340
|
+
frame: Extract<HubToClientFrame, { t: "call-state" }>,
|
|
341
|
+
): void {
|
|
342
|
+
if (!this.call || this.call.callId !== frame.callId) return;
|
|
343
|
+
if (
|
|
344
|
+
frame.state === "missed" ||
|
|
345
|
+
frame.state === "cancelled" ||
|
|
346
|
+
frame.state === "rejected" ||
|
|
347
|
+
frame.state === "failed" ||
|
|
348
|
+
frame.state === "ended"
|
|
349
|
+
) {
|
|
350
|
+
this.teardown("ended");
|
|
351
|
+
}
|
|
352
|
+
}
|
|
353
|
+
|
|
354
|
+
private async onRemoteOffer(
|
|
355
|
+
frame: Extract<HubToClientFrame, { t: "offer" }>,
|
|
356
|
+
): Promise<void> {
|
|
357
|
+
let call = this.call;
|
|
358
|
+
if (!call || call.callId !== frame.callId) {
|
|
359
|
+
// Offer for a call we have not seen a ring for yet — adopt it.
|
|
360
|
+
call = this.newCall(
|
|
361
|
+
frame.callId,
|
|
362
|
+
"unknown",
|
|
363
|
+
frame.media ?? { audio: true, video: false },
|
|
364
|
+
"callee",
|
|
365
|
+
[],
|
|
366
|
+
);
|
|
367
|
+
this.call = call;
|
|
368
|
+
this.setState("incoming");
|
|
369
|
+
}
|
|
370
|
+
if (!call.pc) this.buildPeerConnection(call);
|
|
371
|
+
await call.pc?.setRemoteDescription({ type: "offer", sdp: frame.sdp });
|
|
372
|
+
call.remoteDescriptionSet = true;
|
|
373
|
+
await this.drainRemoteCandidates(call);
|
|
374
|
+
}
|
|
375
|
+
|
|
376
|
+
private async onRemoteAnswer(
|
|
377
|
+
frame: Extract<HubToClientFrame, { t: "answer" }>,
|
|
378
|
+
): Promise<void> {
|
|
379
|
+
const call = this.call;
|
|
380
|
+
if (!call || call.callId !== frame.callId || !call.pc) return;
|
|
381
|
+
await call.pc.setRemoteDescription({ type: "answer", sdp: frame.sdp });
|
|
382
|
+
call.remoteDescriptionSet = true;
|
|
383
|
+
await this.drainRemoteCandidates(call);
|
|
384
|
+
}
|
|
385
|
+
|
|
386
|
+
private async onRemoteCandidates(
|
|
387
|
+
frame: Extract<HubToClientFrame, { t: "candidates" }>,
|
|
388
|
+
): Promise<void> {
|
|
389
|
+
const call = this.call;
|
|
390
|
+
if (!call || call.callId !== frame.callId) return;
|
|
391
|
+
for (const cand of frame.candidates) {
|
|
392
|
+
if (call.pc && call.remoteDescriptionSet) {
|
|
393
|
+
try {
|
|
394
|
+
await call.pc.addIceCandidate(cand);
|
|
395
|
+
} catch {
|
|
396
|
+
// ignore malformed candidate
|
|
397
|
+
}
|
|
398
|
+
} else {
|
|
399
|
+
call.pendingRemoteCandidates.push(cand);
|
|
400
|
+
}
|
|
401
|
+
}
|
|
402
|
+
}
|
|
403
|
+
|
|
404
|
+
private async drainRemoteCandidates(call: ActiveCall): Promise<void> {
|
|
405
|
+
if (!call.pc) return;
|
|
406
|
+
const pending = call.pendingRemoteCandidates.splice(0);
|
|
407
|
+
for (const cand of pending) {
|
|
408
|
+
try {
|
|
409
|
+
await call.pc.addIceCandidate(cand);
|
|
410
|
+
} catch {
|
|
411
|
+
// ignore
|
|
412
|
+
}
|
|
413
|
+
}
|
|
414
|
+
}
|
|
415
|
+
|
|
416
|
+
// --- media + peer connection ---------------------------------------------
|
|
417
|
+
private async setupMedia(call: ActiveCall): Promise<void> {
|
|
418
|
+
if (!this.localStream) {
|
|
419
|
+
try {
|
|
420
|
+
this.localStream = await navigator.mediaDevices.getUserMedia({
|
|
421
|
+
audio: call.media.audio,
|
|
422
|
+
video: call.media.video,
|
|
423
|
+
});
|
|
424
|
+
} catch (err) {
|
|
425
|
+
this.emit("error", "media_denied", String(err));
|
|
426
|
+
this.hangup("media_denied");
|
|
427
|
+
return;
|
|
428
|
+
}
|
|
429
|
+
this.emit("localstream", this.localStream);
|
|
430
|
+
}
|
|
431
|
+
if (!call.pc) this.buildPeerConnection(call);
|
|
432
|
+
for (const track of this.localStream.getTracks()) {
|
|
433
|
+
call.pc?.addTrack(track, this.localStream);
|
|
434
|
+
}
|
|
435
|
+
}
|
|
436
|
+
|
|
437
|
+
private buildPeerConnection(call: ActiveCall): void {
|
|
438
|
+
const pc = new RTCPeerConnection({
|
|
439
|
+
iceServers: call.iceServers.map((s) => ({
|
|
440
|
+
urls: s.urls,
|
|
441
|
+
username: s.username,
|
|
442
|
+
credential: s.credential,
|
|
443
|
+
})),
|
|
444
|
+
});
|
|
445
|
+
pc.onicecandidate = (ev) => {
|
|
446
|
+
if (ev.candidate) this.bufferCandidate(call, ev.candidate.toJSON());
|
|
447
|
+
else this.flushCandidates(call);
|
|
448
|
+
};
|
|
449
|
+
pc.ontrack = (ev) => {
|
|
450
|
+
this.emit("remotestream", ev.streams[0] ?? null);
|
|
451
|
+
};
|
|
452
|
+
pc.onconnectionstatechange = () => {
|
|
453
|
+
if (pc.connectionState === "connected") this.setState("connected");
|
|
454
|
+
else if (
|
|
455
|
+
pc.connectionState === "failed" ||
|
|
456
|
+
pc.connectionState === "closed"
|
|
457
|
+
) {
|
|
458
|
+
this.teardown("ended");
|
|
459
|
+
}
|
|
460
|
+
};
|
|
461
|
+
call.pc = pc;
|
|
462
|
+
}
|
|
463
|
+
|
|
464
|
+
private async makeOffer(call: ActiveCall): Promise<void> {
|
|
465
|
+
if (!call.pc) this.buildPeerConnection(call);
|
|
466
|
+
const offer = await call.pc!.createOffer();
|
|
467
|
+
await call.pc!.setLocalDescription(offer);
|
|
468
|
+
this.send({ t: "offer", callId: call.callId, sdp: offer.sdp ?? "" });
|
|
469
|
+
}
|
|
470
|
+
|
|
471
|
+
private bufferCandidate(call: ActiveCall, cand: RTCIceCandidateInit): void {
|
|
472
|
+
call.candidateBuffer.push(cand);
|
|
473
|
+
if (call.flushTimer) return;
|
|
474
|
+
call.flushTimer = setTimeout(
|
|
475
|
+
() => this.flushCandidates(call),
|
|
476
|
+
CANDIDATE_FLUSH_MS,
|
|
477
|
+
);
|
|
478
|
+
}
|
|
479
|
+
|
|
480
|
+
private flushCandidates(call: ActiveCall): void {
|
|
481
|
+
if (call.flushTimer) {
|
|
482
|
+
clearTimeout(call.flushTimer);
|
|
483
|
+
call.flushTimer = null;
|
|
484
|
+
}
|
|
485
|
+
if (call.candidateBuffer.length === 0) return;
|
|
486
|
+
const candidates = call.candidateBuffer.splice(0).map((c) => ({
|
|
487
|
+
candidate: c.candidate ?? "",
|
|
488
|
+
sdpMid: c.sdpMid ?? null,
|
|
489
|
+
sdpMLineIndex: c.sdpMLineIndex ?? null,
|
|
490
|
+
usernameFragment: c.usernameFragment ?? null,
|
|
491
|
+
}));
|
|
492
|
+
this.send({ t: "candidates", callId: call.callId, candidates });
|
|
493
|
+
}
|
|
494
|
+
|
|
495
|
+
private newCall(
|
|
496
|
+
callId: string,
|
|
497
|
+
peer: string,
|
|
498
|
+
media: CallMediaKind,
|
|
499
|
+
role: "caller" | "callee",
|
|
500
|
+
iceServers: IceServerConfig[],
|
|
501
|
+
): ActiveCall {
|
|
502
|
+
return {
|
|
503
|
+
callId,
|
|
504
|
+
peer,
|
|
505
|
+
media,
|
|
506
|
+
role,
|
|
507
|
+
pc: null,
|
|
508
|
+
iceServers,
|
|
509
|
+
pendingRemoteCandidates: [],
|
|
510
|
+
candidateBuffer: [],
|
|
511
|
+
flushTimer: null,
|
|
512
|
+
remoteDescriptionSet: false,
|
|
513
|
+
};
|
|
514
|
+
}
|
|
515
|
+
|
|
516
|
+
private teardown(finalState: CallUiState): void {
|
|
517
|
+
const call = this.call;
|
|
518
|
+
if (call) {
|
|
519
|
+
if (call.flushTimer) clearTimeout(call.flushTimer);
|
|
520
|
+
call.pc?.getSenders().forEach((s) => s.track?.stop());
|
|
521
|
+
try {
|
|
522
|
+
call.pc?.close();
|
|
523
|
+
} catch {
|
|
524
|
+
// ignore
|
|
525
|
+
}
|
|
526
|
+
}
|
|
527
|
+
if (this.localStream) {
|
|
528
|
+
this.localStream.getTracks().forEach((t) => t.stop());
|
|
529
|
+
this.localStream = null;
|
|
530
|
+
this.emit("localstream", null);
|
|
531
|
+
}
|
|
532
|
+
this.emit("remotestream", null);
|
|
533
|
+
this.call = null;
|
|
534
|
+
this.setState(finalState);
|
|
535
|
+
// Return to idle shortly so the UI can show an "ended" flash then reset.
|
|
536
|
+
if (finalState === "ended") {
|
|
537
|
+
setTimeout(() => {
|
|
538
|
+
if (!this.call) this.setState("idle");
|
|
539
|
+
}, 400);
|
|
540
|
+
}
|
|
541
|
+
}
|
|
542
|
+
}
|