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.
- checksums.yaml +7 -0
- data/README.md +83 -0
- data/Rakefile +33 -0
- data/app/assets/javascripts/fenetre/application.js +14 -0
- data/app/assets/javascripts/fenetre/controllers/index.js +19 -0
- data/app/assets/javascripts/fenetre/controllers/video_chat_controller.js +662 -0
- data/app/assets/javascripts/fenetre/vendor/stimulus.min.js +2588 -0
- data/app/assets/javascripts/fenetre/vendor/stimulus.umd.js +2588 -0
- data/app/assets/javascripts/fenetre.js +10 -0
- data/app/assets/javascripts/stimulus/stimulus.min.js +2588 -0
- data/app/assets/stylesheets/fenetre/video_chat.css +225 -0
- data/app/channels/fenetre/video_chat_channel.rb +164 -0
- data/app/helpers/fenetre/video_chat_helper.rb +97 -0
- data/app/javascript/controllers/fenetre/video_chat_controller.js +662 -0
- data/app/javascript/test/test_runner.js +15 -0
- data/app/javascript/test/video_chat_controller_test.js +215 -0
- data/config/importmap.rb +24 -0
- data/lib/fenetre/engine.rb +190 -0
- data/lib/fenetre/version.rb +5 -0
- data/lib/fenetre/video_chat_channel.rb +36 -0
- data/lib/fenetre.rb +9 -0
- data/lib/tasks/javascript_test.rake +69 -0
- metadata +262 -0
@@ -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
|
+
}
|