@furious.luke/argus-js 0.2.1 → 0.3.1

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/index.d.cts CHANGED
@@ -18,9 +18,29 @@ type SignalMessage = {
18
18
  } | {
19
19
  type: "connection_state";
20
20
  state: string;
21
+ } | {
22
+ type: "media_stall";
23
+ track: string;
24
+ frame_age_ms: number;
25
+ } | {
26
+ type: "media_resumed";
27
+ track: string;
28
+ duration_ms: number;
29
+ } | {
30
+ type: "media_track_ended";
31
+ track: string;
32
+ reason?: string;
33
+ } | {
34
+ type: "recovery_event";
35
+ event: "recovery_started" | "recovery_retry" | "recovery_failed";
36
+ track: string;
37
+ action?: "sender_restart" | "ice_restart";
38
+ reason?: "capture_ended" | "automatic_recovery_failed";
21
39
  } | {
22
40
  type: "error";
23
41
  error: string;
42
+ } | {
43
+ type: "resumed";
24
44
  } | {
25
45
  type: "accepted";
26
46
  } | {
@@ -38,10 +58,24 @@ type SignalMessage = {
38
58
  interface PublisherCallbacks {
39
59
  /** Called when the WebRTC peer connection state changes. */
40
60
  onConnectionStateChange?: (state: RTCPeerConnectionState) => void;
41
- /** Called when a fatal error occurs (e.g. signaling error, WebSocket close). */
61
+ /** Called when a fatal error occurs, including when signaling cannot resume before its deadline. */
42
62
  onError?: (error: Error) => void;
43
63
  /** Called when the browser has successfully connected to the media server. */
44
64
  onConnected?: () => void;
65
+ /** Called for each transition in automatic media recovery. */
66
+ onRecoveryStateChange?: (event: PublisherRecoveryEvent) => void;
67
+ /** Called when recovery requires the host application to obtain a new screen share. */
68
+ onRecoveryRequired?: (event: PublisherRecoveryEvent) => void;
69
+ }
70
+ type PublisherRecoveryState = "recovering" | "recovered" | "failed";
71
+ type PublisherRecoveryAction = "sender_restart" | "ice_restart";
72
+ type PublisherRecoveryFailureReason = "capture_ended" | "automatic_recovery_failed";
73
+ /** A transition in the publisher's fixed automatic media-recovery ladder. */
74
+ interface PublisherRecoveryEvent {
75
+ state: PublisherRecoveryState;
76
+ track: string;
77
+ action?: PublisherRecoveryAction;
78
+ reason?: PublisherRecoveryFailureReason;
45
79
  }
46
80
  /**
47
81
  * TURN and read-token info delivered in the gateway `ready` message.
@@ -51,6 +85,7 @@ interface GatewayReadyInfo {
51
85
  turn_urls?: string[];
52
86
  turn_username?: string;
53
87
  turn_credential?: string;
88
+ /** One-hour token used for frame reads and resuming signaling in the selected region. */
54
89
  read_token?: string;
55
90
  }
56
91
  /**
@@ -63,6 +98,8 @@ interface PublisherOptions {
63
98
  token: string;
64
99
  /** Optional extra ICE servers (e.g. STUN). TURN is supplied by the winning gateway. */
65
100
  iceServers?: RTCIceServer[];
101
+ /** How long to retry a dropped signaling connection to the selected gateway. Defaults to 20 seconds. */
102
+ signalingReconnectTimeoutMs?: number;
66
103
  /** Callbacks for lifecycle events. */
67
104
  callbacks?: PublisherCallbacks;
68
105
  }
@@ -97,8 +134,18 @@ declare class Publisher {
97
134
  private pendingRemoteCandidates;
98
135
  private localStream;
99
136
  private readToken;
137
+ private selectedGatewayURL;
138
+ private stopped;
139
+ private reconnecting;
140
+ private reconnectGeneration;
141
+ private resumeSocket;
142
+ private recoveryGeneration;
143
+ private recoveringMedia;
144
+ private recoveryRequired;
145
+ private recoveryAction;
146
+ private trackEndHandlers;
100
147
  constructor(opts: PublisherOptions);
101
- /** The read token received from the gateway after connecting, for fetching frames. */
148
+ /** The read token used for frame fetches and signaling resume in the selected region. */
102
149
  get frameReadToken(): string | null;
103
150
  /**
104
151
  * Starts the publisher: races all gateways to find the fastest, completes
@@ -116,7 +163,23 @@ declare class Publisher {
116
163
  /** Returns true if the peer connection is in the "connected" state. */
117
164
  get isConnected(): boolean;
118
165
  private raceGateways;
166
+ private installSignaling;
167
+ private resumeSignaling;
168
+ private openResumeSocket;
169
+ private wait;
170
+ private terminateWithError;
119
171
  private handleSignal;
172
+ private beginMediaRecovery;
173
+ private restartSenders;
174
+ private renegotiate;
175
+ private completeMediaRecovery;
176
+ private failMediaRecovery;
177
+ private cancelMediaRecovery;
178
+ private isCurrentRecovery;
179
+ private emitRecoveryTransition;
180
+ private sendRecoveryDiagnostic;
181
+ private watchStreamTracks;
182
+ private unwatchStreamTracks;
120
183
  /** Waits for ICE gathering to reach the "complete" state. */
121
184
  private gatherComplete;
122
185
  }
@@ -238,4 +301,4 @@ declare function captureCamera(opts?: CaptureCameraOptions): Promise<MediaStream
238
301
  */
239
302
  declare function captureScreen(opts?: CaptureScreenOptions): Promise<MediaStream>;
240
303
 
241
- export { type CaptureCameraOptions, type CaptureScreenOptions, type GatewayReadyInfo, Publisher, type PublisherCallbacks, type PublisherOptions, type SignalMessage, captureCamera, captureScreen };
304
+ export { type CaptureCameraOptions, type CaptureScreenOptions, type GatewayReadyInfo, Publisher, type PublisherCallbacks, type PublisherOptions, type PublisherRecoveryAction, type PublisherRecoveryEvent, type PublisherRecoveryFailureReason, type PublisherRecoveryState, type SignalMessage, captureCamera, captureScreen };
package/dist/index.d.ts CHANGED
@@ -18,9 +18,29 @@ type SignalMessage = {
18
18
  } | {
19
19
  type: "connection_state";
20
20
  state: string;
21
+ } | {
22
+ type: "media_stall";
23
+ track: string;
24
+ frame_age_ms: number;
25
+ } | {
26
+ type: "media_resumed";
27
+ track: string;
28
+ duration_ms: number;
29
+ } | {
30
+ type: "media_track_ended";
31
+ track: string;
32
+ reason?: string;
33
+ } | {
34
+ type: "recovery_event";
35
+ event: "recovery_started" | "recovery_retry" | "recovery_failed";
36
+ track: string;
37
+ action?: "sender_restart" | "ice_restart";
38
+ reason?: "capture_ended" | "automatic_recovery_failed";
21
39
  } | {
22
40
  type: "error";
23
41
  error: string;
42
+ } | {
43
+ type: "resumed";
24
44
  } | {
25
45
  type: "accepted";
26
46
  } | {
@@ -38,10 +58,24 @@ type SignalMessage = {
38
58
  interface PublisherCallbacks {
39
59
  /** Called when the WebRTC peer connection state changes. */
40
60
  onConnectionStateChange?: (state: RTCPeerConnectionState) => void;
41
- /** Called when a fatal error occurs (e.g. signaling error, WebSocket close). */
61
+ /** Called when a fatal error occurs, including when signaling cannot resume before its deadline. */
42
62
  onError?: (error: Error) => void;
43
63
  /** Called when the browser has successfully connected to the media server. */
44
64
  onConnected?: () => void;
65
+ /** Called for each transition in automatic media recovery. */
66
+ onRecoveryStateChange?: (event: PublisherRecoveryEvent) => void;
67
+ /** Called when recovery requires the host application to obtain a new screen share. */
68
+ onRecoveryRequired?: (event: PublisherRecoveryEvent) => void;
69
+ }
70
+ type PublisherRecoveryState = "recovering" | "recovered" | "failed";
71
+ type PublisherRecoveryAction = "sender_restart" | "ice_restart";
72
+ type PublisherRecoveryFailureReason = "capture_ended" | "automatic_recovery_failed";
73
+ /** A transition in the publisher's fixed automatic media-recovery ladder. */
74
+ interface PublisherRecoveryEvent {
75
+ state: PublisherRecoveryState;
76
+ track: string;
77
+ action?: PublisherRecoveryAction;
78
+ reason?: PublisherRecoveryFailureReason;
45
79
  }
46
80
  /**
47
81
  * TURN and read-token info delivered in the gateway `ready` message.
@@ -51,6 +85,7 @@ interface GatewayReadyInfo {
51
85
  turn_urls?: string[];
52
86
  turn_username?: string;
53
87
  turn_credential?: string;
88
+ /** One-hour token used for frame reads and resuming signaling in the selected region. */
54
89
  read_token?: string;
55
90
  }
56
91
  /**
@@ -63,6 +98,8 @@ interface PublisherOptions {
63
98
  token: string;
64
99
  /** Optional extra ICE servers (e.g. STUN). TURN is supplied by the winning gateway. */
65
100
  iceServers?: RTCIceServer[];
101
+ /** How long to retry a dropped signaling connection to the selected gateway. Defaults to 20 seconds. */
102
+ signalingReconnectTimeoutMs?: number;
66
103
  /** Callbacks for lifecycle events. */
67
104
  callbacks?: PublisherCallbacks;
68
105
  }
@@ -97,8 +134,18 @@ declare class Publisher {
97
134
  private pendingRemoteCandidates;
98
135
  private localStream;
99
136
  private readToken;
137
+ private selectedGatewayURL;
138
+ private stopped;
139
+ private reconnecting;
140
+ private reconnectGeneration;
141
+ private resumeSocket;
142
+ private recoveryGeneration;
143
+ private recoveringMedia;
144
+ private recoveryRequired;
145
+ private recoveryAction;
146
+ private trackEndHandlers;
100
147
  constructor(opts: PublisherOptions);
101
- /** The read token received from the gateway after connecting, for fetching frames. */
148
+ /** The read token used for frame fetches and signaling resume in the selected region. */
102
149
  get frameReadToken(): string | null;
103
150
  /**
104
151
  * Starts the publisher: races all gateways to find the fastest, completes
@@ -116,7 +163,23 @@ declare class Publisher {
116
163
  /** Returns true if the peer connection is in the "connected" state. */
117
164
  get isConnected(): boolean;
118
165
  private raceGateways;
166
+ private installSignaling;
167
+ private resumeSignaling;
168
+ private openResumeSocket;
169
+ private wait;
170
+ private terminateWithError;
119
171
  private handleSignal;
172
+ private beginMediaRecovery;
173
+ private restartSenders;
174
+ private renegotiate;
175
+ private completeMediaRecovery;
176
+ private failMediaRecovery;
177
+ private cancelMediaRecovery;
178
+ private isCurrentRecovery;
179
+ private emitRecoveryTransition;
180
+ private sendRecoveryDiagnostic;
181
+ private watchStreamTracks;
182
+ private unwatchStreamTracks;
120
183
  /** Waits for ICE gathering to reach the "complete" state. */
121
184
  private gatherComplete;
122
185
  }
@@ -238,4 +301,4 @@ declare function captureCamera(opts?: CaptureCameraOptions): Promise<MediaStream
238
301
  */
239
302
  declare function captureScreen(opts?: CaptureScreenOptions): Promise<MediaStream>;
240
303
 
241
- export { type CaptureCameraOptions, type CaptureScreenOptions, type GatewayReadyInfo, Publisher, type PublisherCallbacks, type PublisherOptions, type SignalMessage, captureCamera, captureScreen };
304
+ export { type CaptureCameraOptions, type CaptureScreenOptions, type GatewayReadyInfo, Publisher, type PublisherCallbacks, type PublisherOptions, type PublisherRecoveryAction, type PublisherRecoveryEvent, type PublisherRecoveryFailureReason, type PublisherRecoveryState, type SignalMessage, captureCamera, captureScreen };
package/dist/index.js CHANGED
@@ -45,6 +45,12 @@ function parseSignal(data) {
45
45
  }
46
46
 
47
47
  // src/publisher.ts
48
+ var defaultSignalingReconnectTimeoutMs = 2e4;
49
+ var signalingResumeAttemptTimeoutMs = 3e3;
50
+ var signalingResumeMaxBackoffMs = 3e3;
51
+ var senderRestartPauseMs = 100;
52
+ var senderRecoveryWaitMs = 4e3;
53
+ var iceRecoveryWaitMs = 8e3;
48
54
  var Publisher = class {
49
55
  opts;
50
56
  sig = null;
@@ -53,10 +59,20 @@ var Publisher = class {
53
59
  pendingRemoteCandidates = [];
54
60
  localStream = null;
55
61
  readToken = null;
62
+ selectedGatewayURL = null;
63
+ stopped = true;
64
+ reconnecting = false;
65
+ reconnectGeneration = 0;
66
+ resumeSocket = null;
67
+ recoveryGeneration = 0;
68
+ recoveringMedia = false;
69
+ recoveryRequired = false;
70
+ recoveryAction = null;
71
+ trackEndHandlers = /* @__PURE__ */ new Map();
56
72
  constructor(opts) {
57
73
  this.opts = opts;
58
74
  }
59
- /** The read token received from the gateway after connecting, for fetching frames. */
75
+ /** The read token used for frame fetches and signaling resume in the selected region. */
60
76
  get frameReadToken() {
61
77
  return this.readToken;
62
78
  }
@@ -67,8 +83,12 @@ var Publisher = class {
67
83
  * use onConnected for that).
68
84
  */
69
85
  async start(stream) {
86
+ this.stopped = false;
87
+ this.recoveryRequired = false;
70
88
  this.localStream = stream;
71
- const { ws, readyInfo } = await this.raceGateways();
89
+ this.watchStreamTracks(stream);
90
+ const { ws, readyInfo, gatewayURL } = await this.raceGateways();
91
+ this.selectedGatewayURL = gatewayURL;
72
92
  if (readyInfo.read_token) {
73
93
  this.readToken = readyInfo.read_token;
74
94
  }
@@ -103,17 +123,17 @@ var Publisher = class {
103
123
  const offer = await this.pc.createOffer();
104
124
  await this.pc.setLocalDescription(offer);
105
125
  await this.gatherComplete();
106
- this.sig = SignalingChannel.wrap(ws);
107
- this.sig.onMessage = (msg) => this.handleSignal(msg);
108
- this.sig.onClose = () => this.opts.callbacks?.onError?.(new Error("signaling closed"));
109
- this.sig.onError = (err) => this.opts.callbacks?.onError?.(err);
126
+ const signaling = this.installSignaling(ws);
110
127
  const local = this.pc.localDescription;
111
128
  if (!local) throw new Error("local description missing after gather");
112
- this.sig.send({ type: "offer", sdp: local.sdp, sdp_type: "offer" });
129
+ signaling.send({ type: "offer", sdp: local.sdp, sdp_type: "offer" });
113
130
  }
114
131
  /** Replaces the currently published stream with a new one. */
115
132
  async replaceStream(stream) {
116
133
  if (!this.pc) throw new Error("publisher not started");
134
+ this.cancelMediaRecovery();
135
+ this.recoveryRequired = false;
136
+ this.unwatchStreamTracks();
117
137
  const senders = this.pc.getSenders();
118
138
  for (const sender of senders) {
119
139
  if (sender.track) {
@@ -124,21 +144,20 @@ var Publisher = class {
124
144
  this.pc.addTrack(track, stream);
125
145
  }
126
146
  this.localStream = stream;
127
- const offer = await this.pc.createOffer();
128
- await this.pc.setLocalDescription(offer);
129
- await this.gatherComplete();
130
- const local = this.pc.localDescription;
131
- if (!local) throw new Error("local description missing");
132
- this.sig?.send({
133
- type: "offer",
134
- sdp: local.sdp,
135
- sdp_type: "offer"
136
- });
147
+ this.watchStreamTracks(stream);
148
+ await this.renegotiate(false);
137
149
  }
138
150
  /** Stops publishing and tears down the peer connection. */
139
151
  stop() {
152
+ this.stopped = true;
153
+ this.cancelMediaRecovery();
154
+ this.reconnectGeneration++;
155
+ this.reconnecting = false;
156
+ this.resumeSocket?.close();
157
+ this.resumeSocket = null;
140
158
  this.sig?.close();
141
159
  this.sig = null;
160
+ this.unwatchStreamTracks();
142
161
  this.localStream?.getTracks().forEach((t) => t.stop());
143
162
  this.localStream = null;
144
163
  this.pc?.close();
@@ -146,6 +165,7 @@ var Publisher = class {
146
165
  this.hasAnswer = false;
147
166
  this.pendingRemoteCandidates = [];
148
167
  this.readToken = null;
168
+ this.selectedGatewayURL = null;
149
169
  }
150
170
  /** Returns the current RTCPeerConnection, or null if not started. */
151
171
  get peerConnection() {
@@ -200,7 +220,7 @@ var Publisher = class {
200
220
  } else if (accepted && msg.type === "ready") {
201
221
  settled = true;
202
222
  closeAll(ws);
203
- resolve({ ws, readyInfo: msg });
223
+ resolve({ ws, readyInfo: msg, gatewayURL });
204
224
  }
205
225
  } catch {
206
226
  }
@@ -210,6 +230,113 @@ var Publisher = class {
210
230
  }
211
231
  });
212
232
  }
233
+ installSignaling(ws) {
234
+ const channel = SignalingChannel.wrap(ws);
235
+ this.sig = channel;
236
+ channel.onMessage = (msg) => this.handleSignal(msg);
237
+ channel.onClose = () => {
238
+ if (this.sig !== channel || this.stopped) return;
239
+ this.sig = null;
240
+ void this.resumeSignaling();
241
+ };
242
+ channel.onError = () => {
243
+ };
244
+ return channel;
245
+ }
246
+ async resumeSignaling() {
247
+ if (this.reconnecting || this.stopped) return;
248
+ if (!this.selectedGatewayURL || !this.readToken) {
249
+ this.terminateWithError(new Error("signaling closed and cannot be resumed"));
250
+ return;
251
+ }
252
+ this.reconnecting = true;
253
+ const generation = ++this.reconnectGeneration;
254
+ const timeout = this.opts.signalingReconnectTimeoutMs ?? defaultSignalingReconnectTimeoutMs;
255
+ const deadline = Date.now() + Math.max(0, timeout);
256
+ let backoffMs = 0;
257
+ while (!this.stopped && generation === this.reconnectGeneration && Date.now() <= deadline) {
258
+ if (backoffMs > 0) {
259
+ const waitMs = Math.min(backoffMs, Math.max(0, deadline - Date.now()));
260
+ if (waitMs === 0) break;
261
+ await this.wait(waitMs);
262
+ if (this.stopped || generation !== this.reconnectGeneration) return;
263
+ }
264
+ const remaining = deadline - Date.now();
265
+ if (remaining < 0) break;
266
+ try {
267
+ const ws = await this.openResumeSocket(Math.min(signalingResumeAttemptTimeoutMs, Math.max(1, remaining)));
268
+ if (this.stopped || generation !== this.reconnectGeneration) {
269
+ ws.close();
270
+ return;
271
+ }
272
+ this.resumeSocket = null;
273
+ this.reconnecting = false;
274
+ this.installSignaling(ws);
275
+ return;
276
+ } catch {
277
+ backoffMs = backoffMs === 0 ? 250 : Math.min(backoffMs * 2, signalingResumeMaxBackoffMs);
278
+ }
279
+ }
280
+ if (!this.stopped && generation === this.reconnectGeneration) {
281
+ this.reconnecting = false;
282
+ this.terminateWithError(new Error("unable to resume signaling with the selected gateway"));
283
+ }
284
+ }
285
+ openResumeSocket(timeoutMs) {
286
+ return new Promise((resolve, reject) => {
287
+ const u = new URL(this.selectedGatewayURL);
288
+ u.searchParams.set("token", this.readToken);
289
+ const ws = new WebSocket(u.toString());
290
+ this.resumeSocket = ws;
291
+ let settled = false;
292
+ const timer = setTimeout(() => fail(), timeoutMs);
293
+ const fail = () => {
294
+ if (settled) return;
295
+ settled = true;
296
+ clearTimeout(timer);
297
+ if (this.resumeSocket === ws) this.resumeSocket = null;
298
+ ws.onmessage = null;
299
+ ws.onerror = null;
300
+ ws.onclose = null;
301
+ ws.close();
302
+ reject(new Error("signaling resume attempt failed"));
303
+ };
304
+ ws.onmessage = (ev) => {
305
+ try {
306
+ const msg = JSON.parse(ev.data);
307
+ if (msg.type !== "resumed" || settled) return;
308
+ settled = true;
309
+ clearTimeout(timer);
310
+ resolve(ws);
311
+ } catch {
312
+ }
313
+ };
314
+ ws.onerror = fail;
315
+ ws.onclose = fail;
316
+ });
317
+ }
318
+ wait(ms) {
319
+ return new Promise((resolve) => setTimeout(resolve, ms));
320
+ }
321
+ terminateWithError(err) {
322
+ this.stopped = true;
323
+ this.cancelMediaRecovery();
324
+ this.reconnectGeneration++;
325
+ this.resumeSocket?.close();
326
+ this.resumeSocket = null;
327
+ this.sig?.close();
328
+ this.sig = null;
329
+ this.pc?.close();
330
+ this.pc = null;
331
+ this.unwatchStreamTracks();
332
+ this.localStream?.getTracks().forEach((track) => track.stop());
333
+ this.localStream = null;
334
+ this.hasAnswer = false;
335
+ this.pendingRemoteCandidates = [];
336
+ this.readToken = null;
337
+ this.selectedGatewayURL = null;
338
+ this.opts.callbacks?.onError?.(err);
339
+ }
213
340
  handleSignal(msg) {
214
341
  switch (msg.type) {
215
342
  case "answer": {
@@ -249,11 +376,123 @@ var Publisher = class {
249
376
  case "connection_state": {
250
377
  break;
251
378
  }
379
+ case "media_stall":
380
+ case "media_track_ended": {
381
+ void this.beginMediaRecovery(msg.track);
382
+ break;
383
+ }
384
+ case "media_resumed": {
385
+ this.completeMediaRecovery(msg.track);
386
+ break;
387
+ }
252
388
  case "error": {
253
389
  this.opts.callbacks?.onError?.(new Error(msg.error));
254
390
  break;
255
391
  }
392
+ case "resumed":
393
+ break;
394
+ }
395
+ }
396
+ async beginMediaRecovery(trackType) {
397
+ if (this.stopped || this.recoveringMedia || this.recoveryRequired) return;
398
+ const tracks = this.localStream?.getVideoTracks() ?? [];
399
+ const liveTracks = tracks.filter((track) => track.readyState !== "ended");
400
+ if (liveTracks.length === 0) {
401
+ this.failMediaRecovery(trackType, "capture_ended");
402
+ return;
403
+ }
404
+ this.recoveringMedia = true;
405
+ const generation = ++this.recoveryGeneration;
406
+ this.recoveryAction = "sender_restart";
407
+ this.emitRecoveryTransition({ state: "recovering", track: trackType, action: "sender_restart" });
408
+ this.sendRecoveryDiagnostic("recovery_started", trackType, "sender_restart");
409
+ await this.restartSenders(liveTracks, generation);
410
+ if (!this.isCurrentRecovery(generation)) return;
411
+ await this.wait(senderRecoveryWaitMs);
412
+ if (!this.isCurrentRecovery(generation)) return;
413
+ this.recoveryAction = "ice_restart";
414
+ this.emitRecoveryTransition({ state: "recovering", track: trackType, action: "ice_restart" });
415
+ this.sendRecoveryDiagnostic("recovery_retry", trackType, "ice_restart");
416
+ try {
417
+ await this.renegotiate(true);
418
+ } catch {
419
+ }
420
+ await this.wait(iceRecoveryWaitMs);
421
+ if (!this.isCurrentRecovery(generation)) return;
422
+ this.failMediaRecovery(trackType, "automatic_recovery_failed");
423
+ }
424
+ async restartSenders(tracks, generation) {
425
+ const live = new Set(tracks);
426
+ const senders = this.pc?.getSenders().filter(
427
+ (sender) => sender.track && live.has(sender.track)
428
+ ) ?? [];
429
+ if (senders.length === 0) return;
430
+ const originals = senders.map((sender) => ({ sender, track: sender.track }));
431
+ try {
432
+ await Promise.all(originals.map(({ sender }) => sender.replaceTrack(null)));
433
+ await this.wait(senderRestartPauseMs);
434
+ if (!this.isCurrentRecovery(generation)) return;
435
+ await Promise.all(originals.map(({ sender, track }) => sender.replaceTrack(track)));
436
+ await this.renegotiate(false);
437
+ } catch {
438
+ }
439
+ }
440
+ async renegotiate(iceRestart) {
441
+ const pc = this.pc;
442
+ const signaling = this.sig;
443
+ if (!pc || !signaling) throw new Error("publisher signaling is unavailable");
444
+ if (iceRestart) pc.restartIce?.();
445
+ const offer = await pc.createOffer(iceRestart ? { iceRestart: true } : void 0);
446
+ await pc.setLocalDescription(offer);
447
+ await this.gatherComplete();
448
+ const local = pc.localDescription;
449
+ if (!local) throw new Error("local description missing");
450
+ this.hasAnswer = false;
451
+ this.pendingRemoteCandidates = [];
452
+ signaling.send({ type: "offer", sdp: local.sdp, sdp_type: "offer" });
453
+ }
454
+ completeMediaRecovery(trackType) {
455
+ if (!this.recoveringMedia) return;
456
+ const action = this.recoveryAction ?? void 0;
457
+ this.cancelMediaRecovery();
458
+ this.emitRecoveryTransition({ state: "recovered", track: trackType, action });
459
+ }
460
+ failMediaRecovery(trackType, reason) {
461
+ if (this.stopped || this.recoveryRequired) return;
462
+ this.recoveryRequired = true;
463
+ const action = this.recoveryAction ?? void 0;
464
+ this.cancelMediaRecovery();
465
+ const event = { state: "failed", track: trackType, action, reason };
466
+ this.emitRecoveryTransition(event);
467
+ this.opts.callbacks?.onRecoveryRequired?.(event);
468
+ this.sendRecoveryDiagnostic("recovery_failed", trackType, action, reason);
469
+ }
470
+ cancelMediaRecovery() {
471
+ this.recoveryGeneration++;
472
+ this.recoveringMedia = false;
473
+ this.recoveryAction = null;
474
+ }
475
+ isCurrentRecovery(generation) {
476
+ return !this.stopped && this.recoveringMedia && generation === this.recoveryGeneration;
477
+ }
478
+ emitRecoveryTransition(event) {
479
+ this.opts.callbacks?.onRecoveryStateChange?.(event);
480
+ }
481
+ sendRecoveryDiagnostic(event, track, action, reason) {
482
+ this.sig?.send({ type: "recovery_event", event, track, action, reason });
483
+ }
484
+ watchStreamTracks(stream) {
485
+ for (const track of stream.getVideoTracks()) {
486
+ const handler = () => this.failMediaRecovery("screen", "capture_ended");
487
+ track.addEventListener("ended", handler);
488
+ this.trackEndHandlers.set(track, handler);
489
+ }
490
+ }
491
+ unwatchStreamTracks() {
492
+ for (const [track, handler] of this.trackEndHandlers) {
493
+ track.removeEventListener("ended", handler);
256
494
  }
495
+ this.trackEndHandlers.clear();
257
496
  }
258
497
  /** Waits for ICE gathering to reach the "complete" state. */
259
498
  gatherComplete() {