@lumencast/runtime 0.9.0 → 0.11.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/dist/.tsbuildinfo +1 -1
- package/dist/app.d.ts +6 -1
- package/dist/app.d.ts.map +1 -1
- package/dist/app.js +3 -1
- package/dist/app.js.map +1 -1
- package/dist/{broadcast-ryjLRD5q.js → broadcast-DtHoU_fS.js} +3 -3
- package/dist/{broadcast-ryjLRD5q.js.map → broadcast-DtHoU_fS.js.map} +1 -1
- package/dist/{control-AgxbXOVS.js → control-B9frEbNG.js} +4 -4
- package/dist/{control-AgxbXOVS.js.map → control-B9frEbNG.js.map} +1 -1
- package/dist/index-Dz27r92m.js +1327 -0
- package/dist/index-Dz27r92m.js.map +1 -0
- package/dist/index.d.ts +3 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.html +1 -1
- package/dist/index.js +16 -0
- package/dist/index.js.map +1 -1
- package/dist/lumencast.js +21 -12
- package/dist/mount.d.ts.map +1 -1
- package/dist/mount.js +22 -0
- package/dist/mount.js.map +1 -1
- package/dist/overlay/runtime-context.d.ts +10 -0
- package/dist/overlay/runtime-context.d.ts.map +1 -1
- package/dist/overlay/runtime-context.js.map +1 -1
- package/dist/render/bundle.d.ts +1 -1
- package/dist/render/bundle.d.ts.map +1 -1
- package/dist/render/bundle.js.map +1 -1
- package/dist/render/primitives/capture.d.ts +13 -4
- package/dist/render/primitives/capture.d.ts.map +1 -1
- package/dist/render/primitives/capture.js +54 -22
- package/dist/render/primitives/capture.js.map +1 -1
- package/dist/render/primitives/index.d.ts.map +1 -1
- package/dist/render/primitives/index.js +10 -0
- package/dist/render/primitives/index.js.map +1 -1
- package/dist/render/primitives/live-peer-video.d.ts +27 -0
- package/dist/render/primitives/live-peer-video.d.ts.map +1 -0
- package/dist/render/primitives/live-peer-video.js +64 -0
- package/dist/render/primitives/live-peer-video.js.map +1 -0
- package/dist/render/primitives/media.d.ts +37 -12
- package/dist/render/primitives/media.d.ts.map +1 -1
- package/dist/render/primitives/media.js +43 -17
- package/dist/render/primitives/media.js.map +1 -1
- package/dist/render/primitives/meet-peer-slot.d.ts +29 -0
- package/dist/render/primitives/meet-peer-slot.d.ts.map +1 -0
- package/dist/render/primitives/meet-peer-slot.js +46 -0
- package/dist/render/primitives/meet-peer-slot.js.map +1 -0
- package/dist/render/primitives/meet-peer.d.ts +31 -0
- package/dist/render/primitives/meet-peer.d.ts.map +1 -0
- package/dist/render/primitives/meet-peer.js +46 -0
- package/dist/render/primitives/meet-peer.js.map +1 -0
- package/dist/render/prop-allowlist.d.ts.map +1 -1
- package/dist/render/prop-allowlist.js +27 -1
- package/dist/render/prop-allowlist.js.map +1 -1
- package/dist/render/tree.js +42 -8
- package/dist/render/tree.js.map +1 -1
- package/dist/state/reserved-leaves.d.ts +37 -0
- package/dist/state/reserved-leaves.d.ts.map +1 -0
- package/dist/state/reserved-leaves.js +96 -0
- package/dist/state/reserved-leaves.js.map +1 -0
- package/dist/{status-pill-BxCdj-KZ.js → status-pill-B2vBTwRC.js} +2 -2
- package/dist/{status-pill-BxCdj-KZ.js.map → status-pill-B2vBTwRC.js.map} +1 -1
- package/dist/{test-CaRHj_J6.js → test-DD2SBDku.js} +4 -4
- package/dist/{test-CaRHj_J6.js.map → test-DD2SBDku.js.map} +1 -1
- package/dist/{tree-BLIxJbD3.js → tree-CgU_sUwI.js} +581 -479
- package/dist/tree-CgU_sUwI.js.map +1 -0
- package/dist/types.d.ts +25 -0
- package/dist/types.d.ts.map +1 -1
- package/dist/webrtc/index.d.ts +76 -0
- package/dist/webrtc/index.d.ts.map +1 -0
- package/dist/webrtc/index.js +180 -0
- package/dist/webrtc/index.js.map +1 -0
- package/dist/webrtc/meet-viewer.d.ts +139 -0
- package/dist/webrtc/meet-viewer.d.ts.map +1 -0
- package/dist/webrtc/meet-viewer.js +379 -0
- package/dist/webrtc/meet-viewer.js.map +1 -0
- package/dist/webrtc/peer-stream-registry.d.ts +21 -0
- package/dist/webrtc/peer-stream-registry.d.ts.map +1 -0
- package/dist/webrtc/peer-stream-registry.js +77 -0
- package/dist/webrtc/peer-stream-registry.js.map +1 -0
- package/package.json +4 -4
- package/src/app.tsx +9 -0
- package/src/index.ts +47 -0
- package/src/mount.ts +23 -0
- package/src/overlay/runtime-context.tsx +10 -0
- package/src/render/bundle.ts +11 -1
- package/src/render/primitives/capture.tsx +73 -28
- package/src/render/primitives/index.ts +10 -0
- package/src/render/primitives/live-peer-video.tsx +90 -0
- package/src/render/primitives/media.tsx +66 -17
- package/src/render/primitives/meet-peer-slot.tsx +55 -0
- package/src/render/primitives/meet-peer.tsx +57 -0
- package/src/render/prop-allowlist.ts +27 -1
- package/src/render/tree.tsx +44 -8
- package/src/state/reserved-leaves.ts +121 -0
- package/src/types.ts +25 -0
- package/src/webrtc/index.ts +252 -0
- package/src/webrtc/meet-viewer.ts +497 -0
- package/src/webrtc/peer-stream-registry.ts +93 -0
- package/dist/index-DrXsLYhe.js +0 -903
- package/dist/index-DrXsLYhe.js.map +0 -1
- package/dist/tree-BLIxJbD3.js.map +0 -1
|
@@ -0,0 +1,497 @@
|
|
|
1
|
+
// WebRTC viewer (mesh, VIEWER role) — ADR 006 §3.3 (C1), issue #3.
|
|
2
|
+
//
|
|
3
|
+
// A viewer-only port of `Prism/src/renderer/src/lib/meet-client.ts` (§1.4) : it
|
|
4
|
+
// joins a Meet room, receives N peers and exposes one `MediaStream` per peer.
|
|
5
|
+
// It NEVER publishes : no `getUserMedia`, no local stream, every transceiver is
|
|
6
|
+
// `recvonly`. Recovering a WebRTC flow needs no capture permission (ADR §2),
|
|
7
|
+
// which is the whole point of the pivot — Solar-CEF (on-air) and the Prism
|
|
8
|
+
// return webview can both be viewers without a capture grant.
|
|
9
|
+
//
|
|
10
|
+
// It reuses the reference's hard-won mesh logic verbatim in spirit :
|
|
11
|
+
// - perfect-negotiation (polite/impolite glare handling) ;
|
|
12
|
+
// - symmetric m-line ordering (audio transceiver first, video second) ;
|
|
13
|
+
// - STUN-first + per-URL TURN iceServers ;
|
|
14
|
+
// - one aggregated `MediaStream` per peer (`track` → addTrack, `ended` →
|
|
15
|
+
// removeTrack), handed to the consumer as `RemoteTrackEvent`.
|
|
16
|
+
//
|
|
17
|
+
// OWNERSHIP : the viewer owns each peer's `RTCPeerConnection` AND the
|
|
18
|
+
// `MediaStream` it aggregates. It is the SOLE authority on the track lifecycle —
|
|
19
|
+
// created on `track`, removed on `ended` / `peer-left` / `connectionstatechange
|
|
20
|
+
// failed|closed`, all torn down on `leave()`. A downstream `<video srcObject>`
|
|
21
|
+
// (the `media` primitive #4) is a pure consumer : unmounting it clears its own
|
|
22
|
+
// `srcObject` and stops nothing. A returning mirror can never kill a peer for
|
|
23
|
+
// the on-air composite.
|
|
24
|
+
//
|
|
25
|
+
// PEER LABEL : the transverse invariant gravé by Conduit (ZabCam contract #6) is
|
|
26
|
+
// a strict STRING equality — `peer_label == MeetClientOptions.name ==
|
|
27
|
+
// RemoteTrackEvent.peerName`, all matching `^[a-z][a-z0-9_-]{0,63}$`. Publishers
|
|
28
|
+
// (#5 Prism / #2 Pulsar) join with `name = peer_label` ; it comes back on the
|
|
29
|
+
// remote side as `PeerInfo.name`, surfaced verbatim as `RemoteTrackEvent.peerName`.
|
|
30
|
+
// The viewer therefore resolves `peer_label → MediaStream` by indexing peers by
|
|
31
|
+
// `peerName` (== label), NEVER by the opaque `peerId`. No separate label channel
|
|
32
|
+
// is needed : the join announce already carries it.
|
|
33
|
+
|
|
34
|
+
export type PeerRole = "publisher" | "viewer";
|
|
35
|
+
|
|
36
|
+
export interface PeerInfo {
|
|
37
|
+
id: string;
|
|
38
|
+
/** The peer's join name — the STABLE `peer_label` (ZabCam contract #6). */
|
|
39
|
+
name: string;
|
|
40
|
+
role: PeerRole;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
export type SignalPayload =
|
|
44
|
+
| {
|
|
45
|
+
kind: "sdp";
|
|
46
|
+
description: { type: "offer" | "answer" | "pranswer" | "rollback"; sdp: string };
|
|
47
|
+
}
|
|
48
|
+
| {
|
|
49
|
+
kind: "ice";
|
|
50
|
+
candidate: {
|
|
51
|
+
candidate: string;
|
|
52
|
+
sdpMid: string | null;
|
|
53
|
+
sdpMLineIndex: number | null;
|
|
54
|
+
usernameFragment?: string | null;
|
|
55
|
+
};
|
|
56
|
+
}
|
|
57
|
+
| { kind: "control"; event: "screen-start" | "screen-stop" | "mute" | "unmute" };
|
|
58
|
+
|
|
59
|
+
export type ClientMessage =
|
|
60
|
+
| { type: "join"; name: string; role?: PeerRole }
|
|
61
|
+
| { type: "signal"; to: string; payload: SignalPayload }
|
|
62
|
+
| { type: "leave" };
|
|
63
|
+
|
|
64
|
+
export type ServerMessage =
|
|
65
|
+
| {
|
|
66
|
+
type: "joined";
|
|
67
|
+
peerId: string;
|
|
68
|
+
roomId: string;
|
|
69
|
+
role: PeerRole;
|
|
70
|
+
peers: PeerInfo[];
|
|
71
|
+
turn: { urls: string[]; username: string; credential: string; ttl: number };
|
|
72
|
+
}
|
|
73
|
+
| { type: "peer-joined"; peer: PeerInfo }
|
|
74
|
+
| { type: "peer-left"; peerId: string }
|
|
75
|
+
| { type: "signal"; from: string; payload: SignalPayload }
|
|
76
|
+
| { type: "error"; code: string; message: string };
|
|
77
|
+
|
|
78
|
+
export interface RemoteTrackEvent {
|
|
79
|
+
peerId: string;
|
|
80
|
+
/** The peer's join name == its STABLE `peer_label` (Conduit invariant #6 :
|
|
81
|
+
* `peer_label == name == peerName`, strict string equality). This is the key
|
|
82
|
+
* a `meet.peer` LSML node's `peerLabel` resolves against — never `peerId`. */
|
|
83
|
+
peerName: string;
|
|
84
|
+
stream: MediaStream;
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
type EventMap = {
|
|
88
|
+
joined: { peerId: string; peers: PeerInfo[] };
|
|
89
|
+
"peer-joined": PeerInfo;
|
|
90
|
+
"peer-left": { peerId: string; peerName: string };
|
|
91
|
+
"remote-track": RemoteTrackEvent;
|
|
92
|
+
"connection-state": { peerId: string; state: RTCPeerConnectionState };
|
|
93
|
+
error: { code: string; message: string };
|
|
94
|
+
close: { code: number; reason: string };
|
|
95
|
+
};
|
|
96
|
+
|
|
97
|
+
type Listener<K extends keyof EventMap> = (event: EventMap[K]) => void;
|
|
98
|
+
|
|
99
|
+
interface RemoteState {
|
|
100
|
+
info: PeerInfo;
|
|
101
|
+
pc: RTCPeerConnection;
|
|
102
|
+
stream: MediaStream;
|
|
103
|
+
makingOffer: boolean;
|
|
104
|
+
ignoreOffer: boolean;
|
|
105
|
+
pendingCandidates: RTCIceCandidateInit[];
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
/** Minimal injectable factory for the WebSocket + RTCPeerConnection, so the
|
|
109
|
+
* viewer is testable without a real browser stack. Defaults to the globals. */
|
|
110
|
+
export interface MeetViewerDeps {
|
|
111
|
+
WebSocket: typeof WebSocket;
|
|
112
|
+
RTCPeerConnection: typeof RTCPeerConnection;
|
|
113
|
+
MediaStream: typeof MediaStream;
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
export interface MeetViewerOptions {
|
|
117
|
+
signalingUrl: string;
|
|
118
|
+
roomId: string;
|
|
119
|
+
/** Room/viewer token, sent as a query param to the signaling WS. */
|
|
120
|
+
token: string;
|
|
121
|
+
/** This viewer's announce name on the mesh (it does not publish a stream). */
|
|
122
|
+
name: string;
|
|
123
|
+
deps?: Partial<MeetViewerDeps>;
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
export class MeetViewer {
|
|
127
|
+
private ws: WebSocket | null = null;
|
|
128
|
+
private remotes = new Map<string, RemoteState>();
|
|
129
|
+
private iceServers: RTCIceServer[] = [];
|
|
130
|
+
private selfId: string | null = null;
|
|
131
|
+
private listeners = new Map<keyof EventMap, Set<Listener<never>>>();
|
|
132
|
+
private readonly deps: MeetViewerDeps;
|
|
133
|
+
|
|
134
|
+
constructor(private readonly options: MeetViewerOptions) {
|
|
135
|
+
this.deps = {
|
|
136
|
+
WebSocket: options.deps?.WebSocket ?? globalThis.WebSocket,
|
|
137
|
+
RTCPeerConnection: options.deps?.RTCPeerConnection ?? globalThis.RTCPeerConnection,
|
|
138
|
+
MediaStream: options.deps?.MediaStream ?? globalThis.MediaStream,
|
|
139
|
+
};
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
on<K extends keyof EventMap>(type: K, listener: Listener<K>): () => void {
|
|
143
|
+
const set = this.listeners.get(type) ?? new Set<Listener<never>>();
|
|
144
|
+
this.listeners.set(type, set);
|
|
145
|
+
set.add(listener as Listener<never>);
|
|
146
|
+
return () => set.delete(listener as Listener<never>);
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
/** Join the room as a VIEWER (recvonly). No capture, no publish. */
|
|
150
|
+
join(): Promise<void> {
|
|
151
|
+
return this.openSocket();
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
/** Leave and tear down every peer connection + aggregated stream. As the
|
|
155
|
+
* track owner, this is where the streams (and the device-side tracks) end. */
|
|
156
|
+
leave(): void {
|
|
157
|
+
this.send({ type: "leave" });
|
|
158
|
+
this.ws?.close(1000, "viewer-leave");
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
/* ---- Socket ------------------------------------------------------- */
|
|
162
|
+
|
|
163
|
+
private openSocket(): Promise<void> {
|
|
164
|
+
const url = new URL(this.options.signalingUrl);
|
|
165
|
+
url.searchParams.set("room", this.options.roomId);
|
|
166
|
+
url.searchParams.set("token", this.options.token);
|
|
167
|
+
|
|
168
|
+
return new Promise((resolve, reject) => {
|
|
169
|
+
const ws = new this.deps.WebSocket(url.toString());
|
|
170
|
+
this.ws = ws;
|
|
171
|
+
|
|
172
|
+
const onOpen = () => {
|
|
173
|
+
ws.removeEventListener("error", onError);
|
|
174
|
+
// role:"viewer" — the signaling server allocates no publish slot.
|
|
175
|
+
this.send({ type: "join", name: this.options.name, role: "viewer" });
|
|
176
|
+
resolve();
|
|
177
|
+
};
|
|
178
|
+
const onError = (event: Event) => {
|
|
179
|
+
ws.removeEventListener("open", onOpen);
|
|
180
|
+
reject(event);
|
|
181
|
+
};
|
|
182
|
+
|
|
183
|
+
ws.addEventListener("open", onOpen, { once: true });
|
|
184
|
+
ws.addEventListener("error", onError, { once: true });
|
|
185
|
+
ws.addEventListener("message", (ev) => void this.onMessage((ev as MessageEvent).data));
|
|
186
|
+
ws.addEventListener("close", (ev) => {
|
|
187
|
+
this.tearDown();
|
|
188
|
+
this.emit("close", { code: (ev as CloseEvent).code, reason: (ev as CloseEvent).reason });
|
|
189
|
+
});
|
|
190
|
+
});
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
private send(msg: ClientMessage): void {
|
|
194
|
+
if (this.ws && this.ws.readyState === this.deps.WebSocket.OPEN) {
|
|
195
|
+
this.ws.send(JSON.stringify(msg));
|
|
196
|
+
}
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
private tearDown(): void {
|
|
200
|
+
for (const r of this.remotes.values()) r.pc.close();
|
|
201
|
+
this.remotes.clear();
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
/* ---- Protocol ----------------------------------------------------- */
|
|
205
|
+
|
|
206
|
+
private async onMessage(raw: unknown): Promise<void> {
|
|
207
|
+
let msg: ServerMessage;
|
|
208
|
+
try {
|
|
209
|
+
msg = JSON.parse(String(raw)) as ServerMessage;
|
|
210
|
+
} catch {
|
|
211
|
+
return;
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
switch (msg.type) {
|
|
215
|
+
case "joined": {
|
|
216
|
+
this.selfId = msg.peerId;
|
|
217
|
+
// TURN ONLY (no STUN). This viewer runs in an Electron <webview> whose
|
|
218
|
+
// P2P stack has NO mDNS resolver, so host `.local` candidates never
|
|
219
|
+
// resolve (-105), and it can't resolve STUN *hostnames* either
|
|
220
|
+
// (stun.l.google.com / stun.cloudflare.com → -105). The only viable
|
|
221
|
+
// path is the relay, and the server now advertises TURN by IP
|
|
222
|
+
// (turn:51.91.126.43:3478), which needs no DNS. Dropping the unusable
|
|
223
|
+
// STUN hostnames removes the -105 noise and the dead srflx gathering.
|
|
224
|
+
this.iceServers = msg.turn.urls.map((url) => ({
|
|
225
|
+
urls: url,
|
|
226
|
+
username: msg.turn.username,
|
|
227
|
+
credential: msg.turn.credential,
|
|
228
|
+
}));
|
|
229
|
+
this.emit("joined", { peerId: msg.peerId, peers: msg.peers });
|
|
230
|
+
for (const peer of msg.peers) this.ensureRemote(peer);
|
|
231
|
+
break;
|
|
232
|
+
}
|
|
233
|
+
case "peer-joined": {
|
|
234
|
+
this.emit("peer-joined", msg.peer);
|
|
235
|
+
this.ensureRemote(msg.peer);
|
|
236
|
+
break;
|
|
237
|
+
}
|
|
238
|
+
case "peer-left": {
|
|
239
|
+
const remote = this.remotes.get(msg.peerId);
|
|
240
|
+
if (remote) {
|
|
241
|
+
// The pc owns the tracks — closing it ends them. The registry/consumer
|
|
242
|
+
// are notified via the peer-left event (label-keyed).
|
|
243
|
+
remote.pc.close();
|
|
244
|
+
this.remotes.delete(msg.peerId);
|
|
245
|
+
this.emit("peer-left", { peerId: msg.peerId, peerName: remote.info.name });
|
|
246
|
+
}
|
|
247
|
+
break;
|
|
248
|
+
}
|
|
249
|
+
case "signal": {
|
|
250
|
+
await this.handleSignal(msg.from, msg.payload);
|
|
251
|
+
break;
|
|
252
|
+
}
|
|
253
|
+
case "error": {
|
|
254
|
+
this.emit("error", { code: msg.code, message: msg.message });
|
|
255
|
+
break;
|
|
256
|
+
}
|
|
257
|
+
}
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
private async handleSignal(from: string, payload: SignalPayload): Promise<void> {
|
|
261
|
+
let remote = this.remotes.get(from);
|
|
262
|
+
if (!remote) {
|
|
263
|
+
remote = this.ensureRemote({ id: from, name: from.slice(0, 8), role: "publisher" });
|
|
264
|
+
}
|
|
265
|
+
const { pc } = remote;
|
|
266
|
+
|
|
267
|
+
if (payload.kind === "sdp") {
|
|
268
|
+
const desc = payload.description;
|
|
269
|
+
const offerCollision =
|
|
270
|
+
desc.type === "offer" && (remote.makingOffer || pc.signalingState !== "stable");
|
|
271
|
+
remote.ignoreOffer = !this.isPolite(from) && offerCollision;
|
|
272
|
+
if (remote.ignoreOffer) return;
|
|
273
|
+
|
|
274
|
+
await pc.setRemoteDescription(desc);
|
|
275
|
+
for (const c of remote.pendingCandidates) {
|
|
276
|
+
try {
|
|
277
|
+
await pc.addIceCandidate(c);
|
|
278
|
+
} catch {
|
|
279
|
+
/* ignore late/stale candidates */
|
|
280
|
+
}
|
|
281
|
+
}
|
|
282
|
+
remote.pendingCandidates = [];
|
|
283
|
+
|
|
284
|
+
if (desc.type === "offer") {
|
|
285
|
+
await pc.setLocalDescription();
|
|
286
|
+
if (pc.localDescription) {
|
|
287
|
+
this.sendSignal(from, {
|
|
288
|
+
kind: "sdp",
|
|
289
|
+
description: {
|
|
290
|
+
type: pc.localDescription.type as "offer" | "answer" | "pranswer" | "rollback",
|
|
291
|
+
sdp: pc.localDescription.sdp,
|
|
292
|
+
},
|
|
293
|
+
});
|
|
294
|
+
}
|
|
295
|
+
}
|
|
296
|
+
return;
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
if (payload.kind === "ice") {
|
|
300
|
+
const init = payload.candidate;
|
|
301
|
+
if (pc.remoteDescription) {
|
|
302
|
+
try {
|
|
303
|
+
await pc.addIceCandidate(init);
|
|
304
|
+
} catch (err) {
|
|
305
|
+
if (!remote.ignoreOffer) throw err;
|
|
306
|
+
}
|
|
307
|
+
} else {
|
|
308
|
+
remote.pendingCandidates.push(init);
|
|
309
|
+
}
|
|
310
|
+
return;
|
|
311
|
+
}
|
|
312
|
+
// control payloads ignored — the viewer does not act on screen/mute hints.
|
|
313
|
+
}
|
|
314
|
+
|
|
315
|
+
/* ---- Peer setup --------------------------------------------------- */
|
|
316
|
+
|
|
317
|
+
private ensureRemote(peer: PeerInfo): RemoteState {
|
|
318
|
+
const existing = this.remotes.get(peer.id);
|
|
319
|
+
if (existing) return existing;
|
|
320
|
+
|
|
321
|
+
// relay-only: in the Electron <webview> host (.local) and srflx candidates
|
|
322
|
+
// are unusable (no mDNS resolver, STUN hostnames unresolvable), so force the
|
|
323
|
+
// ICE agent to gather/use ONLY relay candidates via the by-IP TURN server.
|
|
324
|
+
// The publisher (browser) gathers all transports incl. relay, so the
|
|
325
|
+
// relay↔relay pair connects. Avoids ICE stalling on dead host/srflx checks.
|
|
326
|
+
const pc = new this.deps.RTCPeerConnection({
|
|
327
|
+
iceServers: this.iceServers,
|
|
328
|
+
iceTransportPolicy: "relay",
|
|
329
|
+
});
|
|
330
|
+
const stream = new this.deps.MediaStream();
|
|
331
|
+
|
|
332
|
+
// VIEWER : both transceivers are recvonly and carry NO local track. Order
|
|
333
|
+
// (audio first, video second) must match the publisher's so the m-lines
|
|
334
|
+
// line up — the same invariant as the reference.
|
|
335
|
+
const audioTx = pc.addTransceiver("audio", { direction: "recvonly" });
|
|
336
|
+
const videoTx = pc.addTransceiver("video", { direction: "recvonly" });
|
|
337
|
+
|
|
338
|
+
// BUNDLE codec-collision fix (ADR 006 #3). A recvonly viewer offering the
|
|
339
|
+
// FULL Chromium codec catalogue (VP8/VP9×profiles/H264×profiles/AV1/H265 +
|
|
340
|
+
// rtx/red/ulpfec/flexfec, audio opus + telephone-event) maximises payload-
|
|
341
|
+
// type pressure. Under BUNDLE all m-lines share ONE PT namespace, so when
|
|
342
|
+
// the publisher (answerer, with pre-allocated sendrecv transceivers — see
|
|
343
|
+
// `meet-client.ts`) reconciles that dense offer, PTs collide ACROSS m-lines
|
|
344
|
+
// (the observed `126` audio telephone-event vs `39` video H264). A pure
|
|
345
|
+
// viewer needs ONE coherent codec per kind, not the whole catalogue : we
|
|
346
|
+
// pin a deduplicated, minimal preference set so the offered PT space is
|
|
347
|
+
// small and collision-free by construction. This is NOT SDP munging — it is
|
|
348
|
+
// the spec'd `setCodecPreferences` API ; Chromium still owns PT assignment.
|
|
349
|
+
pinViewerCodecs(audioTx, videoTx);
|
|
350
|
+
|
|
351
|
+
const state: RemoteState = {
|
|
352
|
+
info: peer,
|
|
353
|
+
pc,
|
|
354
|
+
stream,
|
|
355
|
+
makingOffer: false,
|
|
356
|
+
ignoreOffer: false,
|
|
357
|
+
pendingCandidates: [],
|
|
358
|
+
};
|
|
359
|
+
|
|
360
|
+
pc.addEventListener("negotiationneeded", () => {
|
|
361
|
+
void (async () => {
|
|
362
|
+
try {
|
|
363
|
+
state.makingOffer = true;
|
|
364
|
+
await pc.setLocalDescription();
|
|
365
|
+
if (pc.localDescription) {
|
|
366
|
+
this.sendSignal(peer.id, {
|
|
367
|
+
kind: "sdp",
|
|
368
|
+
description: {
|
|
369
|
+
type: pc.localDescription.type as "offer" | "answer" | "pranswer" | "rollback",
|
|
370
|
+
sdp: pc.localDescription.sdp,
|
|
371
|
+
},
|
|
372
|
+
});
|
|
373
|
+
}
|
|
374
|
+
} catch {
|
|
375
|
+
/* transient — glare or mid-close */
|
|
376
|
+
} finally {
|
|
377
|
+
state.makingOffer = false;
|
|
378
|
+
}
|
|
379
|
+
})();
|
|
380
|
+
});
|
|
381
|
+
|
|
382
|
+
pc.addEventListener("icecandidate", (ev) => {
|
|
383
|
+
const candidate = (ev as RTCPeerConnectionIceEvent).candidate;
|
|
384
|
+
if (!candidate) return;
|
|
385
|
+
this.sendSignal(peer.id, {
|
|
386
|
+
kind: "ice",
|
|
387
|
+
candidate: {
|
|
388
|
+
candidate: candidate.candidate,
|
|
389
|
+
sdpMid: candidate.sdpMid,
|
|
390
|
+
sdpMLineIndex: candidate.sdpMLineIndex,
|
|
391
|
+
usernameFragment: candidate.usernameFragment,
|
|
392
|
+
},
|
|
393
|
+
});
|
|
394
|
+
});
|
|
395
|
+
|
|
396
|
+
pc.addEventListener("track", (ev) => {
|
|
397
|
+
const track = (ev as RTCTrackEvent).track;
|
|
398
|
+
// Aggregate every incoming track into the peer's single MediaStream so
|
|
399
|
+
// the consumer gets one coherent source for `<video srcObject>`.
|
|
400
|
+
if (!state.stream.getTracks().includes(track)) {
|
|
401
|
+
state.stream.addTrack(track);
|
|
402
|
+
}
|
|
403
|
+
track.addEventListener("ended", () => {
|
|
404
|
+
state.stream.removeTrack(track);
|
|
405
|
+
});
|
|
406
|
+
this.emit("remote-track", {
|
|
407
|
+
peerId: peer.id,
|
|
408
|
+
peerName: peer.name,
|
|
409
|
+
stream: state.stream,
|
|
410
|
+
});
|
|
411
|
+
});
|
|
412
|
+
|
|
413
|
+
pc.addEventListener("connectionstatechange", () => {
|
|
414
|
+
this.emit("connection-state", { peerId: peer.id, state: pc.connectionState });
|
|
415
|
+
if (pc.connectionState === "failed" || pc.connectionState === "closed") {
|
|
416
|
+
this.remotes.delete(peer.id);
|
|
417
|
+
this.emit("peer-left", { peerId: peer.id, peerName: peer.name });
|
|
418
|
+
}
|
|
419
|
+
});
|
|
420
|
+
|
|
421
|
+
this.remotes.set(peer.id, state);
|
|
422
|
+
return state;
|
|
423
|
+
}
|
|
424
|
+
|
|
425
|
+
/* ---- Helpers ------------------------------------------------------ */
|
|
426
|
+
|
|
427
|
+
private isPolite(otherId: string): boolean {
|
|
428
|
+
if (!this.selfId) return false;
|
|
429
|
+
return this.selfId > otherId;
|
|
430
|
+
}
|
|
431
|
+
|
|
432
|
+
private sendSignal(to: string, payload: SignalPayload): void {
|
|
433
|
+
this.send({ type: "signal", to, payload });
|
|
434
|
+
}
|
|
435
|
+
|
|
436
|
+
private emit<K extends keyof EventMap>(type: K, event: EventMap[K]): void {
|
|
437
|
+
const set = this.listeners.get(type);
|
|
438
|
+
if (!set) return;
|
|
439
|
+
for (const listener of set) (listener as Listener<K>)(event);
|
|
440
|
+
}
|
|
441
|
+
}
|
|
442
|
+
|
|
443
|
+
/* ---- Codec preference (BUNDLE collision fix) ----------------------- */
|
|
444
|
+
|
|
445
|
+
/** Pin a minimal, deduplicated codec preference on a viewer's recvonly
|
|
446
|
+
* transceivers so the offered payload-type space stays small and BUNDLE-safe.
|
|
447
|
+
*
|
|
448
|
+
* AUDIO : opus (+ keep telephone-event for spec completeness, it is harmless).
|
|
449
|
+
* VIDEO : H264 + its rtx ONLY (drop VP9/AV1/H265 multi-profile clutter). H264
|
|
450
|
+
* is what the publisher (Prism cam / Pulsar NVENC) emits ; a viewer
|
|
451
|
+
* that only ever RECEIVES needs no broader set. rtx is kept so NACK
|
|
452
|
+
* retransmission still works.
|
|
453
|
+
*
|
|
454
|
+
* Feature-detected end-to-end : if `setCodecPreferences` or
|
|
455
|
+
* `RTCRtpReceiver.getCapabilities` is unavailable (older engines, jsdom/test
|
|
456
|
+
* fakes), this is a silent no-op and the transceiver keeps Chromium's default
|
|
457
|
+
* full list — behaviour is never WORSE than before the fix. */
|
|
458
|
+
function pinViewerCodecs(audioTx: unknown, videoTx: unknown): void {
|
|
459
|
+
const getCaps = (
|
|
460
|
+
globalThis as {
|
|
461
|
+
RTCRtpReceiver?: { getCapabilities?: (k: string) => RTCRtpCapabilities | null };
|
|
462
|
+
}
|
|
463
|
+
).RTCRtpReceiver?.getCapabilities;
|
|
464
|
+
if (typeof getCaps !== "function") return;
|
|
465
|
+
|
|
466
|
+
pinKind(videoTx, getCaps("video"), (mime) => {
|
|
467
|
+
const m = mime.toLowerCase();
|
|
468
|
+
// Keep H264 and the generic rtx (retransmission) codec only.
|
|
469
|
+
return m === "video/h264" || m === "video/rtx";
|
|
470
|
+
});
|
|
471
|
+
pinKind(audioTx, getCaps("audio"), (mime) => {
|
|
472
|
+
const m = mime.toLowerCase();
|
|
473
|
+
return m === "audio/opus" || m === "audio/telephone-event";
|
|
474
|
+
});
|
|
475
|
+
}
|
|
476
|
+
|
|
477
|
+
/** Apply `setCodecPreferences` with the subset of `caps` whose mimeType passes
|
|
478
|
+
* `keep`, preserving the platform's preferred order. Guarded : no transceiver,
|
|
479
|
+
* no `setCodecPreferences`, no caps, or an empty filtered set → no-op. */
|
|
480
|
+
function pinKind(
|
|
481
|
+
tx: unknown,
|
|
482
|
+
caps: RTCRtpCapabilities | null | undefined,
|
|
483
|
+
keep: (mimeType: string) => boolean,
|
|
484
|
+
): void {
|
|
485
|
+
const setPrefs = (tx as { setCodecPreferences?: (codecs: RTCRtpCodec[]) => void } | null)
|
|
486
|
+
?.setCodecPreferences;
|
|
487
|
+
if (typeof setPrefs !== "function" || !caps) return;
|
|
488
|
+
const codecs = caps.codecs.filter((c) => keep(c.mimeType));
|
|
489
|
+
if (codecs.length === 0) return; // never offer an empty m-line
|
|
490
|
+
try {
|
|
491
|
+
setPrefs.call(tx, codecs);
|
|
492
|
+
} catch {
|
|
493
|
+
// Some engines reject a preference list (e.g. rtx without its apt primary in
|
|
494
|
+
// the same call) — fall back to the default full list rather than break the
|
|
495
|
+
// transceiver. The fix is best-effort hardening, never a hard dependency.
|
|
496
|
+
}
|
|
497
|
+
}
|
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
// Peer-stream registry — the bridge between the WebRTC viewer (#3) and the
|
|
2
|
+
// `media` primitive's LIVE mode (#4).
|
|
3
|
+
//
|
|
4
|
+
// The viewer feeds this registry one entry per CONNECTED peer, keyed by the
|
|
5
|
+
// peer's STABLE `peer_label` (the ZabCam contract label #6, announced on the
|
|
6
|
+
// mesh as the peer's join `name` — see `PeerInfo.name` / `RemoteTrackEvent`).
|
|
7
|
+
// The `media` primitive resolves `peerLabel → MediaStream` through it.
|
|
8
|
+
//
|
|
9
|
+
// Reactivity : a peer's stream becomes available ASYNCHRONOUSLY (after the room
|
|
10
|
+
// join + SDP/ICE), so a `media` node that mounted before the peer connected
|
|
11
|
+
// must be notified when its stream arrives. The registry exposes both a
|
|
12
|
+
// one-shot `resolvePeerStream` (the #4 contract, synchronous) AND a
|
|
13
|
+
// `subscribePeerStream(label, cb)` push channel the LIVE primitive uses to
|
|
14
|
+
// re-render on connect / disconnect. The store is a plain Map guarded by a
|
|
15
|
+
// listener set — no signals dependency, so it adds nothing to the render path.
|
|
16
|
+
//
|
|
17
|
+
// OWNERSHIP (the #3↔#4 lifecycle decision) : the registry NEVER creates or
|
|
18
|
+
// stops a track. It only HOLDS a reference to a `MediaStream` owned by the
|
|
19
|
+
// viewer's `RTCPeerConnection`. The viewer is the sole authority on the track
|
|
20
|
+
// lifecycle (created on `track`, removed on `ended` / `peer-left` / teardown).
|
|
21
|
+
// A consuming `<video>` unmounting clears its own `srcObject` and nothing else
|
|
22
|
+
// — it can never tear a peer down for the on-air composite.
|
|
23
|
+
|
|
24
|
+
export type PeerStreamListener = (stream: MediaStream | null) => void;
|
|
25
|
+
|
|
26
|
+
export interface PeerStreamRegistry {
|
|
27
|
+
/** #4 contract — the current stream for a label, or `null` if the peer is not
|
|
28
|
+
* connected (yet / any more). Synchronous, side-effect free. */
|
|
29
|
+
resolve(peerLabel: string): MediaStream | null;
|
|
30
|
+
/** Push channel for the LIVE `media` primitive : invoked immediately with the
|
|
31
|
+
* current value, then on every change for `peerLabel`. Returns an
|
|
32
|
+
* unsubscribe. */
|
|
33
|
+
subscribe(peerLabel: string, listener: PeerStreamListener): () => void;
|
|
34
|
+
/** Viewer-side : publish / replace a peer's stream (peer connected). */
|
|
35
|
+
set(peerLabel: string, stream: MediaStream): void;
|
|
36
|
+
/** Viewer-side : drop a peer's stream (peer left / connection failed). The
|
|
37
|
+
* registry forgets the reference ; it does NOT stop the tracks (the viewer's
|
|
38
|
+
* pc.close() does, as the track owner). */
|
|
39
|
+
remove(peerLabel: string): void;
|
|
40
|
+
/** Viewer-side : forget every entry (room teardown). Reference-only, no track
|
|
41
|
+
* stops. */
|
|
42
|
+
clear(): void;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
export function createPeerStreamRegistry(): PeerStreamRegistry {
|
|
46
|
+
const streams = new Map<string, MediaStream>();
|
|
47
|
+
const listeners = new Map<string, Set<PeerStreamListener>>();
|
|
48
|
+
|
|
49
|
+
function notify(peerLabel: string): void {
|
|
50
|
+
const set = listeners.get(peerLabel);
|
|
51
|
+
if (set === undefined) return;
|
|
52
|
+
const value = streams.get(peerLabel) ?? null;
|
|
53
|
+
for (const listener of set) listener(value);
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
return {
|
|
57
|
+
resolve(peerLabel) {
|
|
58
|
+
return streams.get(peerLabel) ?? null;
|
|
59
|
+
},
|
|
60
|
+
subscribe(peerLabel, listener) {
|
|
61
|
+
let set = listeners.get(peerLabel);
|
|
62
|
+
if (set === undefined) {
|
|
63
|
+
set = new Set();
|
|
64
|
+
listeners.set(peerLabel, set);
|
|
65
|
+
}
|
|
66
|
+
set.add(listener);
|
|
67
|
+
// Emit the current value synchronously so a late subscriber sees an
|
|
68
|
+
// already-connected peer without waiting for the next change.
|
|
69
|
+
listener(streams.get(peerLabel) ?? null);
|
|
70
|
+
return () => {
|
|
71
|
+
const s = listeners.get(peerLabel);
|
|
72
|
+
if (s === undefined) return;
|
|
73
|
+
s.delete(listener);
|
|
74
|
+
if (s.size === 0) listeners.delete(peerLabel);
|
|
75
|
+
};
|
|
76
|
+
},
|
|
77
|
+
set(peerLabel, stream) {
|
|
78
|
+
if (streams.get(peerLabel) === stream) return; // idempotent re-emit guard
|
|
79
|
+
streams.set(peerLabel, stream);
|
|
80
|
+
notify(peerLabel);
|
|
81
|
+
},
|
|
82
|
+
remove(peerLabel) {
|
|
83
|
+
if (!streams.has(peerLabel)) return;
|
|
84
|
+
streams.delete(peerLabel);
|
|
85
|
+
notify(peerLabel);
|
|
86
|
+
},
|
|
87
|
+
clear() {
|
|
88
|
+
const labels = [...streams.keys()];
|
|
89
|
+
streams.clear();
|
|
90
|
+
for (const label of labels) notify(label);
|
|
91
|
+
},
|
|
92
|
+
};
|
|
93
|
+
}
|