fenetre 0.1.0 → 0.2.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.
@@ -1,662 +1,702 @@
1
1
  import { Controller } from "@hotwired/stimulus"
2
+ import { createConsumer } from "@rails/actioncable"
2
3
 
3
4
  // Connects to data-controller="fenetre--video-chat"
4
5
  export default class extends Controller {
5
- static targets = [ "localVideo", "remoteVideos", "roomId", "chatInput", "chatMessages", "connectionStatus" ]
6
- static values = { userId: String }
6
+ static targets = [
7
+ "localVideo",
8
+ "remoteVideos",
9
+ "roomId",
10
+ "chatInput",
11
+ "chatMessages",
12
+ "connectionStatus",
13
+ "participants",
14
+ ]
15
+ static values = {
16
+ userId: String,
17
+ iceServers: { type: Array, default: [{ urls: "stun:stun.l.google.com:19302" }] },
18
+ maxVideoBitrate: { type: Number, default: 2_500_000 },
19
+ maxAudioBitrate: { type: Number, default: 128_000 },
20
+ videoWidth: { type: Number, default: 1280 },
21
+ videoHeight: { type: Number, default: 720 },
22
+ frameRate: { type: Number, default: 30 },
23
+ }
7
24
 
8
25
  connect() {
9
- console.log("VideoChat controller connected");
10
- this.peerConnections = {};
11
- this.localStream = null;
12
- this.screenStream = null;
13
- this.isScreenSharing = false;
14
- this.roomId = this.roomIdTarget.value;
15
-
26
+ this.peerConnections = Object.create(null)
27
+ this.makingOffer = Object.create(null)
28
+ this.settingRemoteAnswer = Object.create(null)
29
+ this.ignoreOffer = Object.create(null)
30
+ this.iceCandidateQueue = Object.create(null)
31
+ this.iceRestartTimers = Object.create(null)
32
+ this.active = true
33
+ this.generation = (this.generation || 0) + 1
34
+ this.localStream = null
35
+ this.screenStream = null
36
+ this.isScreenSharing = false
37
+ this.savedVideoTrack = null
38
+
39
+ if (!this.hasConnectionStatusTarget || !this.hasRoomIdTarget) {
40
+ console.error("Fenetre: required targets missing (connectionStatus / roomId).")
41
+ return
42
+ }
43
+
44
+ this.roomId = this.roomIdTarget.value
16
45
  if (!this.roomId) {
17
- console.error("Room ID is missing!");
18
- return;
19
- }
20
-
21
- this.updateConnectionStatus('connecting');
22
-
23
- // Listen for ActionCable connection events
24
- this.setupConnectionEventListeners();
25
-
26
- this.startLocalVideo()
27
- .then(() => this.createSubscription())
28
- .catch(error => this.handleMediaError(error));
29
- }
30
-
31
- // Set up listeners for ActionCable connection events
32
- setupConnectionEventListeners() {
33
- // Listen for ActionCable connected event
34
- document.addEventListener('cable-ready:connected', this.handleActionCableConnected.bind(this));
35
-
36
- // Listen for ActionCable disconnected event
37
- document.addEventListener('cable-ready:disconnected', this.handleActionCableDisconnected.bind(this));
38
-
39
- // Listen for screen sharing status changes (for testing)
40
- document.addEventListener('screen-sharing-changed', this.handleScreenSharingChange.bind(this));
41
- }
42
-
43
- // Handle ActionCable connected event
44
- handleActionCableConnected(event) {
45
- console.log('ActionCable connected:', event);
46
- this.updateConnectionStatus('connected');
47
- }
48
-
49
- // Handle ActionCable disconnected event
50
- handleActionCableDisconnected(event) {
51
- console.log('ActionCable disconnected:', event);
52
- this.updateConnectionStatus('disconnected');
53
- }
54
-
55
- // Handle screen sharing status change (for testing)
56
- handleScreenSharingChange(event) {
57
- console.log('Screen sharing status changed:', event.detail.status);
58
- if (event.detail.status === 'started') {
59
- const button = this.element.querySelector('button[data-action*="fenetre--video-chat#toggleScreenShare"]');
60
- if (button) {
61
- button.classList.add('screen-sharing');
62
- button.textContent = 'Stop Sharing';
63
- }
64
- } else if (event.detail.status === 'stopped') {
65
- const button = this.element.querySelector('button[data-action*="fenetre--video-chat#toggleScreenShare"]');
66
- if (button) {
67
- button.classList.remove('screen-sharing');
68
- button.textContent = 'Share Screen';
69
- }
46
+ console.error("Fenetre: Room ID value is missing!")
47
+ return
70
48
  }
49
+
50
+ // Perfect negotiation: the peer with the lexicographically greater id is polite.
51
+ this.isPolite = (peerId) => String(this.userIdValue) > String(peerId)
52
+
53
+ this.updateConnectionStatus("connecting")
54
+
55
+ const generation = this.generation
56
+ this.startLocalVideo(generation)
57
+ .then(() => {
58
+ if (this.active && this.generation === generation) this.createSubscription()
59
+ })
60
+ .catch((error) => {
61
+ if (this.active && this.generation === generation) this.handleMediaError(error)
62
+ })
71
63
  }
72
-
64
+
73
65
  disconnect() {
74
- console.log("VideoChat controller disconnected");
75
-
76
- // Remove event listeners
77
- document.removeEventListener('cable-ready:connected', this.handleActionCableConnected);
78
- document.removeEventListener('cable-ready:disconnected', this.handleActionCableDisconnected);
79
- document.removeEventListener('screen-sharing-changed', this.handleScreenSharingChange);
80
-
66
+ this.active = false
67
+ this.generation = (this.generation || 0) + 1
68
+ Object.keys(this.iceRestartTimers || {}).forEach((id) => {
69
+ clearTimeout(this.iceRestartTimers[id])
70
+ })
71
+ this.iceRestartTimers = Object.create(null)
72
+
81
73
  if (this.subscription) {
82
- this.subscription.unsubscribe();
74
+ this.subscription.unsubscribe?.()
75
+ this.subscription = null
83
76
  }
77
+ this._consumer?.disconnect()
78
+ this._consumer = null
79
+ this.savedVideoTrack?.stop()
80
+ this.savedVideoTrack = null
84
81
  if (this.localStream) {
85
- this.localStream.getTracks().forEach(track => track.stop());
82
+ this.localStream.getTracks().forEach((t) => t.stop())
83
+ this.localStream = null
86
84
  }
87
85
  if (this.screenStream) {
88
- this.screenStream.getTracks().forEach(track => track.stop());
86
+ this.screenStream.getTracks().forEach((t) => t.stop())
87
+ this.screenStream = null
89
88
  }
90
- Object.values(this.peerConnections).forEach(pc => pc.close());
91
- this.peerConnections = {};
92
- this.remoteVideosTarget.innerHTML = ''; // Clear remote videos
93
- this.updateConnectionStatus('disconnected');
89
+ Object.keys(this.peerConnections).forEach((id) => this.removePeerConnection(id))
90
+ if (this.hasLocalVideoTarget) this.localVideoTarget.srcObject = null
91
+ this.peerConnections = Object.create(null)
92
+ if (this.hasRemoteVideosTarget) {
93
+ this.remoteVideosTarget.innerHTML = ""
94
+ }
95
+ this.updateConnectionStatus("disconnected")
94
96
  }
95
97
 
96
98
  updateConnectionStatus(status) {
97
- if (this.hasConnectionStatusTarget) {
98
- const statusElement = this.connectionStatusTarget;
99
-
100
- // Clear previous status classes
101
- statusElement.classList.remove('fenetre-status-connecting', 'fenetre-status-connected',
102
- 'fenetre-status-disconnected', 'fenetre-status-reconnecting',
103
- 'fenetre-status-error');
104
-
105
- // Add appropriate class and text based on status
106
- switch(status) {
107
- case 'connecting':
108
- statusElement.classList.add('fenetre-status-connecting');
109
- statusElement.textContent = 'Connecting...';
110
- break;
111
- case 'connected':
112
- statusElement.classList.add('fenetre-status-connected');
113
- statusElement.textContent = 'Connected';
114
- break;
115
- case 'disconnected':
116
- statusElement.classList.add('fenetre-status-disconnected');
117
- statusElement.textContent = 'Disconnected';
118
- break;
119
- case 'reconnecting':
120
- statusElement.classList.add('fenetre-status-reconnecting');
121
- statusElement.textContent = 'Reconnecting...';
122
- break;
123
- case 'error':
124
- statusElement.classList.add('fenetre-status-error');
125
- statusElement.textContent = 'Connection Error';
126
- break;
127
- }
99
+ if (!this.hasConnectionStatusTarget) return
100
+ const el = this.connectionStatusTarget
101
+ el.classList.remove(
102
+ "fenetre-status-connecting",
103
+ "fenetre-status-connected",
104
+ "fenetre-status-disconnected",
105
+ "fenetre-status-reconnecting",
106
+ "fenetre-status-error"
107
+ )
108
+ const labels = {
109
+ connecting: ["fenetre-status-connecting", "Connecting..."],
110
+ connected: ["fenetre-status-connected", "Connected"],
111
+ disconnected: ["fenetre-status-disconnected", "Disconnected"],
112
+ reconnecting: ["fenetre-status-reconnecting", "Reconnecting..."],
113
+ error: ["fenetre-status-error", "Connection Error"],
114
+ }
115
+ const [cls, text] = labels[status] || labels.error
116
+ el.classList.add(cls)
117
+ el.textContent = text
118
+ }
119
+
120
+ mediaConstraints() {
121
+ return {
122
+ audio: {
123
+ echoCancellation: true,
124
+ noiseSuppression: true,
125
+ autoGainControl: true,
126
+ channelCount: 1,
127
+ },
128
+ video: {
129
+ width: { ideal: this.videoWidthValue, max: 1920 },
130
+ height: { ideal: this.videoHeightValue, max: 1080 },
131
+ frameRate: { ideal: this.frameRateValue, max: 60 },
132
+ facingMode: "user",
133
+ },
128
134
  }
129
135
  }
130
136
 
131
- async startLocalVideo() {
132
- try {
133
- this.localStream = await navigator.mediaDevices.getUserMedia({ video: true, audio: true });
134
- this.localVideoTarget.srcObject = this.localStream;
135
- console.log("Local video stream started");
136
- } catch (error) {
137
- console.error("Error accessing media devices.", error);
138
- // Handle error appropriately (e.g., show a message to the user)
139
- throw error; // Re-throw to prevent subscription if failed
137
+ async startLocalVideo(generation = this.generation) {
138
+ const stream = await navigator.mediaDevices.getUserMedia(this.mediaConstraints())
139
+ if (!this.active || this.generation !== generation) {
140
+ stream.getTracks().forEach((track) => track.stop())
141
+ return
142
+ }
143
+ this.localStream = stream
144
+ if (this.hasLocalVideoTarget) {
145
+ this.localVideoTarget.srcObject = this.localStream
146
+ this.localVideoTarget.muted = true
147
+ this.localVideoTarget.playsInline = true
148
+ try {
149
+ await this.localVideoTarget.play()
150
+ } catch (_) {
151
+ /* autoplay policies may block; muted should allow it */
152
+ }
140
153
  }
141
154
  }
142
155
 
143
156
  handleMediaError(error) {
144
- console.error("Media access error:", error);
145
- // Show user-friendly error message
146
- const errorMessage = document.createElement('div');
147
- errorMessage.className = 'fenetre-media-error';
148
- errorMessage.textContent = "Could not access camera or microphone. Please check your device permissions.";
149
- errorMessage.style.color = 'red';
150
- errorMessage.style.padding = '10px';
151
- errorMessage.style.margin = '10px 0';
152
- errorMessage.style.backgroundColor = '#ffeeee';
153
- errorMessage.style.border = '1px solid red';
154
- this.element.insertBefore(errorMessage, this.element.firstChild);
157
+ console.error("Media access error:", error)
158
+ this.updateConnectionStatus("error")
159
+ const errorMessage = document.createElement("div")
160
+ errorMessage.className = "fenetre-media-error"
161
+ errorMessage.setAttribute("role", "alert")
162
+ errorMessage.textContent =
163
+ "Could not access camera or microphone. Check permissions and that no other app is using them."
164
+ this.element.insertBefore(errorMessage, this.element.firstChild)
155
165
  }
156
166
 
157
167
  createSubscription() {
158
- if (!window.ActionCable) {
159
- console.error("ActionCable not available. Make sure it's properly loaded in your application.");
160
- this.updateConnectionStatus('error');
161
- return;
162
- }
168
+ const consumer = this._consumer ||= createConsumer()
163
169
 
164
- this.subscription = window.ActionCable.createConsumer().subscriptions.create(
170
+ this.subscription = consumer.subscriptions.create(
165
171
  { channel: "Fenetre::VideoChatChannel", room_id: this.roomId },
166
172
  {
167
173
  connected: () => {
168
- console.log(`Connected to ActionCable channel: Fenetre::VideoChatChannel (Room: ${this.roomId})`);
169
- this.updateConnectionStatus('connected');
170
- this.announceJoin();
174
+ this.updateConnectionStatus("connected")
175
+ this.announceJoin()
171
176
  },
172
177
  disconnected: () => {
173
- console.log("Disconnected from ActionCable channel");
174
- this.updateConnectionStatus('disconnected');
178
+ Object.keys(this.peerConnections).forEach((id) => this.removePeerConnection(id))
179
+ this.updateConnectionStatus("reconnecting")
175
180
  },
176
- received: (data) => {
177
- console.log("Received data:", data);
178
- this.handleSignalingData(data);
181
+ rejected: () => {
182
+ this.disconnect()
183
+ this.updateConnectionStatus("error")
179
184
  },
185
+ received: (data) => this.handleSignalingData(data),
180
186
  }
181
- );
187
+ )
182
188
  }
183
189
 
184
190
  announceJoin() {
185
- console.log("Announcing join");
186
- this.subscription.perform("join_room", {});
191
+ this.subscription?.perform("join_room", {})
187
192
  }
188
193
 
189
- // --- WebRTC Signaling Logic ---
190
-
191
194
  handleSignalingData(data) {
192
- // Turbo Stream UI update support
193
- if (data.turbo_stream) {
194
- this.applyTurboStream(data.turbo_stream);
195
- }
195
+ if (!data || typeof data !== "object") return
196
196
 
197
- // Event hooks (can be customized via subclassing or data attributes)
198
- if (data.type === "join") {
199
- this.onJoin?.(data);
200
- } else if (data.type === "leave") {
201
- this.onLeave?.(data);
202
- } else if (data.type === "chat") {
203
- this.onChat?.(data);
204
- } else if (data.type === "mute" || data.type === "unmute") {
205
- this.onModeration?.(data);
206
- } else if (data.type === "raise_hand" || data.type === "lower_hand") {
207
- this.onHandRaise?.(data);
208
- }
197
+ const from = data.from != null ? String(data.from) : null
198
+ const to = data.to != null ? String(data.to) : null
199
+ const selfId = String(this.userIdValue)
200
+ if (to && to !== selfId) return
209
201
 
210
- // Ignore messages from self
211
- if (data.from === this.userIdValue) {
212
- console.log("Ignoring message from self");
213
- return;
214
- }
202
+ if (data.type === "join") this.onJoin(data)
203
+ if (data.type === "leave") this.onLeave(data)
204
+ if (!from || from === selfId) return
205
+ if (data.type === "chat") this.onChat(data)
215
206
 
216
207
  switch (data.type) {
217
208
  case "join":
218
- console.log(`User ${data.from} joined, sending offer...`);
219
- this.createPeerConnection(data.from, true); // Create PC and initiate offer
220
- break;
209
+ // Existing peers offer to the joiner (mesh).
210
+ this.createPeerConnection(from)
211
+ break
221
212
  case "offer":
222
- console.log(`Received offer from ${data.from}`);
223
- this.createPeerConnection(data.from, false); // Create PC
224
- this.peerConnections[data.from].setRemoteDescription(new RTCSessionDescription(data.payload))
225
- .then(() => this.peerConnections[data.from].createAnswer())
226
- .then(answer => this.peerConnections[data.from].setLocalDescription(answer))
227
- .then(() => {
228
- console.log(`Sending answer to ${data.from}`);
229
- this.sendSignal(data.from, "answer", this.peerConnections[data.from].localDescription);
230
- })
231
- .catch(error => console.error("Error handling offer:", error));
232
- break;
213
+ this.handleOffer(from, data.payload)
214
+ break
233
215
  case "answer":
234
- console.log(`Received answer from ${data.from}`);
235
- if (this.peerConnections[data.from]) {
236
- this.peerConnections[data.from].setRemoteDescription(new RTCSessionDescription(data.payload))
237
- .catch(error => console.error("Error setting remote description on answer:", error));
238
- } else {
239
- console.warn(`Received answer from unknown peer: ${data.from}`);
240
- }
241
- break;
216
+ this.handleAnswer(from, data.payload)
217
+ break
242
218
  case "candidate":
243
- console.log(`Received ICE candidate from ${data.from}`);
244
- if (this.peerConnections[data.from]) {
245
- this.peerConnections[data.from].addIceCandidate(new RTCIceCandidate(data.payload))
246
- .catch(error => console.error("Error adding received ICE candidate:", error));
247
- } else {
248
- console.warn(`Received candidate from unknown peer: ${data.from}`);
249
- }
250
- break;
219
+ this.handleRemoteCandidate(from, data.payload)
220
+ break
251
221
  case "leave":
252
- console.log(`User ${data.from} left`);
253
- this.removePeerConnection(data.from);
254
- break;
222
+ if (!data.participants?.map(String).includes(from)) this.removePeerConnection(from)
223
+ break
224
+ case "kick":
225
+ this.disconnect()
226
+ this.connectionStatusTarget.textContent = "Removed from room"
227
+ break
228
+ case "mute":
229
+ if (this.localStream?.getAudioTracks()[0]?.enabled) this.toggleAudio()
230
+ break
231
+ case "unmute":
232
+ this.dispatch("unmute-requested", { detail: { from } })
233
+ break
255
234
  default:
256
- console.warn("Unknown signal type:", data.type);
235
+ break
257
236
  }
258
237
  }
259
238
 
260
- // Turbo Stream support: inject HTML into the DOM
261
- applyTurboStream(turboStreamHtml) {
262
- const template = document.createElement('template');
263
- template.innerHTML = turboStreamHtml.trim();
264
- const stream = template.content.firstElementChild;
265
- if (stream && stream.tagName === 'TURBO-STREAM') {
266
- document.body.appendChild(stream); // Or use Turbo.renderStreamMessage if available
267
- }
268
- }
269
-
270
- // Minimal default event handlers (can be overridden)
271
239
  onJoin(data) {
272
- if (data.participants) {
273
- this.updateParticipantList(data.participants);
274
- }
240
+ if (data.participants) this.updateParticipantList(data.participants)
275
241
  }
276
242
 
277
243
  onLeave(data) {
278
- if (data.participants) {
279
- this.updateParticipantList(data.participants);
280
- }
244
+ if (data.participants) this.updateParticipantList(data.participants)
281
245
  }
282
246
 
283
247
  onChat(data) {
284
- this.appendChatMessage(data);
248
+ const message = data.payload?.message ?? data.message
249
+ if (!message) return
250
+ this.appendChatMessage({ from: data.from, message })
285
251
  }
286
252
 
287
253
  updateParticipantList(participants) {
288
- // Minimal default: log or update a list if present
289
- const list = document.getElementById('fenetre-participant-list');
290
- if (list) {
291
- list.innerHTML = '';
292
- participants.forEach(id => {
293
- const li = document.createElement('li');
294
- li.textContent = `User ${id}`;
295
- list.appendChild(li);
296
- });
297
- }
254
+ if (!this.hasParticipantsTarget) return
255
+ const list = this.participantsTarget
256
+ list.innerHTML = ""
257
+ participants.forEach((id) => {
258
+ const li = document.createElement("li")
259
+ li.textContent = `User ${id}`
260
+ list.appendChild(li)
261
+ })
298
262
  }
299
263
 
300
264
  appendChatMessage(data) {
301
- if (!this.hasChatMessagesTarget) return;
302
-
303
- const messageEl = document.createElement('div');
304
- messageEl.className = 'fenetre-chat-message';
305
- messageEl.textContent = `${data.from}: ${data.message}`;
306
- this.chatMessagesTarget.appendChild(messageEl);
307
- this.chatMessagesTarget.scrollTop = this.chatMessagesTarget.scrollHeight;
265
+ if (!this.hasChatMessagesTarget) return
266
+ const messageEl = document.createElement("div")
267
+ messageEl.className = "fenetre-chat-message"
268
+ messageEl.textContent = `${data.from}: ${data.message}`
269
+ this.chatMessagesTarget.appendChild(messageEl)
270
+ this.chatMessagesTarget.scrollTop = this.chatMessagesTarget.scrollHeight
308
271
  }
309
272
 
310
- // Chat functionality
311
273
  sendChat(event) {
312
- event.preventDefault();
313
-
314
- if (!this.hasChatInputTarget) {
315
- console.error("Chat input target not found!");
316
- return;
317
- }
318
-
319
- const message = this.chatInputTarget.value.trim();
320
- if (!message) return;
321
-
322
- // Send via ActionCable
323
- if (this.subscription) {
324
- this.subscription.perform("send_message", { message });
325
-
326
- // Clear input field
327
- this.chatInputTarget.value = "";
328
-
329
- // Add to local display
330
- if (this.hasChatMessagesTarget) {
331
- const messageEl = document.createElement('div');
332
- messageEl.className = 'fenetre-chat-message fenetre-chat-message-self';
333
- messageEl.textContent = "You: " + message;
334
- this.chatMessagesTarget.appendChild(messageEl);
335
- this.chatMessagesTarget.scrollTop = this.chatMessagesTarget.scrollHeight;
336
- }
337
- } else {
338
- console.warn("Subscription not available for sending message");
339
- }
274
+ event?.preventDefault?.()
275
+ if (!this.hasChatInputTarget) return
276
+ const message = this.chatInputTarget.value.trim()
277
+ if (!message || !this.subscription) return
278
+
279
+ if (this.subscription.perform("send_message", { message }) === false) return
280
+ this.chatInputTarget.value = ""
281
+ this.appendChatMessage({ from: "You", message })
340
282
  }
341
-
342
- // Video toggle functionality
283
+
343
284
  toggleVideo() {
344
- if (this.localStream) {
345
- const videoTracks = this.localStream.getVideoTracks();
346
- if (videoTracks.length > 0) {
347
- const track = videoTracks[0];
348
- track.enabled = !track.enabled;
349
-
350
- // Update UI to reflect current state
351
- const button = this.element.querySelector('button[data-action*="fenetre--video-chat#toggleVideo"]');
352
- if (button) {
353
- button.classList.toggle('video-off', !track.enabled);
354
- button.setAttribute('aria-label', track.enabled ? 'Turn off camera' : 'Turn on camera');
355
- }
356
- }
285
+ const track = this.localStream?.getVideoTracks()?.[0]
286
+ if (!track) return
287
+ track.enabled = !track.enabled
288
+ const button = this.element.querySelector(
289
+ 'button[data-action*="fenetre--video-chat#toggleVideo"]'
290
+ )
291
+ if (button) {
292
+ button.classList.toggle("video-off", !track.enabled)
293
+ button.setAttribute("aria-label", track.enabled ? "Turn off camera" : "Turn on camera")
294
+ button.setAttribute("aria-pressed", String(!track.enabled))
357
295
  }
358
296
  }
359
-
360
- // Audio toggle functionality
297
+
361
298
  toggleAudio() {
362
- if (this.localStream) {
363
- const audioTracks = this.localStream.getAudioTracks();
364
- if (audioTracks.length > 0) {
365
- const track = audioTracks[0];
366
- track.enabled = !track.enabled;
367
-
368
- // Update UI to reflect current state
369
- const button = this.element.querySelector('button[data-action*="fenetre--video-chat#toggleAudio"]');
370
- if (button) {
371
- button.classList.toggle('audio-off', !track.enabled);
372
- button.setAttribute('aria-label', track.enabled ? 'Mute microphone' : 'Unmute microphone');
373
- }
374
- }
299
+ const track = this.localStream?.getAudioTracks()?.[0]
300
+ if (!track) return
301
+ track.enabled = !track.enabled
302
+ const button = this.element.querySelector(
303
+ 'button[data-action*="fenetre--video-chat#toggleAudio"]'
304
+ )
305
+ if (button) {
306
+ button.classList.toggle("audio-off", !track.enabled)
307
+ button.setAttribute("aria-label", track.enabled ? "Mute microphone" : "Unmute microphone")
308
+ button.setAttribute("aria-pressed", String(!track.enabled))
375
309
  }
376
310
  }
377
311
 
378
- // Screen sharing functionality
379
312
  toggleScreenShare() {
380
- if (this.isScreenSharing) {
381
- this.stopScreenSharing();
382
- } else {
383
- this.startScreenSharing();
384
- }
313
+ if (this.isScreenSharing) return this.stopScreenSharing()
314
+ return this.startScreenSharing()
385
315
  }
386
316
 
387
317
  async startScreenSharing() {
318
+ if (this.startingScreenShare || this.isScreenSharing || !this.localStream) return
319
+ this.startingScreenShare = true
320
+ const generation = this.generation
388
321
  try {
389
- // Get screen sharing stream
390
- this.screenStream = await navigator.mediaDevices.getDisplayMedia({
391
- video: {
322
+ const stream = await navigator.mediaDevices.getDisplayMedia({
323
+ video: {
392
324
  cursor: "always",
393
- displaySurface: "monitor"
325
+ width: { ideal: 1920 },
326
+ height: { ideal: 1080 },
327
+ frameRate: { ideal: 30, max: 60 },
394
328
  },
395
- audio: false
396
- });
397
-
398
- // Save current video stream for later
399
- this.savedVideoTrack = this.localStream.getVideoTracks()[0];
400
-
401
- // Replace video track in local stream with screen share track
402
- const screenTrack = this.screenStream.getVideoTracks()[0];
403
-
404
- // Replace track in all peer connections
405
- Object.values(this.peerConnections).forEach(pc => {
406
- const senders = pc.getSenders();
407
- const videoSender = senders.find(sender =>
408
- sender.track && sender.track.kind === 'video'
409
- );
410
-
411
- if (videoSender) {
412
- videoSender.replaceTrack(screenTrack);
413
- }
414
- });
415
-
416
- // Replace track in local video
417
- this.localStream.removeTrack(this.savedVideoTrack);
418
- this.localStream.addTrack(screenTrack);
419
-
420
- // Show local screen share
421
- this.localVideoTarget.srcObject = this.localStream;
422
-
423
- // Update UI
424
- const button = this.element.querySelector('button[data-action*="fenetre--video-chat#toggleScreenShare"]');
329
+ audio: false,
330
+ })
331
+
332
+ if (!this.active || this.generation !== generation) {
333
+ stream.getTracks().forEach((track) => track.stop())
334
+ return
335
+ }
336
+ this.screenStream = stream
337
+ this.savedVideoTrack = this.localStream?.getVideoTracks()?.[0] || null
338
+ const screenTrack = this.screenStream.getVideoTracks()[0]
339
+
340
+ await this.replaceVideoTrack(screenTrack, 4_000_000)
341
+
342
+ if (this.savedVideoTrack && this.localStream) {
343
+ this.localStream.removeTrack(this.savedVideoTrack)
344
+ this.localStream.addTrack(screenTrack)
345
+ if (this.hasLocalVideoTarget) this.localVideoTarget.srcObject = this.localStream
346
+ }
347
+
348
+ const button = this.element.querySelector(
349
+ 'button[data-action*="fenetre--video-chat#toggleScreenShare"]'
350
+ )
425
351
  if (button) {
426
- button.classList.add('screen-sharing');
427
- button.setAttribute('aria-label', 'Stop screen sharing');
428
- button.textContent = 'Stop Sharing';
352
+ button.classList.add("screen-sharing")
353
+ button.setAttribute("aria-label", "Stop screen sharing")
354
+ button.textContent = "Stop Sharing"
429
355
  }
430
-
431
- this.isScreenSharing = true;
432
-
433
- // Handle case when user stops sharing via browser UI
434
- screenTrack.onended = () => {
435
- this.stopScreenSharing();
436
- };
437
-
356
+
357
+ this.isScreenSharing = true
358
+ screenTrack.onended = () => this.stopScreenSharing()
438
359
  } catch (error) {
439
- console.error("Error starting screen share:", error);
360
+ this.screenStream?.getTracks().forEach((track) => track.stop())
361
+ this.screenStream = null
362
+ if (error.name !== "NotAllowedError") console.error("Error starting screen share:", error)
363
+ } finally {
364
+ this.startingScreenShare = false
440
365
  }
441
366
  }
442
367
 
443
- stopScreenSharing() {
444
- if (!this.isScreenSharing || !this.screenStream || !this.savedVideoTrack) {
445
- return;
446
- }
447
-
368
+ async stopScreenSharing() {
369
+ if (!this.isScreenSharing) return
370
+ this.isScreenSharing = false
448
371
  try {
449
- // Stop all tracks in screen stream
450
- this.screenStream.getTracks().forEach(track => track.stop());
451
-
452
- // Remove screen sharing track from local stream
453
- const screenTrack = this.localStream.getVideoTracks()[0];
454
- if (screenTrack) {
455
- this.localStream.removeTrack(screenTrack);
456
- }
457
-
458
- // Add back the original camera video track
459
- this.localStream.addTrack(this.savedVideoTrack);
460
-
461
- // Replace track in all peer connections
462
- Object.values(this.peerConnections).forEach(pc => {
463
- const senders = pc.getSenders();
464
- const videoSender = senders.find(sender =>
465
- sender.track && sender.track.kind === 'video'
466
- );
467
-
468
- if (videoSender) {
469
- videoSender.replaceTrack(this.savedVideoTrack);
372
+ this.screenStream?.getTracks()?.forEach((t) => t.stop())
373
+ this.screenStream = null
374
+
375
+ if (this.localStream) {
376
+ const screenTrack = this.localStream.getVideoTracks()[0]
377
+ if (screenTrack) this.localStream.removeTrack(screenTrack)
378
+ if (this.savedVideoTrack) {
379
+ this.localStream.addTrack(this.savedVideoTrack)
380
+ await this.replaceVideoTrack(this.savedVideoTrack, this.maxVideoBitrateValue)
470
381
  }
471
- });
472
-
473
- // Update local video
474
- this.localVideoTarget.srcObject = this.localStream;
475
-
476
- // Update UI
477
- const button = this.element.querySelector('button[data-action*="fenetre--video-chat#toggleScreenShare"]');
382
+ if (this.hasLocalVideoTarget) this.localVideoTarget.srcObject = this.localStream
383
+ }
384
+
385
+ const button = this.element.querySelector(
386
+ 'button[data-action*="fenetre--video-chat#toggleScreenShare"]'
387
+ )
478
388
  if (button) {
479
- button.classList.remove('screen-sharing');
480
- button.setAttribute('aria-label', 'Share screen');
481
- button.textContent = 'Share Screen';
389
+ button.classList.remove("screen-sharing")
390
+ button.setAttribute("aria-label", "Share screen")
391
+ button.textContent = "Share Screen"
482
392
  }
483
-
484
- this.isScreenSharing = false;
485
- this.screenStream = null;
486
-
393
+
394
+ this.isScreenSharing = false
395
+ this.savedVideoTrack = null
487
396
  } catch (error) {
488
- console.error("Error stopping screen share:", error);
397
+ console.error("Error stopping screen share:", error)
398
+ }
399
+ }
400
+
401
+ async replaceVideoTrack(track, maxBitrate) {
402
+ const replacements = Object.values(this.peerConnections).map(async (pc) => {
403
+ const sender = pc.getSenders().find((s) => s.track && s.track.kind === "video")
404
+ if (!sender) return
405
+ await sender.replaceTrack(track)
406
+ await this.applySenderBitrate(sender, maxBitrate)
407
+ })
408
+ await Promise.all(replacements)
409
+ }
410
+
411
+ async applySenderBitrate(sender, maxBitrate) {
412
+ try {
413
+ const params = sender.getParameters()
414
+ if (!params.encodings || params.encodings.length === 0) {
415
+ params.encodings = [{}]
416
+ }
417
+ params.encodings[0].maxBitrate = maxBitrate
418
+ if (sender.track?.kind === "video") {
419
+ params.encodings[0].maxFramerate = this.frameRateValue
420
+ }
421
+ await sender.setParameters(params)
422
+ } catch (e) {
423
+ console.warn("Fenetre: setParameters failed", e)
424
+ }
425
+ }
426
+
427
+ iceConfiguration() {
428
+ const servers = this.iceServersValue
429
+ return {
430
+ iceServers: servers,
431
+ iceCandidatePoolSize: 4,
432
+ bundlePolicy: "max-bundle",
433
+ rtcpMuxPolicy: "require",
489
434
  }
490
435
  }
491
436
 
492
- createPeerConnection(peerId, isOffering) {
437
+ createPeerConnection(peerId) {
438
+ peerId = String(peerId)
493
439
  if (this.peerConnections[peerId]) {
494
- console.log(`Peer connection already exists for ${peerId}`);
495
- return this.peerConnections[peerId];
440
+ return this.peerConnections[peerId]
496
441
  }
497
442
 
498
- console.log(`Creating peer connection for ${peerId}, offering: ${isOffering}`);
499
- const pc = new RTCPeerConnection({
500
- iceServers: [
501
- { urls: 'stun:stun.l.google.com:19302' } // Example STUN server
502
- // Add TURN servers here if needed for NAT traversal
503
- ]
504
- });
505
- this.peerConnections[peerId] = pc;
443
+ const pc = new RTCPeerConnection(this.iceConfiguration())
444
+ this.peerConnections[peerId] = pc
445
+ this.iceCandidateQueue[peerId] ||= []
446
+ this.makingOffer[peerId] = false
447
+ this.ignoreOffer[peerId] = false
506
448
 
507
- // Add local stream tracks
508
449
  if (this.localStream) {
509
- this.localStream.getTracks().forEach(track => {
510
- pc.addTrack(track, this.localStream);
511
- });
512
- console.log(`Added local tracks to PC for ${peerId}`);
513
- } else {
514
- console.warn("Local stream not available when creating peer connection");
450
+ this.localStream.getTracks().forEach((track) => {
451
+ const sender = pc.addTrack(track, this.localStream)
452
+ const max =
453
+ track.kind === "audio" ? this.maxAudioBitrateValue : this.maxVideoBitrateValue
454
+ this.applySenderBitrate(sender, max)
455
+ })
515
456
  }
516
457
 
517
- // Handle incoming remote tracks
518
458
  pc.ontrack = (event) => {
519
- console.log(`Track received from ${peerId}`);
520
- this.addRemoteVideo(peerId, event.streams[0]);
521
- };
459
+ const stream = event.streams[0] || new MediaStream([event.track])
460
+ this.addRemoteVideo(peerId, stream)
461
+ }
522
462
 
523
- // Handle ICE candidates
524
463
  pc.onicecandidate = (event) => {
525
464
  if (event.candidate) {
526
- console.log(`Sending ICE candidate to ${peerId}`);
527
- this.sendSignal(peerId, "candidate", event.candidate);
528
- } else {
529
- console.log(`All ICE candidates sent for ${peerId}`);
465
+ this.sendSignal(peerId, "candidate", event.candidate.toJSON?.() || event.candidate)
530
466
  }
531
- };
467
+ }
532
468
 
533
469
  pc.oniceconnectionstatechange = () => {
534
- console.log(`ICE connection state for ${peerId}: ${pc.iceConnectionState}`);
535
-
536
- // Update connection status UI for this peer
537
- this.updatePeerConnectionState(peerId, pc);
538
-
539
- if (pc.iceConnectionState === 'disconnected' || pc.iceConnectionState === 'closed' || pc.iceConnectionState === 'failed') {
540
- this.removePeerConnection(peerId);
470
+ this.updatePeerConnectionState(peerId, pc)
471
+ const state = pc.iceConnectionState
472
+ if (state === "failed") {
473
+ this.restartIce(peerId)
474
+ } else if (state === "disconnected") {
475
+ clearTimeout(this.iceRestartTimers[peerId])
476
+ this.iceRestartTimers[peerId] = setTimeout(() => {
477
+ if (
478
+ this.peerConnections[peerId] &&
479
+ ["disconnected", "failed"].includes(this.peerConnections[peerId].iceConnectionState)
480
+ ) {
481
+ this.restartIce(peerId)
482
+ }
483
+ }, 2000)
484
+ } else if (state === "connected" || state === "completed") {
485
+ clearTimeout(this.iceRestartTimers[peerId])
486
+ } else if (state === "closed") {
487
+ this.removePeerConnection(peerId)
541
488
  }
542
- };
489
+ }
543
490
 
544
491
  pc.onconnectionstatechange = () => {
545
- console.log(`Connection state for ${peerId}: ${pc.connectionState}`);
546
-
547
- // Update connection status UI for this peer
548
- this.updatePeerConnectionState(peerId, pc);
549
-
550
- if (pc.connectionState === 'failed' || pc.connectionState === 'disconnected' || pc.connectionState === 'closed') {
551
- this.removePeerConnection(peerId);
492
+ this.updatePeerConnectionState(peerId, pc)
493
+ if (pc.connectionState === "failed") {
494
+ this.restartIce(peerId)
495
+ } else if (pc.connectionState === "closed") {
496
+ this.removePeerConnection(peerId)
552
497
  }
553
- };
498
+ }
554
499
 
555
- // If offering, create and send offer
556
- if (isOffering) {
557
- pc.createOffer()
558
- .then(offer => pc.setLocalDescription(offer))
559
- .then(() => {
560
- console.log(`Sending offer to ${peerId}`);
561
- this.sendSignal(peerId, "offer", pc.localDescription);
562
- })
563
- .catch(error => console.error("Error creating offer:", error));
500
+ pc.onnegotiationneeded = async () => {
501
+ try {
502
+ await this.makeOffer(peerId)
503
+ } catch (e) {
504
+ console.error("negotiationneeded failed", e)
505
+ }
564
506
  }
565
507
 
566
- return pc;
508
+ return pc
509
+ }
510
+
511
+ async makeOffer(peerId) {
512
+ const pc = this.peerConnections[peerId]
513
+ if (!pc || this.makingOffer[peerId] || pc.signalingState !== "stable") return
514
+ try {
515
+ this.makingOffer[peerId] = true
516
+ await pc.setLocalDescription()
517
+ this.sendSignal(peerId, "offer", pc.localDescription)
518
+ } catch (e) {
519
+ console.error("Error creating offer:", e)
520
+ } finally {
521
+ this.makingOffer[peerId] = false
522
+ }
523
+ }
524
+
525
+ async restartIce(peerId) {
526
+ const pc = this.peerConnections[peerId]
527
+ if (!pc) return
528
+ this.updateConnectionStatus("reconnecting")
529
+ pc.restartIce()
530
+ }
531
+
532
+ async handleOffer(peerId, payload) {
533
+ peerId = String(peerId)
534
+ const pc = this.createPeerConnection(peerId)
535
+ const offerCollision =
536
+ this.makingOffer[peerId] || (pc.signalingState !== "stable" && !this.settingRemoteAnswer[peerId])
537
+ this.ignoreOffer[peerId] = !this.isPolite(peerId) && offerCollision
538
+ if (this.ignoreOffer[peerId]) return
539
+
540
+ try {
541
+ await pc.setRemoteDescription(payload)
542
+ await this.flushIceCandidateQueue(peerId)
543
+ await pc.setLocalDescription()
544
+ this.sendSignal(peerId, "answer", pc.localDescription)
545
+ } catch (e) {
546
+ console.error("Error handling offer:", e)
547
+ }
548
+ }
549
+
550
+ async handleAnswer(peerId, payload) {
551
+ peerId = String(peerId)
552
+ const pc = this.peerConnections[peerId]
553
+ if (!pc) return
554
+ try {
555
+ if (pc.signalingState === "have-local-offer") {
556
+ this.settingRemoteAnswer[peerId] = true
557
+ await pc.setRemoteDescription(payload)
558
+ this.settingRemoteAnswer[peerId] = false
559
+ await this.flushIceCandidateQueue(peerId)
560
+ }
561
+ } catch (e) {
562
+ console.error("Error setting remote description on answer:", e)
563
+ } finally {
564
+ this.settingRemoteAnswer[peerId] = false
565
+ }
566
+ }
567
+
568
+ async handleRemoteCandidate(peerId, candidate) {
569
+ peerId = String(peerId)
570
+ const pc = this.peerConnections[peerId]
571
+ if (!candidate || this.ignoreOffer[peerId]) return
572
+ if (!pc?.remoteDescription) {
573
+ this.iceCandidateQueue[peerId] = this.iceCandidateQueue[peerId] || []
574
+ this.iceCandidateQueue[peerId].push(candidate)
575
+ return
576
+ }
577
+ try {
578
+ await pc.addIceCandidate(candidate)
579
+ } catch (e) {
580
+ if (!this.ignoreOffer[peerId]) {
581
+ console.error("Error adding ICE candidate:", e)
582
+ }
583
+ }
584
+ }
585
+
586
+ async flushIceCandidateQueue(peerId) {
587
+ const pc = this.peerConnections[peerId]
588
+ const queue = this.iceCandidateQueue[peerId] || []
589
+ this.iceCandidateQueue[peerId] = []
590
+ for (const candidate of queue) {
591
+ try {
592
+ await pc.addIceCandidate(candidate)
593
+ } catch (e) {
594
+ console.error("Error flushing ICE candidate:", e)
595
+ }
596
+ }
567
597
  }
568
598
 
569
599
  updatePeerConnectionState(peerId, pc) {
570
- // Find the status indicator for this peer
571
- const videoContainer = this.remoteVideosTarget.querySelector(`[data-peer-id="${peerId}"]`);
572
- if (!videoContainer) return;
573
-
574
- let statusElement = videoContainer.querySelector('.fenetre-peer-status');
600
+ if (!this.hasRemoteVideosTarget) return
601
+ const videoContainer = this.remoteVideosTarget.querySelector(`[data-peer-id="${CSS.escape(String(peerId))}"]`)
602
+ if (!videoContainer) return
603
+
604
+ let statusElement = videoContainer.querySelector(".fenetre-peer-status")
575
605
  if (!statusElement) {
576
- statusElement = document.createElement('div');
577
- statusElement.className = 'fenetre-peer-status';
578
- videoContainer.appendChild(statusElement);
579
- }
580
-
581
- // Clear previous classes
582
- statusElement.className = 'fenetre-peer-status';
583
-
584
- // Set status based on connection state
585
- if (pc.connectionState === 'connected') {
586
- statusElement.classList.add('fenetre-peer-connected');
587
- statusElement.textContent = 'Connected';
588
- } else if (pc.connectionState === 'connecting' || pc.iceConnectionState === 'checking') {
589
- statusElement.classList.add('fenetre-peer-connecting');
590
- statusElement.textContent = 'Connecting...';
591
- } else if (pc.connectionState === 'disconnected' || pc.iceConnectionState === 'disconnected') {
592
- statusElement.classList.add('fenetre-peer-disconnected');
593
- statusElement.textContent = 'Disconnected';
594
- } else if (pc.connectionState === 'failed' || pc.iceConnectionState === 'failed') {
595
- statusElement.classList.add('fenetre-peer-failed');
596
- statusElement.textContent = 'Connection Failed';
606
+ statusElement = document.createElement("div")
607
+ statusElement.className = "fenetre-peer-status"
608
+ videoContainer.appendChild(statusElement)
609
+ }
610
+
611
+ statusElement.className = "fenetre-peer-status"
612
+ if (pc.connectionState === "connected" || pc.iceConnectionState === "completed") {
613
+ statusElement.classList.add("fenetre-peer-connected")
614
+ statusElement.textContent = "Connected"
615
+ } else if (
616
+ pc.connectionState === "connecting" ||
617
+ pc.iceConnectionState === "checking" ||
618
+ pc.iceConnectionState === "disconnected"
619
+ ) {
620
+ statusElement.classList.add("fenetre-peer-connecting")
621
+ statusElement.textContent =
622
+ pc.iceConnectionState === "disconnected" ? "Reconnecting..." : "Connecting..."
623
+ } else if (pc.connectionState === "failed" || pc.iceConnectionState === "failed") {
624
+ statusElement.classList.add("fenetre-peer-failed")
625
+ statusElement.textContent = "Connection Failed"
597
626
  }
598
627
  }
599
628
 
600
629
  removePeerConnection(peerId) {
601
- console.log(`Removing peer connection and video for ${peerId}`);
630
+ peerId = String(peerId)
631
+ clearTimeout(this.iceRestartTimers[peerId])
632
+ delete this.iceRestartTimers[peerId]
633
+ delete this.makingOffer[peerId]
634
+ delete this.settingRemoteAnswer[peerId]
635
+ delete this.ignoreOffer[peerId]
636
+ delete this.iceCandidateQueue[peerId]
602
637
  if (this.peerConnections[peerId]) {
603
- this.peerConnections[peerId].close();
604
- delete this.peerConnections[peerId];
605
- }
606
- const remoteVideoElement = this.remoteVideosTarget.querySelector(`[data-peer-id="${peerId}"]`);
607
- if (remoteVideoElement) {
608
- remoteVideoElement.remove();
609
- }
638
+ const pc = this.peerConnections[peerId]
639
+ delete this.peerConnections[peerId]
640
+ pc.oniceconnectionstatechange = null
641
+ pc.onconnectionstatechange = null
642
+ pc.onnegotiationneeded = null
643
+ pc.onicecandidate = null
644
+ pc.ontrack = null
645
+ pc.close()
646
+ }
647
+ if (this.hasRemoteVideosTarget) this.remoteVideosTarget
648
+ .querySelector(`[data-peer-id="${CSS.escape(String(peerId))}"]`)
649
+ ?.remove()
610
650
  }
611
651
 
612
652
  sendSignal(peerId, type, payload) {
613
- // Note: The channel broadcasts to the room, the server relays it.
614
- // We don't send directly to a peerId via ActionCable here.
615
- this.subscription.perform("signal", { type, payload });
616
- // Log what would be sent if it were direct (for clarity)
617
- // console.log(`Sending signal to ${peerId}: ${type}`, payload);
653
+ if (!this.subscription) return
654
+ const plain =
655
+ payload && typeof payload.toJSON === "function" ? payload.toJSON() : payload
656
+ this.subscription.perform("signal", {
657
+ type,
658
+ to: peerId,
659
+ payload: plain,
660
+ })
618
661
  }
619
662
 
620
663
  addRemoteVideo(peerId, stream) {
621
- console.log(`Adding remote video for ${peerId}`);
622
- let videoElement = this.remoteVideosTarget.querySelector(`[data-peer-id="${peerId}"] video`);
664
+ if (!this.hasRemoteVideosTarget) return
665
+ peerId = String(peerId)
666
+ let videoElement = this.remoteVideosTarget.querySelector(
667
+ `[data-peer-id="${CSS.escape(String(peerId))}"] video`
668
+ )
623
669
 
624
670
  if (!videoElement) {
625
- const videoContainer = document.createElement('div');
626
- videoContainer.setAttribute('data-peer-id', peerId);
627
- videoContainer.className = 'fenetre-remote-video-container';
628
- videoContainer.style.display = 'inline-block'; // Basic layout
629
- videoContainer.style.margin = '5px';
630
- videoContainer.style.position = 'relative';
631
-
632
- videoElement = document.createElement('video');
633
- videoElement.setAttribute('autoplay', '');
634
- videoElement.setAttribute('playsinline', ''); // Important for mobile
635
- videoElement.style.width = '200px'; // Example size
636
- videoElement.style.height = '150px';
637
- videoElement.muted = false; // Unmute remote streams
638
-
639
- const peerIdLabel = document.createElement('p');
640
- peerIdLabel.textContent = `User: ${peerId}`;
641
- peerIdLabel.style.fontSize = '12px';
642
- peerIdLabel.style.textAlign = 'center';
643
-
644
- // Add connection status indicator
645
- const statusElement = document.createElement('div');
646
- statusElement.className = 'fenetre-peer-status fenetre-peer-connecting';
647
- statusElement.textContent = 'Connecting...';
648
-
649
- videoContainer.appendChild(videoElement);
650
- videoContainer.appendChild(peerIdLabel);
651
- videoContainer.appendChild(statusElement);
652
- this.remoteVideosTarget.appendChild(videoContainer);
653
-
654
- // Update connection state for this new peer
671
+ const videoContainer = document.createElement("div")
672
+ videoContainer.setAttribute("data-peer-id", peerId)
673
+ videoContainer.className = "fenetre-remote-video-container"
674
+
675
+ videoElement = document.createElement("video")
676
+ videoElement.autoplay = true
677
+ videoElement.playsInline = true
678
+ videoElement.setAttribute("playsinline", "")
679
+ videoElement.muted = false
680
+
681
+ const peerIdLabel = document.createElement("p")
682
+ peerIdLabel.className = "fenetre-peer-label"
683
+ peerIdLabel.textContent = `User ${peerId}`
684
+
685
+ const statusElement = document.createElement("div")
686
+ statusElement.className = "fenetre-peer-status fenetre-peer-connecting"
687
+ statusElement.textContent = "Connecting..."
688
+
689
+ videoContainer.appendChild(videoElement)
690
+ videoContainer.appendChild(peerIdLabel)
691
+ videoContainer.appendChild(statusElement)
692
+ this.remoteVideosTarget.appendChild(videoContainer)
693
+
655
694
  if (this.peerConnections[peerId]) {
656
- this.updatePeerConnectionState(peerId, this.peerConnections[peerId]);
695
+ this.updatePeerConnectionState(peerId, this.peerConnections[peerId])
657
696
  }
658
697
  }
659
698
 
660
- videoElement.srcObject = stream;
699
+ videoElement.srcObject = stream
700
+ videoElement.play?.().catch(() => {})
661
701
  }
662
702
  }