@ksp-gonogo/kerbcast 1.4.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/src/client.ts ADDED
@@ -0,0 +1,1219 @@
1
+ import type {
2
+ AdaptiveShedPayload,
3
+ CameraState,
4
+ ClientMessage,
5
+ ErrorPayload,
6
+ Layer,
7
+ QualityPreset,
8
+ ServerMessage,
9
+ SettingsStatePayload,
10
+ } from "./__generated__/types";
11
+ import { CameraLifecycle, ErrorSource } from "./__generated__/types";
12
+ import { type NoisePipeline, tryCreateNoisePipeline } from "./noise";
13
+
14
+ /** Intensity the static runs at when a camera has no live source (signal lost). */
15
+ const SOURCELESS_INTENSITY = 1.0;
16
+ /** Floor applied to the degrade-driven intensity of a live feed. */
17
+ const LIVE_INTENSITY_FLOOR = 0.05;
18
+
19
+ /** Controls the digital-static noise overlay baked into `cam.mediaStream`. */
20
+ export interface NoiseConfig {
21
+ /** When false, the pipeline is bypassed and `mediaStream` is the raw WebRTC track. */
22
+ enabled: boolean;
23
+ }
24
+
25
+ /**
26
+ * Per-camera handle returned from {@link KerbcastClient.camera}.
27
+ * Stable identity per flight ID for the lifetime of the client.
28
+ */
29
+ export interface KerbcastCameraHandle {
30
+ readonly flightId: number;
31
+ readonly state: CameraState | null;
32
+ readonly mediaStream: MediaStream | null;
33
+ /**
34
+ * True while the camera has a live source whose frames have stopped
35
+ * arriving (the noise pipeline's stall detector). Always false when the
36
+ * noise pipeline is unavailable (no `captureStream`) or bypassed.
37
+ * Transitions are emitted as `stall` events.
38
+ */
39
+ readonly stalled: boolean;
40
+ /** Current {@link setShowStatic} setting. */
41
+ readonly showStatic: boolean;
42
+
43
+ setLayers(layers: Layer[]): Promise<void>;
44
+ setRenderSize(width: number, height: number): Promise<void>;
45
+ setFov(fov: number): Promise<void>;
46
+ setPan(yaw: number, pitch: number): Promise<void>;
47
+ /**
48
+ * Set a persistent pan/tilt velocity (normalised, -1..1 per axis;
49
+ * +yaw = right, +pitch = up). Holds until superseded — send
50
+ * `setPanRate(0, 0)` to stop. Smoothing happens in the plugin's frame
51
+ * loop, so call this only when the input changes (e.g. arrow down /
52
+ * release), not on a timer.
53
+ */
54
+ setPanRate(yawRate: number, pitchRate: number): Promise<void>;
55
+ /**
56
+ * Set a persistent zoom velocity (normalised, -1..1; +1 = zoom in,
57
+ * FoV decreasing). Holds until superseded — send `setZoomRate(0)` to
58
+ * stop. Like {@link setPanRate}, call on input change (press-and-hold),
59
+ * not on a timer.
60
+ */
61
+ setZoomRate(rate: number): Promise<void>;
62
+ setDegrade(level: number): Promise<void>;
63
+ /**
64
+ * Request a resolution preset for this camera, or `null` for auto
65
+ * (clear the viewer clamp). Server-wide and last-write-wins across
66
+ * peers; the authoritative result arrives as a `change` event
67
+ * (`state.viewerQuality` + `state.qualityLimitedBy`). The effective
68
+ * resolution is min(operator ceiling, adaptive level, preset): a
69
+ * preset can only lower quality, and the sidecar's adaptive perf
70
+ * machinery keeps overriding it downward while throttled.
71
+ */
72
+ setQuality(preset: QualityPreset | null): Promise<void>;
73
+ requestKeyframe(): Promise<void>;
74
+
75
+ /**
76
+ * Override noise settings for this camera only. Takes precedence over the
77
+ * client-level default in {@link KerbcastClientConfig.noise}.
78
+ */
79
+ configure(options: { noise?: Partial<NoiseConfig> }): void;
80
+
81
+ /**
82
+ * Choose whether animated static is drawn (default true). When false:
83
+ * a stalled source holds its last decoded frame with no noise (the
84
+ * consumer's chrome carries the staleness indicator via the `stall`
85
+ * event / {@link stalled}), and a sourceless path clears to black
86
+ * without noise so only the text overlay renders.
87
+ */
88
+ setShowStatic(enabled: boolean): void;
89
+
90
+ on<K extends keyof KerbcastCameraEvents>(
91
+ event: K,
92
+ handler: (data: KerbcastCameraEvents[K]) => void,
93
+ ): () => void;
94
+ }
95
+
96
+ /** Event payloads emitted by a {@link KerbcastCameraHandle}. */
97
+ export interface KerbcastCameraEvents {
98
+ /** Latest `CameraState` from the sidecar's `camera-state-changed`. */
99
+ change: CameraState;
100
+ /** Track arrival or teardown for this camera. `null` on disconnect. */
101
+ stream: MediaStream | null;
102
+ /** Frames stopped arriving on a live source (`true`) / resumed (`false`). */
103
+ stall: boolean;
104
+ }
105
+
106
+ /** Configuration for {@link KerbcastClient} construction. */
107
+ export interface KerbcastClientConfig {
108
+ /** Sidecar host (matches the sidecar's `--http-bind`). */
109
+ host: string;
110
+ /** Sidecar port. */
111
+ port: number;
112
+ /**
113
+ * ICE servers to use. Defaults to Google's public STUN server,
114
+ * which is enough for LAN streaming.
115
+ */
116
+ iceServers?: RTCIceServer[];
117
+ /**
118
+ * Default noise settings for all cameras. Individual cameras can override
119
+ * this via `cam.configure({ noise: ... })`. Noise is enabled by default.
120
+ */
121
+ noise?: Partial<NoiseConfig>;
122
+ /**
123
+ * ICE gathering timeout in milliseconds. Passed to the default
124
+ * {@link BrowserKerbcastTransport}; ignored when a custom transport is
125
+ * provided (the transport owns gathering). Defaults to 2000 ms.
126
+ *
127
+ * On LAN topologies where the STUN server is unreachable (e.g. Steam Deck
128
+ * IPv6 LAN with no internet path), gathering can stall for the full STUN
129
+ * timeout. Host candidates alone are sufficient for LAN streaming, so this
130
+ * timeout lets the connect flow proceed once host candidates are gathered.
131
+ */
132
+ iceGatheringTimeoutMs?: number;
133
+ /**
134
+ * Override how the SDP offer/answer is exchanged. Defaults to a POST to the
135
+ * sidecar's HTTP `/offer`. A station screen injects a version that relays
136
+ * the offer through the main screen (which can reach the sidecar), so the
137
+ * station needs no direct sidecar address. The media itself still flows
138
+ * peer-to-sidecar (direct or via TURN) -- only the handshake is brokered.
139
+ */
140
+ negotiate?: (offer: {
141
+ sdp: string;
142
+ cameras: number[];
143
+ slots?: number;
144
+ }) => Promise<{ sdp: string; cameras: number[] }>;
145
+ }
146
+
147
+ /** WebRTC connection state surface. */
148
+ export type KerbcastConnectionState =
149
+ | "disconnected"
150
+ | "connecting"
151
+ | "connected"
152
+ | "failed";
153
+
154
+ /**
155
+ * Inbound RTP video statistics for one camera track, derived from the
156
+ * WebRTC stats report. Fields are `undefined` when the browser has not yet
157
+ * emitted that stat (e.g. `framesPerSecond` before the first full second).
158
+ * `packetsReceived` and `bytesReceived` default to 0 in the same case.
159
+ *
160
+ * Retrieve per-camera stats via {@link KerbcastClient.inboundVideoStats}.
161
+ */
162
+ export interface InboundVideoStats {
163
+ packetsReceived: number;
164
+ bytesReceived: number;
165
+ framesReceived: number | undefined;
166
+ framesDecoded: number | undefined;
167
+ jitter: number | undefined;
168
+ framesPerSecond: number | undefined;
169
+ }
170
+
171
+ /**
172
+ * Camera summary returned by {@link KerbcastClient.discover} (`GET /cameras`).
173
+ *
174
+ * This is the shape the sidecar serialises from its internal `CameraInfo`
175
+ * struct. It is a discovery snapshot, not a live operating state: it carries
176
+ * capability and identity fields but not per-connection operator state
177
+ * (`layers`, `renderWidth`, `panYaw`, etc.) -- those arrive over the data
178
+ * channel after `connect()`.
179
+ *
180
+ * Deliberately separate from `CameraState` even though the two share several
181
+ * field names, because the shapes differ: `DiscoveredCamera` has `maxWidth`
182
+ * / `maxHeight` (physical sensor limits) which `CameraState` omits, while
183
+ * `CameraState` adds runtime fields (`layers`, `renderWidth`, `panYaw`, ...).
184
+ */
185
+ export interface DiscoveredCamera {
186
+ flightId: number;
187
+ /** Part-destruction lifecycle. `"active"` for live cameras. */
188
+ lifecycle?: CameraLifecycle;
189
+ partName: string;
190
+ partTitle: string;
191
+ cameraName: string;
192
+ vesselName: string;
193
+ /** Physical sensor width ceiling (pixels). Distinct from `renderWidth` on
194
+ * `CameraState`, which is the currently-negotiated resolution. */
195
+ maxWidth: number;
196
+ /** Physical sensor height ceiling (pixels). */
197
+ maxHeight: number;
198
+ supportsZoom: boolean;
199
+ fov: number;
200
+ fovMin: number;
201
+ fovMax: number;
202
+ supportsPan: boolean;
203
+ panYawMin: number;
204
+ panYawMax: number;
205
+ panPitchMin: number;
206
+ panPitchMax: number;
207
+ /** Current encoder bitrate in bits per second. 0 when no encoder is running. */
208
+ encoderBitrateBps: number;
209
+ /** REMB-derived bandwidth target. 0 until the first feedback arrives. */
210
+ targetBitrateBps: number;
211
+ /** Effective degrade level (max across active subscribers). 0.0 to 1.0. */
212
+ degradeLevel: number;
213
+ }
214
+
215
+ /** Event payloads emitted by a {@link KerbcastClient}. */
216
+ export interface KerbcastClientEvents {
217
+ "state-change": KerbcastConnectionState;
218
+ "cameras-change": CameraState[];
219
+ "adaptive-shed": AdaptiveShedPayload;
220
+ error: ErrorPayload;
221
+ /**
222
+ * Fired whenever a `ping` keepalive arrives from the sidecar.
223
+ * Consumers can use this to reset their own staleness watchdogs
224
+ * without intercepting at the transport layer.
225
+ */
226
+ ping: undefined;
227
+ /**
228
+ * Fired when a `settings-state` message arrives (after `Hello` and
229
+ * whenever the plugin-reported throttle state changes). Payload
230
+ * reflects what the plugin has applied, not just what was requested.
231
+ */
232
+ "settings-change": SettingsStatePayload;
233
+ }
234
+
235
+ // ---------------------------------------------------------------------------
236
+ // Transport abstraction for tests
237
+ // ---------------------------------------------------------------------------
238
+
239
+ /**
240
+ * Pluggable WebRTC transport. Production code uses the default
241
+ * (browser `RTCPeerConnection`). Tests substitute an in-memory
242
+ * implementation to exercise the state machine without a real peer.
243
+ */
244
+ export interface KerbcastTransport {
245
+ createPeer(iceServers: RTCIceServer[]): KerbcastPeer;
246
+ }
247
+
248
+ export interface KerbcastPeer {
249
+ addRecvOnlyTransceiver(): void;
250
+ createDataChannel(label: string): KerbcastDataChannel;
251
+ onTrack(
252
+ handler: (track: MediaStreamTrack, idx: number, mid: string) => void,
253
+ ): void;
254
+ onStateChange(handler: (state: KerbcastConnectionState) => void): void;
255
+ createOffer(): Promise<string>;
256
+ setLocalDescription(sdp: string): Promise<void>;
257
+ setRemoteAnswer(sdp: string): Promise<void>;
258
+ waitForIceComplete(): Promise<void>;
259
+ localSdp(): string;
260
+ close(): void;
261
+ /**
262
+ * Retrieve the raw WebRTC stats report. Optional so existing third-party
263
+ * transports and mocks that predate this method keep compiling without
264
+ * changes. Equivalent to `RTCPeerConnection.getStats(null)`.
265
+ */
266
+ getStats?(): Promise<RTCStatsReport>;
267
+ }
268
+
269
+ export interface KerbcastDataChannel {
270
+ send(payload: string): void;
271
+ onOpen(handler: () => void): void;
272
+ onMessage(handler: (raw: string) => void): void;
273
+ onClose(handler: () => void): void;
274
+ }
275
+
276
+ // ---------------------------------------------------------------------------
277
+ // Default transport: browser RTCPeerConnection
278
+ // ---------------------------------------------------------------------------
279
+
280
+ /** Options for {@link BrowserKerbcastTransport}. */
281
+ export interface BrowserKerbcastTransportOptions {
282
+ /**
283
+ * Maximum time (ms) to wait for ICE gathering to complete before proceeding
284
+ * with whatever candidates have been gathered. Defaults to 2000 ms.
285
+ *
286
+ * On LAN paths where the STUN server is unreachable, gathering blocks until
287
+ * the STUN timeout elapses. Host candidates are all a local LAN stream
288
+ * needs, so this timeout lets connect proceed without waiting for STUN.
289
+ */
290
+ iceGatheringTimeoutMs?: number;
291
+ }
292
+
293
+ export class BrowserKerbcastTransport implements KerbcastTransport {
294
+ private readonly opts: Required<BrowserKerbcastTransportOptions>;
295
+
296
+ constructor(opts: BrowserKerbcastTransportOptions = {}) {
297
+ this.opts = { iceGatheringTimeoutMs: opts.iceGatheringTimeoutMs ?? 2000 };
298
+ }
299
+
300
+ createPeer(iceServers: RTCIceServer[]): KerbcastPeer {
301
+ const pc = new RTCPeerConnection({ iceServers });
302
+ const { iceGatheringTimeoutMs } = this.opts;
303
+ let trackIdx = 0;
304
+ let onTrack:
305
+ | ((t: MediaStreamTrack, idx: number, mid: string) => void)
306
+ | null = null;
307
+ let onStateChange: ((s: KerbcastConnectionState) => void) | null = null;
308
+
309
+ pc.ontrack = (ev) => {
310
+ // ev.transceiver.mid is the stable m-line id the sidecar keys its
311
+ // SlotMap on; it's set by the time ontrack fires (post-answer).
312
+ onTrack?.(ev.track, trackIdx++, ev.transceiver.mid ?? "");
313
+ };
314
+ pc.onconnectionstatechange = () => {
315
+ onStateChange?.(mapPeerState(pc.connectionState));
316
+ };
317
+
318
+ return {
319
+ addRecvOnlyTransceiver: () => {
320
+ pc.addTransceiver("video", { direction: "recvonly" });
321
+ },
322
+ createDataChannel: (label) =>
323
+ wrapBrowserDataChannel(pc.createDataChannel(label)),
324
+ onTrack: (h) => {
325
+ onTrack = h;
326
+ },
327
+ onStateChange: (h) => {
328
+ onStateChange = h;
329
+ },
330
+ createOffer: async () => {
331
+ const offer = await pc.createOffer();
332
+ return offer.sdp ?? "";
333
+ },
334
+ setLocalDescription: async (sdp) => {
335
+ await pc.setLocalDescription({ type: "offer", sdp });
336
+ },
337
+ setRemoteAnswer: async (sdp) => {
338
+ await pc.setRemoteDescription({ type: "answer", sdp });
339
+ },
340
+ waitForIceComplete: () =>
341
+ new Promise<void>((resolve) => {
342
+ if (pc.iceGatheringState === "complete") {
343
+ resolve();
344
+ return;
345
+ }
346
+ let timer: ReturnType<typeof setTimeout> | null = null;
347
+ const cleanup = () => {
348
+ pc.removeEventListener("icegatheringstatechange", check);
349
+ if (timer !== null) {
350
+ clearTimeout(timer);
351
+ timer = null;
352
+ }
353
+ };
354
+ const check = () => {
355
+ if (pc.iceGatheringState === "complete") {
356
+ cleanup();
357
+ resolve();
358
+ }
359
+ };
360
+ timer = setTimeout(() => {
361
+ timer = null;
362
+ cleanup();
363
+ resolve();
364
+ }, iceGatheringTimeoutMs);
365
+ pc.addEventListener("icegatheringstatechange", check);
366
+ }),
367
+ localSdp: () => pc.localDescription?.sdp ?? "",
368
+ getStats: () => pc.getStats(null),
369
+ close: () => {
370
+ pc.close();
371
+ },
372
+ };
373
+ }
374
+ }
375
+
376
+ function wrapBrowserDataChannel(dc: RTCDataChannel): KerbcastDataChannel {
377
+ let onOpen: (() => void) | null = null;
378
+ let onMessage: ((raw: string) => void) | null = null;
379
+ let onClose: (() => void) | null = null;
380
+ dc.onopen = () => onOpen?.();
381
+ dc.onclose = () => onClose?.();
382
+ dc.onmessage = (ev) => {
383
+ if (typeof ev.data === "string") onMessage?.(ev.data);
384
+ };
385
+ return {
386
+ send: (s) => {
387
+ dc.send(s);
388
+ },
389
+ onOpen: (h) => {
390
+ onOpen = h;
391
+ },
392
+ onMessage: (h) => {
393
+ onMessage = h;
394
+ },
395
+ onClose: (h) => {
396
+ onClose = h;
397
+ },
398
+ };
399
+ }
400
+
401
+ function mapPeerState(state: RTCPeerConnectionState): KerbcastConnectionState {
402
+ switch (state) {
403
+ case "new":
404
+ case "connecting":
405
+ return "connecting";
406
+ case "connected":
407
+ return "connected";
408
+ case "failed":
409
+ return "failed";
410
+ case "disconnected":
411
+ case "closed":
412
+ return "disconnected";
413
+ }
414
+ }
415
+
416
+ // ---------------------------------------------------------------------------
417
+ // Tiny typed event emitter
418
+ // ---------------------------------------------------------------------------
419
+
420
+ type Listener<T> = (data: T) => void;
421
+
422
+ // No bound on E so consumers can use `interface` declarations
423
+ // (interfaces don't carry an implicit string index signature, so
424
+ // `extends Record<string, unknown>` would reject them).
425
+ class TypedEmitter<E> {
426
+ private readonly listeners = new Map<keyof E, Set<Listener<unknown>>>();
427
+
428
+ on<K extends keyof E>(event: K, handler: (data: E[K]) => void): () => void {
429
+ let set = this.listeners.get(event);
430
+ if (!set) {
431
+ set = new Set();
432
+ this.listeners.set(event, set);
433
+ }
434
+ set.add(handler as Listener<unknown>);
435
+ return () => {
436
+ set?.delete(handler as Listener<unknown>);
437
+ };
438
+ }
439
+
440
+ protected emit<K extends keyof E>(event: K, data: E[K]): void {
441
+ const set = this.listeners.get(event);
442
+ set?.forEach((h) => (h as Listener<E[K]>)(data));
443
+ }
444
+ }
445
+
446
+ // ---------------------------------------------------------------------------
447
+ // Camera handle implementation
448
+ // ---------------------------------------------------------------------------
449
+
450
+ class CameraHandle
451
+ extends TypedEmitter<KerbcastCameraEvents>
452
+ implements KerbcastCameraHandle
453
+ {
454
+ readonly flightId: number;
455
+ private _state: CameraState | null = null;
456
+ private _mediaStream: MediaStream | null = null;
457
+ private _rawStream: MediaStream | null = null;
458
+ private _noisePipeline: NoisePipeline | null = null;
459
+ private _noiseOverride: Partial<NoiseConfig> | null = null;
460
+ /**
461
+ * True while the handle has no live source but is still showing static
462
+ * (signal-loss / camera-switch gap). Distinct from a true teardown, where
463
+ * the pipeline is destroyed and `_mediaStream` goes null. While sourceless,
464
+ * intensity is pinned at full and degrade updates must not lower it.
465
+ */
466
+ private _sourceless = false;
467
+ /** Stall state mirrored from the pipeline's onStallChange callback. */
468
+ private _stalled = false;
469
+ /** Whether animated static is drawn; survives pipeline rebuilds. */
470
+ private _showStatic = true;
471
+ private readonly client: KerbcastClient;
472
+
473
+ constructor(flightId: number, client: KerbcastClient) {
474
+ super();
475
+ this.flightId = flightId;
476
+ this.client = client;
477
+ }
478
+
479
+ get state(): CameraState | null {
480
+ return this._state;
481
+ }
482
+
483
+ get mediaStream(): MediaStream | null {
484
+ return this._mediaStream;
485
+ }
486
+
487
+ get stalled(): boolean {
488
+ return this._stalled;
489
+ }
490
+
491
+ get showStatic(): boolean {
492
+ return this._showStatic;
493
+ }
494
+
495
+ configure(options: { noise?: Partial<NoiseConfig> }): void {
496
+ this._noiseOverride = options.noise ?? null;
497
+ // Re-evaluate with the new noise setting: rebuild from whatever source
498
+ // state we're currently in (live, sourceless, or torn down).
499
+ this._applySource(this._rawStream);
500
+ }
501
+
502
+ setShowStatic(enabled: boolean): void {
503
+ this._showStatic = enabled;
504
+ this._noisePipeline?.setShowStatic(enabled);
505
+ }
506
+
507
+ /**
508
+ * Internal — called by the client when CameraState pushes arrive. Returns
509
+ * `true` when this update resurrected the camera (Destroyed -> Active), so
510
+ * the client can re-source the handle from its retained track.
511
+ */
512
+ _setState(state: CameraState): boolean {
513
+ const prevDestroyed = this._state?.lifecycle === CameraLifecycle.Destroyed;
514
+ this._state = state;
515
+ this.emit("change", state);
516
+
517
+ // A destroyed camera stops forwarding frames but its track never ends, so
518
+ // nothing else drives the handle into the sourceless state — do it here.
519
+ if (state.lifecycle === CameraLifecycle.Destroyed) {
520
+ if (!this._sourceless || !prevDestroyed) this._applySource(null);
521
+ return false;
522
+ }
523
+
524
+ // Live feed: degrade drives intensity. Don't touch a sourceless pipeline,
525
+ // which is pinned at full static.
526
+ if (!this._sourceless) {
527
+ this._noisePipeline?.setIntensity(this._liveIntensity());
528
+ }
529
+
530
+ // Resurrection: KSP reused this part.flightID after a revert, so the
531
+ // sidecar tombstoned then re-attached the SAME camera and rebound the SAME
532
+ // track with no new slot-map. The handle went sourceless on destroy and
533
+ // only re-sources on a slot-map, so signal the client to re-source it from
534
+ // the track it still holds.
535
+ return prevDestroyed;
536
+ }
537
+
538
+ /**
539
+ * Internal — called by the client when a track arrives or drops.
540
+ * A null stream does NOT tear down: if a pipeline can run, the handle keeps
541
+ * emitting live full-intensity static (signal-loss / camera-switch gap).
542
+ * Use {@link _teardown} for genuine teardown (disconnect / peer failure).
543
+ */
544
+ _setMediaStream(stream: MediaStream | null): void {
545
+ this._applySource(stream);
546
+ }
547
+
548
+ /**
549
+ * Drive the persistent pipeline to a given source (or sourceless static when
550
+ * `raw` is null). Creates the pipeline lazily on first need and reuses it
551
+ * thereafter so the output stream stays stable across source swaps.
552
+ */
553
+ private _applySource(raw: MediaStream | null): void {
554
+ this._rawStream = raw;
555
+ this._sourceless = raw === null;
556
+
557
+ const noiseEnabled = this.client._resolveNoise(this._noiseOverride);
558
+
559
+ if (noiseEnabled) {
560
+ // Reuse an existing pipeline; otherwise try to create one.
561
+ if (!this._noisePipeline) {
562
+ const initial = raw ? this._liveIntensity() : SOURCELESS_INTENSITY;
563
+ this._noisePipeline = tryCreateNoisePipeline(raw, initial, {
564
+ showStatic: this._showStatic,
565
+ onStallChange: (stalled) => this._setStalled(stalled),
566
+ });
567
+ } else {
568
+ this._noisePipeline.setSource(raw);
569
+ }
570
+
571
+ const pipeline = this._noisePipeline;
572
+ if (pipeline) {
573
+ pipeline.setIntensity(raw ? this._liveIntensity() : SOURCELESS_INTENSITY);
574
+ this._setOutput(pipeline.processedStream);
575
+ return;
576
+ }
577
+ } else if (this._noisePipeline) {
578
+ // Noise just got disabled — drop the pipeline and fall through to raw.
579
+ this._noisePipeline.destroy();
580
+ this._noisePipeline = null;
581
+ this._setStalled(false);
582
+ }
583
+
584
+ // Noise disabled, or captureStream unavailable (no pipeline creatable):
585
+ // expose the raw stream directly, or null when there's no source.
586
+ this._setOutput(raw);
587
+ }
588
+
589
+ private _setOutput(stream: MediaStream | null): void {
590
+ this._mediaStream = stream;
591
+ this.emit("stream", stream);
592
+ }
593
+
594
+ private _setStalled(stalled: boolean): void {
595
+ if (this._stalled === stalled) return;
596
+ this._stalled = stalled;
597
+ this.emit("stall", stalled);
598
+ }
599
+
600
+ private _liveIntensity(): number {
601
+ return Math.max(LIVE_INTENSITY_FLOOR, this._state?.degradeLevel ?? 0);
602
+ }
603
+
604
+ /**
605
+ * Internal — genuine teardown (client disconnect / peer failure). Destroys
606
+ * the persistent pipeline, stops its rAF loop, and nulls the stream.
607
+ */
608
+ _teardown(): void {
609
+ this._noisePipeline?.destroy();
610
+ this._noisePipeline = null;
611
+ this._rawStream = null;
612
+ this._sourceless = false;
613
+ this._setStalled(false);
614
+ this._setOutput(null);
615
+ }
616
+
617
+ async setLayers(layers: Layer[]): Promise<void> {
618
+ await this.client._send({
619
+ type: "set-layers",
620
+ content: { flightId: this.flightId, layers },
621
+ });
622
+ }
623
+
624
+ async setRenderSize(width: number, height: number): Promise<void> {
625
+ await this.client._send({
626
+ type: "set-render-size",
627
+ content: { flightId: this.flightId, width, height },
628
+ });
629
+ }
630
+
631
+ async setFov(fov: number): Promise<void> {
632
+ await this.client._send({
633
+ type: "set-fov",
634
+ content: { flightId: this.flightId, fov },
635
+ });
636
+ }
637
+
638
+ async setPan(yaw: number, pitch: number): Promise<void> {
639
+ await this.client._send({
640
+ type: "set-pan",
641
+ content: { flightId: this.flightId, yaw, pitch },
642
+ });
643
+ }
644
+
645
+ async setPanRate(yawRate: number, pitchRate: number): Promise<void> {
646
+ await this.client._send({
647
+ type: "set-pan-rate",
648
+ content: { flightId: this.flightId, yawRate, pitchRate },
649
+ });
650
+ }
651
+
652
+ async setZoomRate(rate: number): Promise<void> {
653
+ await this.client._send({
654
+ type: "set-zoom-rate",
655
+ content: { flightId: this.flightId, rate },
656
+ });
657
+ }
658
+
659
+ async setDegrade(level: number): Promise<void> {
660
+ await this.client._send({
661
+ type: "set-degrade",
662
+ content: { flightId: this.flightId, level },
663
+ });
664
+ }
665
+
666
+ async setQuality(preset: QualityPreset | null): Promise<void> {
667
+ await this.client._send({
668
+ type: "set-quality",
669
+ // null (auto) deserializes to None on the sidecar; the generated
670
+ // payload type marks the field optional, so widen for the wire.
671
+ content: { flightId: this.flightId, preset: preset ?? undefined },
672
+ });
673
+ }
674
+
675
+ async requestKeyframe(): Promise<void> {
676
+ await this.client._send({
677
+ type: "request-keyframe",
678
+ content: { flightId: this.flightId },
679
+ });
680
+ }
681
+ }
682
+
683
+ // ---------------------------------------------------------------------------
684
+ // KerbcastClient
685
+ // ---------------------------------------------------------------------------
686
+
687
+ /**
688
+ * High-level kerbcast sidecar client. Owns the WebRTC peer + the
689
+ * `kerbcast-control` data channel + the per-camera registry + the
690
+ * `MediaStream`s for each subscribed camera.
691
+ *
692
+ * Usage:
693
+ *
694
+ * ```ts
695
+ * const client = new KerbcastClient({ host: "192.168.1.74", port: 8088 });
696
+ * await client.connect();
697
+ *
698
+ * const cam = client.camera(2592004302);
699
+ * await cam.setFov(35);
700
+ * videoEl.srcObject = cam.mediaStream;
701
+ *
702
+ * cam.on("change", (state) => { ... });
703
+ * client.on("adaptive-shed", (event) => { ... });
704
+ * ```
705
+ *
706
+ * Disconnect with {@link disconnect}. The handles returned by
707
+ * `camera()` remain valid across reconnects; their `mediaStream`
708
+ * goes null on disconnect and is replaced when the next connect's
709
+ * tracks arrive.
710
+ */
711
+ export class KerbcastClient extends TypedEmitter<KerbcastClientEvents> {
712
+ private readonly cfg: KerbcastClientConfig;
713
+ private readonly transport: KerbcastTransport;
714
+ private peer: KerbcastPeer | null = null;
715
+ private control: KerbcastDataChannel | null = null;
716
+ private _state: KerbcastConnectionState = "disconnected";
717
+ private _cameras: CameraState[] = [];
718
+ private readonly handles = new Map<number, CameraHandle>();
719
+ private requestedOrder: number[] = [];
720
+ /** Set when `connect` is given an explicit slot pool. Switches track
721
+ * routing from legacy index order to mid-keyed dynamic slots. */
722
+ private dynamicMode = false;
723
+ /** Dynamic mode: transceiver mid -> the live track on that slot. */
724
+ private readonly trackByMid = new Map<string, MediaStreamTrack>();
725
+ /** Dynamic mode: transceiver mid -> the camera bound to that slot
726
+ * (populated by SlotMap -- initial bindings announced on Hello). */
727
+ private readonly flightByMid = new Map<string, number>();
728
+ /**
729
+ * Legacy mode: raw incoming track id -> flightId. Populated in handleTrack
730
+ * so inboundVideoStats() can match inbound-rtp report entries by
731
+ * trackIdentifier. Cleared on disconnect. Not used in dynamic mode (which
732
+ * matches by mid instead).
733
+ */
734
+ private readonly flightByTrackId = new Map<string, number>();
735
+ /** Sidecar version reported on `Hello`; null before handshake. */
736
+ private _sidecarVersion: string | null = null;
737
+ private _encoderBackend: string | null = null;
738
+ /** Last-received throttle state from `settings-state`. False until the first push. */
739
+ private _throttleMainScreen = false;
740
+ /**
741
+ * Mission-time capture clock from `settings-state`. `_captureUt` is null
742
+ * until the first clock arrives (and against an old sidecar that never
743
+ * sends one), so consumers treat it as "no clock". `_captureEpoch` bumps
744
+ * on discontinuities (revert / quickload / scene reload); only the change
745
+ * is meaningful. `_warpRate` defaults to 1 when the field is absent.
746
+ */
747
+ private _captureUt: number | null = null;
748
+ private _captureEpoch = 0;
749
+ private _warpRate = 1;
750
+
751
+ constructor(cfg: KerbcastClientConfig, transport?: KerbcastTransport) {
752
+ super();
753
+ this.cfg = cfg;
754
+ this.transport =
755
+ transport ??
756
+ new BrowserKerbcastTransport({
757
+ iceGatheringTimeoutMs: cfg.iceGatheringTimeoutMs,
758
+ });
759
+ }
760
+
761
+ get state(): KerbcastConnectionState {
762
+ return this._state;
763
+ }
764
+
765
+ get cameras(): ReadonlyArray<CameraState> {
766
+ return this._cameras;
767
+ }
768
+
769
+ get sidecarVersion(): string | null {
770
+ return this._sidecarVersion;
771
+ }
772
+
773
+ get encoderBackend(): string | null {
774
+ return this._encoderBackend;
775
+ }
776
+
777
+ /**
778
+ * Current effective state of the "Throttle KSP main render" setting.
779
+ * Reflects what the plugin has applied (from `settings-state` messages),
780
+ * not just what was last requested. False until the first push arrives.
781
+ */
782
+ get throttleMainScreen(): boolean {
783
+ return this._throttleMainScreen;
784
+ }
785
+
786
+ /**
787
+ * Mission-time capture clock, from the sidecar's ~1Hz `settings-state`.
788
+ * `captureUt` is the KSP universal time (seconds) the current video was
789
+ * captured at, or null when no clock is known (old plugin/sidecar, or
790
+ * before the first push). `epoch` bumps on a UT discontinuity so a
791
+ * consumer can flush and resync. `warpRate` lets a consumer interpolate
792
+ * `captureUt` between samples; defaults to 1.
793
+ */
794
+ get clock(): { captureUt: number | null; epoch: number; warpRate: number } {
795
+ return {
796
+ captureUt: this._captureUt,
797
+ epoch: this._captureEpoch,
798
+ warpRate: this._warpRate,
799
+ };
800
+ }
801
+
802
+ /**
803
+ * Send a `set-throttle-main-screen` command to the sidecar, which writes
804
+ * `global.control.json` for the plugin to apply. The plugin persists the
805
+ * change in the per-save difficulty parameter. The resulting
806
+ * `settings-state` broadcast reflects the applied value.
807
+ */
808
+ async setThrottleMainScreen(enabled: boolean): Promise<void> {
809
+ await this._send({
810
+ type: "set-throttle-main-screen",
811
+ content: { enabled },
812
+ });
813
+ }
814
+
815
+ /**
816
+ * Pre-handshake camera discovery via the sidecar's HTTP `/cameras`
817
+ * endpoint. Useful when the consumer wants to pick which cameras
818
+ * to subscribe to before opening a peer connection.
819
+ */
820
+ async discover(): Promise<DiscoveredCamera[]> {
821
+ const res = await fetch(`http://${this.cfg.host}:${this.cfg.port}/cameras`);
822
+ if (!res.ok) throw new Error(`/cameras returned ${res.status}`);
823
+ const body = (await res.json()) as { cameras: DiscoveredCamera[] };
824
+ return body.cameras;
825
+ }
826
+
827
+ /**
828
+ * Open the WebRTC peer and subscribe to cameras. Idempotent: calling
829
+ * `connect` while already connected disconnects first.
830
+ *
831
+ * Two modes:
832
+ * - **Legacy** (no `opts.slots`): one transceiver per requested camera;
833
+ * an empty array subscribes to every currently-known camera. Tracks
834
+ * route by m-line index. Unchanged behaviour.
835
+ * - **Dynamic** (`opts.slots` set): negotiates a pool of `opts.slots`
836
+ * recv-only video slots. `requestedCameras` are the *initial*
837
+ * subscription bound to the first slots; spare slots are filled at
838
+ * runtime via {@link subscribe}. Tracks route by transceiver mid
839
+ * (the sidecar announces bindings via SlotMap), so switching a slot's
840
+ * camera needs no renegotiation.
841
+ */
842
+ async connect(
843
+ requestedCameras: number[] = [],
844
+ opts: { slots?: number } = {},
845
+ ): Promise<void> {
846
+ this.disconnect();
847
+ this.setState("connecting");
848
+ this.requestedOrder = [...requestedCameras];
849
+ this.dynamicMode = opts.slots !== undefined;
850
+ this.trackByMid.clear();
851
+ this.flightByMid.clear();
852
+ this.flightByTrackId.clear();
853
+
854
+ const peer = this.transport.createPeer(
855
+ this.cfg.iceServers ?? [{ urls: "stun:stun.l.google.com:19302" }],
856
+ );
857
+ this.peer = peer;
858
+
859
+ // Slot-pool size = the recv-only transceivers we offer. Dynamic mode
860
+ // uses the explicit pool size (spares subscribable at runtime); legacy
861
+ // uses one per requested camera (>=1).
862
+ const slotCount = this.dynamicMode
863
+ ? Math.max(opts.slots ?? 0, 1)
864
+ : Math.max(this.requestedOrder.length, 1);
865
+ for (let i = 0; i < slotCount; i++) {
866
+ peer.addRecvOnlyTransceiver();
867
+ }
868
+
869
+ peer.onTrack((track, idx, mid) => this.handleTrack(track, idx, mid));
870
+
871
+ peer.onStateChange((s) => {
872
+ this.setState(s);
873
+ if (s === "disconnected" || s === "failed") {
874
+ this.tearDownStreams();
875
+ }
876
+ });
877
+
878
+ this.control = peer.createDataChannel("kerbcast-control");
879
+ this.control.onOpen(() => {
880
+ void this._send({ type: "hello" });
881
+ });
882
+ this.control.onMessage((raw) => {
883
+ this.handleServerMessage(raw);
884
+ });
885
+ this.control.onClose(() => {
886
+ // Channel closure usually follows peer disconnect; the peer
887
+ // state-change handler covers the tear-down. Nothing to do here.
888
+ });
889
+
890
+ await peer.createOffer().then((sdp) => peer.setLocalDescription(sdp));
891
+ await peer.waitForIceComplete();
892
+
893
+ const body: { sdp: string; cameras: number[]; slots?: number } = {
894
+ sdp: peer.localSdp(),
895
+ cameras: this.requestedOrder,
896
+ };
897
+ if (this.dynamicMode) body.slots = slotCount;
898
+
899
+ let answer: { sdp: string; cameras: number[] };
900
+ try {
901
+ answer = await (this.cfg.negotiate
902
+ ? this.cfg.negotiate(body)
903
+ : this.httpNegotiate(body));
904
+ } catch (err) {
905
+ this.setState("failed");
906
+ throw err;
907
+ }
908
+ await peer.setRemoteAnswer(answer.sdp);
909
+ // Legacy mode routes by index → requestedOrder, so realign to what the
910
+ // sidecar actually wired. Dynamic mode routes by mid via SlotMap (initial
911
+ // bindings arrive on Hello), so the answer's camera list isn't used.
912
+ if (!this.dynamicMode) {
913
+ this.requestedOrder = answer.cameras;
914
+ }
915
+ }
916
+
917
+ /** Default signaling: POST the offer to the sidecar's HTTP `/offer`. */
918
+ private async httpNegotiate(offer: {
919
+ sdp: string;
920
+ cameras: number[];
921
+ slots?: number;
922
+ }): Promise<{ sdp: string; cameras: number[] }> {
923
+ const res = await fetch(`http://${this.cfg.host}:${this.cfg.port}/offer`, {
924
+ method: "POST",
925
+ headers: { "Content-Type": "application/json" },
926
+ body: JSON.stringify(offer),
927
+ });
928
+ if (!res.ok) throw new Error(`POST /offer returned ${res.status}`);
929
+ return (await res.json()) as { sdp: string; cameras: number[] };
930
+ }
931
+
932
+ disconnect(): void {
933
+ this.peer?.close();
934
+ this.peer = null;
935
+ this.control = null;
936
+ this.tearDownStreams();
937
+ this.setState("disconnected");
938
+ }
939
+
940
+ /**
941
+ * Stable per-flight-id handle. Creates a new handle on first call
942
+ * for a given ID; returns the same handle on subsequent calls.
943
+ */
944
+ camera(flightId: number): KerbcastCameraHandle {
945
+ return this.getOrCreateHandle(flightId);
946
+ }
947
+
948
+ /**
949
+ * Dynamic mode only: bind a camera to a free slot on the already-connected
950
+ * peer. The camera's stream appears on its handle once the sidecar answers
951
+ * with a SlotMap; the sidecar emits an `error` event if no slot is free. No
952
+ * renegotiation.
953
+ */
954
+ async subscribe(flightId: number): Promise<void> {
955
+ await this._send({ type: "subscribe", content: { flightId } });
956
+ }
957
+
958
+ /**
959
+ * Dynamic mode only: release a camera's slot. Its handle's `mediaStream`
960
+ * goes null once the sidecar confirms with a SlotMap.
961
+ */
962
+ async unsubscribe(flightId: number): Promise<void> {
963
+ await this._send({ type: "unsubscribe", content: { flightId } });
964
+ }
965
+
966
+ /**
967
+ * Internal — resolves the effective noise enabled state for a camera,
968
+ * merging the per-camera override (if any) with the client-level default.
969
+ */
970
+ _resolveNoise(override: Partial<NoiseConfig> | null): boolean {
971
+ if (override?.enabled !== undefined) return override.enabled;
972
+ return this.cfg.noise?.enabled !== false; // default: enabled
973
+ }
974
+
975
+ /**
976
+ * Internal — sends a typed `ClientMessage` over the control
977
+ * channel. Drops silently if the channel hasn't opened yet (and
978
+ * logs to console). Public on the camera handle, but consumers
979
+ * shouldn't need to call this directly.
980
+ */
981
+ async _send(msg: ClientMessage): Promise<void> {
982
+ if (!this.control) {
983
+ throw new Error("[kerbcast] control channel not open");
984
+ }
985
+ this.control.send(JSON.stringify(msg));
986
+ }
987
+
988
+ // -- private --
989
+
990
+ private getOrCreateHandle(flightId: number): CameraHandle {
991
+ let handle = this.handles.get(flightId);
992
+ if (!handle) {
993
+ handle = new CameraHandle(flightId, this);
994
+ this.handles.set(flightId, handle);
995
+ }
996
+ return handle;
997
+ }
998
+
999
+ private handleTrack(
1000
+ track: MediaStreamTrack,
1001
+ idx: number,
1002
+ mid: string,
1003
+ ): void {
1004
+ if (this.dynamicMode) {
1005
+ // Store the slot's track keyed by mid; bind it to a camera if (or once)
1006
+ // a SlotMap names this mid. Track and SlotMap can arrive in either
1007
+ // order — whichever lands second triggers the binding.
1008
+ this.trackByMid.set(mid, track);
1009
+ const flightId = this.flightByMid.get(mid);
1010
+ if (flightId !== undefined) {
1011
+ this.getOrCreateHandle(flightId)._setMediaStream(
1012
+ new MediaStream([track]),
1013
+ );
1014
+ }
1015
+ return;
1016
+ }
1017
+ // Legacy: m-line index maps to the requested camera list.
1018
+ const flightId = this.requestedOrder[idx];
1019
+ if (flightId === undefined) return;
1020
+ // Record the raw track id so inboundVideoStats() can match it against
1021
+ // the report's trackIdentifier field.
1022
+ if (track.id) this.flightByTrackId.set(track.id, flightId);
1023
+ this.getOrCreateHandle(flightId)._setMediaStream(new MediaStream([track]));
1024
+ }
1025
+
1026
+ private handleServerMessage(raw: string): void {
1027
+ let msg: ServerMessage;
1028
+ try {
1029
+ msg = JSON.parse(raw) as ServerMessage;
1030
+ } catch (err) {
1031
+ this.emit("error", {
1032
+ message: err instanceof Error ? err.message : String(err),
1033
+ source: ErrorSource.Client,
1034
+ });
1035
+ return;
1036
+ }
1037
+ switch (msg.type) {
1038
+ case "hello":
1039
+ this._sidecarVersion = msg.content.sidecarVersion;
1040
+ this._encoderBackend = msg.content.encoderBackend;
1041
+ break;
1042
+ case "camera-snapshot":
1043
+ this._cameras = msg.content.cameras;
1044
+ for (const cam of this._cameras) {
1045
+ if (this.getOrCreateHandle(cam.flightId)._setState(cam)) {
1046
+ this._resourceHandle(cam.flightId);
1047
+ }
1048
+ }
1049
+ this.emit("cameras-change", this._cameras);
1050
+ break;
1051
+ case "camera-state-changed": {
1052
+ const next = this._cameras.filter(
1053
+ (c) => c.flightId !== msg.content.state.flightId,
1054
+ );
1055
+ next.push(msg.content.state);
1056
+ next.sort((a, b) => a.flightId - b.flightId);
1057
+ this._cameras = next;
1058
+ if (
1059
+ this.getOrCreateHandle(msg.content.state.flightId)._setState(
1060
+ msg.content.state,
1061
+ )
1062
+ ) {
1063
+ this._resourceHandle(msg.content.state.flightId);
1064
+ }
1065
+ this.emit("cameras-change", this._cameras);
1066
+ break;
1067
+ }
1068
+ case "slot-map": {
1069
+ const { mid, flightId } = msg.content;
1070
+ const prev = this.flightByMid.get(mid);
1071
+ if (flightId == null) {
1072
+ // Slot freed — the camera that held it loses its stream.
1073
+ if (prev !== undefined) {
1074
+ this.getOrCreateHandle(prev)._setMediaStream(null);
1075
+ }
1076
+ this.flightByMid.delete(mid);
1077
+ } else {
1078
+ // A switch (a different camera held this slot) clears the old one.
1079
+ if (prev !== undefined && prev !== flightId) {
1080
+ this.getOrCreateHandle(prev)._setMediaStream(null);
1081
+ }
1082
+ this.flightByMid.set(mid, flightId);
1083
+ const track = this.trackByMid.get(mid);
1084
+ if (track) {
1085
+ this.getOrCreateHandle(flightId)._setMediaStream(
1086
+ new MediaStream([track]),
1087
+ );
1088
+ } else {
1089
+ // Bound to a slot but the track hasn't arrived yet (the
1090
+ // camera-switch gap). Drive the incoming handle sourceless so it
1091
+ // shows static rather than going blank until the track lands.
1092
+ this.getOrCreateHandle(flightId)._setMediaStream(null);
1093
+ }
1094
+ }
1095
+ break;
1096
+ }
1097
+ case "adaptive-shed":
1098
+ this.emit("adaptive-shed", msg.content);
1099
+ break;
1100
+ case "error":
1101
+ this.emit("error", msg.content);
1102
+ break;
1103
+ case "ping":
1104
+ /*
1105
+ * A ping can race a closing channel (disconnect mid-flight); a
1106
+ * pong that can't be delivered is moot, not an error.
1107
+ */
1108
+ void this._send({ type: "pong" }).catch(() => {});
1109
+ this.emit("ping", undefined);
1110
+ break;
1111
+ case "settings-state":
1112
+ this._throttleMainScreen = msg.content.throttleMainScreen;
1113
+ /*
1114
+ * Capture clock: null captureUt means "no clock" (absent field).
1115
+ * Epoch is retained across pushes that omit it (only a present
1116
+ * value updates it); warpRate falls back to 1 when absent.
1117
+ */
1118
+ this._captureUt = msg.content.captureUt ?? null;
1119
+ if (msg.content.captureEpoch != null) {
1120
+ this._captureEpoch = msg.content.captureEpoch;
1121
+ }
1122
+ this._warpRate = msg.content.timeWarpRate ?? 1;
1123
+ this.emit("settings-change", msg.content);
1124
+ break;
1125
+ }
1126
+ }
1127
+
1128
+ /**
1129
+ * Re-source a resurrected camera's handle from the track the client still
1130
+ * holds for its slot. Called when {@link KerbcastCameraHandle._setState}
1131
+ * reports a Destroyed -> Active transition: the sidecar rebinds the same
1132
+ * track without a new slot-map, so nothing else drives the handle back out
1133
+ * of the sourceless (static) state it entered on destroy.
1134
+ */
1135
+ private _resourceHandle(flightId: number): void {
1136
+ for (const [mid, boundFlightId] of this.flightByMid) {
1137
+ if (boundFlightId !== flightId) continue;
1138
+ const track = this.trackByMid.get(mid);
1139
+ if (track) {
1140
+ this.getOrCreateHandle(flightId)._setMediaStream(
1141
+ new MediaStream([track]),
1142
+ );
1143
+ }
1144
+ return;
1145
+ }
1146
+ }
1147
+
1148
+ private setState(state: KerbcastConnectionState): void {
1149
+ if (this._state === state) return;
1150
+ this._state = state;
1151
+ this.emit("state-change", state);
1152
+ }
1153
+
1154
+ /**
1155
+ * Inbound RTP video statistics keyed by flight ID. Returns an empty map
1156
+ * when not connected or when the transport does not implement `getStats`.
1157
+ *
1158
+ * Iterates the stats report and matches `inbound-rtp` + `kind === "video"`
1159
+ * entries to cameras. Legacy mode matches by `trackIdentifier`; dynamic
1160
+ * mode falls back to the `mid` field when `trackIdentifier` is absent
1161
+ * (modern browsers include it; some implementations omit it).
1162
+ */
1163
+ async inboundVideoStats(): Promise<Map<number, InboundVideoStats>> {
1164
+ const result = new Map<number, InboundVideoStats>();
1165
+ if (!this.peer?.getStats) return result;
1166
+
1167
+ let report: RTCStatsReport;
1168
+ try {
1169
+ report = await this.peer.getStats();
1170
+ } catch {
1171
+ return result;
1172
+ }
1173
+
1174
+ report.forEach((raw) => {
1175
+ if (raw.type !== "inbound-rtp" || raw.kind !== "video") return;
1176
+ const entry = raw as {
1177
+ type: string;
1178
+ kind: string;
1179
+ trackIdentifier?: string;
1180
+ mid?: string;
1181
+ packetsReceived?: number;
1182
+ bytesReceived?: number;
1183
+ framesReceived?: number;
1184
+ framesDecoded?: number;
1185
+ jitter?: number;
1186
+ framesPerSecond?: number;
1187
+ };
1188
+
1189
+ // Resolve the flightId: trackIdentifier wins (legacy path populated it),
1190
+ // mid fallback covers dynamic mode and browsers that omit trackIdentifier.
1191
+ let flightId: number | undefined;
1192
+ if (entry.trackIdentifier) {
1193
+ flightId = this.flightByTrackId.get(entry.trackIdentifier);
1194
+ }
1195
+ if (flightId === undefined && entry.mid) {
1196
+ flightId = this.flightByMid.get(entry.mid);
1197
+ }
1198
+ if (flightId === undefined) return;
1199
+
1200
+ result.set(flightId, {
1201
+ packetsReceived: entry.packetsReceived ?? 0,
1202
+ bytesReceived: entry.bytesReceived ?? 0,
1203
+ framesReceived: entry.framesReceived,
1204
+ framesDecoded: entry.framesDecoded,
1205
+ jitter: entry.jitter,
1206
+ framesPerSecond: entry.framesPerSecond,
1207
+ });
1208
+ });
1209
+
1210
+ return result;
1211
+ }
1212
+
1213
+ private tearDownStreams(): void {
1214
+ this.flightByTrackId.clear();
1215
+ for (const handle of this.handles.values()) {
1216
+ handle._teardown();
1217
+ }
1218
+ }
1219
+ }