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.
- checksums.yaml +4 -4
- data/LICENSE.txt +21 -0
- data/README.md +98 -9
- data/app/assets/javascripts/fenetre/application.js +1 -14
- data/app/assets/javascripts/fenetre/controllers/index.js +1 -19
- data/app/assets/javascripts/fenetre/controllers/video_chat_controller.js +556 -516
- data/app/assets/javascripts/fenetre/vendor/stimulus.js +2567 -0
- data/app/assets/javascripts/fenetre/vendor/stimulus.min.js +5 -2588
- data/app/assets/javascripts/fenetre.js +12 -8
- data/app/assets/javascripts/stimulus/stimulus.min.js +5 -2588
- data/app/assets/stylesheets/fenetre/video_chat.css +218 -157
- data/app/channels/fenetre/video_chat_channel.rb +69 -111
- data/app/controllers/fenetre/status_controller.rb +13 -0
- data/app/helpers/fenetre/video_chat_helper.rb +54 -32
- data/app/javascript/controllers/fenetre/video_chat_controller.js +1 -662
- data/config/importmap/fenetre.rb +4 -0
- data/config/importmap.rb +7 -21
- data/config/routes.rb +6 -0
- data/lib/fenetre/engine.rb +4 -167
- data/lib/fenetre/parallel_formatter.rb +25 -0
- data/lib/fenetre/presence.rb +34 -0
- data/lib/fenetre/version.rb +1 -1
- data/lib/fenetre.rb +40 -2
- metadata +25 -42
- data/Rakefile +0 -33
- data/app/javascript/test/test_runner.js +0 -15
- data/app/javascript/test/video_chat_controller_test.js +0 -215
- data/lib/fenetre/video_chat_channel.rb +0 -36
- data/lib/tasks/javascript_test.rake +0 -69
|
@@ -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 = [
|
|
6
|
-
|
|
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
|
-
|
|
10
|
-
this.
|
|
11
|
-
this.
|
|
12
|
-
this.
|
|
13
|
-
this.
|
|
14
|
-
this.
|
|
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
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
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(
|
|
82
|
+
this.localStream.getTracks().forEach((t) => t.stop())
|
|
83
|
+
this.localStream = null
|
|
86
84
|
}
|
|
87
85
|
if (this.screenStream) {
|
|
88
|
-
this.screenStream.getTracks().forEach(
|
|
86
|
+
this.screenStream.getTracks().forEach((t) => t.stop())
|
|
87
|
+
this.screenStream = null
|
|
89
88
|
}
|
|
90
|
-
Object.
|
|
91
|
-
this.
|
|
92
|
-
this.
|
|
93
|
-
this.
|
|
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
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
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
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
}
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
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
|
-
|
|
146
|
-
const errorMessage = document.createElement(
|
|
147
|
-
errorMessage.className =
|
|
148
|
-
errorMessage.
|
|
149
|
-
errorMessage.
|
|
150
|
-
|
|
151
|
-
errorMessage.
|
|
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
|
-
|
|
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 =
|
|
170
|
+
this.subscription = consumer.subscriptions.create(
|
|
165
171
|
{ channel: "Fenetre::VideoChatChannel", room_id: this.roomId },
|
|
166
172
|
{
|
|
167
173
|
connected: () => {
|
|
168
|
-
|
|
169
|
-
this.
|
|
170
|
-
this.announceJoin();
|
|
174
|
+
this.updateConnectionStatus("connected")
|
|
175
|
+
this.announceJoin()
|
|
171
176
|
},
|
|
172
177
|
disconnected: () => {
|
|
173
|
-
|
|
174
|
-
this.updateConnectionStatus(
|
|
178
|
+
Object.keys(this.peerConnections).forEach((id) => this.removePeerConnection(id))
|
|
179
|
+
this.updateConnectionStatus("reconnecting")
|
|
175
180
|
},
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
this.
|
|
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
|
-
|
|
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
|
-
|
|
193
|
-
if (data.turbo_stream) {
|
|
194
|
-
this.applyTurboStream(data.turbo_stream);
|
|
195
|
-
}
|
|
195
|
+
if (!data || typeof data !== "object") return
|
|
196
196
|
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
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
|
-
|
|
211
|
-
if (data.
|
|
212
|
-
|
|
213
|
-
|
|
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
|
-
|
|
219
|
-
this.createPeerConnection(
|
|
220
|
-
break
|
|
209
|
+
// Existing peers offer to the joiner (mesh).
|
|
210
|
+
this.createPeerConnection(from)
|
|
211
|
+
break
|
|
221
212
|
case "offer":
|
|
222
|
-
|
|
223
|
-
|
|
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
|
-
|
|
235
|
-
|
|
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
|
-
|
|
244
|
-
|
|
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
|
-
|
|
253
|
-
|
|
254
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
289
|
-
const list =
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
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
|
-
|
|
304
|
-
messageEl.
|
|
305
|
-
|
|
306
|
-
this.chatMessagesTarget.
|
|
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
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
}
|
|
318
|
-
|
|
319
|
-
|
|
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
|
-
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
|
|
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
|
-
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
|
|
369
|
-
|
|
370
|
-
|
|
371
|
-
|
|
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
|
-
|
|
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
|
-
|
|
390
|
-
|
|
391
|
-
video: {
|
|
322
|
+
const stream = await navigator.mediaDevices.getDisplayMedia({
|
|
323
|
+
video: {
|
|
392
324
|
cursor: "always",
|
|
393
|
-
|
|
325
|
+
width: { ideal: 1920 },
|
|
326
|
+
height: { ideal: 1080 },
|
|
327
|
+
frameRate: { ideal: 30, max: 60 },
|
|
394
328
|
},
|
|
395
|
-
audio: false
|
|
396
|
-
})
|
|
397
|
-
|
|
398
|
-
|
|
399
|
-
|
|
400
|
-
|
|
401
|
-
|
|
402
|
-
|
|
403
|
-
|
|
404
|
-
|
|
405
|
-
|
|
406
|
-
|
|
407
|
-
|
|
408
|
-
|
|
409
|
-
)
|
|
410
|
-
|
|
411
|
-
if (
|
|
412
|
-
|
|
413
|
-
|
|
414
|
-
|
|
415
|
-
|
|
416
|
-
|
|
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(
|
|
427
|
-
button.setAttribute(
|
|
428
|
-
button.textContent =
|
|
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
|
-
|
|
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
|
|
445
|
-
|
|
446
|
-
}
|
|
447
|
-
|
|
368
|
+
async stopScreenSharing() {
|
|
369
|
+
if (!this.isScreenSharing) return
|
|
370
|
+
this.isScreenSharing = false
|
|
448
371
|
try {
|
|
449
|
-
|
|
450
|
-
this.screenStream
|
|
451
|
-
|
|
452
|
-
|
|
453
|
-
|
|
454
|
-
|
|
455
|
-
this.
|
|
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
|
-
|
|
474
|
-
|
|
475
|
-
|
|
476
|
-
|
|
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(
|
|
480
|
-
button.setAttribute(
|
|
481
|
-
button.textContent =
|
|
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.
|
|
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
|
|
437
|
+
createPeerConnection(peerId) {
|
|
438
|
+
peerId = String(peerId)
|
|
493
439
|
if (this.peerConnections[peerId]) {
|
|
494
|
-
|
|
495
|
-
return this.peerConnections[peerId];
|
|
440
|
+
return this.peerConnections[peerId]
|
|
496
441
|
}
|
|
497
442
|
|
|
498
|
-
|
|
499
|
-
|
|
500
|
-
|
|
501
|
-
|
|
502
|
-
|
|
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
|
-
|
|
513
|
-
|
|
514
|
-
|
|
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
|
-
|
|
520
|
-
this.addRemoteVideo(peerId,
|
|
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
|
-
|
|
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
|
-
|
|
535
|
-
|
|
536
|
-
|
|
537
|
-
|
|
538
|
-
|
|
539
|
-
|
|
540
|
-
this.
|
|
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
|
-
|
|
546
|
-
|
|
547
|
-
|
|
548
|
-
|
|
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
|
-
|
|
556
|
-
|
|
557
|
-
|
|
558
|
-
|
|
559
|
-
.
|
|
560
|
-
|
|
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
|
-
|
|
571
|
-
const videoContainer = this.remoteVideosTarget.querySelector(`[data-peer-id="${peerId}"]`)
|
|
572
|
-
if (!videoContainer) return
|
|
573
|
-
|
|
574
|
-
let statusElement = videoContainer.querySelector(
|
|
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(
|
|
577
|
-
statusElement.className =
|
|
578
|
-
videoContainer.appendChild(statusElement)
|
|
579
|
-
}
|
|
580
|
-
|
|
581
|
-
|
|
582
|
-
|
|
583
|
-
|
|
584
|
-
|
|
585
|
-
if (
|
|
586
|
-
|
|
587
|
-
|
|
588
|
-
|
|
589
|
-
|
|
590
|
-
statusElement.
|
|
591
|
-
|
|
592
|
-
|
|
593
|
-
|
|
594
|
-
|
|
595
|
-
statusElement.
|
|
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
|
-
|
|
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]
|
|
604
|
-
delete this.peerConnections[peerId]
|
|
605
|
-
|
|
606
|
-
|
|
607
|
-
|
|
608
|
-
|
|
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
|
-
|
|
614
|
-
|
|
615
|
-
|
|
616
|
-
|
|
617
|
-
|
|
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
|
-
|
|
622
|
-
|
|
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(
|
|
626
|
-
videoContainer.setAttribute(
|
|
627
|
-
videoContainer.className =
|
|
628
|
-
|
|
629
|
-
|
|
630
|
-
|
|
631
|
-
|
|
632
|
-
videoElement
|
|
633
|
-
videoElement.
|
|
634
|
-
|
|
635
|
-
|
|
636
|
-
|
|
637
|
-
|
|
638
|
-
|
|
639
|
-
const
|
|
640
|
-
|
|
641
|
-
|
|
642
|
-
|
|
643
|
-
|
|
644
|
-
|
|
645
|
-
|
|
646
|
-
|
|
647
|
-
|
|
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
|
}
|