@phystack/hub-client 4.5.19-dev → 4.5.21-dev

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 +252 -378
  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 +383 -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 +336 -0
  32. package/dist/services/webrtc/peer-connection-manager.js.map +1 -0
  33. package/dist/services/webrtc/types.d.ts +134 -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 +20 -0
  66. package/dist/twin-messaging.d.ts.map +1 -0
  67. package/dist/twin-messaging.js +94 -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 +62 -14
  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 +378 -568
  86. package/src/peripheral-twin.ts +337 -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 +536 -0
  92. package/src/services/webrtc/peer-connection-manager.ts +467 -0
  93. package/src/services/webrtc/types.ts +273 -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 +184 -0
  102. package/src/twin-registry.ts +39 -0
  103. package/src/types/index.ts +3 -0
  104. package/src/types/twin.types.ts +80 -14
  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,536 @@
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
+ if (this.localStream && this.localStream.getTracks().length > 0) {
148
+ // Initiator with local stream: Add actual tracks before offer
149
+ this.localStream.getTracks().forEach((track) => {
150
+ pc.addTrack(track, this.localStream!);
151
+ });
152
+ } else {
153
+ // Initiator without local stream: Add transceiver for receiving
154
+ pc.addTransceiver('video', { direction: this.options.mediaOptions.direction });
155
+ }
156
+ } else if (this.localStream) {
157
+ // Responder: Add local tracks before answer is sent
158
+ this.localStream.getTracks().forEach((track) => {
159
+ pc.addTrack(track, this.localStream!);
160
+ });
161
+ }
162
+
163
+ // Handle incoming tracks (both sides)
164
+ pc.ontrack = (event) => {
165
+ // Get the stream from the event or create one if not provided
166
+ // Some WebRTC implementations may not provide streams in the event
167
+ let stream: MediaStream;
168
+ if (event.streams && event.streams[0]) {
169
+ stream = event.streams[0];
170
+ } else {
171
+ // Create a stream if not provided (some WebRTC implementations don't include streams)
172
+ if (!this.remoteStream) {
173
+ this.remoteStream = new MediaStream();
174
+ }
175
+ stream = this.remoteStream;
176
+ // Add the track to our created stream
177
+ if (!stream.getTrackById(event.track.id)) {
178
+ stream.addTrack(event.track);
179
+ }
180
+ }
181
+
182
+ this.remoteStream = stream;
183
+ this.handleRemoteStream(stream);
184
+ };
185
+ },
186
+ };
187
+
188
+ this.pcManager = new PeerConnectionManager(pcConfig, this.twinMessaging, {
189
+ connectionTimeout: this.options.connectionTimeout,
190
+ initialRetryDelay: this.options.initialRetryDelay,
191
+ maxRetryDelay: this.options.maxRetryDelay,
192
+ });
193
+
194
+ const pc = await this.pcManager.connect();
195
+ await this.setupMediaStream(pc);
196
+
197
+ return this.createPhygridMediaStream();
198
+ }
199
+
200
+ /**
201
+ * Close the media stream and clean up
202
+ */
203
+ close(): void {
204
+ if (this.isClosed) return;
205
+
206
+ this.isClosed = true;
207
+ this.isReceiving = false;
208
+
209
+ this.stopFrameActivityMonitor();
210
+
211
+ // Stop all tracks
212
+ this.tracks.forEach((track) => {
213
+ try {
214
+ track.stop();
215
+ } catch (err) {
216
+ console.error('[MediaStreamHandler] Error stopping track:', err);
217
+ }
218
+ });
219
+ this.tracks = [];
220
+
221
+ if (this.remoteStream) {
222
+ this.remoteStream.getTracks().forEach((track) => track.stop());
223
+ this.remoteStream = null;
224
+ }
225
+
226
+ if (this.localStream) {
227
+ this.localStream.getTracks().forEach((track) => track.stop());
228
+ this.localStream = null;
229
+ }
230
+
231
+ if (this.pcManager) {
232
+ this.pcManager.close();
233
+ this.pcManager = null;
234
+ }
235
+
236
+ this.notifyCloseListeners();
237
+ this.trackListeners.clear();
238
+ this.frameListeners.clear();
239
+ this.closeListeners.clear();
240
+ this.trackLastFrameTime.clear();
241
+ }
242
+
243
+ // ===========================================================================
244
+ // Private Methods
245
+ // ===========================================================================
246
+
247
+ private async setupMediaStream(_pc: RTCPeerConnection): Promise<void> {
248
+ // Transceivers and ontrack handler are set up in onPeerConnectionCreated
249
+ // This method now just waits for the connection to be established
250
+ // or returns immediately if we're not expecting incoming tracks
251
+ if (this.options.mediaOptions.direction === 'sendonly') {
252
+ return;
253
+ }
254
+
255
+ // For recvonly/sendrecv, wait for tracks if not already received
256
+ if (this.remoteStream && this.remoteStream.getTracks().length > 0) {
257
+ return; // Already have tracks
258
+ }
259
+
260
+ // Wait a short time for tracks to arrive (they may come after connection)
261
+ return new Promise<void>((resolve) => {
262
+ const timeout = setTimeout(() => {
263
+ console.log('[MediaStreamHandler] Proceeding without waiting for remote tracks');
264
+ resolve();
265
+ }, 1000);
266
+
267
+ // If we get a track, resolve immediately
268
+ const checkForTracks = () => {
269
+ if (this.remoteStream && this.remoteStream.getTracks().length > 0) {
270
+ clearTimeout(timeout);
271
+ resolve();
272
+ }
273
+ };
274
+
275
+ // Check periodically
276
+ const interval = setInterval(() => {
277
+ checkForTracks();
278
+ if (this.remoteStream && this.remoteStream.getTracks().length > 0) {
279
+ clearInterval(interval);
280
+ }
281
+ }, 100);
282
+
283
+ // Clean up interval on timeout
284
+ setTimeout(() => clearInterval(interval), 1000);
285
+ });
286
+ }
287
+
288
+ private handleRemoteStream(stream: MediaStream): void {
289
+ this.startFrameActivityMonitor();
290
+
291
+ stream.getTracks().forEach((track) => {
292
+ this.addTrackToCollection(track, stream);
293
+ });
294
+
295
+ this.onConnectedCallback?.();
296
+ }
297
+
298
+ private addTrackToCollection(track: MediaStreamTrack, stream: MediaStream): void {
299
+ // Check for duplicate
300
+ const existingIndex = this.tracks.findIndex((existingTrack) => existingTrack.id === track.id);
301
+ if (existingIndex !== -1) {
302
+ this.tracks.splice(existingIndex, 1);
303
+ }
304
+
305
+ this.tracks.push(track);
306
+ this.trackLastFrameTime.set(track.id, Date.now());
307
+
308
+ // Set up frame monitoring for the track
309
+ this.setupTrackFrameMonitoring(track, stream);
310
+
311
+ // Notify listeners
312
+ this.notifyTrackListeners(track);
313
+ }
314
+
315
+ private setupTrackFrameMonitoring(track: MediaStreamTrack, stream: MediaStream): void {
316
+ const extendedTrack = track as ExtendedMediaStreamTrack;
317
+
318
+ // Try to use onFrame if available (Node.js @roamhq/wrtc)
319
+ if (typeof extendedTrack.onFrame === 'function') {
320
+ const originalOnFrame = extendedTrack.onFrame;
321
+ extendedTrack.onFrame = (frame: any) => {
322
+ this.trackLastFrameTime.set(track.id, Date.now());
323
+ this.isReceiving = true;
324
+ this.notifyFrameListeners(frame);
325
+ originalOnFrame.call(extendedTrack, frame);
326
+ };
327
+ }
328
+
329
+ // Handle track ended
330
+ track.onended = () => {
331
+ this.trackLastFrameTime.delete(track.id);
332
+ const index = this.tracks.findIndex((existingTrack) => existingTrack.id === track.id);
333
+ if (index !== -1) {
334
+ this.tracks.splice(index, 1);
335
+ }
336
+
337
+ // Check if all tracks ended
338
+ const allEnded = stream.getTracks().every((streamTrack) => streamTrack.readyState === 'ended');
339
+ if (allEnded && !this.isClosed) {
340
+ this.handlePeerDisconnected();
341
+ }
342
+ };
343
+ }
344
+
345
+ private startFrameActivityMonitor(): void {
346
+ if (this.frameActivityInterval) return;
347
+
348
+ this.frameActivityInterval = setInterval(() => {
349
+ this.checkFrameActivity();
350
+ }, 1000);
351
+ }
352
+
353
+ private stopFrameActivityMonitor(): void {
354
+ if (this.frameActivityInterval) {
355
+ clearInterval(this.frameActivityInterval);
356
+ this.frameActivityInterval = null;
357
+ }
358
+ }
359
+
360
+ private checkFrameActivity(): void {
361
+ if (this.trackLastFrameTime.size === 0) return;
362
+
363
+ const now = Date.now();
364
+ let hasActiveTrack = false;
365
+
366
+ this.trackLastFrameTime.forEach((lastTime) => {
367
+ if (now - lastTime < FRAME_INACTIVITY_TIMEOUT) {
368
+ hasActiveTrack = true;
369
+ }
370
+ });
371
+
372
+ if (!hasActiveTrack && this.isReceiving) {
373
+ console.log('[MediaStreamHandler] Frame inactivity detected, connection may be stale');
374
+ this.isReceiving = false;
375
+
376
+ // Trigger reconnect
377
+ if (!this.isClosed && this.pcManager) {
378
+ this.pcManager.reconnect();
379
+ }
380
+ }
381
+ }
382
+
383
+ private handlePeerConnected(): void {
384
+ this.isConnecting = false;
385
+ // Peer connection established - media setup will follow
386
+ }
387
+
388
+ private handlePeerDisconnected(): void {
389
+ this.isReceiving = false;
390
+ this.onDisconnectedCallback?.();
391
+ }
392
+
393
+ private handleReconnected(attempt: number): void {
394
+ // Tracks are re-added via onPeerConnectionCreated when new peer connection is created
395
+ // Just notify callback that reconnection succeeded
396
+ console.log(`[MediaStreamHandler] Reconnected after ${attempt} attempts`);
397
+ this.onReconnectedCallback?.(attempt);
398
+ }
399
+
400
+ private handleError(error: Error): void {
401
+ console.error('[MediaStreamHandler] MediaStream error:', error);
402
+ this.onErrorCallback?.(error);
403
+ }
404
+
405
+ private notifyTrackListeners(track: MediaStreamTrack): void {
406
+ this.trackListeners.forEach((listener) => {
407
+ try {
408
+ listener(track);
409
+ } catch (err) {
410
+ console.error('[MediaStreamHandler] Error in track listener:', err);
411
+ }
412
+ });
413
+ }
414
+
415
+ private notifyFrameListeners(frameData: any): void {
416
+ this.frameListeners.forEach((listener) => {
417
+ try {
418
+ listener(frameData);
419
+ } catch (err) {
420
+ console.error('[MediaStreamHandler] Error in frame listener:', err);
421
+ }
422
+ });
423
+ }
424
+
425
+ private notifyCloseListeners(): void {
426
+ this.closeListeners.forEach((listener) => {
427
+ try {
428
+ listener();
429
+ } catch (err) {
430
+ console.error('[MediaStreamHandler] Error in close listener:', err);
431
+ }
432
+ });
433
+ }
434
+
435
+ private createPhygridMediaStream(): PhygridMediaStream {
436
+ return {
437
+ getTracks: () => [...this.tracks],
438
+
439
+ getStream: () => this.remoteStream,
440
+
441
+ addTrack: (track: MediaStreamTrack) => {
442
+ if (this.isClosed) {
443
+ console.warn('[MediaStreamHandler] Cannot add track to closed stream');
444
+ return;
445
+ }
446
+
447
+ // Add to local stream
448
+ if (this.localStream) {
449
+ const existing = this.localStream.getTrackById(track.id);
450
+ if (existing) {
451
+ this.localStream.removeTrack(existing);
452
+ }
453
+ this.localStream.addTrack(track);
454
+ }
455
+
456
+ // Add to tracks collection
457
+ this.tracks.push(track);
458
+
459
+ // Add to peer connection if connected
460
+ const pc = this.pcManager?.getPeerConnection();
461
+ if (pc && pc.connectionState === 'connected' && this.localStream) {
462
+ const senders = pc.getSenders();
463
+ const existingSender = senders.find((sender) => sender.track?.id === track.id);
464
+ if (existingSender) {
465
+ existingSender.replaceTrack(track);
466
+ } else {
467
+ pc.addTrack(track, this.localStream);
468
+ }
469
+ }
470
+
471
+ this.notifyTrackListeners(track);
472
+ },
473
+
474
+ onTrack: (callback: (track: MediaStreamTrack) => void) => {
475
+ this.trackListeners.add(callback);
476
+
477
+ // Notify for existing tracks
478
+ this.tracks.forEach((track) => {
479
+ try {
480
+ callback(track);
481
+ } catch (err) {
482
+ console.error('[MediaStreamHandler] Error in track listener:', err);
483
+ }
484
+ });
485
+ },
486
+
487
+ offTrack: (callback: (track: MediaStreamTrack) => void) => {
488
+ this.trackListeners.delete(callback);
489
+ },
490
+
491
+ onFrame: (callback: (frameData: any) => void) => {
492
+ this.frameListeners.add(callback);
493
+ },
494
+
495
+ offFrame: (callback: (frameData: any) => void) => {
496
+ this.frameListeners.delete(callback);
497
+ },
498
+
499
+ onClose: (callback: () => void) => {
500
+ this.closeListeners.add(callback);
501
+ },
502
+
503
+ offClose: (callback: () => void) => {
504
+ this.closeListeners.delete(callback);
505
+ },
506
+
507
+ close: () => {
508
+ this.close();
509
+ },
510
+
511
+ isReceivingFrames: () => {
512
+ if (this.trackLastFrameTime.size === 0) return false;
513
+
514
+ const now = Date.now();
515
+ for (const lastTime of this.trackLastFrameTime.values()) {
516
+ if (now - lastTime < FRAME_INACTIVITY_TIMEOUT) {
517
+ return true;
518
+ }
519
+ }
520
+ return false;
521
+ },
522
+
523
+ getTargetTwinId: () => {
524
+ return this.targetTwinId;
525
+ },
526
+
527
+ getChannelName: () => {
528
+ return this.channelName;
529
+ },
530
+
531
+ isConnecting: () => {
532
+ return this.isConnecting;
533
+ },
534
+ };
535
+ }
536
+ }