@phystack/hub-client 4.4.52 → 4.4.54

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.
Files changed (119) hide show
  1. package/dist/index.d.ts +22 -28
  2. package/dist/index.d.ts.map +1 -1
  3. package/dist/index.js +268 -365
  4. package/dist/index.js.map +1 -1
  5. package/dist/peripheral-twin.d.ts +34 -0
  6. package/dist/peripheral-twin.d.ts.map +1 -0
  7. package/dist/peripheral-twin.js +234 -0
  8. package/dist/peripheral-twin.js.map +1 -0
  9. package/dist/services/phyhub-connection.service.d.ts +1 -0
  10. package/dist/services/phyhub-connection.service.d.ts.map +1 -1
  11. package/dist/services/phyhub-connection.service.js +17 -0
  12. package/dist/services/phyhub-connection.service.js.map +1 -1
  13. package/dist/services/phyhub-direct-connection.service.d.ts +21 -0
  14. package/dist/services/phyhub-direct-connection.service.d.ts.map +1 -0
  15. package/dist/services/phyhub-direct-connection.service.js +101 -0
  16. package/dist/services/phyhub-direct-connection.service.js.map +1 -0
  17. package/dist/services/webrtc/data-channel-handler.d.ts +45 -0
  18. package/dist/services/webrtc/data-channel-handler.d.ts.map +1 -0
  19. package/dist/services/webrtc/data-channel-handler.js +260 -0
  20. package/dist/services/webrtc/data-channel-handler.js.map +1 -0
  21. package/dist/services/webrtc/index.d.ts +8 -0
  22. package/dist/services/webrtc/index.d.ts.map +1 -0
  23. package/dist/services/webrtc/index.js +18 -0
  24. package/dist/services/webrtc/index.js.map +1 -0
  25. package/dist/services/webrtc/media-stream-handler.d.ts +57 -0
  26. package/dist/services/webrtc/media-stream-handler.d.ts.map +1 -0
  27. package/dist/services/webrtc/media-stream-handler.js +367 -0
  28. package/dist/services/webrtc/media-stream-handler.js.map +1 -0
  29. package/dist/services/webrtc/peer-connection-manager.d.ts +40 -0
  30. package/dist/services/webrtc/peer-connection-manager.d.ts.map +1 -0
  31. package/dist/services/webrtc/peer-connection-manager.js +335 -0
  32. package/dist/services/webrtc/peer-connection-manager.js.map +1 -0
  33. package/dist/services/webrtc/types.d.ts +133 -0
  34. package/dist/services/webrtc/types.d.ts.map +1 -0
  35. package/dist/services/webrtc/types.js +12 -0
  36. package/dist/services/webrtc/types.js.map +1 -0
  37. package/dist/services/webrtc/webrtc-globals.d.ts +4 -0
  38. package/dist/services/webrtc/webrtc-globals.d.ts.map +1 -0
  39. package/dist/services/webrtc/webrtc-globals.js +72 -0
  40. package/dist/services/webrtc/webrtc-globals.js.map +1 -0
  41. package/dist/services/webrtc/webrtc-manager.d.ts +35 -0
  42. package/dist/services/webrtc/webrtc-manager.d.ts.map +1 -0
  43. package/dist/services/webrtc/webrtc-manager.js +274 -0
  44. package/dist/services/webrtc/webrtc-manager.js.map +1 -0
  45. package/dist/test/communication-comprehensive-test.d.ts +8 -0
  46. package/dist/test/communication-comprehensive-test.d.ts.map +1 -0
  47. package/dist/test/communication-comprehensive-test.js +356 -0
  48. package/dist/test/communication-comprehensive-test.js.map +1 -0
  49. package/dist/test/webrtc-channel-names-test.d.ts +2 -0
  50. package/dist/test/webrtc-channel-names-test.d.ts.map +1 -0
  51. package/dist/test/webrtc-channel-names-test.js +177 -0
  52. package/dist/test/webrtc-channel-names-test.js.map +1 -0
  53. package/dist/test/webrtc-comprehensive-test.d.ts +2 -0
  54. package/dist/test/webrtc-comprehensive-test.d.ts.map +1 -0
  55. package/dist/test/webrtc-comprehensive-test.js +328 -0
  56. package/dist/test/webrtc-comprehensive-test.js.map +1 -0
  57. package/dist/test/webrtc-reconnect-test.d.ts +4 -0
  58. package/dist/test/webrtc-reconnect-test.d.ts.map +1 -0
  59. package/dist/test/webrtc-reconnect-test.js +244 -0
  60. package/dist/test/webrtc-reconnect-test.js.map +1 -0
  61. package/dist/test/webrtc-test-harness.d.ts +4 -0
  62. package/dist/test/webrtc-test-harness.d.ts.map +1 -0
  63. package/dist/test/webrtc-test-harness.js +169 -0
  64. package/dist/test/webrtc-test-harness.js.map +1 -0
  65. package/dist/twin-messaging.d.ts +23 -0
  66. package/dist/twin-messaging.d.ts.map +1 -0
  67. package/dist/twin-messaging.js +91 -0
  68. package/dist/twin-messaging.js.map +1 -0
  69. package/dist/twin-registry.d.ts +9 -0
  70. package/dist/twin-registry.d.ts.map +1 -0
  71. package/dist/twin-registry.js +26 -0
  72. package/dist/twin-registry.js.map +1 -0
  73. package/dist/types/index.d.ts +4 -0
  74. package/dist/types/index.d.ts.map +1 -0
  75. package/dist/types/index.js +20 -0
  76. package/dist/types/index.js.map +1 -0
  77. package/dist/types/twin.types.d.ts +66 -15
  78. package/dist/types/twin.types.d.ts.map +1 -1
  79. package/dist/types/twin.types.js +8 -1
  80. package/dist/types/twin.types.js.map +1 -1
  81. package/docs/webrtc-howto.md +398 -0
  82. package/docs/webrtc-test.md +330 -0
  83. package/package.json +3 -3
  84. package/scripts/webrtc-test.sh +401 -0
  85. package/src/index.ts +399 -540
  86. package/src/peripheral-twin.ts +333 -0
  87. package/src/services/phyhub-connection.service.ts +24 -0
  88. package/src/services/phyhub-direct-connection.service.ts +159 -0
  89. package/src/services/webrtc/data-channel-handler.ts +362 -0
  90. package/src/services/webrtc/index.ts +36 -0
  91. package/src/services/webrtc/media-stream-handler.ts +515 -0
  92. package/src/services/webrtc/peer-connection-manager.ts +463 -0
  93. package/src/services/webrtc/types.ts +270 -0
  94. package/src/services/webrtc/webrtc-globals.ts +108 -0
  95. package/src/services/webrtc/webrtc-manager.ts +490 -0
  96. package/src/test/communication-comprehensive-test.ts +533 -0
  97. package/src/test/webrtc-channel-names-test.ts +266 -0
  98. package/src/test/webrtc-comprehensive-test.ts +494 -0
  99. package/src/test/webrtc-reconnect-test.ts +345 -0
  100. package/src/test/webrtc-test-harness.ts +254 -0
  101. package/src/twin-messaging.ts +188 -0
  102. package/src/twin-registry.ts +39 -0
  103. package/src/types/index.ts +3 -0
  104. package/src/types/twin.types.ts +87 -15
  105. package/dist/services/webrtc/datachannel.d.ts +0 -10
  106. package/dist/services/webrtc/datachannel.d.ts.map +0 -1
  107. package/dist/services/webrtc/datachannel.js +0 -290
  108. package/dist/services/webrtc/datachannel.js.map +0 -1
  109. package/dist/services/webrtc/mediastream.d.ts +0 -10
  110. package/dist/services/webrtc/mediastream.d.ts.map +0 -1
  111. package/dist/services/webrtc/mediastream.js +0 -396
  112. package/dist/services/webrtc/mediastream.js.map +0 -1
  113. package/dist/services/webrtc/peer-connection-ice.d.ts +0 -32
  114. package/dist/services/webrtc/peer-connection-ice.d.ts.map +0 -1
  115. package/dist/services/webrtc/peer-connection-ice.js +0 -483
  116. package/dist/services/webrtc/peer-connection-ice.js.map +0 -1
  117. package/src/services/webrtc/datachannel.ts +0 -421
  118. package/src/services/webrtc/mediastream.ts +0 -602
  119. package/src/services/webrtc/peer-connection-ice.ts +0 -689
@@ -0,0 +1,515 @@
1
+ /**
2
+ * MediaStreamHandler
3
+ *
4
+ * Manages MediaStream connections including:
5
+ * - Stream setup for sending/receiving media
6
+ * - Track lifecycle management (add/remove)
7
+ * - Frame activity monitoring for stale connection detection
8
+ * - Persistent stream abstraction across reconnections
9
+ */
10
+
11
+ import { PeerConnectionManager, PeerConnectionManagerOptions } from './peer-connection-manager';
12
+ import {
13
+ TwinMessagingInterface,
14
+ PhygridMediaStream,
15
+ MediaStreamOptions,
16
+ PeerConnectionConfig,
17
+ ExtendedMediaStreamTrack,
18
+ DEFAULT_WEBRTC_OPTIONS,
19
+ } from './types';
20
+
21
+ const FRAME_INACTIVITY_TIMEOUT = 5000; // 5 seconds without frames = stale
22
+
23
+ export interface MediaStreamHandlerOptions extends Partial<PeerConnectionManagerOptions> {
24
+ /** Use STUN servers. Default: true */
25
+ useStun?: boolean;
26
+
27
+ /** STUN server URLs */
28
+ stunServers?: string[];
29
+
30
+ /** Media options */
31
+ mediaOptions?: MediaStreamOptions;
32
+ }
33
+
34
+ export class MediaStreamHandler {
35
+ private targetTwinId: string;
36
+ private channelName: string;
37
+ private isInitiator: boolean;
38
+ private twinMessaging: TwinMessagingInterface;
39
+ private options: Required<Omit<MediaStreamHandlerOptions, 'mediaOptions'>> & {
40
+ mediaOptions: MediaStreamOptions;
41
+ };
42
+
43
+ private pcManager: PeerConnectionManager | null = null;
44
+ private remoteStream: MediaStream | null = null;
45
+ private localStream: MediaStream | null = null;
46
+ private isClosed = false;
47
+ private isReceiving = false;
48
+ private isConnecting = false;
49
+
50
+ // Track management
51
+ private tracks: MediaStreamTrack[] = [];
52
+ private trackLastFrameTime: Map<string, number> = new Map();
53
+ private frameActivityInterval: NodeJS.Timeout | null = null;
54
+
55
+ // Event listeners
56
+ private trackListeners: Set<(track: MediaStreamTrack) => void> = new Set();
57
+ private frameListeners: Set<(frameData: any) => void> = new Set();
58
+ private closeListeners: Set<() => void> = new Set();
59
+
60
+ // Callbacks for WebRTCManager
61
+ private onConnectedCallback?: () => void;
62
+ private onDisconnectedCallback?: () => void;
63
+ private onErrorCallback?: (error: Error) => void;
64
+ private onReconnectingCallback?: (attempt: number) => void;
65
+ private onReconnectedCallback?: (attempt: number) => void;
66
+
67
+ constructor(
68
+ targetTwinId: string,
69
+ isInitiator: boolean,
70
+ twinMessaging: TwinMessagingInterface,
71
+ options: MediaStreamHandlerOptions = {},
72
+ channelName: string = 'default'
73
+ ) {
74
+ this.targetTwinId = targetTwinId;
75
+ this.channelName = channelName;
76
+ this.isInitiator = isInitiator;
77
+ this.twinMessaging = twinMessaging;
78
+ this.options = {
79
+ useStun: options.useStun ?? DEFAULT_WEBRTC_OPTIONS.useStun,
80
+ stunServers: options.stunServers ?? DEFAULT_WEBRTC_OPTIONS.stunServers,
81
+ connectionTimeout: options.connectionTimeout ?? DEFAULT_WEBRTC_OPTIONS.connectionTimeout,
82
+ initialRetryDelay: options.initialRetryDelay ?? DEFAULT_WEBRTC_OPTIONS.initialRetryDelay,
83
+ maxRetryDelay: options.maxRetryDelay ?? DEFAULT_WEBRTC_OPTIONS.maxRetryDelay,
84
+ mediaOptions: {
85
+ direction: options.mediaOptions?.direction ?? (isInitiator ? 'recvonly' : 'sendrecv'),
86
+ localStream: options.mediaOptions?.localStream,
87
+ },
88
+ };
89
+
90
+ // Create local stream for non-initiator if not provided
91
+ if (!isInitiator && !this.options.mediaOptions.localStream) {
92
+ this.localStream = new MediaStream();
93
+ this.options.mediaOptions.localStream = this.localStream;
94
+ } else {
95
+ this.localStream = this.options.mediaOptions.localStream ?? null;
96
+ }
97
+ }
98
+
99
+ /**
100
+ * Set callbacks for connection events
101
+ */
102
+ setCallbacks(callbacks: {
103
+ onConnected?: () => void;
104
+ onDisconnected?: () => void;
105
+ onError?: (error: Error) => void;
106
+ onReconnecting?: (attempt: number) => void;
107
+ onReconnected?: (attempt: number) => void;
108
+ }): void {
109
+ this.onConnectedCallback = callbacks.onConnected;
110
+ this.onDisconnectedCallback = callbacks.onDisconnected;
111
+ this.onErrorCallback = callbacks.onError;
112
+ this.onReconnectingCallback = callbacks.onReconnecting;
113
+ this.onReconnectedCallback = callbacks.onReconnected;
114
+ }
115
+
116
+ /**
117
+ * Connect and return a PhygridMediaStream
118
+ */
119
+ async connect(): Promise<PhygridMediaStream> {
120
+ if (this.isClosed) {
121
+ throw new Error('MediaStreamHandler has been closed');
122
+ }
123
+
124
+ this.isConnecting = true;
125
+ // Channel prefix includes name for unique signaling per channel
126
+ const channelPrefix = `media-${this.channelName}`;
127
+
128
+ const pcConfig: PeerConnectionConfig = {
129
+ targetTwinId: this.targetTwinId,
130
+ isInitiator: this.isInitiator,
131
+ connectionType: 'mediastream',
132
+ channelPrefix,
133
+ useStun: this.options.useStun,
134
+ stunServers: this.options.stunServers,
135
+ onConnected: () => this.handlePeerConnected(),
136
+ onDisconnected: () => this.handlePeerDisconnected(),
137
+ onError: (err) => this.handleError(err),
138
+ onReconnecting: (attempt) => {
139
+ this.isConnecting = true;
140
+ this.isReceiving = false;
141
+ this.onReconnectingCallback?.(attempt);
142
+ },
143
+ onReconnected: (attempt) => this.handleReconnected(attempt),
144
+ // Setup transceivers/tracks BEFORE offer is created (critical for ICE gathering)
145
+ onPeerConnectionCreated: (pc) => {
146
+ if (this.isInitiator) {
147
+ // Initiator: Add transceiver before offer is sent
148
+ console.log('[MediaStreamHandler] Adding transceiver before offer');
149
+ pc.addTransceiver('video', { direction: this.options.mediaOptions.direction });
150
+ } else if (this.localStream) {
151
+ // Responder: Add local tracks before answer is sent
152
+ console.log('[MediaStreamHandler] Adding local tracks before answer');
153
+ this.localStream.getTracks().forEach((track) => {
154
+ pc.addTrack(track, this.localStream!);
155
+ });
156
+ }
157
+
158
+ // Handle incoming tracks (both sides)
159
+ pc.ontrack = (event) => {
160
+ console.log('[MediaStreamHandler] Received remote track');
161
+ if (event.streams && event.streams[0]) {
162
+ this.remoteStream = event.streams[0];
163
+ this.handleRemoteStream(event.streams[0]);
164
+ }
165
+ };
166
+ },
167
+ };
168
+
169
+ this.pcManager = new PeerConnectionManager(pcConfig, this.twinMessaging, {
170
+ connectionTimeout: this.options.connectionTimeout,
171
+ initialRetryDelay: this.options.initialRetryDelay,
172
+ maxRetryDelay: this.options.maxRetryDelay,
173
+ });
174
+
175
+ const pc = await this.pcManager.connect();
176
+ await this.setupMediaStream(pc);
177
+
178
+ return this.createPhygridMediaStream();
179
+ }
180
+
181
+ /**
182
+ * Close the media stream and clean up
183
+ */
184
+ close(): void {
185
+ if (this.isClosed) return;
186
+
187
+ this.isClosed = true;
188
+ this.isReceiving = false;
189
+
190
+ this.stopFrameActivityMonitor();
191
+
192
+ // Stop all tracks
193
+ this.tracks.forEach((track) => {
194
+ try {
195
+ track.stop();
196
+ } catch (err) {
197
+ console.error('[MediaStreamHandler] Error stopping track:', err);
198
+ }
199
+ });
200
+ this.tracks = [];
201
+
202
+ if (this.remoteStream) {
203
+ this.remoteStream.getTracks().forEach((track) => track.stop());
204
+ this.remoteStream = null;
205
+ }
206
+
207
+ if (this.localStream) {
208
+ this.localStream.getTracks().forEach((track) => track.stop());
209
+ this.localStream = null;
210
+ }
211
+
212
+ if (this.pcManager) {
213
+ this.pcManager.close();
214
+ this.pcManager = null;
215
+ }
216
+
217
+ this.notifyCloseListeners();
218
+ this.trackListeners.clear();
219
+ this.frameListeners.clear();
220
+ this.closeListeners.clear();
221
+ this.trackLastFrameTime.clear();
222
+ }
223
+
224
+ // ===========================================================================
225
+ // Private Methods
226
+ // ===========================================================================
227
+
228
+ private async setupMediaStream(_pc: RTCPeerConnection): Promise<void> {
229
+ // Transceivers and ontrack handler are set up in onPeerConnectionCreated
230
+ // This method now just waits for the connection to be established
231
+ // or returns immediately if we're not expecting incoming tracks
232
+ if (this.options.mediaOptions.direction === 'sendonly') {
233
+ return;
234
+ }
235
+
236
+ // For recvonly/sendrecv, wait for tracks if not already received
237
+ if (this.remoteStream && this.remoteStream.getTracks().length > 0) {
238
+ return; // Already have tracks
239
+ }
240
+
241
+ // Wait a short time for tracks to arrive (they may come after connection)
242
+ return new Promise<void>((resolve) => {
243
+ const timeout = setTimeout(() => {
244
+ console.log('[MediaStreamHandler] Proceeding without waiting for remote tracks');
245
+ resolve();
246
+ }, 1000);
247
+
248
+ // If we get a track, resolve immediately
249
+ const checkForTracks = () => {
250
+ if (this.remoteStream && this.remoteStream.getTracks().length > 0) {
251
+ clearTimeout(timeout);
252
+ resolve();
253
+ }
254
+ };
255
+
256
+ // Check periodically
257
+ const interval = setInterval(() => {
258
+ checkForTracks();
259
+ if (this.remoteStream && this.remoteStream.getTracks().length > 0) {
260
+ clearInterval(interval);
261
+ }
262
+ }, 100);
263
+
264
+ // Clean up interval on timeout
265
+ setTimeout(() => clearInterval(interval), 1000);
266
+ });
267
+ }
268
+
269
+ private handleRemoteStream(stream: MediaStream): void {
270
+ this.startFrameActivityMonitor();
271
+
272
+ stream.getTracks().forEach((track) => {
273
+ this.addTrackToCollection(track, stream);
274
+ });
275
+
276
+ this.onConnectedCallback?.();
277
+ }
278
+
279
+ private addTrackToCollection(track: MediaStreamTrack, stream: MediaStream): void {
280
+ // Check for duplicate
281
+ const existingIndex = this.tracks.findIndex((existingTrack) => existingTrack.id === track.id);
282
+ if (existingIndex !== -1) {
283
+ this.tracks.splice(existingIndex, 1);
284
+ }
285
+
286
+ this.tracks.push(track);
287
+ this.trackLastFrameTime.set(track.id, Date.now());
288
+
289
+ // Set up frame monitoring for the track
290
+ this.setupTrackFrameMonitoring(track, stream);
291
+
292
+ // Notify listeners
293
+ this.notifyTrackListeners(track);
294
+ }
295
+
296
+ private setupTrackFrameMonitoring(track: MediaStreamTrack, stream: MediaStream): void {
297
+ const extendedTrack = track as ExtendedMediaStreamTrack;
298
+
299
+ // Try to use onFrame if available (Node.js @roamhq/wrtc)
300
+ if (typeof extendedTrack.onFrame === 'function') {
301
+ const originalOnFrame = extendedTrack.onFrame;
302
+ extendedTrack.onFrame = (frame: any) => {
303
+ this.trackLastFrameTime.set(track.id, Date.now());
304
+ this.isReceiving = true;
305
+ this.notifyFrameListeners(frame);
306
+ originalOnFrame.call(extendedTrack, frame);
307
+ };
308
+ }
309
+
310
+ // Handle track ended
311
+ track.onended = () => {
312
+ this.trackLastFrameTime.delete(track.id);
313
+ const index = this.tracks.findIndex((existingTrack) => existingTrack.id === track.id);
314
+ if (index !== -1) {
315
+ this.tracks.splice(index, 1);
316
+ }
317
+
318
+ // Check if all tracks ended
319
+ const allEnded = stream.getTracks().every((streamTrack) => streamTrack.readyState === 'ended');
320
+ if (allEnded && !this.isClosed) {
321
+ this.handlePeerDisconnected();
322
+ }
323
+ };
324
+ }
325
+
326
+ private startFrameActivityMonitor(): void {
327
+ if (this.frameActivityInterval) return;
328
+
329
+ this.frameActivityInterval = setInterval(() => {
330
+ this.checkFrameActivity();
331
+ }, 1000);
332
+ }
333
+
334
+ private stopFrameActivityMonitor(): void {
335
+ if (this.frameActivityInterval) {
336
+ clearInterval(this.frameActivityInterval);
337
+ this.frameActivityInterval = null;
338
+ }
339
+ }
340
+
341
+ private checkFrameActivity(): void {
342
+ if (this.trackLastFrameTime.size === 0) return;
343
+
344
+ const now = Date.now();
345
+ let hasActiveTrack = false;
346
+
347
+ this.trackLastFrameTime.forEach((lastTime) => {
348
+ if (now - lastTime < FRAME_INACTIVITY_TIMEOUT) {
349
+ hasActiveTrack = true;
350
+ }
351
+ });
352
+
353
+ if (!hasActiveTrack && this.isReceiving) {
354
+ console.log('[MediaStreamHandler] Frame inactivity detected, connection may be stale');
355
+ this.isReceiving = false;
356
+
357
+ // Trigger reconnect
358
+ if (!this.isClosed && this.pcManager) {
359
+ this.pcManager.reconnect();
360
+ }
361
+ }
362
+ }
363
+
364
+ private handlePeerConnected(): void {
365
+ this.isConnecting = false;
366
+ // Peer connection established - media setup will follow
367
+ }
368
+
369
+ private handlePeerDisconnected(): void {
370
+ this.isReceiving = false;
371
+ this.onDisconnectedCallback?.();
372
+ }
373
+
374
+ private handleReconnected(attempt: number): void {
375
+ // Tracks are re-added via onPeerConnectionCreated when new peer connection is created
376
+ // Just notify callback that reconnection succeeded
377
+ console.log(`[MediaStreamHandler] Reconnected after ${attempt} attempts`);
378
+ this.onReconnectedCallback?.(attempt);
379
+ }
380
+
381
+ private handleError(error: Error): void {
382
+ console.error('[MediaStreamHandler] MediaStream error:', error);
383
+ this.onErrorCallback?.(error);
384
+ }
385
+
386
+ private notifyTrackListeners(track: MediaStreamTrack): void {
387
+ this.trackListeners.forEach((listener) => {
388
+ try {
389
+ listener(track);
390
+ } catch (err) {
391
+ console.error('[MediaStreamHandler] Error in track listener:', err);
392
+ }
393
+ });
394
+ }
395
+
396
+ private notifyFrameListeners(frameData: any): void {
397
+ this.frameListeners.forEach((listener) => {
398
+ try {
399
+ listener(frameData);
400
+ } catch (err) {
401
+ console.error('[MediaStreamHandler] Error in frame listener:', err);
402
+ }
403
+ });
404
+ }
405
+
406
+ private notifyCloseListeners(): void {
407
+ this.closeListeners.forEach((listener) => {
408
+ try {
409
+ listener();
410
+ } catch (err) {
411
+ console.error('[MediaStreamHandler] Error in close listener:', err);
412
+ }
413
+ });
414
+ }
415
+
416
+ private createPhygridMediaStream(): PhygridMediaStream {
417
+ return {
418
+ getTracks: () => [...this.tracks],
419
+
420
+ addTrack: (track: MediaStreamTrack) => {
421
+ if (this.isClosed) {
422
+ console.warn('[MediaStreamHandler] Cannot add track to closed stream');
423
+ return;
424
+ }
425
+
426
+ // Add to local stream
427
+ if (this.localStream) {
428
+ const existing = this.localStream.getTrackById(track.id);
429
+ if (existing) {
430
+ this.localStream.removeTrack(existing);
431
+ }
432
+ this.localStream.addTrack(track);
433
+ }
434
+
435
+ // Add to tracks collection
436
+ this.tracks.push(track);
437
+
438
+ // Add to peer connection if connected
439
+ const pc = this.pcManager?.getPeerConnection();
440
+ if (pc && pc.connectionState === 'connected' && this.localStream) {
441
+ const senders = pc.getSenders();
442
+ const existingSender = senders.find((sender) => sender.track?.id === track.id);
443
+ if (existingSender) {
444
+ existingSender.replaceTrack(track);
445
+ } else {
446
+ pc.addTrack(track, this.localStream);
447
+ }
448
+ }
449
+
450
+ this.notifyTrackListeners(track);
451
+ },
452
+
453
+ onTrack: (callback: (track: MediaStreamTrack) => void) => {
454
+ this.trackListeners.add(callback);
455
+
456
+ // Notify for existing tracks
457
+ this.tracks.forEach((track) => {
458
+ try {
459
+ callback(track);
460
+ } catch (err) {
461
+ console.error('[MediaStreamHandler] Error in track listener:', err);
462
+ }
463
+ });
464
+ },
465
+
466
+ offTrack: (callback: (track: MediaStreamTrack) => void) => {
467
+ this.trackListeners.delete(callback);
468
+ },
469
+
470
+ onFrame: (callback: (frameData: any) => void) => {
471
+ this.frameListeners.add(callback);
472
+ },
473
+
474
+ offFrame: (callback: (frameData: any) => void) => {
475
+ this.frameListeners.delete(callback);
476
+ },
477
+
478
+ onClose: (callback: () => void) => {
479
+ this.closeListeners.add(callback);
480
+ },
481
+
482
+ offClose: (callback: () => void) => {
483
+ this.closeListeners.delete(callback);
484
+ },
485
+
486
+ close: () => {
487
+ this.close();
488
+ },
489
+
490
+ isReceivingFrames: () => {
491
+ if (this.trackLastFrameTime.size === 0) return false;
492
+
493
+ const now = Date.now();
494
+ for (const lastTime of this.trackLastFrameTime.values()) {
495
+ if (now - lastTime < FRAME_INACTIVITY_TIMEOUT) {
496
+ return true;
497
+ }
498
+ }
499
+ return false;
500
+ },
501
+
502
+ getTargetTwinId: () => {
503
+ return this.targetTwinId;
504
+ },
505
+
506
+ getChannelName: () => {
507
+ return this.channelName;
508
+ },
509
+
510
+ isConnecting: () => {
511
+ return this.isConnecting;
512
+ },
513
+ };
514
+ }
515
+ }