fenetre 0.1.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.
@@ -0,0 +1,662 @@
1
+ import { Controller } from "@hotwired/stimulus"
2
+
3
+ // Connects to data-controller="fenetre--video-chat"
4
+ export default class extends Controller {
5
+ static targets = [ "localVideo", "remoteVideos", "roomId", "chatInput", "chatMessages", "connectionStatus" ]
6
+ static values = { userId: String }
7
+
8
+ 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
+
16
+ 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
+ }
70
+ }
71
+ }
72
+
73
+ 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
+
81
+ if (this.subscription) {
82
+ this.subscription.unsubscribe();
83
+ }
84
+ if (this.localStream) {
85
+ this.localStream.getTracks().forEach(track => track.stop());
86
+ }
87
+ if (this.screenStream) {
88
+ this.screenStream.getTracks().forEach(track => track.stop());
89
+ }
90
+ Object.values(this.peerConnections).forEach(pc => pc.close());
91
+ this.peerConnections = {};
92
+ this.remoteVideosTarget.innerHTML = ''; // Clear remote videos
93
+ this.updateConnectionStatus('disconnected');
94
+ }
95
+
96
+ 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
+ }
128
+ }
129
+ }
130
+
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
140
+ }
141
+ }
142
+
143
+ 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);
155
+ }
156
+
157
+ 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
+ }
163
+
164
+ this.subscription = window.ActionCable.createConsumer().subscriptions.create(
165
+ { channel: "Fenetre::VideoChatChannel", room_id: this.roomId },
166
+ {
167
+ connected: () => {
168
+ console.log(`Connected to ActionCable channel: Fenetre::VideoChatChannel (Room: ${this.roomId})`);
169
+ this.updateConnectionStatus('connected');
170
+ this.announceJoin();
171
+ },
172
+ disconnected: () => {
173
+ console.log("Disconnected from ActionCable channel");
174
+ this.updateConnectionStatus('disconnected');
175
+ },
176
+ received: (data) => {
177
+ console.log("Received data:", data);
178
+ this.handleSignalingData(data);
179
+ },
180
+ }
181
+ );
182
+ }
183
+
184
+ announceJoin() {
185
+ console.log("Announcing join");
186
+ this.subscription.perform("join_room", {});
187
+ }
188
+
189
+ // --- WebRTC Signaling Logic ---
190
+
191
+ handleSignalingData(data) {
192
+ // Turbo Stream UI update support
193
+ if (data.turbo_stream) {
194
+ this.applyTurboStream(data.turbo_stream);
195
+ }
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
+ }
209
+
210
+ // Ignore messages from self
211
+ if (data.from === this.userIdValue) {
212
+ console.log("Ignoring message from self");
213
+ return;
214
+ }
215
+
216
+ switch (data.type) {
217
+ case "join":
218
+ console.log(`User ${data.from} joined, sending offer...`);
219
+ this.createPeerConnection(data.from, true); // Create PC and initiate offer
220
+ break;
221
+ 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;
233
+ 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;
242
+ 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;
251
+ case "leave":
252
+ console.log(`User ${data.from} left`);
253
+ this.removePeerConnection(data.from);
254
+ break;
255
+ default:
256
+ console.warn("Unknown signal type:", data.type);
257
+ }
258
+ }
259
+
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
+ onJoin(data) {
272
+ if (data.participants) {
273
+ this.updateParticipantList(data.participants);
274
+ }
275
+ }
276
+
277
+ onLeave(data) {
278
+ if (data.participants) {
279
+ this.updateParticipantList(data.participants);
280
+ }
281
+ }
282
+
283
+ onChat(data) {
284
+ this.appendChatMessage(data);
285
+ }
286
+
287
+ 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
+ }
298
+ }
299
+
300
+ 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;
308
+ }
309
+
310
+ // Chat functionality
311
+ 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
+ }
340
+ }
341
+
342
+ // Video toggle functionality
343
+ 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
+ }
357
+ }
358
+ }
359
+
360
+ // Audio toggle functionality
361
+ 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
+ }
375
+ }
376
+ }
377
+
378
+ // Screen sharing functionality
379
+ toggleScreenShare() {
380
+ if (this.isScreenSharing) {
381
+ this.stopScreenSharing();
382
+ } else {
383
+ this.startScreenSharing();
384
+ }
385
+ }
386
+
387
+ async startScreenSharing() {
388
+ try {
389
+ // Get screen sharing stream
390
+ this.screenStream = await navigator.mediaDevices.getDisplayMedia({
391
+ video: {
392
+ cursor: "always",
393
+ displaySurface: "monitor"
394
+ },
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"]');
425
+ if (button) {
426
+ button.classList.add('screen-sharing');
427
+ button.setAttribute('aria-label', 'Stop screen sharing');
428
+ button.textContent = 'Stop Sharing';
429
+ }
430
+
431
+ this.isScreenSharing = true;
432
+
433
+ // Handle case when user stops sharing via browser UI
434
+ screenTrack.onended = () => {
435
+ this.stopScreenSharing();
436
+ };
437
+
438
+ } catch (error) {
439
+ console.error("Error starting screen share:", error);
440
+ }
441
+ }
442
+
443
+ stopScreenSharing() {
444
+ if (!this.isScreenSharing || !this.screenStream || !this.savedVideoTrack) {
445
+ return;
446
+ }
447
+
448
+ 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);
470
+ }
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"]');
478
+ if (button) {
479
+ button.classList.remove('screen-sharing');
480
+ button.setAttribute('aria-label', 'Share screen');
481
+ button.textContent = 'Share Screen';
482
+ }
483
+
484
+ this.isScreenSharing = false;
485
+ this.screenStream = null;
486
+
487
+ } catch (error) {
488
+ console.error("Error stopping screen share:", error);
489
+ }
490
+ }
491
+
492
+ createPeerConnection(peerId, isOffering) {
493
+ if (this.peerConnections[peerId]) {
494
+ console.log(`Peer connection already exists for ${peerId}`);
495
+ return this.peerConnections[peerId];
496
+ }
497
+
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;
506
+
507
+ // Add local stream tracks
508
+ 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");
515
+ }
516
+
517
+ // Handle incoming remote tracks
518
+ pc.ontrack = (event) => {
519
+ console.log(`Track received from ${peerId}`);
520
+ this.addRemoteVideo(peerId, event.streams[0]);
521
+ };
522
+
523
+ // Handle ICE candidates
524
+ pc.onicecandidate = (event) => {
525
+ 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}`);
530
+ }
531
+ };
532
+
533
+ 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);
541
+ }
542
+ };
543
+
544
+ 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);
552
+ }
553
+ };
554
+
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));
564
+ }
565
+
566
+ return pc;
567
+ }
568
+
569
+ 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');
575
+ 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';
597
+ }
598
+ }
599
+
600
+ removePeerConnection(peerId) {
601
+ console.log(`Removing peer connection and video for ${peerId}`);
602
+ 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
+ }
610
+ }
611
+
612
+ 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);
618
+ }
619
+
620
+ addRemoteVideo(peerId, stream) {
621
+ console.log(`Adding remote video for ${peerId}`);
622
+ let videoElement = this.remoteVideosTarget.querySelector(`[data-peer-id="${peerId}"] video`);
623
+
624
+ 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
655
+ if (this.peerConnections[peerId]) {
656
+ this.updatePeerConnectionState(peerId, this.peerConnections[peerId]);
657
+ }
658
+ }
659
+
660
+ videoElement.srcObject = stream;
661
+ }
662
+ }