Luisnguyen1 commited on
Commit
5094fdb
·
1 Parent(s): 14c0d68
.dockerignore ADDED
@@ -0,0 +1,4 @@
 
 
 
 
 
1
+ node_modules
2
+ npm-debug.log
3
+ .git
4
+ .gitignore
.gitignore ADDED
@@ -0,0 +1,6 @@
 
 
 
 
 
 
 
1
+ package-lock.json
2
+ node_modules
3
+ public/docs
4
+ .DS_Store
5
+ .idea
6
+ .env
Dockerfile ADDED
@@ -0,0 +1,23 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ FROM node:16-slim
2
+
3
+ # Set working directory
4
+ WORKDIR /app
5
+
6
+ # Copy package files
7
+ COPY package*.json ./
8
+
9
+ # Install dependencies
10
+ RUN npm install
11
+
12
+ # Copy project files
13
+ COPY . .
14
+
15
+ # Expose port
16
+ EXPOSE 7860
17
+
18
+ # Set environment variables
19
+ ENV PORT=7860
20
+ ENV HOST=0.0.0.0
21
+
22
+ # Start the application
23
+ CMD ["node", "index.js"]
README.md CHANGED
@@ -1,10 +1,8 @@
1
  ---
2
- title: DappMeetingV3
3
- emoji: 🦀
4
- colorFrom: red
5
- colorTo: indigo
6
  sdk: docker
7
  pinned: false
8
  ---
9
-
10
- Check out the configuration reference at https://huggingface.co/docs/hub/spaces-config-reference
 
1
  ---
2
+ title: Cloudflare Calls Backend
3
+ emoji: 📞
4
+ colorFrom: blue
5
+ colorTo: purple
6
  sdk: docker
7
  pinned: false
8
  ---
 
 
apidoc.json ADDED
@@ -0,0 +1,14 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ {
2
+ "name": "Cloudflare Calls Backend Server (Express)",
3
+ "version": "0.1.4",
4
+ "description": "A reference implementation of a backend (in Express) for CloudflareCalls.js",
5
+ "title": "Backend Documentation",
6
+ "url" : "",
7
+ "header": {
8
+ "title": "Backend Documentation",
9
+ "filename": "header.md"
10
+ },
11
+ "template": {
12
+ "forceLanguage": "en"
13
+ }
14
+ }
chat.html ADDED
@@ -0,0 +1,209 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ <!DOCTYPE html>
2
+ <html lang="en">
3
+ <head>
4
+ <meta charset="UTF-8">
5
+ <meta name="viewport" content="width=device-width, initial-scale=1.0">
6
+ <title>Blockchain Chat</title>
7
+ <script src="https://cdn.jsdelivr.net/npm/web3@1.7.4/dist/web3.min.js"></script>
8
+ </head>
9
+ <body>
10
+ <h2>Decentralized Chat</h2>
11
+ <p>Connected as: <span id="userAddress">Not connected</span></p>
12
+
13
+ <input type="text" id="messageInput" placeholder="Type a message...">
14
+ <button onclick="sendMessage()">Send</button>
15
+
16
+ <h3>Messages:</h3>
17
+ <ul id="messagesList"></ul>
18
+
19
+ <script>
20
+ const CONTRACT_ADDRESS = "0xE9EE1420464A20B177107eF61F9D3E8430246739";
21
+ const PRIVATE_KEY = "77a38de82eccbcb54cd10c5470ec204b74337322da47bea3cf5ecaff8ff136f7"; // 🔥 Thay Private Key của bạn
22
+ const PUBLIC_ADDRESS = "0xb0aC901da2bFde714cEB0b88B6B7CD19b33d080f"; // 🔥 Thay địa chỉ ví của bạn
23
+
24
+ const ABI = [
25
+ {
26
+ "anonymous": false,
27
+ "inputs": [
28
+ {
29
+ "indexed": true,
30
+ "internalType": "address",
31
+ "name": "sender",
32
+ "type": "address"
33
+ },
34
+ {
35
+ "indexed": false,
36
+ "internalType": "string",
37
+ "name": "content",
38
+ "type": "string"
39
+ },
40
+ {
41
+ "indexed": false,
42
+ "internalType": "uint256",
43
+ "name": "timestamp",
44
+ "type": "uint256"
45
+ }
46
+ ],
47
+ "name": "NewMessage",
48
+ "type": "event"
49
+ },
50
+ {
51
+ "inputs": [
52
+ {
53
+ "internalType": "string",
54
+ "name": "_content",
55
+ "type": "string"
56
+ }
57
+ ],
58
+ "name": "sendMessage",
59
+ "outputs": [],
60
+ "stateMutability": "nonpayable",
61
+ "type": "function"
62
+ },
63
+ {
64
+ "inputs": [],
65
+ "name": "getAllMessages",
66
+ "outputs": [
67
+ {
68
+ "components": [
69
+ {
70
+ "internalType": "address",
71
+ "name": "sender",
72
+ "type": "address"
73
+ },
74
+ {
75
+ "internalType": "string",
76
+ "name": "content",
77
+ "type": "string"
78
+ },
79
+ {
80
+ "internalType": "uint256",
81
+ "name": "timestamp",
82
+ "type": "uint256"
83
+ }
84
+ ],
85
+ "internalType": "struct Chat.Message[]",
86
+ "name": "",
87
+ "type": "tuple[]"
88
+ }
89
+ ],
90
+ "stateMutability": "view",
91
+ "type": "function"
92
+ },
93
+ {
94
+ "inputs": [
95
+ {
96
+ "internalType": "uint256",
97
+ "name": "",
98
+ "type": "uint256"
99
+ }
100
+ ],
101
+ "name": "messages",
102
+ "outputs": [
103
+ {
104
+ "internalType": "address",
105
+ "name": "sender",
106
+ "type": "address"
107
+ },
108
+ {
109
+ "internalType": "string",
110
+ "name": "content",
111
+ "type": "string"
112
+ },
113
+ {
114
+ "internalType": "uint256",
115
+ "name": "timestamp",
116
+ "type": "uint256"
117
+ }
118
+ ],
119
+ "stateMutability": "view",
120
+ "type": "function"
121
+ }
122
+ ];
123
+
124
+ // Kết nối Web3 với WebSocket trên BSC Testnet
125
+ const wsWeb3 = new Web3(new Web3.providers.WebsocketProvider('wss://bsc-testnet-rpc.publicnode.com'));
126
+ const web3 = new Web3('https://data-seed-prebsc-2-s1.bnbchain.org:8545'); // HTTP RPC cho giao dịch
127
+ const contract = new web3.eth.Contract(ABI, CONTRACT_ADDRESS);
128
+ const wsContract = new wsWeb3.eth.Contract(ABI, CONTRACT_ADDRESS);
129
+
130
+ document.getElementById("userAddress").innerText = PUBLIC_ADDRESS;
131
+ // Load tin nhắn cũ
132
+ loadMessages();
133
+
134
+ async function loadMessages() {
135
+ const messages = await contract.methods.getAllMessages().call();
136
+ const messagesList = document.getElementById("messagesList");
137
+ messagesList.innerHTML = "";
138
+ messages.forEach(msg => {
139
+ const listItem = document.createElement("li");
140
+ listItem.textContent = `${msg.sender}: ${msg.content}`;
141
+ messagesList.appendChild(listItem);
142
+ });
143
+ }
144
+
145
+ async function sendMessage() {
146
+ const message = document.getElementById("messageInput").value;
147
+ if (!message) return;
148
+
149
+ const tx = {
150
+ from: PUBLIC_ADDRESS,
151
+ to: CONTRACT_ADDRESS,
152
+ gas: 300000,
153
+ data: contract.methods.sendMessage(message).encodeABI()
154
+ };
155
+
156
+ const signedTx = await web3.eth.accounts.signTransaction(tx, PRIVATE_KEY);
157
+ web3.eth.sendSignedTransaction(signedTx.rawTransaction)
158
+ .on('receipt', receipt => {
159
+ console.log("✅ Message sent:", receipt);
160
+ document.getElementById("messageInput").value = "";
161
+ })
162
+ .on('error', console.error);
163
+ }
164
+
165
+ function listenForMessages() {
166
+ console.log("🔄 Listening for NewMessage events...");
167
+ wsContract.events.NewMessage()
168
+ .on("data", event => {
169
+ console.log("📥 New message received:", event.returnValues);
170
+ const { sender, content } = event.returnValues;
171
+ const messagesList = document.getElementById("messagesList");
172
+ const listItem = document.createElement("li");
173
+ listItem.textContent = `${sender}: ${content}`;
174
+ messagesList.appendChild(listItem);
175
+ })
176
+ .on("error", error => {
177
+ console.error("❌ WebSocket Error:", error);
178
+ });
179
+ }
180
+
181
+ listenForMessages();
182
+ </script>
183
+ </body>
184
+ </html>
185
+
186
+
187
+ // SPDX-License-Identifier: MIT
188
+ pragma solidity ^0.8.19;
189
+
190
+ contract Chat {
191
+ struct Message {
192
+ address sender;
193
+ string content;
194
+ uint256 timestamp;
195
+ }
196
+
197
+ Message[] public messages;
198
+
199
+ event NewMessage(address indexed sender, string content, uint256 timestamp);
200
+
201
+ function sendMessage(string memory _content) external {
202
+ messages.push(Message(msg.sender, _content, block.timestamp));
203
+ emit NewMessage(msg.sender, _content, block.timestamp);
204
+ }
205
+
206
+ function getAllMessages() external view returns (Message[] memory) {
207
+ return messages;
208
+ }
209
+ }
cloudflare-calls-api-2024-05-21.yaml ADDED
@@ -0,0 +1,447 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ openapi: 3.0.0
2
+ info:
3
+ title: Cloudflare Calls API
4
+ version: "1.0"
5
+ externalDocs:
6
+ description: Find out more about Cloudflare Calls
7
+ url: https://developers.cloudflare.com/calls/
8
+ servers:
9
+ - url: https://rtc.live.cloudflare.com/v1
10
+ paths:
11
+ /apps/{appId}/sessions/new:
12
+ post:
13
+ tags:
14
+ - New Session
15
+ summary: Create a new PeerConnection
16
+ security:
17
+ - secret: []
18
+ parameters:
19
+ - in: path
20
+ name: appId
21
+ schema:
22
+ type: string
23
+ required: true
24
+ description: WebRTC application ID
25
+ responses:
26
+ "201":
27
+ description: Created
28
+ headers:
29
+ vary:
30
+ schema:
31
+ type: string
32
+ example: Origin
33
+ content:
34
+ application/json:
35
+ schema:
36
+ allOf:
37
+ - $ref: "#/components/schemas/NewSessionResponse"
38
+ - example:
39
+ sessionId: e017a2629c754fedc1f7d8587e06d126
40
+ /apps/{appId}/sessions/{sessionId}/tracks/new:
41
+ post:
42
+ tags:
43
+ - Add a track
44
+ summary: Solve the given track object(s) and add the track(s) to the WebRTC session
45
+ requestBody:
46
+ content:
47
+ application/json:
48
+ schema:
49
+ $ref: "#/components/schemas/TracksRequest"
50
+ examples:
51
+ local_tracks:
52
+ description: Share a track to be played by remote peers
53
+ value:
54
+ sessionDescription:
55
+ sdp: |
56
+ v=0
57
+ o=- 0 0 IN IP4 127.0.0.1
58
+ s=-
59
+ c=IN IP4 127.0.0.1
60
+ t=0 0
61
+ m=audio 4000 RTP/AVP 111
62
+ a=rtpmap:111 OPUS/48000/2
63
+ m=video 4002 RTP/AVP 96
64
+ a=rtpmap:96 VP8/90000
65
+ ...
66
+ type: offer
67
+ tracks:
68
+ - location: local
69
+ mid: "4"
70
+ trackName: 1a037563-c35c-4bf6-a9ee-2b474cbb9a51
71
+ remote_tracks:
72
+ description: Play a track from a remote peer
73
+ value:
74
+ tracks:
75
+ - location: remote
76
+ sessionId: 2a45361d5fd7cc14eface0587c276c94
77
+ trackName: 2e037563-a35d-4bf6-a9ee-2d474cbb9a58
78
+ security:
79
+ - secret: []
80
+ parameters:
81
+ - in: path
82
+ name: appId
83
+ schema:
84
+ type: string
85
+ required: true
86
+ description: WebRTC application ID
87
+ - in: path
88
+ name: sessionId
89
+ schema:
90
+ type: string
91
+ required: true
92
+ description: Current PeerConnection session ID
93
+ responses:
94
+ "200":
95
+ description: OK
96
+ headers:
97
+ vary:
98
+ schema:
99
+ type: string
100
+ example: Origin
101
+ content:
102
+ application/json:
103
+ schema:
104
+ $ref: "#/components/schemas/TracksResponse"
105
+ examples:
106
+ local_tracks:
107
+ value:
108
+ requiresImmediateRenegotiation: false
109
+ tracks:
110
+ - trackName: 1a037563-c35c-4bf6-a9ee-2b474cbb9a51
111
+ mid: "4"
112
+ sessionDescription:
113
+ sdp: |
114
+ v=0
115
+ o=- 0 0 IN IP4 127.0.0.1
116
+ s=-
117
+ c=IN IP4 127.0.0.1
118
+ t=0 0
119
+ m=audio 4000 RTP/AVP 111
120
+ a=rtpmap:111 OPUS/48000/2
121
+ m=video 4002 RTP/AVP 96
122
+ a=rtpmap:96 VP8/90000
123
+ ...
124
+ type: answer
125
+ remote_tracks:
126
+ value:
127
+ requiresImmediateRenegotiation: true
128
+ tracks:
129
+ - sessionId: 2a45361d5fd7cc14eface0587c276c94
130
+ trackName: 2e037563-a35d-4bf6-a9ee-2d474cbb9a58
131
+ mid: "7"
132
+ sessionDescription:
133
+ sdp: |
134
+ v=0
135
+ o=- 0 0 IN IP4 127.0.0.1
136
+ s=-
137
+ c=IN IP4 127.0.0.1
138
+ t=0 0
139
+ m=audio 4000 RTP/AVP 111
140
+ a=rtpmap:111 OPUS/48000/2
141
+ m=video 4002 RTP/AVP 96
142
+ a=rtpmap:96 VP8/90000
143
+ ...
144
+ type: offer
145
+ /apps/{appId}/sessions/{sessionId}/renegotiate:
146
+ put:
147
+ tags:
148
+ - Renegotiate WebRTC session
149
+ summary: When a previous response has requiresImmediateRenegotiation, you must renegotiate
150
+ requestBody:
151
+ content:
152
+ application/json:
153
+ schema:
154
+ properties:
155
+ sessionDescription:
156
+ $ref: "#/components/schemas/SessionDescription"
157
+ example:
158
+ sessionDescription:
159
+ sdp: |
160
+ v=0
161
+ o=- 0 0 IN IP4 127.0.0.1
162
+ s=-
163
+ c=IN IP4 127.0.0.1
164
+ t=0 0
165
+ m=audio 4000 RTP/AVP 111
166
+ a=rtpmap:111 OPUS/48000/2
167
+ m=video 4002 RTP/AVP 96
168
+ a=rtpmap:96 VP8/90000
169
+ ...
170
+ type: answer
171
+ security:
172
+ - secret: []
173
+ parameters:
174
+ - in: path
175
+ name: appId
176
+ schema:
177
+ type: string
178
+ required: true
179
+ description: WebRTC application ID
180
+ - in: path
181
+ name: sessionId
182
+ schema:
183
+ type: string
184
+ required: true
185
+ responses:
186
+ "200":
187
+ description: OK
188
+ headers:
189
+ vary:
190
+ schema:
191
+ type: string
192
+ example: Origin
193
+ content:
194
+ application/json:
195
+ schema:
196
+ $ref: "#/components/schemas/SessionDescription"
197
+ example: {}
198
+ /apps/{appId}/sessions/{sessionId}/tracks/close:
199
+ put:
200
+ tags:
201
+ - Close a track
202
+ summary: Close a local or remote track
203
+ requestBody:
204
+ content:
205
+ application/json:
206
+ schema:
207
+ allOf:
208
+ - $ref: "#/components/schemas/CloseTracksRequest"
209
+ - example:
210
+ tracks:
211
+ - mid: "7"
212
+ sessionDescription:
213
+ sdp: |
214
+ v=0
215
+ o=- 0 0 IN IP4 127.0.0.1
216
+ s=-
217
+ c=IN IP4 127.0.0.1
218
+ t=0 0
219
+ m=audio 4000 RTP/AVP 111
220
+ a=rtpmap:111 OPUS/48000/2
221
+ m=video 4002 RTP/AVP 96
222
+ a=rtpmap:96 VP8/90000
223
+ ...
224
+ type: offer
225
+ force: false
226
+ security:
227
+ - secret: []
228
+ parameters:
229
+ - in: path
230
+ name: appId
231
+ schema:
232
+ type: string
233
+ required: true
234
+ description: WebRTC application ID
235
+ - in: path
236
+ name: sessionId
237
+ schema:
238
+ type: string
239
+ required: true
240
+ responses:
241
+ "200":
242
+ description: OK
243
+ headers:
244
+ vary:
245
+ schema:
246
+ type: string
247
+ example: Origin
248
+ content:
249
+ application/json:
250
+ schema:
251
+ $ref: "#/components/schemas/CloseTracksResponse"
252
+ example:
253
+ sessionDescription:
254
+ sdp: |
255
+ v=0
256
+ o=- 0 0 IN IP4 127.0.0.1
257
+ s=-
258
+ c=IN IP4 127.0.0.1
259
+ t=0 0
260
+ m=audio 4000 RTP/AVP 111
261
+ a=rtpmap:111 OPUS/48000/2
262
+ m=video 4002 RTP/AVP 96
263
+ a=rtpmap:96 VP8/90000
264
+ ...
265
+ type: answer
266
+ requiresImmediateRenegotiation: false
267
+ tracks:
268
+ - mid: "7"
269
+ /apps/{appId}/sessions/{sessionId}:
270
+ get:
271
+ tags:
272
+ - Get session state
273
+ summary: Return the list of tracks associated to the session
274
+ security:
275
+ - secret: []
276
+ parameters:
277
+ - in: path
278
+ name: appId
279
+ schema:
280
+ type: string
281
+ required: true
282
+ description: WebRTC application ID
283
+ - in: path
284
+ name: sessionId
285
+ schema:
286
+ type: string
287
+ required: true
288
+ responses:
289
+ "200":
290
+ description: OK
291
+ headers:
292
+ vary:
293
+ schema:
294
+ type: string
295
+ example: Origin
296
+ content:
297
+ application/json:
298
+ schema:
299
+ $ref: "#/components/schemas/GetSessionStateResponse"
300
+ example:
301
+ tracks:
302
+ - location: local
303
+ mid: "2"
304
+ trackName: 1a037563-c35c-4bf6-a9ee-2b474cbb9a51
305
+ status: active
306
+ - location: remote
307
+ mid: "7"
308
+ sessionId: 2a45361d5fd7cc14eface0587c276c94
309
+ trackName: 2e037563-a35d-4bf6-a9ee-2d474cbb9a58
310
+ status: active
311
+
312
+ components:
313
+ securitySchemes:
314
+ secret:
315
+ type: http
316
+ scheme: bearer
317
+ schemas:
318
+ SessionDescription:
319
+ type: object
320
+ properties:
321
+ sdp:
322
+ type: string
323
+ type:
324
+ type: string
325
+ enum:
326
+ - answer
327
+ - offer
328
+ TrackObject:
329
+ type: object
330
+ properties:
331
+ location:
332
+ type: string
333
+ enum:
334
+ - local
335
+ - remote
336
+ description: If you want to share a track, it should be local. If you want to play a track shared by a remote agent, it should be remote
337
+ mid:
338
+ type: string
339
+ description: mid associated to track's transceiver. It should be set with local tracks only
340
+ sessionId:
341
+ type: string
342
+ description: Session ID of the track owner. It should be set for remote tracks only
343
+ trackName:
344
+ type: string
345
+ description: Given name for the track
346
+ CloseTrackObject:
347
+ type: object
348
+ properties:
349
+ mid:
350
+ type: string
351
+ description: mid associated to the track's transceiver to close
352
+ TracksRequest:
353
+ type: object
354
+ properties:
355
+ sessionDescription:
356
+ $ref: "#/components/schemas/SessionDescription"
357
+ tracks:
358
+ type: array
359
+ items:
360
+ $ref: "#/components/schemas/TrackObject"
361
+ TracksResponse:
362
+ type: object
363
+ properties:
364
+ requiresImmediateRenegotiation:
365
+ type: boolean
366
+ sessionDescription:
367
+ $ref: "#/components/schemas/SessionDescription"
368
+ tracks:
369
+ type: array
370
+ items:
371
+ allOf:
372
+ - $ref: "#/components/schemas/TrackObject"
373
+ - properties:
374
+ error:
375
+ type: object
376
+ properties:
377
+ errorCode:
378
+ type: string
379
+ errorDescription:
380
+ type: string
381
+ NewSessionRequest:
382
+ type: object
383
+ properties:
384
+ sessionDescription:
385
+ $ref: "#/components/schemas/SessionDescription"
386
+ NewSessionResponse:
387
+ type: object
388
+ properties:
389
+ sessionDescription:
390
+ type: object
391
+ properties:
392
+ sdp:
393
+ type: string
394
+ type:
395
+ type: string
396
+ enum:
397
+ - answer
398
+ - offer
399
+ sessionId:
400
+ type: string
401
+ CloseTracksRequest:
402
+ type: object
403
+ properties:
404
+ sessionDescription:
405
+ $ref: "#/components/schemas/SessionDescription"
406
+ tracks:
407
+ type: array
408
+ items:
409
+ $ref: "#/components/schemas/CloseTrackObject"
410
+ force:
411
+ type: boolean
412
+ description: True if you want to stop just the data flow for the tracks, no WebRTC renegotiation
413
+ CloseTracksResponse:
414
+ type: object
415
+ properties:
416
+ sessionDescription:
417
+ $ref: "#/components/schemas/SessionDescription"
418
+ tracks:
419
+ type: array
420
+ items:
421
+ allOf:
422
+ - $ref: "#/components/schemas/CloseTrackObject"
423
+ - properties:
424
+ error:
425
+ type: object
426
+ properties:
427
+ errorCode:
428
+ type: string
429
+ errorDescription:
430
+ type: string
431
+ requiresImmediateRenegotiation:
432
+ type: boolean
433
+ GetSessionStateResponse:
434
+ type: object
435
+ properties:
436
+ tracks:
437
+ type: array
438
+ items:
439
+ allOf:
440
+ - $ref: "#/components/schemas/TrackObject"
441
+ - properties:
442
+ status:
443
+ type: string
444
+ enum:
445
+ - active
446
+ - inactive
447
+ - waiting
header.md ADDED
@@ -0,0 +1 @@
 
 
1
+ CloudflareCalls.js: A High-level library for Cloudflare Calls SFU.
index.js ADDED
@@ -0,0 +1,1086 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ /**
2
+ * Cloudflare Calls Backend Server (Express)
3
+ *
4
+ * Illustrates how to:
5
+ * 1. Store each participant’s local track offers in memory.
6
+ * 2. Perform the Cloudflare Calls track negotiation on the server.
7
+ */
8
+
9
+ require('dotenv').config();
10
+ const express = require('express');
11
+ const fetch = require('node-fetch');
12
+ const path = require('path');
13
+ const jwt = require('jsonwebtoken');
14
+ const WebSocket = require('ws');
15
+ const crypto = require('crypto');
16
+ const http = require('http');
17
+
18
+ const app = express();
19
+ app.use(express.json());
20
+ app.use(express.static('public'));
21
+
22
+ const AUTH_REQUIRED = true; // You can turn off auth for your demo if you want
23
+ const port = process.env.PORT || 5000;
24
+ const CLOUDFLARE_APP_ID = process.env.CLOUDFLARE_APP_ID;
25
+ const CLOUDFLARE_APP_SECRET = process.env.CLOUDFLARE_APP_SECRET;
26
+ const SECRET_KEY = process.env.JWT_SECRET || 'thisisjustademokey';
27
+ const CLOUDFLARE_CALLS_BASE_URL = process.env.CLOUDFLARE_APPS_URL || 'https://rtc.live.cloudflare.com/v1/apps';
28
+ const CLOUDFLARE_BASE_PATH = `${CLOUDFLARE_CALLS_BASE_URL}/${CLOUDFLARE_APP_ID}`;
29
+ const DEBUG = process.env.DEBUG === 'true' || false;
30
+
31
+ // Middleware to verify token from the Authorization header
32
+ function verifyToken(req, res, next) {
33
+ const authHeader = req.headers['authorization'];
34
+ if (!AUTH_REQUIRED) return next();
35
+
36
+ if (!authHeader || !authHeader.startsWith('Bearer ')) {
37
+ return res.status(401).json({ error: 'Unauthorized: No token provided' });
38
+ }
39
+
40
+ const token = authHeader.split(' ')[1];
41
+
42
+ try {
43
+ const decoded = jwt.verify(token, SECRET_KEY);
44
+ req.user = decoded; // Attach decoded token data to the request object
45
+ next();
46
+ } catch (err) {
47
+ return res.status(403).json({ error: 'Forbidden: Invalid token' });
48
+ }
49
+ }
50
+
51
+ // Example token generation endpoint
52
+ // Has no usefulness in production, just facilitates the demo
53
+ app.post('/auth/token', (req, res) => {
54
+ const { username } = req.body;
55
+ const userId = crypto.randomUUID(); // Generate unique user ID
56
+
57
+ // Generate a token with arbitrary JSON payload
58
+ const token = jwt.sign({
59
+ userId,
60
+ username,
61
+ role: 'demo',
62
+ isModerator: true // In production, this would come from your database
63
+ }, SECRET_KEY, {
64
+ expiresIn: '8h'
65
+ });
66
+
67
+ // Store initial user info
68
+ users.set(userId, {
69
+ userId,
70
+ username,
71
+ isModerator: true,
72
+ role: 'demo'
73
+ });
74
+
75
+ res.json({ token });
76
+ });
77
+
78
+ /**
79
+ * In-memory storage for rooms and participants.
80
+ * @typedef {Object} Room
81
+ * @property {string} userId - Unique identifier for the user.
82
+ * @property {string} sessionId - Unique identifier for the session.
83
+ * @property {number} createdAt - Timestamp when the participant was added.
84
+ * @property {Array} offers - Array of offer objects.
85
+ */
86
+
87
+ /**
88
+ * @type {Object.<string, Array<Room>>}
89
+ */
90
+ const rooms = new Map(); // Using Map instead of plain object for better key handling
91
+
92
+ const wsConnections = {};
93
+
94
+ // Add this near the top with other in-memory storage
95
+ const users = new Map(); // Store user info
96
+
97
+ // Helper function to serialize room data
98
+ function serializeRoom(roomId, roomData) {
99
+ return {
100
+ roomId,
101
+ name: roomData.name || '',
102
+ metadata: roomData.metadata || {},
103
+ participantCount: roomData.participants.length,
104
+ createdAt: roomData.createdAt
105
+ };
106
+ }
107
+
108
+ /* ------------------------------------------------------------------
109
+ Basic endpoints
110
+ ------------------------------------------------------------------ */
111
+
112
+ /**
113
+ * @api {post} /api/rooms Create a new room
114
+ * @apiName CreateRoom
115
+ * @apiGroup Rooms
116
+ *
117
+ * @apiSuccess {String} roomId The unique ID of the created room.
118
+ * @apiError (404) NotFound Room not found.
119
+ */
120
+ app.post('/api/rooms', verifyToken, (req, res) => {
121
+ const roomId = crypto.randomUUID();
122
+ const { name, metadata } = req.body;
123
+
124
+ rooms.set(roomId, {
125
+ name: name || '',
126
+ metadata: metadata || {},
127
+ participants: [],
128
+ createdAt: Date.now()
129
+ });
130
+
131
+ res.json(serializeRoom(roomId, rooms.get(roomId)));
132
+ });
133
+
134
+ /**
135
+ * @api {get} /inspect-rooms Inspect all rooms (development only)
136
+ * @apiName InspectRooms
137
+ * @apiGroup Rooms
138
+ * @apiDescription Retrieve all rooms and their participants (development mode only).
139
+ *
140
+ * @apiSuccess {Object} rooms Object containing all rooms and participants.
141
+ */
142
+ if (process.env.NODE_ENV === 'development') {
143
+ app.get('/inspect-rooms', (req, res) => {
144
+ const debug = {
145
+ rooms: Object.fromEntries(rooms),
146
+ roomCount: rooms.size,
147
+ users: Array.from(users.entries()),
148
+ wsConnections: Object.keys(wsConnections),
149
+ raw: rooms,
150
+ };
151
+
152
+ res.json(debug);
153
+ });
154
+ }
155
+
156
+ /**
157
+ * @api {post} /api/rooms/:roomId/join Join a room
158
+ * @apiName JoinRoom
159
+ * @apiGroup Rooms
160
+ *
161
+ * @apiParam {String} roomId The ID of the room to join.
162
+ * @apiBody {String} userId The user's unique identifier.
163
+ *
164
+ * @apiSuccess {String} sessionId The session ID of the participant.
165
+ * @apiSuccess {Array} otherSessions List of other participants in the room.
166
+ * @apiError (404) NotFound Room not found.
167
+ * @apiError (500) ServerError Failed to create Calls session.
168
+ */
169
+ app.post('/api/rooms/:roomId/join', verifyToken, async (req, res) => {
170
+ const { roomId } = req.params;
171
+ const { userId } = req.user;
172
+
173
+ if (!rooms.has(roomId)) {
174
+ return res.status(404).json({ error: 'Room not found' });
175
+ }
176
+
177
+ const room = rooms.get(roomId);
178
+
179
+ const response = await fetch(`${CLOUDFLARE_BASE_PATH}/sessions/new`, {
180
+ method: 'POST',
181
+ headers: { 'Authorization': `Bearer ${CLOUDFLARE_APP_SECRET}` }
182
+ });
183
+ const sessionResponse = await response.json();
184
+ if (!sessionResponse.sessionId) {
185
+ return res.status(500).json({ error: 'Could not create Calls session' });
186
+ }
187
+
188
+ const participant = {
189
+ userId,
190
+ sessionId: sessionResponse.sessionId,
191
+ createdAt: Date.now(),
192
+ publishedTracks: []
193
+ };
194
+
195
+ room.participants.push(participant);
196
+ rooms.set(roomId, room);
197
+
198
+ const otherParticipants = room.participants
199
+ .filter(p => p.userId !== userId)
200
+ .map(p => ({
201
+ userId: p.userId,
202
+ sessionId: p.sessionId,
203
+ publishedTracks: p.publishedTracks
204
+ }));
205
+
206
+ broadcastToRoom(roomId, {
207
+ type: 'participant-joined',
208
+ payload: {
209
+ userId,
210
+ username: users.get(userId).username,
211
+ sessionId: participant.sessionId,
212
+ },
213
+ }, userId);
214
+
215
+ res.json({
216
+ sessionId: participant.sessionId,
217
+ otherSessions: otherParticipants
218
+ });
219
+ });
220
+
221
+ /**
222
+ * @api {post} /api/rooms/:roomId/sessions/:sessionId/publish Publish Tracks
223
+ * @apiName PublishTracks
224
+ * @apiGroup Sessions
225
+ *
226
+ * @apiParam {String} roomId The ID of the room.
227
+ * @apiParam {String} sessionId The session ID of the participant.
228
+ * @apiBody {Object} offer The SDP offer.
229
+ * @apiBody {Array} tracks Array of track objects.
230
+ *
231
+ * @apiSuccess {Object} data Response from Cloudflare Calls API.
232
+ * @apiError (404) NotFound Session not found in this room.
233
+ */
234
+ app.post('/api/rooms/:roomId/sessions/:sessionId/publish', verifyToken, async (req, res) => {
235
+ const { roomId, sessionId } = req.params;
236
+ const { offer, tracks } = req.body;
237
+
238
+ const room = rooms.get(roomId);
239
+ if (!room) {
240
+ return res.status(404).json({ error: 'Room not found' });
241
+ }
242
+
243
+ const participant = room.participants.find(p => p.sessionId === sessionId);
244
+ if (!participant) {
245
+ return res.status(404).json({ error: 'Session not found in this room' });
246
+ }
247
+
248
+ // Store these trackName(s) in participant.publishedTracks
249
+ for (const t of tracks) {
250
+ if (!participant.publishedTracks.includes(t.trackName)) {
251
+ participant.publishedTracks.push(t.trackName);
252
+ }
253
+ }
254
+
255
+ rooms.set(roomId, room);
256
+ // Now call Cloudflare to finalize the push
257
+ const cfResp = await fetch(`${CLOUDFLARE_BASE_PATH}/sessions/${sessionId}/tracks/new`, {
258
+ method: 'POST',
259
+ headers: {
260
+ 'Authorization': `Bearer ${CLOUDFLARE_APP_SECRET}`,
261
+ 'Content-Type': 'application/json'
262
+ },
263
+ body: JSON.stringify({
264
+ sessionDescription: offer,
265
+ tracks
266
+ })
267
+ });
268
+ const data = await cfResp.json();
269
+ if (data.sessionDescription) {
270
+ // Emit a 'track-published' event to other participants in the room
271
+ broadcastToRoom(roomId, {
272
+ type: 'track-published',
273
+ payload: {
274
+ sessionId,
275
+ trackNames: tracks.map(t => t.trackName)
276
+ }
277
+ }, participant.userId);
278
+ }
279
+ return res.json(data);
280
+ });
281
+
282
+ /**
283
+ * @api {post} /api/rooms/:roomId/sessions/:sessionId/unpublish Unpublish Track
284
+ * @apiName UnpublishTrack
285
+ * @apiGroup Sessions
286
+ *
287
+ * @apiParam {String} roomId The ID of the room
288
+ * @apiParam {String} sessionId The session ID of the track owner
289
+ *
290
+ * @apiHeader {String} Authorization Bearer token
291
+ *
292
+ * @apiError (403) Forbidden User is not authorized to force unpublish others' tracks
293
+ */
294
+ app.post('/api/rooms/:roomId/sessions/:sessionId/unpublish', verifyToken, async (req, res) => {
295
+ try {
296
+ const { roomId, sessionId } = req.params;
297
+ const { trackName, mid, force, sessionDescription } = req.body;
298
+
299
+ // If trying to force unpublish someone else's track
300
+ if (force && sessionId !== req.user.sessionId) {
301
+ // Check if user is moderator
302
+ if (!req.user.isModerator) {
303
+ return res.status(403).json({
304
+ errorCode: 'NOT_AUTHORIZED',
305
+ errorDescription: 'Only moderators can force unpublish other participants\' tracks'
306
+ });
307
+ }
308
+ }
309
+
310
+ if (DEBUG) console.log('Unpublishing track:', { roomId, sessionId, trackName, mid });
311
+
312
+ if (!mid) {
313
+ return res.status(400).json({
314
+ errorCode: 'INVALID_REQUEST',
315
+ errorDescription: 'mid is required to unpublish a track.'
316
+ });
317
+ }
318
+
319
+ if (!sessionDescription) {
320
+ return res.status(400).json({
321
+ errorCode: 'INVALID_REQUEST',
322
+ errorDescription: 'sessionDescription is required to unpublish a track.'
323
+ });
324
+ }
325
+
326
+ // Call Cloudflare API to close the track
327
+ const cfUrl = `${CLOUDFLARE_BASE_PATH}/sessions/${sessionId}/tracks/close`;
328
+ if (DEBUG) console.log('Calling Cloudflare API:', cfUrl);
329
+
330
+ const requestBody = {
331
+ tracks: [{
332
+ mid: mid.toString()
333
+ }],
334
+ force: Boolean(force),
335
+ sessionDescription
336
+ };
337
+
338
+ if (DEBUG) console.log('Request body:', JSON.stringify(requestBody, null, 2));
339
+
340
+ const response = await fetch(cfUrl, {
341
+ method: 'PUT',
342
+ headers: {
343
+ 'Authorization': `Bearer ${CLOUDFLARE_APP_SECRET}`,
344
+ 'Content-Type': 'application/json'
345
+ },
346
+ body: JSON.stringify(requestBody)
347
+ });
348
+
349
+ const data = await response.json();
350
+ if (DEBUG) console.log('Cloudflare API response:', data);
351
+
352
+ broadcastToRoom(roomId, {
353
+ type: 'track-unpublished',
354
+ payload: { sessionId, trackName }
355
+ }, sessionId);
356
+
357
+ res.json(data);
358
+
359
+ } catch (error) {
360
+ console.error('Detailed error unpublishing track:', error);
361
+ res.status(500).json({
362
+ errorCode: 'UNPUBLISH_ERROR',
363
+ errorDescription: error.message
364
+ });
365
+ }
366
+ });
367
+
368
+ /**
369
+ * @api {post} /api/rooms/:roomId/sessions/:sessionId/pull Pull remote tracks
370
+ * @apiName PullTracks
371
+ * @apiGroup Sessions
372
+ *
373
+ * @apiParam {String} roomId The ID of the room.
374
+ * @apiParam {String} sessionId The session ID of the participant.
375
+ * @apiBody {String} remoteSessionId The session ID of the remote participant.
376
+ * @apiBody {String} trackName The exact name of the track to pull.
377
+ *
378
+ * @apiSuccess {Object} data Response from Cloudflare Calls API.
379
+ * @apiError (404) NotFound Room or Session not found.
380
+ */
381
+ app.post('/api/rooms/:roomId/sessions/:sessionId/pull', verifyToken, async (req, res) => {
382
+ const { roomId, sessionId } = req.params;
383
+ const { remoteSessionId, trackName } = req.body;
384
+
385
+ const room = rooms.get(roomId);
386
+ if (!room) {
387
+ return res.status(404).json({ error: 'Room not found' });
388
+ }
389
+
390
+ const participant = room.participants.find(p => p.sessionId === sessionId);
391
+ if (!participant) {
392
+ return res.status(404).json({ error: 'Session not found in this room' });
393
+ }
394
+
395
+ const tracksToPull = [{
396
+ location: 'remote',
397
+ sessionId: remoteSessionId,
398
+ trackName
399
+ }];
400
+
401
+ const cfResp = await fetch(`${CLOUDFLARE_BASE_PATH}/sessions/${sessionId}/tracks/new`, {
402
+ method: 'POST',
403
+ headers: {
404
+ 'Authorization': `Bearer ${CLOUDFLARE_APP_SECRET}`,
405
+ 'Content-Type': 'application/json'
406
+ },
407
+ body: JSON.stringify({ tracks: tracksToPull })
408
+ });
409
+ const data = await cfResp.json();
410
+ return res.json(data);
411
+ });
412
+
413
+ /**
414
+ * @apiDefine Error404
415
+ * @apiError 404 Room or Participant not found.
416
+ */
417
+
418
+ /**
419
+ * @apiDefine Error400
420
+ * @apiError 400 Error from Cloudflare Calls API.
421
+ */
422
+
423
+ /* ------------------------------------------------------------------
424
+ Renegotiate, Publish, and Data Channels Endpoints
425
+ ------------------------------------------------------------------ */
426
+
427
+ /**
428
+ * @api {put} /api/rooms/:roomId/sessions/:sessionId/renegotiate Renegotiate Session
429
+ * @apiName RenegotiateSession
430
+ * @apiGroup Sessions
431
+ * @apiDescription Renegotiate an existing session with new SDP offer
432
+ * @apiParam {String} roomId Room identifier
433
+ * @apiParam {String} sessionId Session identifier
434
+ * @apiBody {Object} sessionDescription WebRTC session description
435
+ * @apiBody {String} sessionDescription.sdp SDP offer
436
+ * @apiBody {String} sessionDescription.type Type of SDP message
437
+ *
438
+ * @apiSuccess {Object} data Response from Cloudflare Calls API
439
+ */
440
+ app.put('/api/rooms/:roomId/sessions/:sessionId/renegotiate', verifyToken, async (req, res) => {
441
+ const { sessionId } = req.params;
442
+ const { sdp, type } = req.body; // The client's answer
443
+ const body = {
444
+ sessionDescription: { sdp, type },
445
+ };
446
+ const cfResp = await fetch(`${CLOUDFLARE_BASE_PATH}/sessions/${sessionId}/renegotiate`, {
447
+ method: 'PUT',
448
+ headers: {
449
+ 'Authorization': `Bearer ${CLOUDFLARE_APP_SECRET}`,
450
+ 'Content-Type': 'application/json',
451
+ },
452
+ body: JSON.stringify(body),
453
+ });
454
+ const result = await cfResp.json();
455
+ if (result.errorCode) {
456
+ return res.status(400).json(result);
457
+ }
458
+ res.json(result);
459
+ });
460
+
461
+ /**
462
+ * @api {post} /api/rooms/:roomId/sessions/:sessionId/datachannels/new Manage Data Channels
463
+ * @apiName ManageDataChannels
464
+ * @apiGroup Sessions
465
+ * @apiDescription Manage data channel subscriptions
466
+ * @apiParam {String} roomId Room identifier
467
+ * @apiParam {String} sessionId Session identifier
468
+ * @apiBody {Array} dataChannels Array of data channel names
469
+ *
470
+ * @apiSuccess {Object} response Response from Cloudflare Calls API.
471
+ *
472
+ * @apiUse Error404
473
+ * @apiUse Error400
474
+ */
475
+ app.post('/api/rooms/:roomId/sessions/:sessionId/datachannels/new', verifyToken, async (req, res) => {
476
+ const { roomId, sessionId } = req.params;
477
+ const { dataChannels } = req.body;
478
+
479
+ // Check that this room and session exist in memory
480
+ const room = rooms.get(roomId);
481
+ if (!room) {
482
+ return res.status(404).json({ error: 'Room not found' });
483
+ }
484
+
485
+ // Forward this datachannels request to Cloudflare
486
+ // The official CF endpoint is:
487
+ // POST /v1/apps/:APP_ID/sessions/:sessionId/datachannels/new
488
+ // with a JSON body { dataChannels: [...] }.
489
+
490
+ const cfUrl = `${CLOUDFLARE_BASE_PATH}/sessions/${sessionId}/datachannels/new`;
491
+ const cfResp = await fetch(cfUrl, {
492
+ method: 'POST',
493
+ headers: {
494
+ 'Authorization': `Bearer ${CLOUDFLARE_APP_SECRET}`,
495
+ 'Content-Type': 'application/json'
496
+ },
497
+ body: JSON.stringify({ dataChannels })
498
+ });
499
+
500
+ const data = await cfResp.json();
501
+ if (data.errorCode) {
502
+ return res.status(400).json(data);
503
+ }
504
+
505
+ // Optionally, if the user is publishing a channel, you could record that in `participant.publishedDataChannels` in memory
506
+ dataChannels.forEach(dc => {
507
+ if (dc.location === 'local') {
508
+ // E.g. store in participant.publishedDataChannels = [...(existing), dc.dataChannelName];
509
+ }
510
+ });
511
+
512
+ res.json(data); // Return the CF Calls response to the client
513
+ });
514
+
515
+ /* ------------------------------------------------------------------
516
+ Participants and Tracks Endpoints
517
+ ------------------------------------------------------------------ */
518
+
519
+ /**
520
+ * @api {get} /api/rooms/:roomId/participants Get Participants
521
+ * @apiName GetParticipants
522
+ * @apiGroup Participants
523
+ * @apiDescription Retrieves a list of all participants in a specified room along with their published tracks.
524
+ *
525
+ * @apiParam {String} roomId The ID of the room.
526
+ *
527
+ * @apiSuccess {Object} participants An object containing an array of participants.
528
+ *
529
+ * @apiUse Error404
530
+ */
531
+ app.get('/api/rooms/:roomId/participants', verifyToken, (req, res) => {
532
+ const { roomId } = req.params;
533
+
534
+ if (!rooms.has(roomId)) {
535
+ return res.status(404).json({ error: 'Room not found' });
536
+ }
537
+
538
+ const room = rooms.get(roomId);
539
+ res.json({ participants: room.participants });
540
+ });
541
+
542
+ /**
543
+ * @api {get} /api/rooms/:roomId/participant/:sessionId/tracks Get Participant Tracks
544
+ * @apiName GetParticipantTracks
545
+ * @apiGroup Participants
546
+ * @apiDescription Retrieves a list of tracks for a specific participant in a room.
547
+ *
548
+ * @apiParam {String} roomId The ID of the room.
549
+ * @apiParam {String} sessionId The session ID of the participant.
550
+ *
551
+ * @apiSuccess {Object} publishedTracks Array of published track names.
552
+ *
553
+ * @apiUse Error404
554
+ */
555
+ app.get('/api/rooms/:roomId/participant/:sessionId/tracks', verifyToken, async (req, res) => {
556
+ const { sessionId, roomId } = req.params;
557
+
558
+ if (!rooms.has(roomId)) {
559
+ return res.status(404).json({ error: 'Room not found' });
560
+ }
561
+
562
+ const room = rooms.get(roomId);
563
+ const participant = room.participants.find(p => p.sessionId === sessionId);
564
+
565
+ if (!participant) {
566
+ return res.status(404).json({ error: 'Participant not found' });
567
+ }
568
+
569
+ res.json(participant.publishedTracks);
570
+ });
571
+
572
+ /* ------------------------------------------------------------------
573
+ ICE Servers Endpoint
574
+ ------------------------------------------------------------------ */
575
+
576
+ /**
577
+ * @api {get} /api/ice-servers Get ICE Servers
578
+ * @apiName GetICEServers
579
+ * @apiGroup ICEServers
580
+ * @apiDescription Generates TURN credentials and returns the iceServers configuration.
581
+ *
582
+ * @apiSuccess {Object} iceServers iceServers configuration.
583
+ *
584
+ * @apiError 500 Failed to generate ICE servers.
585
+ */
586
+ app.get('/api/ice-servers', verifyToken, (req, res) => {
587
+ if (!process.env.CLOUDFLARE_TURN_ID || !process.env.CLOUDFLARE_TURN_TOKEN) {
588
+ return res.json({
589
+ iceServers: [
590
+ { urls: 'stun:stun.cloudflare.com:3478' },
591
+ ]
592
+ });
593
+ }
594
+
595
+ try {
596
+ const lifetime = 600; // Credentials valid for 10 minutes (600 seconds)
597
+ const timestamp = Math.floor(Date.now() / 1000) + lifetime;
598
+ const username = `${timestamp}:${process.env.CLOUDFLARE_TURN_ID}`;
599
+
600
+ // Create HMAC-SHA256 hash using CLOUDFLARE_TURN_TOKEN as the key
601
+ const hmac = crypto.createHmac('sha256', process.env.CLOUDFLARE_TURN_TOKEN);
602
+ hmac.update(username);
603
+ const credential = hmac.digest('base64');
604
+
605
+ const iceServers = {
606
+ iceServers: [
607
+ { urls: 'stun:stun.cloudflare.com:3478' },
608
+ {
609
+ urls: 'turn:turn.cloudflare.com:3478?transport=udp',
610
+ username,
611
+ credential
612
+ },
613
+ {
614
+ urls: 'turn:turn.cloudflare.com:3478?transport=tcp',
615
+ username,
616
+ credential
617
+ },
618
+ {
619
+ urls: 'turns:turn.cloudflare.com:5349?transport=tcp',
620
+ username,
621
+ credential
622
+ }
623
+ ]
624
+ };
625
+
626
+ res.json(iceServers);
627
+ } catch (error) {
628
+ console.error('Error generating ICE servers:', error);
629
+ res.status(500).json({ error: 'Failed to generate ICE servers' });
630
+ }
631
+ });
632
+
633
+ /* ------------------------------------------------------------------
634
+ Basic WebSocket for "participant joined" etc.
635
+ ------------------------------------------------------------------ */
636
+
637
+ /**
638
+ * Sets up the WebSocket server and handles incoming connections and messages.
639
+ */
640
+ const server = http.createServer(app);
641
+ const wss = new WebSocket.Server({ server });
642
+
643
+ wss.on('connection', (ws) => {
644
+ if (DEBUG) console.log('New WebSocket connection.');
645
+ // ws.setNoDelay(true);
646
+
647
+ ws.isAuthenticated = false;
648
+
649
+ ws.on('message', (message) => {
650
+ let data;
651
+ try {
652
+ data = JSON.parse(message);
653
+ } catch {
654
+ console.warn('Received invalid JSON message via WebSocket.');
655
+ return;
656
+ }
657
+ switch (data.type) {
658
+ case 'join-websocket':
659
+ handleWSJoin(ws, data.payload);
660
+ break;
661
+ case 'data-message':
662
+ if (AUTH_REQUIRED && !ws.isAuthenticated) {
663
+ ws.send(JSON.stringify({ error: 'Unauthorized: Please authenticate first' }));
664
+ if (DEBUG) console.log('Unauthenticated websocket request to send data-message');
665
+ return;
666
+ }
667
+ handleDataMessage(ws, data.payload);
668
+ break;
669
+ default:
670
+ console.warn(`Unknown message type: ${data.type}`);
671
+ break;
672
+ }
673
+ });
674
+ ws.on('close', () => handleWSDisconnect(ws));
675
+ });
676
+
677
+ /**
678
+ * Handles incoming data messages from clients and broadcasts them.
679
+ * @param {WebSocket} ws - The WebSocket connection from the sender.
680
+ * @param {Object} payload - The payload containing from, to, and message.
681
+ */
682
+ function handleDataMessage(ws, payload) {
683
+ const { from, to, message } = payload;
684
+ if (!from || !message) {
685
+ console.warn('Invalid data-message payload:', payload);
686
+ return;
687
+ }
688
+
689
+ // Get room ID from the session ID
690
+ const roomId = getRoomIdBySessionId(from);
691
+ if (!roomId) {
692
+ console.warn(`Room not found for session: ${from}`);
693
+ return;
694
+ }
695
+
696
+ // Broadcast to all participants in the room except the sender
697
+ broadcastToRoom(roomId, {
698
+ type: 'data-message',
699
+ payload: {
700
+ from,
701
+ data: message
702
+ }
703
+ }, from);
704
+ }
705
+
706
+ /**
707
+ * Utility function to get roomId by userId.
708
+ * Assumes each user is in only one room.
709
+ * @param {string} userId - The user's unique identifier.
710
+ * @returns {string|null} - The room ID if found, otherwise null.
711
+ */
712
+ function getRoomIdByUserId(userId) {
713
+ for (const [roomId, room] of rooms.entries()) {
714
+ if (room.participants.find(p => p.userId === userId)) {
715
+ return roomId;
716
+ }
717
+ }
718
+ return null;
719
+ }
720
+
721
+ /**
722
+ * Utility function to get WebSocket connection by userId.
723
+ * @param {string} userId - The user's unique identifier.
724
+ * @returns {WebSocket|null} - The WebSocket connection if found, otherwise null.
725
+ */
726
+ function getWebSocketByUserId(userId) {
727
+ for (const users of Object.values(wsConnections)) {
728
+ if (users[userId]) {
729
+ return users[userId];
730
+ }
731
+ }
732
+ return null;
733
+ }
734
+
735
+ /**
736
+ * Handles a WebSocket join request by authenticating and adding the user to wsConnections.
737
+ * @param {WebSocket} ws - The WebSocket connection.
738
+ * @param {Object} payload - The payload containing roomId, userId, and token.
739
+ * @param {string} payload.roomId - The ID of the room to join.
740
+ * @param {string} payload.userId - The user's unique identifier.
741
+ * @param {string} payload.token - The JWT token for authentication.
742
+ */
743
+ function handleWSJoin(ws, { roomId, userId, token }) {
744
+ if (!roomId || !userId || (AUTH_REQUIRED && !token)) {
745
+ console.warn('Missing roomId, userId, or token in WS join');
746
+ ws.send(JSON.stringify({ error: 'Missing roomId, userId, or token' }));
747
+ return;
748
+ }
749
+
750
+ try {
751
+ // Verify the token
752
+ if (AUTH_REQUIRED) {
753
+ const user = jwt.verify(token, SECRET_KEY);
754
+ }
755
+
756
+ ws.isAuthenticated = true;
757
+
758
+ // Add user to the room
759
+ if (!wsConnections[roomId]) {
760
+ wsConnections[roomId] = {};
761
+ }
762
+ wsConnections[roomId][userId] = ws;
763
+
764
+ if (DEBUG) console.log(`User ${userId} joined room ${roomId} via WS`);
765
+ ws.send(JSON.stringify({ message: 'Joined room successfully' }));
766
+ } catch (err) {
767
+ if (DEBUG) console.warn('Invalid token in WS join:', err.message);
768
+ ws.send(JSON.stringify({ error: 'Invalid or expired token' }));
769
+ }
770
+ }
771
+
772
+ /**
773
+ * Handles WebSocket disconnections by removing the user from wsConnections.
774
+ * @param {WebSocket} ws - The WebSocket connection that was closed.
775
+ */
776
+ function handleWSDisconnect(ws) {
777
+ for (const [rId, userMap] of Object.entries(wsConnections)) {
778
+ for (const [uId, sock] of Object.entries(userMap)) {
779
+ if (sock === ws) {
780
+ if (DEBUG) console.log(`User ${uId} disconnected from room ${rId}`);
781
+ delete wsConnections[rId][uId];
782
+ }
783
+ }
784
+ }
785
+ }
786
+
787
+ /**
788
+ * Broadcasts a message to all participants in a room, optionally excluding a specific user.
789
+ * @param {string} roomId - The ID of the room.
790
+ * @param {Object} message - The message object to broadcast.
791
+ * @param {string|null} excludeUserId - The user ID to exclude from broadcasting.
792
+ */
793
+ function broadcastToRoom(roomId, message, excludeUserId = null) {
794
+ if (DEBUG) console.log('Broadcasting to room:', { roomId, message, excludeUserId });
795
+ if (!rooms.has(roomId)) return;
796
+
797
+ if (!wsConnections[roomId]) return;
798
+ for (const [userId, ws] of Object.entries(wsConnections[roomId])) {
799
+ if (userId === excludeUserId) continue;
800
+ if (ws && ws.readyState === WebSocket.OPEN) {
801
+ ws.send(JSON.stringify(message));
802
+ if (DEBUG) console.log('Sent Broadcast message to user:', userId);
803
+ }
804
+ }
805
+ }
806
+
807
+ /**
808
+ * @api {get} /api/rooms/:roomId/sessions/:sessionId/state Get Session State
809
+ * @apiName GetSessionState
810
+ * @apiGroup Sessions
811
+ * @apiDescription Retrieves the current state of a session from Cloudflare Calls API.
812
+ *
813
+ * @apiParam {String} roomId The ID of the room.
814
+ * @apiParam {String} sessionId The session ID to query.
815
+ *
816
+ * @apiSuccess {Object} response Session state from Cloudflare Calls API.
817
+ * @apiSuccess {Array} response.tracks List of tracks in the session.
818
+ * @apiSuccess {String} response.tracks.location Track location ('local' or 'remote').
819
+ * @apiSuccess {String} response.tracks.mid Media ID of the track.
820
+ * @apiSuccess {String} response.tracks.trackName Name/ID of the track.
821
+ * @apiSuccess {String} response.tracks.status Track status ('active', 'inactive', or 'waiting').
822
+ *
823
+ * @apiError (500) SessionStateError Failed to retrieve session state.
824
+ * @apiError (403) Forbidden Invalid or missing authentication token.
825
+ */
826
+ app.get('/api/rooms/:roomId/sessions/:sessionId/state', verifyToken, async (req, res) => {
827
+ const { roomId, sessionId } = req.params;
828
+
829
+ try {
830
+ const response = await fetch(`${CLOUDFLARE_BASE_PATH}/sessions/${sessionId}`, {
831
+ headers: {
832
+ 'Authorization': `Bearer ${CLOUDFLARE_APP_SECRET}`
833
+ }
834
+ });
835
+
836
+ const data = await response.json();
837
+ res.json(data);
838
+ } catch (error) {
839
+ console.error('Error getting session state:', error);
840
+ res.status(500).json({
841
+ errorCode: 'SESSION_STATE_ERROR',
842
+ errorDescription: error.message
843
+ });
844
+ }
845
+ });
846
+
847
+ /**
848
+ * @api {get} /api/users/:userId Get User Info
849
+ * @apiName GetUserInfo
850
+ * @apiGroup Users
851
+ * @apiDescription Get information about a user. Returns full info for own user, limited info for others.
852
+ *
853
+ * @apiParam {String} userId User ID or 'me' for current user
854
+ * @apiHeader {String} Authorization Bearer token required
855
+ *
856
+ * @apiSuccess {String} userId User's unique identifier
857
+ * @apiSuccess {String} username User's display name
858
+ * @apiSuccess {Boolean} [isModerator] Whether user is moderator (only included for own user)
859
+ * @apiSuccess {String} [role] User's role (only included for own user)
860
+ *
861
+ * @apiError (403) Forbidden Invalid or missing token
862
+ * @apiError (404) NotFound User not found
863
+ */
864
+ app.get('/api/users/:userId', verifyToken, (req, res) => {
865
+ const { userId } = req.params;
866
+
867
+ // Handle 'me' request
868
+ if (userId === 'me') {
869
+ const userInfo = users.get(req.user.userId);
870
+ if (!userInfo) {
871
+ return res.status(404).json({
872
+ errorCode: 'USER_NOT_FOUND',
873
+ errorDescription: 'Current user not found'
874
+ });
875
+ }
876
+ return res.json(userInfo);
877
+ }
878
+
879
+ // Handle specific user request
880
+ const requestedUser = users.get(userId);
881
+ if (!requestedUser) {
882
+ return res.status(404).json({
883
+ errorCode: 'USER_NOT_FOUND',
884
+ errorDescription: 'User not found'
885
+ });
886
+ }
887
+
888
+ // Return limited info for other users
889
+ return res.json({
890
+ userId: requestedUser.userId,
891
+ username: requestedUser.username
892
+ });
893
+ });
894
+
895
+ /**
896
+ * @api {get} /api/users/:userId Get User Info
897
+ * @apiName GetUserInfo
898
+ * @apiGroup Users
899
+ * @apiDescription Get information about a user. Returns full info for own user, limited info for others.
900
+ *
901
+ * @apiParam {String} userId User ID or 'me' for current user
902
+ * @apiHeader {String} Authorization Bearer token required
903
+ *
904
+ * @apiSuccess {String} userId User's unique identifier
905
+ * @apiSuccess {String} username User's display name
906
+ * @apiSuccess {Boolean} [isModerator] Whether user is moderator (only included for own user)
907
+ * @apiSuccess {String} [role] User's role (only included for own user)
908
+ *
909
+ * @apiError (403) Forbidden Invalid or missing token
910
+ * @apiError (404) NotFound User not found
911
+ */
912
+ app.get('/api/users/:userId', verifyToken, (req, res) => {
913
+ const { userId } = req.params;
914
+
915
+ // Handle 'me' request
916
+ if (userId === 'me') {
917
+ const userInfo = users.get(req.user.userId);
918
+ if (!userInfo) {
919
+ return res.status(404).json({
920
+ errorCode: 'USER_NOT_FOUND',
921
+ errorDescription: 'Current user not found'
922
+ });
923
+ }
924
+ return res.json(userInfo);
925
+ }
926
+
927
+ // Handle specific user request
928
+ const requestedUser = users.get(userId);
929
+ if (!requestedUser) {
930
+ return res.status(404).json({
931
+ errorCode: 'USER_NOT_FOUND',
932
+ errorDescription: 'User not found'
933
+ });
934
+ }
935
+
936
+ // Return limited info for other users
937
+ return res.json({
938
+ userId: requestedUser.userId,
939
+ username: requestedUser.username
940
+ });
941
+ });
942
+
943
+ app.post('/api/rooms/:roomId/leave', verifyToken, async (req, res) => {
944
+ const { roomId } = req.params;
945
+ const { sessionId } = req.body;
946
+
947
+ if (!rooms.has(roomId)) {
948
+ return res.status(404).json({ error: 'Room not found' });
949
+ }
950
+
951
+ const room = rooms.get(roomId);
952
+ const participantIndex = room.participants.findIndex(p => p.sessionId === sessionId);
953
+
954
+ if (participantIndex !== -1) {
955
+ const participant = room.participants[participantIndex];
956
+ room.participants.splice(participantIndex, 1);
957
+
958
+ // Notify other participants about the leave
959
+ broadcastToRoom(roomId, {
960
+ type: 'participant-left',
961
+ payload: {
962
+ sessionId,
963
+ userId: participant.userId,
964
+ metadata: participant.metadata
965
+ }
966
+ }, sessionId);
967
+
968
+ // If room is empty, delete it
969
+ if (room.participants.length === 0) {
970
+ rooms.delete(roomId);
971
+ }
972
+ }
973
+
974
+ res.json({ success: true });
975
+ });
976
+
977
+ process.on('SIGINT', () => {
978
+ users.clear();
979
+ process.exit();
980
+ });
981
+
982
+ server.listen(port, () => {
983
+ console.log(`Server listening on http://localhost:${port}`);
984
+ });
985
+
986
+ /**
987
+ * @api {post} /api/rooms/:roomId/sessions/:sessionId/track-status Update Track Status
988
+ * @apiName UpdateTrackStatus
989
+ * @apiGroup Sessions
990
+ * @apiDescription Updates the enabled/disabled status of a track
991
+ *
992
+ * @apiParam {String} roomId The ID of the room
993
+ * @apiParam {String} sessionId The session ID
994
+ * @apiBody {String} trackId The track ID
995
+ * @apiBody {String} kind The track kind ('audio' or 'video')
996
+ * @apiBody {Boolean} enabled Whether the track should be enabled
997
+ * @apiBody {Boolean} [force] Whether to force the status change
998
+ *
999
+ * @apiSuccess {Object} result Operation result
1000
+ * @apiError (403) Forbidden Not authorized to update track status
1001
+ */
1002
+ app.post('/api/rooms/:roomId/sessions/:sessionId/track-status', verifyToken, async (req, res) => {
1003
+ try {
1004
+ const { roomId, sessionId } = req.params;
1005
+ const { trackId, kind, enabled, force } = req.body;
1006
+
1007
+ // If trying to force change someone else's track
1008
+ if (force && sessionId !== req.user.sessionId) {
1009
+ if (!req.user.isModerator) {
1010
+ return res.status(403).json({
1011
+ errorCode: 'NOT_AUTHORIZED',
1012
+ errorDescription: 'Only moderators can force change other participants\' tracks'
1013
+ });
1014
+ }
1015
+ }
1016
+
1017
+ // Notify other participants about the track status change
1018
+ broadcastToRoom(roomId, {
1019
+ type: 'track-status-changed',
1020
+ payload: {
1021
+ sessionId,
1022
+ trackId,
1023
+ kind,
1024
+ enabled
1025
+ }
1026
+ }, sessionId);
1027
+
1028
+ res.json({ success: true });
1029
+ } catch (error) {
1030
+ console.error('Error updating track status:', error);
1031
+ res.status(500).json({
1032
+ errorCode: 'UPDATE_TRACK_STATUS_ERROR',
1033
+ errorDescription: error.message
1034
+ });
1035
+ }
1036
+ });
1037
+
1038
+ app.get('/api/rooms', verifyToken, (req, res) => {
1039
+ const roomList = Array.from(rooms.entries()).map(([roomId, room]) =>
1040
+ serializeRoom(roomId, room)
1041
+ );
1042
+
1043
+ res.json({ rooms: roomList });
1044
+ });
1045
+
1046
+ app.put('/api/rooms/:roomId/metadata', verifyToken, (req, res) => {
1047
+ const { roomId } = req.params;
1048
+ const { name, metadata } = req.body;
1049
+
1050
+ if (!rooms.has(roomId)) {
1051
+ return res.status(404).json({ error: 'Room not found' });
1052
+ }
1053
+
1054
+ const room = rooms.get(roomId);
1055
+
1056
+ if (name !== undefined) {
1057
+ room.name = name;
1058
+ }
1059
+
1060
+ if (metadata !== undefined) {
1061
+ room.metadata = { ...room.metadata, ...metadata };
1062
+ }
1063
+
1064
+ rooms.set(roomId, room);
1065
+
1066
+ // Notify room participants about the update
1067
+ broadcastToRoom(roomId, {
1068
+ type: 'room-metadata-updated',
1069
+ payload: {
1070
+ roomId,
1071
+ name: room.name,
1072
+ metadata: room.metadata
1073
+ }
1074
+ });
1075
+
1076
+ res.json(serializeRoom(roomId, room));
1077
+ });
1078
+
1079
+ function getRoomIdBySessionId(sessionId) {
1080
+ for (const [roomId, room] of rooms.entries()) {
1081
+ if (room.participants.find(p => p.sessionId === sessionId)) {
1082
+ return roomId;
1083
+ }
1084
+ }
1085
+ return null;
1086
+ }
jsdoc.json ADDED
@@ -0,0 +1,21 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ {
2
+ "tags": {
3
+ "allowUnknownTags": true
4
+ },
5
+ "source": {
6
+ "include": ["index.js", "public/CloudflareCalls.js"],
7
+ "includePattern": ".js$",
8
+ "excludePattern": "(node_modules|docs)"
9
+ },
10
+ "opts": {
11
+ "destination": "./public/docs",
12
+ "recurse": true,
13
+ "template": "node_modules/docdash",
14
+ "readme": "./README.md"
15
+ },
16
+ "plugins": [],
17
+ "templates": {
18
+ "cleverLinks": true,
19
+ "monospaceLinks": true
20
+ }
21
+ }
package.json ADDED
@@ -0,0 +1,29 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ {
2
+ "name": "cloudflare-calls",
3
+ "version": "0.1.4",
4
+ "description": "A Reference implementation for Cloudflare Calls",
5
+ "module": "public/CloudflareCalls.js",
6
+ "browser": "public/CloudflareCalls.min.js",
7
+ "scripts": {
8
+ "test": "echo \"Error: no test specified\" && exit 1",
9
+ "rollup": "rollup public/CloudflareCalls.js --file public/CloudflareCalls.min.js --format umd --name \"CloudflareCalls\" --plugin @rollup/plugin-terser",
10
+ "apidocs": "apidoc -i ./ -e \"(node_modules|public)\" -o public/docs/api",
11
+ "docs": "jsdoc -c jsdoc.json",
12
+ "build": "npm run docs && mkdir -p public/docs/api && npm run apidocs && npm run rollup",
13
+ "start": "node ."
14
+ },
15
+ "author": "",
16
+ "license": "MIT",
17
+ "dependencies": {
18
+ "@rollup/plugin-terser": "^0.4.4",
19
+ "apidoc": "^1.2.0",
20
+ "docdash": "^2.0.2",
21
+ "dotenv": "^16.4.7",
22
+ "express": "^4.21.2",
23
+ "jsdoc": "^4.0.4",
24
+ "jsonwebtoken": "^9.0.2",
25
+ "node-fetch": "^2.6.7",
26
+ "rollup": "^4.29.1",
27
+ "ws": "^8.18.0"
28
+ }
29
+ }
public/.gitattributes ADDED
@@ -0,0 +1,36 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ *.7z filter=lfs diff=lfs merge=lfs -text
2
+ *.arrow filter=lfs diff=lfs merge=lfs -text
3
+ *.bin filter=lfs diff=lfs merge=lfs -text
4
+ *.bz2 filter=lfs diff=lfs merge=lfs -text
5
+ *.ckpt filter=lfs diff=lfs merge=lfs -text
6
+ *.ftz filter=lfs diff=lfs merge=lfs -text
7
+ *.gz filter=lfs diff=lfs merge=lfs -text
8
+ *.h5 filter=lfs diff=lfs merge=lfs -text
9
+ *.joblib filter=lfs diff=lfs merge=lfs -text
10
+ *.lfs.* filter=lfs diff=lfs merge=lfs -text
11
+ *.mlmodel filter=lfs diff=lfs merge=lfs -text
12
+ *.model filter=lfs diff=lfs merge=lfs -text
13
+ *.msgpack filter=lfs diff=lfs merge=lfs -text
14
+ *.npy filter=lfs diff=lfs merge=lfs -text
15
+ *.npz filter=lfs diff=lfs merge=lfs -text
16
+ *.onnx filter=lfs diff=lfs merge=lfs -text
17
+ *.ot filter=lfs diff=lfs merge=lfs -text
18
+ *.parquet filter=lfs diff=lfs merge=lfs -text
19
+ *.pb filter=lfs diff=lfs merge=lfs -text
20
+ *.pickle filter=lfs diff=lfs merge=lfs -text
21
+ *.pkl filter=lfs diff=lfs merge=lfs -text
22
+ *.pt filter=lfs diff=lfs merge=lfs -text
23
+ *.pth filter=lfs diff=lfs merge=lfs -text
24
+ *.rar filter=lfs diff=lfs merge=lfs -text
25
+ *.safetensors filter=lfs diff=lfs merge=lfs -text
26
+ saved_model/**/* filter=lfs diff=lfs merge=lfs -text
27
+ *.tar.* filter=lfs diff=lfs merge=lfs -text
28
+ *.tar filter=lfs diff=lfs merge=lfs -text
29
+ *.tflite filter=lfs diff=lfs merge=lfs -text
30
+ *.tgz filter=lfs diff=lfs merge=lfs -text
31
+ *.wasm filter=lfs diff=lfs merge=lfs -text
32
+ *.xz filter=lfs diff=lfs merge=lfs -text
33
+ *.zip filter=lfs diff=lfs merge=lfs -text
34
+ *.zst filter=lfs diff=lfs merge=lfs -text
35
+ *tfevents* filter=lfs diff=lfs merge=lfs -text
36
+ *.png filter=lfs diff=lfs merge=lfs -text
public/README.md ADDED
@@ -0,0 +1,10 @@
 
 
 
 
 
 
 
 
 
 
 
1
+ ---
2
+ title: Meeting Dapp Frontend
3
+ emoji: 🌍
4
+ colorFrom: blue
5
+ colorTo: blue
6
+ sdk: static
7
+ pinned: false
8
+ ---
9
+
10
+ Check out the configuration reference at https://huggingface.co/docs/hub/spaces-config-reference
public/assets/mask/basic/mask1.png ADDED

Git LFS Details

  • SHA256: a9b239f3f10cf09f20249d87e67716a094fee1eaaf8ec6cc1ba71daf283a5e39
  • Pointer size: 130 Bytes
  • Size of remote file: 71 kB
public/assets/mask/basic/mask2.png ADDED

Git LFS Details

  • SHA256: d88269d246286dcb36126c3839ba428eaac2f3baf1502b27b801faa4296eb19b
  • Pointer size: 130 Bytes
  • Size of remote file: 76.6 kB
public/assets/mask/basic/mask3.png ADDED

Git LFS Details

  • SHA256: dcc24e24098acfc816d19bcd97037b019a66504872a009f7a6fb3562bfe54bf0
  • Pointer size: 130 Bytes
  • Size of remote file: 28 kB
public/assets/mask/mask.png ADDED

Git LFS Details

  • SHA256: a9b239f3f10cf09f20249d87e67716a094fee1eaaf8ec6cc1ba71daf283a5e39
  • Pointer size: 130 Bytes
  • Size of remote file: 71 kB
public/assets/mask/medicel/mask1.png ADDED

Git LFS Details

  • SHA256: 116782f472ff822bfe7460b25df2079baf499bf4a9741ed7e0655e9ccfe1be6a
  • Pointer size: 132 Bytes
  • Size of remote file: 1.35 MB
public/assets/mask/medicel/mask2.png ADDED

Git LFS Details

  • SHA256: 4e70f4634ee9d9c0554c6b3179ec06568fa7215fb3dc878acc53ef370a492515
  • Pointer size: 131 Bytes
  • Size of remote file: 164 kB
public/assets/mask/medicel/mask3.png ADDED

Git LFS Details

  • SHA256: 14b16c0f1605c40dbcb0ffbaf34b12d095af5970b1d262b2a8f25cadf62d0172
  • Pointer size: 130 Bytes
  • Size of remote file: 47.7 kB
public/check.html ADDED
@@ -0,0 +1,481 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ <!DOCTYPE html>
2
+ <html lang="en">
3
+ <head>
4
+ <meta charset="UTF-8">
5
+ <meta name="viewport" content="width=device-width, initial-scale=1.0">
6
+ <title>Check Your Devices</title>
7
+ <link href="https://fonts.googleapis.com/css2?family=Poppins:wght@400;500;600&display=swap" rel="stylesheet">
8
+ <link href="https://fonts.googleapis.com/icon?family=Material+Icons" rel="stylesheet">
9
+ <style>
10
+ * {
11
+ margin: 0;
12
+ padding: 0;
13
+ box-sizing: border-box;
14
+ font-family: 'Poppins', sans-serif;
15
+ }
16
+
17
+ body {
18
+ background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
19
+ height: 100vh;
20
+ display: flex;
21
+ align-items: center;
22
+ justify-content: center;
23
+ padding: 0;
24
+ margin: 0;
25
+ overflow: hidden;
26
+
27
+ }
28
+
29
+ .container {
30
+ max-width: 1200px;
31
+ width: 100%;
32
+ height: 90vh;
33
+ background: rgba(255, 255, 255, 0.95);
34
+ padding: 20px;
35
+ display: flex;
36
+ gap: 20px;
37
+ border-radius: 0;
38
+ box-shadow: none;
39
+ border-radius: 20px;
40
+ }
41
+
42
+ .video-section {
43
+ flex: 1;
44
+ min-width: 0;
45
+ display: flex;
46
+ flex-direction: column;
47
+ }
48
+
49
+ .controls-section {
50
+ width: 350px;
51
+ flex-shrink: 0;
52
+ overflow-y: auto;
53
+ padding-right: 10px;
54
+ }
55
+
56
+ h2 {
57
+ text-align: left;
58
+ color: #333;
59
+ margin-bottom: 30px;
60
+ font-size: 24px;
61
+ }
62
+
63
+ .room-info {
64
+ background: #f8f9fa;
65
+ padding: 15px;
66
+ border-radius: 12px;
67
+ margin-bottom: 20px;
68
+ }
69
+
70
+ .room-info p {
71
+ margin: 10px 0;
72
+ color: #666;
73
+ }
74
+
75
+ .room-info span {
76
+ color: #333;
77
+ font-weight: 500;
78
+ }
79
+
80
+ .preview-container {
81
+ position: relative;
82
+ width: 100%;
83
+ flex: 1;
84
+ margin-bottom: 0;
85
+ height: auto;
86
+ background: #000;
87
+ border-radius: 12px;
88
+ overflow: hidden;
89
+ }
90
+
91
+ #previewVideo {
92
+ width: 100%;
93
+ height: 100%;
94
+ object-fit: cover;
95
+ }
96
+
97
+ .controls {
98
+ position: absolute;
99
+ bottom: 20px;
100
+ left: 50%;
101
+ transform: translateX(-50%);
102
+ display: flex;
103
+ gap: 15px;
104
+ background: rgba(0, 0, 0, 0.5);
105
+ padding: 12px;
106
+ border-radius: 30px;
107
+ }
108
+
109
+ .control-btn {
110
+ width: 45px;
111
+ height: 45px;
112
+ border-radius: 50%;
113
+ border: none;
114
+ background: white;
115
+ cursor: pointer;
116
+ display: flex;
117
+ align-items: center;
118
+ justify-content: center;
119
+ transition: all 0.3s ease;
120
+ }
121
+
122
+ .control-btn:hover {
123
+ background: #f0f0f0;
124
+ transform: scale(1.1);
125
+ }
126
+
127
+ .control-btn .material-icons {
128
+ font-size: 20px;
129
+ }
130
+
131
+ .device-selection {
132
+ display: flex;
133
+ flex-direction: column;
134
+ gap: 20px;
135
+ margin: 20px 0;
136
+ }
137
+
138
+ .form-group {
139
+ display: flex;
140
+ flex-direction: column;
141
+ gap: 8px;
142
+ }
143
+
144
+ .form-group label {
145
+ font-weight: 500;
146
+ color: #333;
147
+ font-size: 14px;
148
+ }
149
+
150
+ .form-group select {
151
+ padding: 12px;
152
+ border: 2px solid #e1e1e1;
153
+ border-radius: 8px;
154
+ font-size: 14px;
155
+ transition: all 0.3s ease;
156
+ }
157
+
158
+ .form-group select:focus {
159
+ border-color: #667eea;
160
+ outline: none;
161
+ }
162
+
163
+ .audio-meter {
164
+ width: 100%;
165
+ height: 8px;
166
+ background: #f0f0f0;
167
+ border-radius: 4px;
168
+ overflow: hidden;
169
+ margin: 20px 0;
170
+ }
171
+
172
+ #volumeMeter {
173
+ height: 100%;
174
+ width: 0%;
175
+ background: linear-gradient(90deg, #667eea, #764ba2);
176
+ transition: width 0.1s ease;
177
+ }
178
+
179
+ .button-group {
180
+ display: flex;
181
+ gap: 15px;
182
+ margin-top: 30px;
183
+ }
184
+
185
+ .primary-btn, .secondary-btn {
186
+ flex: 1;
187
+ padding: 12px 24px;
188
+ border: none;
189
+ border-radius: 8px;
190
+ font-size: 16px;
191
+ cursor: pointer;
192
+ transition: all 0.3s ease;
193
+ text-align: center;
194
+ font-weight: 500;
195
+ }
196
+
197
+ .primary-btn {
198
+ background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
199
+ color: white;
200
+ }
201
+
202
+ .primary-btn:hover {
203
+ transform: translateY(-2px);
204
+ box-shadow: 0 5px 15px rgba(102,126,234,0.4);
205
+ }
206
+
207
+ .secondary-btn {
208
+ background: #e0e0e0;
209
+ color: #333;
210
+ }
211
+
212
+ .secondary-btn:hover {
213
+ background: #d5d5d5;
214
+ }
215
+
216
+ @media (max-width: 968px) {
217
+ .container {
218
+ flex-direction: column;
219
+ overflow-y: auto;
220
+ }
221
+
222
+ .controls-section {
223
+ width: 100%;
224
+ flex: 1;
225
+ }
226
+
227
+ .preview-container {
228
+ height: 50vh;
229
+ flex: none;
230
+ }
231
+ }
232
+
233
+ @media (max-width: 768px) {
234
+ .device-selection {
235
+ grid-template-columns: 1fr;
236
+ }
237
+ }
238
+ </style>
239
+ </head>
240
+ <body>
241
+ <div class="container">
242
+ <div class="video-section">
243
+ <div class="preview-container">
244
+ <video id="previewVideo" autoplay muted playsinline></video>
245
+ <div class="controls">
246
+ <button id="toggleAudioBtn" class="control-btn">
247
+ <span class="material-icons">mic</span>
248
+ </button>
249
+ <button id="toggleVideoBtn" class="control-btn">
250
+ <span class="material-icons">videocam</span>
251
+ </button>
252
+ </div>
253
+ </div>
254
+ </div>
255
+
256
+ <div class="controls-section">
257
+ <h2>Device Check</h2>
258
+
259
+ <div class="room-info">
260
+ <p>Room ID: <span id="roomIdDisplay"></span></p>
261
+ <p>Username: <span id="usernameDisplay"></span></p>
262
+ </div>
263
+
264
+ <div class="device-selection">
265
+ <div class="form-group">
266
+ <label>Camera</label>
267
+ <select id="videoSelect"></select>
268
+ </div>
269
+ <div class="form-group">
270
+ <label>Microphone</label>
271
+ <select id="audioSelect" ></select>
272
+ </div>
273
+ </div>
274
+
275
+ <div class="audio-meter">
276
+ <div id="volumeMeter"></div>
277
+ </div>
278
+
279
+ <div class="button-group">
280
+ <button class="secondary-btn" onclick="window.location.href='index.html'">Back</button>
281
+ <button id="joinMeetingBtn" class="primary-btn">Join Meeting</button>
282
+ </div>
283
+ </div>
284
+ </div>
285
+
286
+ <script>
287
+ let stream = null;
288
+ let audioContext = null;
289
+ let audioAnalyser = null;
290
+
291
+ // Get stored information from localStorage
292
+ const username = localStorage.getItem('username');
293
+ const roomId = localStorage.getItem('roomId');
294
+
295
+ // Display stored information
296
+ document.getElementById('usernameDisplay').textContent = username || 'Not set';
297
+ document.getElementById('roomIdDisplay').textContent = roomId || 'Not set';
298
+
299
+ // Initialize devices
300
+ async function initializeDevices() {
301
+ try {
302
+ // Get initial access to media devices
303
+ stream = await navigator.mediaDevices.getUserMedia({
304
+ video: true,
305
+ audio: true
306
+ });
307
+
308
+ // Show preview
309
+ const videoElement = document.getElementById('previewVideo');
310
+ videoElement.srcObject = stream;
311
+
312
+ // Setup audio meter
313
+ setupAudioMeter();
314
+
315
+ // Enumerate and populate device lists
316
+ const devices = await navigator.mediaDevices.enumerateDevices();
317
+
318
+ const videoSelect = document.getElementById('videoSelect');
319
+ const audioSelect = document.getElementById('audioSelect');
320
+
321
+ // Clear existing options
322
+ videoSelect.innerHTML = '';
323
+ audioSelect.innerHTML = '';
324
+
325
+ // Add video devices
326
+ devices.filter(device => device.kind === 'videoinput')
327
+ .forEach(device => {
328
+ const option = document.createElement('option');
329
+ option.value = device.deviceId;
330
+ option.text = device.label || `Camera ${videoSelect.length + 1}`;
331
+ videoSelect.appendChild(option);
332
+ });
333
+
334
+ // Add audio devices
335
+ devices.filter(device => device.kind === 'audioinput')
336
+ .forEach(device => {
337
+ const option = document.createElement('option');
338
+ option.value = device.deviceId;
339
+ option.text = device.label || `Microphone ${audioSelect.length + 1}`;
340
+ audioSelect.appendChild(option);
341
+ });
342
+
343
+ } catch (error) {
344
+ console.error('Error accessing media devices:', error);
345
+ alert('Failed to access camera or microphone');
346
+ }
347
+ }
348
+
349
+ function setupAudioMeter() {
350
+ if (!stream) return;
351
+
352
+ audioContext = new AudioContext();
353
+ const microphone = audioContext.createMediaStreamSource(stream);
354
+ audioAnalyser = audioContext.createAnalyser();
355
+
356
+ microphone.connect(audioAnalyser);
357
+ audioAnalyser.fftSize = 256;
358
+
359
+ const bufferLength = audioAnalyser.frequencyBinCount;
360
+ const dataArray = new Uint8Array(bufferLength);
361
+
362
+ function updateMeter() {
363
+ if (!audioAnalyser) return;
364
+
365
+ audioAnalyser.getByteFrequencyData(dataArray);
366
+ const average = dataArray.reduce((a, b) => a + b) / bufferLength;
367
+ const volume = Math.min(100, (average / 128) * 100);
368
+
369
+ document.getElementById('volumeMeter').style.width = `${volume}%`;
370
+ requestAnimationFrame(updateMeter);
371
+ }
372
+
373
+ updateMeter();
374
+ }
375
+
376
+ // Device switching handlers
377
+ document.getElementById('videoSelect').onchange = async (e) => {
378
+ if (!stream) return;
379
+
380
+ try {
381
+ const newStream = await navigator.mediaDevices.getUserMedia({
382
+ video: { deviceId: { exact: e.target.value } },
383
+ audio: false
384
+ });
385
+
386
+ const oldTrack = stream.getVideoTracks()[0];
387
+ const newTrack = newStream.getVideoTracks()[0];
388
+
389
+ stream.removeTrack(oldTrack);
390
+ stream.addTrack(newTrack);
391
+
392
+ document.getElementById('previewVideo').srcObject = stream;
393
+ } catch (error) {
394
+ console.error('Error switching camera:', error);
395
+ alert('Failed to switch camera');
396
+ }
397
+ };
398
+
399
+ document.getElementById('audioSelect').onchange = async (e) => {
400
+ if (!stream) return;
401
+
402
+ try {
403
+ const newStream = await navigator.mediaDevices.getUserMedia({
404
+ audio: { deviceId: { exact: e.target.value } },
405
+ video: false
406
+ });
407
+
408
+ const oldTrack = stream.getAudioTracks()[0];
409
+ const newTrack = newStream.getAudioTracks()[0];
410
+
411
+ stream.removeTrack(oldTrack);
412
+ stream.addTrack(newTrack);
413
+
414
+ // Reinitialize audio meter
415
+ if (audioContext) {
416
+ audioContext.close();
417
+ }
418
+ setupAudioMeter();
419
+ } catch (error) {
420
+ console.error('Error switching microphone:', error);
421
+ alert('Failed to switch microphone');
422
+ }
423
+ };
424
+
425
+ // Toggle controls
426
+ document.getElementById('toggleAudioBtn').onclick = () => {
427
+ const audioTrack = stream?.getAudioTracks()[0];
428
+ if (audioTrack) {
429
+ audioTrack.enabled = !audioTrack.enabled;
430
+ document.querySelector('#toggleAudioBtn .material-icons').textContent =
431
+ audioTrack.enabled ? 'mic' : 'mic_off';
432
+ }
433
+ };
434
+
435
+ document.getElementById('toggleVideoBtn').onclick = () => {
436
+ const videoTrack = stream?.getVideoTracks()[0];
437
+ if (videoTrack) {
438
+ videoTrack.enabled = !videoTrack.enabled;
439
+ document.querySelector('#toggleVideoBtn .material-icons').textContent =
440
+ videoTrack.enabled ? 'videocam' : 'videocam_off';
441
+ }
442
+ };
443
+
444
+ // Join meeting handler
445
+ document.getElementById('joinMeetingBtn').onclick = () => {
446
+ // Store selected devices and their states
447
+ const deviceSettings = {
448
+ audioDeviceId: document.getElementById('audioSelect').value,
449
+ videoDeviceId: document.getElementById('videoSelect').value,
450
+ audioEnabled: stream?.getAudioTracks()[0]?.enabled ?? true,
451
+ videoEnabled: stream?.getVideoTracks()[0]?.enabled ?? true
452
+ };
453
+ localStorage.setItem('deviceSettings', JSON.stringify(deviceSettings));
454
+
455
+ // Clean up
456
+ if (stream) {
457
+ stream.getTracks().forEach(track => track.stop());
458
+ }
459
+ if (audioContext) {
460
+ audioContext.close();
461
+ }
462
+
463
+ // Navigate to meeting room
464
+ window.location.href = `room.html`;
465
+ };
466
+
467
+ // Initialize on page load
468
+ initializeDevices();
469
+
470
+ // Cleanup on page unload
471
+ window.onbeforeunload = () => {
472
+ if (stream) {
473
+ stream.getTracks().forEach(track => track.stop());
474
+ }
475
+ if (audioContext) {
476
+ audioContext.close();
477
+ }
478
+ };
479
+ </script>
480
+ </body>
481
+ </html>
public/css/style.css ADDED
@@ -0,0 +1,490 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ * {
2
+ margin: 0;
3
+ padding: 0;
4
+ box-sizing: border-box;
5
+ font-family: 'Segoe UI', Tahoma, Geneva, Verdana, sans-serif;
6
+ }
7
+
8
+ .app-container {
9
+ height: 100vh;
10
+ padding: 20px;
11
+ background: #f0f2f5;
12
+ }
13
+
14
+ .join-form {
15
+ max-width: 400px;
16
+ margin: 40px auto;
17
+ padding: 20px;
18
+ background: white;
19
+ border-radius: 8px;
20
+ box-shadow: 0 2px 4px rgba(0,0,0,0.1);
21
+ }
22
+
23
+ .form-group {
24
+ margin-bottom: 15px;
25
+ }
26
+
27
+ input {
28
+ width: 100%;
29
+ padding: 10px;
30
+ border: 1px solid #ddd;
31
+ border-radius: 4px;
32
+ font-size: 14px;
33
+ }
34
+
35
+ .button-group {
36
+ display: flex;
37
+ gap: 10px;
38
+ }
39
+
40
+ .primary-btn, .secondary-btn {
41
+ flex: 1;
42
+ padding: 10px;
43
+ border: none;
44
+ border-radius: 4px;
45
+ cursor: pointer;
46
+ font-weight: 500;
47
+ }
48
+
49
+ .primary-btn {
50
+ background: #0056d6;
51
+ color: white;
52
+ }
53
+
54
+ .secondary-btn {
55
+ background: #f0f2f5;
56
+ color: #0056d6;
57
+ }
58
+
59
+ .meeting-room {
60
+ height: 100%;
61
+ display: flex;
62
+ flex-direction: column;
63
+ }
64
+
65
+ .meeting-header {
66
+ padding: 15px;
67
+ background: white;
68
+ border-radius: 8px;
69
+ margin-bottom: 20px;
70
+ display: flex;
71
+ justify-content: space-between;
72
+ align-items: center;
73
+ }
74
+
75
+ .room-info {
76
+ display: flex;
77
+ align-items: center;
78
+ gap: 10px;
79
+ }
80
+
81
+ .video-grid {
82
+ flex: 1;
83
+ display: grid;
84
+ grid-template-columns: repeat(auto-fit, minmax(300px, 1fr));
85
+ gap: 1rem;
86
+ padding: 1rem;
87
+ overflow: auto;
88
+ }
89
+
90
+ .video-container {
91
+ position: relative;
92
+ width: 100%;
93
+ padding-top: 56.25%; /* 16:9 Aspect Ratio */
94
+ background: #2c2c2c;
95
+ border-radius: 8px;
96
+ overflow: hidden;
97
+ background: #2a2a2a;
98
+ aspect-ratio: 16/9;
99
+ }
100
+
101
+ video {
102
+ position: absolute;
103
+ top: 0;
104
+ left: 0;
105
+ width: 100%;
106
+ height: 100%;
107
+ object-fit: cover;
108
+ }
109
+
110
+ .video-overlay {
111
+ position: absolute;
112
+ bottom: 10px;
113
+ left: 10px;
114
+ color: white;
115
+ padding: 5px 10px;
116
+ border-radius: 4px;
117
+ background: rgba(0,0,0,0.5);
118
+ }
119
+
120
+ .controls-bar {
121
+ padding: 20px;
122
+ display: flex;
123
+ justify-content: center;
124
+ gap: 1rem;
125
+ background: rgba(0, 0, 0, 0.8);
126
+ border-radius: 8px;
127
+ }
128
+
129
+ .control-btn {
130
+ width: 50px;
131
+ height: 50px;
132
+ border-radius: 50%;
133
+ border: none;
134
+ background: #424242;
135
+ color: white;
136
+ cursor: pointer;
137
+ transition: all 0.3s ease;
138
+ display: flex;
139
+ align-items: center;
140
+ justify-content: center;
141
+ }
142
+
143
+ .control-btn:hover {
144
+ background: #616161;
145
+ }
146
+
147
+ .control-btn.danger {
148
+ background: #dc3545;
149
+ color: white;
150
+ }
151
+
152
+ .control-btn.leave-btn {
153
+ background: #dc3545;
154
+ }
155
+
156
+ .control-btn.leave-btn:hover {
157
+ background: #c82333;
158
+ }
159
+
160
+ .icon-button {
161
+ border: none;
162
+ background: none;
163
+ cursor: pointer;
164
+ padding: 5px;
165
+ }
166
+
167
+ .hidden {
168
+ display: none;
169
+ }
170
+
171
+ .video-wrapper {
172
+ position: relative;
173
+ width: 100%;
174
+ height: 100%;
175
+ background: #1a1a1a;
176
+ border-radius: 8px;
177
+ overflow: hidden;
178
+ transition: all 0.3s ease; /* Smooth transition for video containers */
179
+ }
180
+
181
+ .video-wrapper.removing {
182
+ opacity: 0;
183
+ transform: scale(0.8);
184
+ }
185
+
186
+ .video-wrapper video {
187
+ width: 100%;
188
+ height: 100%;
189
+ object-fit: cover;
190
+ }
191
+
192
+ .participant-name {
193
+ position: absolute;
194
+ bottom: 10px;
195
+ left: 10px;
196
+ color: white;
197
+ background: rgba(0, 0, 0, 0.5);
198
+ padding: 5px 10px;
199
+ border-radius: 4px;
200
+ font-size: 14px;
201
+ }
202
+
203
+ .video-grid {
204
+ display: grid;
205
+ gap: 10px;
206
+ padding: 10px;
207
+ height: calc(100vh - 100px);
208
+ grid-auto-flow: dense; /* Helps fill gaps automatically */
209
+ transition: all 0.3s ease; /* Smooth transition when layout changes */
210
+ }
211
+
212
+ .video-grid.single-participant {
213
+ grid-template-columns: 1fr;
214
+ }
215
+
216
+ .video-grid.two-participants {
217
+ grid-template-columns: repeat(2, 1fr);
218
+ }
219
+
220
+ .video-grid.few-participants {
221
+ grid-template-columns: repeat(2, 1fr);
222
+ grid-template-rows: repeat(2, 1fr);
223
+ }
224
+
225
+ .video-grid.many-participants {
226
+ grid-template-columns: repeat(auto-fit, minmax(300px, 1fr));
227
+ grid-auto-rows: 1fr;
228
+ }
229
+
230
+ .participant-list {
231
+ position: fixed;
232
+ right: 0;
233
+ top: 0;
234
+ width: 250px;
235
+ height: 100vh;
236
+ background: rgba(0, 0, 0, 0.8);
237
+ color: white;
238
+ padding: 20px;
239
+ transform: translateX(100%);
240
+ transition: transform 0.3s ease;
241
+ }
242
+
243
+ .participant-list.show {
244
+ transform: translateX(0);
245
+ }
246
+
247
+ .participant-list h3 {
248
+ margin: 0 0 10px 0;
249
+ padding-bottom: 5px;
250
+ border-bottom: 1px solid rgba(255, 255, 255, 0.2);
251
+ }
252
+
253
+ .participant-list ul {
254
+ list-style: none;
255
+ padding: 0;
256
+ margin: 0;
257
+ }
258
+
259
+ .participant-list li {
260
+ padding: 8px 10px;
261
+ margin: 5px 0;
262
+ border-radius: 4px;
263
+ background: rgba(255, 255, 255, 0.1);
264
+ display: flex;
265
+ align-items: center;
266
+ animation: participantFade 0.3s ease-in-out;
267
+ }
268
+
269
+ .participant-list li.leaving {
270
+ animation: participantLeave 0.3s ease-in-out forwards;
271
+ }
272
+
273
+ .participant-list li .material-icons {
274
+ margin-right: 8px;
275
+ font-size: 18px;
276
+ }
277
+
278
+ @keyframes participantFade {
279
+ from {
280
+ opacity: 0;
281
+ transform: translateX(20px);
282
+ }
283
+ to {
284
+ opacity: 1;
285
+ transform: translateX(0);
286
+ }
287
+ }
288
+
289
+ @keyframes participantLeave {
290
+ from {
291
+ opacity: 1;
292
+ transform: translateX(0);
293
+ }
294
+ to {
295
+ opacity: 0;
296
+ transform: translateX(20px);
297
+ }
298
+ }
299
+
300
+ @keyframes fadeIn {
301
+ from { opacity: 0; transform: translateY(-10px); }
302
+ to { opacity: 1; transform: translateY(0); }
303
+ }
304
+
305
+ /* Screen share participant styling */
306
+ .screen-share-participant {
307
+ font-style: italic;
308
+ color: #4CAF50;
309
+ }
310
+
311
+ .video-wrapper[id^="video-screen_"] {
312
+ border: 2px solid #4CAF50;
313
+ }
314
+
315
+ /* Active share button state */
316
+ #shareScreenBtn.active {
317
+ background-color: #4CAF50;
318
+ color: white;
319
+ }
320
+
321
+ .notifications-container {
322
+ position: fixed;
323
+ top: 20px;
324
+ right: 20px;
325
+ z-index: 1000;
326
+ }
327
+
328
+ .notification {
329
+ background: rgba(0, 0, 0, 0.8);
330
+ color: white;
331
+ padding: 10px 20px;
332
+ border-radius: 4px;
333
+ margin-bottom: 10px;
334
+ animation: fadeIn 0.3s ease;
335
+ }
336
+
337
+ .notification.error {
338
+ background: rgba(220, 53, 69, 0.9);
339
+ }
340
+
341
+ .room-container {
342
+ height: 100vh;
343
+ display: flex;
344
+ flex-direction: column;
345
+ background: #1a1a1a;
346
+ }
347
+
348
+ /* Modal Styles Enhancement */
349
+ .modal {
350
+ display: none;
351
+ position: fixed;
352
+ top: 0;
353
+ left: 0;
354
+ width: 100%;
355
+ height: 100%;
356
+ background-color: rgba(0, 0, 0, 0.7);
357
+ z-index: 1000;
358
+ opacity: 0;
359
+ transition: opacity 0.3s ease;
360
+ }
361
+
362
+ .modal.show {
363
+ display: flex;
364
+ justify-content: center;
365
+ align-items: center;
366
+ opacity: 1;
367
+ }
368
+
369
+ .modal-content {
370
+ position: relative;
371
+ background-color: #fff;
372
+ width: 90%;
373
+ max-width: 800px;
374
+ max-height: 80vh;
375
+ border-radius: 12px;
376
+ box-shadow: 0 5px 15px rgba(0, 0, 0, 0.3);
377
+ transform: scale(0.7);
378
+ opacity: 0;
379
+ transition: all 0.3s ease;
380
+ }
381
+
382
+ .modal.show .modal-content {
383
+ transform: scale(1);
384
+ opacity: 1;
385
+ }
386
+
387
+ .modal-header {
388
+ display: flex;
389
+ justify-content: space-between;
390
+ align-items: center;
391
+ padding: 20px;
392
+ border-bottom: 1px solid #eee;
393
+ }
394
+
395
+ .modal-header h3 {
396
+ margin: 0;
397
+ color: #333;
398
+ font-size: 1.5rem;
399
+ font-weight: 500;
400
+ }
401
+
402
+ .close-btn {
403
+ background: none;
404
+ border: none;
405
+ color: #666;
406
+ font-size: 24px;
407
+ cursor: pointer;
408
+ padding: 0 8px;
409
+ transition: color 0.2s ease;
410
+ }
411
+
412
+ .close-btn:hover {
413
+ color: #333;
414
+ }
415
+
416
+ .mask-grid {
417
+ display: grid;
418
+ grid-template-columns: repeat(auto-fill, minmax(200px, 1fr));
419
+ gap: 20px;
420
+ padding: 20px;
421
+ max-height: calc(80vh - 80px);
422
+ overflow-y: auto;
423
+ }
424
+
425
+ .mask-option {
426
+ background: #fff;
427
+ border-radius: 8px;
428
+ padding: 15px;
429
+ cursor: pointer;
430
+ transition: all 0.2s ease;
431
+ border: 2px solid transparent;
432
+ box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
433
+ }
434
+
435
+ .mask-option:hover {
436
+ transform: translateY(-2px);
437
+ box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15);
438
+ }
439
+
440
+ .mask-option.selected {
441
+ border-color: #2196F3;
442
+ background-color: #E3F2FD;
443
+ }
444
+
445
+ .mask-option img {
446
+ width: 100%;
447
+ height: 150px;
448
+ object-fit: contain;
449
+ border-radius: 4px;
450
+ margin-bottom: 10px;
451
+ }
452
+
453
+ .mask-name {
454
+ text-align: center;
455
+ font-size: 14px;
456
+ color: #333;
457
+ font-weight: 500;
458
+ text-transform: capitalize;
459
+ }
460
+
461
+ .mask-category {
462
+ color: #666;
463
+ font-size: 12px;
464
+ margin-top: 5px;
465
+ text-align: center;
466
+ }
467
+
468
+ /* Scrollbar style for mask grid */
469
+ .mask-grid::-webkit-scrollbar {
470
+ width: 8px;
471
+ }
472
+
473
+ .mask-grid::-webkit-scrollbar-track {
474
+ background: #f1f1f1;
475
+ border-radius: 4px;
476
+ }
477
+
478
+ .mask-grid::-webkit-scrollbar-thumb {
479
+ background: #888;
480
+ border-radius: 4px;
481
+ }
482
+
483
+ .mask-grid::-webkit-scrollbar-thumb:hover {
484
+ background: #666;
485
+ }
486
+
487
+ .control-btn.active {
488
+ background-color: #dc3545;
489
+ color: white;
490
+ }
public/img/mask1.png ADDED

Git LFS Details

  • SHA256: dcb55652563e64aeaffa188ee38f78de9dbed9386efe4d3953dd5fc0e0015472
  • Pointer size: 130 Bytes
  • Size of remote file: 32.2 kB
public/index.html ADDED
@@ -0,0 +1,295 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ <!DOCTYPE html>
2
+ <html lang="en">
3
+ <head>
4
+ <meta charset="UTF-8">
5
+ <meta name="viewport" content="width=device-width, initial-scale=1.0">
6
+ <title>Video Meeting Room</title>
7
+ <link href="https://fonts.googleapis.com/css2?family=Poppins:wght@400;500;600&display=swap" rel="stylesheet">
8
+ <link href="https://fonts.googleapis.com/icon?family=Material+Icons" rel="stylesheet">
9
+ <style>
10
+ * {
11
+ margin: 0;
12
+ padding: 0;
13
+ box-sizing: border-box;
14
+ font-family: 'Poppins', sans-serif;
15
+ }
16
+
17
+ body {
18
+ background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
19
+ min-height: 100vh;
20
+ display: flex;
21
+ align-items: center;
22
+ justify-content: center;
23
+ padding: 20px;
24
+ }
25
+
26
+ .app-container {
27
+ max-width: 500px;
28
+ width: 100%;
29
+ margin: 0 auto;
30
+ }
31
+
32
+ .join-form {
33
+ background: rgba(255, 255, 255, 0.95);
34
+ padding: 40px;
35
+ border-radius: 20px;
36
+ box-shadow: 0 10px 30px rgba(0,0,0,0.1);
37
+ backdrop-filter: blur(10px);
38
+ }
39
+
40
+ h2 {
41
+ color: #333;
42
+ text-align: center;
43
+ margin-bottom: 30px;
44
+ font-size: 28px;
45
+ font-weight: 600;
46
+ }
47
+
48
+ .form-group {
49
+ margin-bottom: 25px;
50
+ position: relative;
51
+ }
52
+
53
+ .form-group input {
54
+ width: 100%;
55
+ padding: 15px;
56
+ border: 2px solid #e1e1e1;
57
+ border-radius: 12px;
58
+ font-size: 16px;
59
+ transition: all 0.3s ease;
60
+ background: white;
61
+ }
62
+
63
+ .form-group input:focus {
64
+ border-color: #667eea;
65
+ outline: none;
66
+ box-shadow: 0 0 0 4px rgba(102,126,234,0.1);
67
+ }
68
+
69
+ .form-group input::placeholder {
70
+ color: #999;
71
+ }
72
+
73
+ .button-group {
74
+ display: flex;
75
+ gap: 15px;
76
+ margin-top: 35px;
77
+ }
78
+
79
+ button {
80
+ width: 100%;
81
+ padding: 15px;
82
+ border: none;
83
+ border-radius: 12px;
84
+ cursor: pointer;
85
+ font-size: 16px;
86
+ font-weight: 500;
87
+ transition: all 0.3s ease;
88
+ text-transform: uppercase;
89
+ letter-spacing: 1px;
90
+ }
91
+
92
+ .primary-btn {
93
+ background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
94
+ color: white;
95
+ }
96
+
97
+ .primary-btn:hover {
98
+ transform: translateY(-2px);
99
+ box-shadow: 0 5px 15px rgba(102,126,234,0.4);
100
+ }
101
+
102
+ .copy-button {
103
+ background: #4caf50;
104
+ color: white;
105
+ padding: 8px 15px;
106
+ border-radius: 8px;
107
+ font-size: 14px;
108
+ margin-left: 10px;
109
+ }
110
+
111
+ #roomInfo {
112
+ margin-top: 20px;
113
+ padding: 25px;
114
+ background: white;
115
+ border-radius: 20px;
116
+ box-shadow: 0 10px 30px rgba(0,0,0,0.1);
117
+ display: none;
118
+ animation: slideUp 0.5s ease;
119
+ }
120
+
121
+ #roomInfo h3 {
122
+ color: #333;
123
+ margin-bottom: 15px;
124
+ font-size: 20px;
125
+ }
126
+
127
+ #roomInfo p {
128
+ color: #666;
129
+ margin-bottom: 15px;
130
+ line-height: 1.5;
131
+ }
132
+
133
+ #roomIdDisplay {
134
+ background: #f5f5f5;
135
+ padding: 8px 15px;
136
+ border-radius: 8px;
137
+ font-family: monospace;
138
+ font-size: 16px;
139
+ }
140
+
141
+ @keyframes slideUp {
142
+ from {
143
+ opacity: 0;
144
+ transform: translateY(20px);
145
+ }
146
+ to {
147
+ opacity: 1;
148
+ transform: translateY(0);
149
+ }
150
+ }
151
+
152
+ @media (max-width: 480px) {
153
+ .join-form {
154
+ padding: 30px 20px;
155
+ }
156
+
157
+ h2 {
158
+ font-size: 24px;
159
+ }
160
+
161
+ .form-group input {
162
+ padding: 12px;
163
+ font-size: 14px;
164
+ }
165
+
166
+ button {
167
+ padding: 12px;
168
+ font-size: 14px;
169
+ }
170
+ }
171
+ </style>
172
+ </head>
173
+ <body>
174
+ <div class="app-container">
175
+ <div id="joinForm" class="join-form">
176
+ <h2>Video Meeting</h2>
177
+ <div class="form-group">
178
+ <input type="text" id="usernameInput" placeholder="Enter your name" required>
179
+ </div>
180
+ <div class="form-group">
181
+ <input type="text" id="meetingTitleInput" placeholder="Meeting title (optional)">
182
+ </div>
183
+ <div class="button-group">
184
+ <button id="createMeetingBtn" class="primary-btn">Start Meeting</button>
185
+ <button onclick="window.location.href='join.html'" class="secondary-btn">Join Meeting</button>
186
+ </div>
187
+ </div>
188
+ <div id="roomInfo">
189
+ <h3>🎉 Room Created Successfully!</h3>
190
+ <p>Your Room ID: <br>
191
+ <span id="roomIdDisplay"></span>
192
+ <button id="copyButton" class="copy-button">
193
+ <i class="material-icons" style="font-size: 16px; vertical-align: middle;">content_copy</i>
194
+ Copy
195
+ </button>
196
+ </p>
197
+ <p style="font-size: 14px; color: #666;">
198
+ Share this Room ID with participants to join the meeting
199
+ </p>
200
+ <button id="goToMeetingBtn" class="primary-btn">
201
+ Join Meeting Room
202
+ <i class="material-icons" style="vertical-align: middle; margin-left: 5px;">arrow_forward</i>
203
+ </button>
204
+ </div>
205
+ </div>
206
+
207
+ <script src="https://cdnjs.cloudflare.com/ajax/libs/webrtc-adapter/8.1.2/adapter.min.js"></script>
208
+ <script type="module">
209
+ import CloudflareCalls from './js/CloudflareCalls.js';
210
+
211
+ const calls = new CloudflareCalls({
212
+ backendUrl: 'http://localhost:50000', // Default to same host
213
+ websocketUrl: `ws://localhost:50000`
214
+ });
215
+ const baseAPI = "http://localhost:50000";
216
+ const usernameInput = document.getElementById('usernameInput');
217
+ const meetingTitleInput = document.getElementById('meetingTitleInput');
218
+ const createMeetingBtn = document.getElementById('createMeetingBtn');
219
+ const roomInfo = document.getElementById('roomInfo');
220
+ const roomIdDisplay = document.getElementById('roomIdDisplay');
221
+ const copyButton = document.getElementById('copyButton');
222
+ const goToMeetingBtn = document.getElementById('goToMeetingBtn');
223
+ // Get token and initialize calls
224
+ async function ensureInitialized(username) {
225
+ if (!calls.token) {
226
+ try {
227
+ const response = await fetch('/auth/token', {
228
+ method: 'POST',
229
+ headers: {
230
+ 'Content-Type': 'application/json'
231
+ },
232
+ body: JSON.stringify({ username })
233
+ });
234
+
235
+ const { token } = await response.json();
236
+ calls.setToken(token);
237
+ return true;
238
+ } catch (err) {
239
+ console.error('Error getting token:', err);
240
+ alert('Failed to initialize. Please check if the server is running.');
241
+ return false;
242
+ }
243
+ }
244
+ return true;
245
+ }
246
+
247
+ async function createRoom() {
248
+ if (!usernameInput.value) {
249
+ alert('Please enter your name');
250
+ return;
251
+ }
252
+
253
+ try {
254
+
255
+ // Initialize calls
256
+ if (!await ensureInitialized(usernameInput.value)) {
257
+ return;
258
+ }
259
+ // Create room
260
+ const room = await calls.createRoom({
261
+ name: meetingTitleInput.value || `${usernameInput.value}'s Room`,
262
+ metadata: { createdBy: usernameInput.value }
263
+ });
264
+
265
+ // Store username in localStorage for the meeting room
266
+ localStorage.setItem('username', usernameInput.value);
267
+
268
+ // Display room info
269
+ roomIdDisplay.textContent = room.roomId;
270
+ roomInfo.style.display = 'block';
271
+
272
+ // Store room ID in localStorage
273
+ localStorage.setItem('roomId', room.roomId);
274
+
275
+ } catch (err) {
276
+ console.error('Error creating room:', err);
277
+ alert('Failed to create room: ' + err.message);
278
+ }
279
+ }
280
+
281
+ // Event Listeners
282
+ createMeetingBtn.addEventListener('click', createRoom);
283
+
284
+ copyButton.addEventListener('click', () => {
285
+ navigator.clipboard.writeText(roomIdDisplay.textContent)
286
+ .then(() => alert('Room ID copied to clipboard!'))
287
+ .catch(err => console.error('Failed to copy:', err));
288
+ });
289
+
290
+ goToMeetingBtn.addEventListener('click', () => {
291
+ window.location.href = './check.html';
292
+ });
293
+ </script>
294
+ </body>
295
+ </html>
public/join.html ADDED
@@ -0,0 +1,222 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ <!DOCTYPE html>
2
+ <html lang="en">
3
+ <head>
4
+ <meta charset="UTF-8">
5
+ <meta name="viewport" content="width=device-width, initial-scale=1.0">
6
+ <title>Join Meeting Room</title>
7
+ <link href="https://fonts.googleapis.com/css2?family=Poppins:wght@400;500;600&display=swap" rel="stylesheet">
8
+ <link href="https://fonts.googleapis.com/icon?family=Material+Icons" rel="stylesheet">
9
+ <style>
10
+ * {
11
+ margin: 0;
12
+ padding: 0;
13
+ box-sizing: border-box;
14
+ font-family: 'Poppins', sans-serif;
15
+ }
16
+
17
+ body {
18
+ background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
19
+ min-height: 100vh;
20
+ display: flex;
21
+ align-items: center;
22
+ justify-content: center;
23
+ padding: 20px;
24
+ }
25
+
26
+ .app-container {
27
+ max-width: 500px;
28
+ width: 100%;
29
+ margin: 0 auto;
30
+ }
31
+
32
+ .join-form {
33
+ background: rgba(255, 255, 255, 0.95);
34
+ padding: 40px;
35
+ border-radius: 20px;
36
+ box-shadow: 0 10px 30px rgba(0,0,0,0.1);
37
+ backdrop-filter: blur(10px);
38
+ }
39
+
40
+ h2 {
41
+ color: #333;
42
+ text-align: center;
43
+ margin-bottom: 30px;
44
+ font-size: 28px;
45
+ font-weight: 600;
46
+ }
47
+
48
+ .form-group {
49
+ margin-bottom: 25px;
50
+ position: relative;
51
+ }
52
+
53
+ .form-group input {
54
+ width: 100%;
55
+ padding: 15px;
56
+ border: 2px solid #e1e1e1;
57
+ border-radius: 12px;
58
+ font-size: 16px;
59
+ transition: all 0.3s ease;
60
+ background: white;
61
+ }
62
+
63
+ .form-group input:focus {
64
+ border-color: #667eea;
65
+ outline: none;
66
+ box-shadow: 0 0 0 4px rgba(102,126,234,0.1);
67
+ }
68
+
69
+ .button-group {
70
+ display: flex;
71
+ gap: 15px;
72
+ margin-top: 35px;
73
+ }
74
+
75
+ button {
76
+ width: 100%;
77
+ padding: 15px;
78
+ border: none;
79
+ border-radius: 12px;
80
+ cursor: pointer;
81
+ font-size: 16px;
82
+ font-weight: 500;
83
+ transition: all 0.3s ease;
84
+ text-transform: uppercase;
85
+ letter-spacing: 1px;
86
+ }
87
+
88
+ .primary-btn {
89
+ background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
90
+ color: white;
91
+ }
92
+
93
+ .secondary-btn {
94
+ background: #e0e0e0;
95
+ color: #333;
96
+ }
97
+
98
+ .primary-btn:hover {
99
+ transform: translateY(-2px);
100
+ box-shadow: 0 5px 15px rgba(102,126,234,0.4);
101
+ }
102
+
103
+ .secondary-btn:hover {
104
+ background: #d5d5d5;
105
+ }
106
+
107
+ @media (max-width: 480px) {
108
+ .join-form {
109
+ padding: 30px 20px;
110
+ }
111
+
112
+ h2 {
113
+ font-size: 24px;
114
+ }
115
+
116
+ .form-group input {
117
+ padding: 12px;
118
+ font-size: 14px;
119
+ }
120
+
121
+ button {
122
+ padding: 12px;
123
+ font-size: 14px;
124
+ }
125
+ }
126
+ </style>
127
+ </head>
128
+ <body>
129
+ <div class="app-container">
130
+ <div class="join-form">
131
+ <h2>Join Meeting</h2>
132
+ <div class="form-group">
133
+ <input type="text" id="usernameInput" placeholder="Enter your name" required>
134
+ </div>
135
+ <div class="form-group">
136
+ <input type="text" id="roomIdInput" placeholder="Enter Room ID" required>
137
+ </div>
138
+ <div class="button-group">
139
+ <button class="secondary-btn" onclick="window.location.href='index.html'">Back</button>
140
+ <button id="joinMeetingBtn" class="primary-btn">Join Meeting</button>
141
+ </div>
142
+ </div>
143
+ </div>
144
+
145
+ <script src="https://cdnjs.cloudflare.com/ajax/libs/webrtc-adapter/8.1.2/adapter.min.js"></script>
146
+ <script type="module">
147
+ import CloudflareCalls from './js/CloudflareCalls.js';
148
+
149
+ const calls = new CloudflareCalls({
150
+ backendUrl: 'http://localhost:50000',
151
+ websocketUrl: `ws://localhost:50000`
152
+ });
153
+
154
+ const usernameInput = document.getElementById('usernameInput');
155
+ const roomIdInput = document.getElementById('roomIdInput');
156
+ const joinMeetingBtn = document.getElementById('joinMeetingBtn');
157
+
158
+ // Get token and initialize calls
159
+ async function ensureInitialized(username) {
160
+ if (!calls.token) {
161
+ try {
162
+ const response = await fetch('/auth/token', {
163
+ method: 'POST',
164
+ headers: {
165
+ 'Content-Type': 'application/json'
166
+ },
167
+ body: JSON.stringify({ username })
168
+ });
169
+
170
+ const { token } = await response.json();
171
+ calls.setToken(token);
172
+ return true;
173
+ } catch (err) {
174
+ console.error('Error getting token:', err);
175
+ alert('Failed to initialize. Please check if the server is running.');
176
+ return false;
177
+ }
178
+ }
179
+ return true;
180
+ }
181
+
182
+ async function joinMeeting() {
183
+ const username = usernameInput.value.trim();
184
+ const roomId = roomIdInput.value.trim();
185
+
186
+ if (!username || !roomId) {
187
+ alert('Please enter both your name and Room ID');
188
+ return;
189
+ }
190
+
191
+ try {
192
+ // Initialize calls
193
+ if (!await ensureInitialized(username)) {
194
+ return;
195
+ }
196
+
197
+
198
+ // Store information in localStorage
199
+ localStorage.setItem('username', username);
200
+ localStorage.setItem('roomId', roomId);
201
+
202
+ // Redirect to device check page
203
+ window.location.href = './check.html';
204
+
205
+ } catch (err) {
206
+ console.error('Error joining room:', err);
207
+ alert('Failed to join room: ' + err.message);
208
+ }
209
+ }
210
+
211
+ // Event Listeners
212
+ joinMeetingBtn.addEventListener('click', joinMeeting);
213
+
214
+ // Handle enter key
215
+ document.addEventListener('keypress', (e) => {
216
+ if (e.key === 'Enter') {
217
+ joinMeeting();
218
+ }
219
+ });
220
+ </script>
221
+ </body>
222
+ </html>
public/js/CloudflareCalls.js ADDED
@@ -0,0 +1,2472 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ /**
2
+ * CloudflareCalls.js
3
+ *
4
+ * High-level library for Cloudflare Calls using SFU,
5
+ * now leveraging WebSocket for data message publish/subscribe flow.
6
+ */
7
+
8
+ /**
9
+ * Represents the CloudflareCalls library for managing real-time communications.
10
+ */
11
+ class CloudflareCalls {
12
+ /**
13
+ * @typedef {Object} VideoQualitySettings
14
+ * @property {Object} width - Video width settings
15
+ * @property {number} width.ideal - Ideal video width in pixels
16
+ * @property {Object} height - Video height settings
17
+ * @property {number} height.ideal - Ideal video height in pixels
18
+ * @property {Object} frameRate - Frame rate settings
19
+ * @property {number} frameRate.ideal - Ideal frame rate in fps
20
+ * @property {number} maxBitrate - Maximum video bitrate in bps
21
+ */
22
+
23
+ /**
24
+ * @typedef {Object} AudioQualitySettings
25
+ * @property {number} maxBitrate - Maximum audio bitrate in bps
26
+ * @property {number} sampleRate - Audio sample rate in Hz
27
+ * @property {number} channelCount - Number of audio channels (1=mono, 2=stereo)
28
+ */
29
+
30
+ /**
31
+ * @typedef {Object} QualityPreset
32
+ * @property {VideoQualitySettings} video - Video quality settings
33
+ * @property {AudioQualitySettings} audio - Audio quality settings
34
+ */
35
+
36
+ /**
37
+ * @typedef {Object} ConnectionStats
38
+ * @property {Object} outbound - Outbound (sending) statistics
39
+ * @property {number} outbound.bitrate - Current outbound bitrate in bits/s
40
+ * @property {number} outbound.packetLoss - Percentage of packets lost
41
+ * @property {string} outbound.qualityLimitation - Reason for quality limitations (if any)
42
+ * @property {Object} inbound - Inbound (receiving) statistics per track
43
+ * @property {number} inbound.bitrate - Current inbound bitrate in bits/s
44
+ * @property {number} inbound.packetLoss - Percentage of packets lost
45
+ * @property {number} inbound.jitter - Current jitter in seconds
46
+ * @property {Object} connection - Overall connection statistics
47
+ * @property {number} connection.roundTripTime - Current round trip time in seconds
48
+ * @property {string} connection.state - Current connection state
49
+ */
50
+
51
+ /**
52
+ * @typedef {Object} StreamStats
53
+ * @property {string} sessionId - Session ID of the stream
54
+ * @property {number} packetLoss - Packet loss percentage
55
+ * @property {string} qualityLimitation - Quality limitation reason
56
+ * @property {number} bitrate - Current bitrate
57
+ */
58
+
59
+ /**
60
+ * Creates an instance of CloudflareCalls.
61
+ * @param {Object} config - Configuration object.
62
+ * @param {string} config.backendUrl - The backend server URL.
63
+ * @param {string} config.websocketUrl - The WebSocket server URL.
64
+ */
65
+ constructor(config = {}) {
66
+ this.backendUrl = config.backendUrl || '';
67
+ this.websocketUrl = config.websocketUrl || '';
68
+ this.debug = config.debug || false;
69
+
70
+ this.token = null;
71
+ this.roomId = null;
72
+ this.sessionId = null;
73
+ this.userId = this._generateUUID();
74
+
75
+ this.userMetadata = {};
76
+
77
+ this.localStream = null;
78
+ this.peerConnection = null;
79
+ this.ws = null;
80
+
81
+ // Specific message handlers
82
+ this._onParticipantJoinedCallback = null;
83
+ this._onParticipantLeftCallback = null;
84
+ this._onRemoteTrackCallback = null;
85
+ this._onRemoteTrackUnpublishedCallback = null;
86
+ this._onTrackStatusChangedCallback = null;
87
+ this._onDataMessageCallback = null;
88
+ this._onConnectionStatsCallback = null;
89
+
90
+ // Generic message handlers
91
+ this._wsMessageHandlers = new Set();
92
+
93
+ // Track management
94
+ this.pulledTracks = new Map(); // Map<sessionId, Set<trackName>>
95
+ this.pollingInterval = null; // Reference to the polling interval
96
+
97
+ // Device management
98
+ this.availableAudioInputDevices = [];
99
+ this.availableVideoInputDevices = [];
100
+ this.availableAudioOutputDevices = [];
101
+ this.currentAudioOutputDeviceId = null;
102
+
103
+ this._renegotiateTimeout = null;
104
+ this.publishedTracks = new Set();
105
+
106
+ this.midToSessionId = new Map();
107
+ this.midToTrackName = new Map();
108
+
109
+ this._onRoomMetadataUpdatedCallback = null;
110
+
111
+ // Store initial quality settings
112
+ /** @type {QualityPreset} */
113
+ this.pendingQualitySettings = null;
114
+
115
+ this.mediaQuality = CloudflareCalls.QUALITY_PRESETS.medium_16x9_md;
116
+
117
+ /** @type {Object.<string, QualityPreset>} */
118
+ this.QUALITY_PRESETS = CloudflareCalls.QUALITY_PRESETS;
119
+
120
+ // Stats monitoring
121
+ this.statsInterval = null;
122
+ this.previousStats = null;
123
+
124
+ /** @type {'stopped'|'monitoring'} */
125
+ this.statsMonitoringState = 'stopped';
126
+ }
127
+
128
+ /**
129
+ * Internal logging method that only outputs when debug is enabled
130
+ * @private
131
+ * @param {...any} args - Arguments to pass to console.log
132
+ */
133
+ _log(...args) {
134
+ if (this.debug) {
135
+ console.log('[CloudflareCalls]', ...args);
136
+ }
137
+ }
138
+
139
+ /**
140
+ * Internal warning method that only outputs when debug is enabled
141
+ * @private
142
+ * @param {...any} args - Arguments to pass to console.warn
143
+ */
144
+ _warn(...args) {
145
+ if (this.debug) {
146
+ console.warn('[CloudflareCalls]', ...args);
147
+ }
148
+ }
149
+
150
+ /**
151
+ * Internal error method that always outputs (important for debugging)
152
+ * @private
153
+ * @param {...any} args - Arguments to pass to console.error
154
+ */
155
+ _error(...args) {
156
+ console.error('[CloudflareCalls]', ...args);
157
+ }
158
+
159
+ /**
160
+ * Enable or disable debug logging
161
+ * @param {boolean} enabled - Whether to enable debug logging
162
+ */
163
+ setDebugMode(enabled) {
164
+ this.debug = Boolean(enabled);
165
+ }
166
+
167
+ /**
168
+ * Internal method to perform fetch requests with automatic token inclusion and JSON parsing.
169
+ * @private
170
+ * @param {string} url - The full URL to fetch.
171
+ * @param {Object} options - Fetch options such as method, headers, body, etc.
172
+ * @returns {Promise<Object>} The parsed JSON response.
173
+ * @throws {Error} If the response is not OK.
174
+ */
175
+ async _fetch(url, options = {}) {
176
+ // Initialize headers if not provided
177
+ options.headers = options.headers || {};
178
+
179
+ // Add Authorization header if token is set
180
+ if (this.token) {
181
+ options.headers['Authorization'] = `Bearer ${this.token}`;
182
+ }
183
+
184
+ try {
185
+ const response = await fetch(url, options);
186
+
187
+ // Check if the response status is OK (status in the range 200-299)
188
+ if (!response.ok) {
189
+ this._warn(`HTTP error! status: ${response.status}`);
190
+ }
191
+
192
+ return response;
193
+ } catch (error) {
194
+ this._warn(`Fetch error for ${url}:`, error);
195
+ return false;
196
+ }
197
+ }
198
+
199
+
200
+ /************************************************
201
+ * Callback Registration
202
+ ***********************************************/
203
+
204
+ /**
205
+ * Registers a callback for remote track events.
206
+ * @param {Function} callback - The callback function to handle remote tracks.
207
+ */
208
+ onRemoteTrack(callback) {
209
+ this._onRemoteTrackCallback = callback;
210
+ }
211
+
212
+ /**
213
+ * Registers a callback for remote track unpublished events.
214
+ * @param {Function} callback - The callback function to handle track unpublished events.
215
+ */
216
+ onRemoteTrackUnpublished(callback) {
217
+ this._onRemoteTrackUnpublishedCallback = callback;
218
+ }
219
+
220
+ /**
221
+ * Registers a callback for incoming data messages.
222
+ * @param {Function} callback - The callback function to handle data messages.
223
+ */
224
+ onDataMessage(callback) {
225
+ this._onDataMessageCallback = callback;
226
+ }
227
+
228
+ /**
229
+ * Registers a callback for participant joined events.
230
+ * @param {Function} callback - The callback function to handle participant joins.
231
+ */
232
+ onParticipantJoined(callback) {
233
+ this._onParticipantJoinedCallback = callback;
234
+ }
235
+
236
+ /**
237
+ * Registers a callback for participant left events.
238
+ * @param {Function} callback - The callback function to handle participant departures.
239
+ */
240
+ onParticipantLeft(callback) {
241
+ this._onParticipantLeftCallback = callback;
242
+ }
243
+
244
+ /**
245
+ * Registers a callback for track status changed events.
246
+ * @param {Function} callback - The callback function to handle track status changes.
247
+ */
248
+ onTrackStatusChanged(callback) {
249
+ this._onTrackStatusChangedCallback = callback;
250
+ }
251
+
252
+ /**
253
+ * Registers a callback for WebSocket messages
254
+ * @param {Function} callback - Function to call when WebSocket messages are received
255
+ * @returns {Function} Function to unregister the callback
256
+ */
257
+ onWebSocketMessage(callback) {
258
+ this._wsMessageHandlers.add(callback);
259
+ return () => this._wsMessageHandlers.delete(callback);
260
+ }
261
+
262
+ /************************************************
263
+ * User Metadata Management
264
+ ***********************************************/
265
+
266
+ /**
267
+ * Sets the user token for server requests. This should be a JWT token, and will be delivered in Authorization headers (HTTP) and to authenticate websocket join requests.
268
+ * @param {String} token - The metadata to associate with the user.
269
+ */
270
+ setToken(token) {
271
+ this.token = token;
272
+ }
273
+
274
+ /**
275
+ * Register callback for room metadata updates
276
+ * @param {Function} callback Callback function
277
+ */
278
+ onRoomMetadataUpdated(callback) {
279
+ this._onRoomMetadataUpdatedCallback = callback;
280
+ }
281
+
282
+ /**
283
+ * Sets the user metadata and updates it on the server.
284
+ * @param {Object} metadata - The metadata to associate with the user.
285
+ */
286
+ setUserMetadata(metadata) {
287
+ this.userMetadata = metadata;
288
+ this._updateUserMetadataOnServer();
289
+ }
290
+
291
+ /**
292
+ * Retrieves the current user metadata.
293
+ * @returns {Object} The user metadata.
294
+ */
295
+ getUserMetadata() {
296
+ return this.userMetadata;
297
+ }
298
+
299
+ /**
300
+ * Updates user metadata on the server
301
+ * @private
302
+ * @async
303
+ * @returns {Promise<void>}
304
+ */
305
+ async _updateUserMetadataOnServer() {
306
+ if (!this.roomId || !this.sessionId) {
307
+ this._warn('Cannot update metadata before joining a room.');
308
+ return;
309
+ }
310
+
311
+ try {
312
+ const updateUrl = `${this.backendUrl}/api/rooms/${this.roomId}/metadata`;
313
+ const response = await this._fetch(updateUrl, {
314
+ method: 'PUT',
315
+ headers: { 'Content-Type': 'application/json' },
316
+ body: JSON.stringify(this.userMetadata)
317
+ });
318
+
319
+ if (!response.ok) {
320
+ this._error('Failed to update user metadata on server.');
321
+ } else {
322
+ this._log('User metadata updated on server.');
323
+ }
324
+ } catch (error) {
325
+ this._error('Error updating user metadata:', error);
326
+ throw error;
327
+ }
328
+ }
329
+
330
+ /************************************************
331
+ * Room & Session Management
332
+ ***********************************************/
333
+
334
+ /**
335
+ * Creates a new room with optional metadata.
336
+ * @async
337
+ * @param {Object} options Room creation options
338
+ * @param {string} [options.name] Room name
339
+ * @param {Object} [options.metadata] Room metadata
340
+ * @returns {Promise<Object>} Created room information including roomId, name, metadata, etc.
341
+ */
342
+ async createRoom(options = {}) {
343
+ const resp = await this._fetch(`${this.backendUrl}/api/rooms`, {
344
+ method: 'POST',
345
+ headers: { 'Content-Type': 'application/json' },
346
+ body: JSON.stringify(options)
347
+ }).then(r => r.json());
348
+
349
+ // Store the roomId
350
+ this.roomId = resp.roomId;
351
+
352
+ // Return the full room object
353
+ return resp;
354
+ }
355
+
356
+ /**
357
+ * Joins an existing room.
358
+ * @async
359
+ * @param {string} roomId - The ID of the room to join.
360
+ * @param {Object} [metadata={}] - Optional metadata for the user.
361
+ * @returns {Promise<void>}
362
+ */
363
+ async joinRoom(roomId, metadata = {}) {
364
+ this.roomId = roomId;
365
+
366
+ // 1) Ask server to create a CF Calls session
367
+ const joinResp = await this._fetch(`${this.backendUrl}/api/rooms/${roomId}/join`, {
368
+ method: 'POST',
369
+ headers: { 'Content-Type': 'application/json' },
370
+ body: JSON.stringify({ userId: this.userId, metadata: this.userMetadata })
371
+ }).then(r => r.json());
372
+
373
+ await this._initWebSocket();
374
+
375
+ if (!joinResp.sessionId) {
376
+ throw new Error('Failed to join room or retrieve sessionId');
377
+ }
378
+ this.sessionId = joinResp.sessionId;
379
+
380
+ // Initialize pulledTracks map
381
+ this.pulledTracks.set(this.sessionId, new Set());
382
+
383
+ // 2) Create RTCPeerConnection
384
+ this.peerConnection = await this._createPeerConnection();
385
+
386
+ // 3) Get Local Media and Publish Tracks
387
+ if (!this.localStream) {
388
+ this.localStream = await navigator.mediaDevices.getUserMedia({ video: true, audio: true });
389
+ this._log('Acquired local media');
390
+ }
391
+ await this._publishTracks();
392
+
393
+ // 4) Pull other participants' tracks
394
+ const otherSessions = joinResp.otherSessions || [];
395
+ for (const s of otherSessions) {
396
+ this.pulledTracks.set(s.sessionId, new Set());
397
+ for (const tName of s.publishedTracks || []) {
398
+ await this._pullTracks(s.sessionId, tName);
399
+ }
400
+ }
401
+ this._log('Joined room', roomId, 'my session:', this.sessionId);
402
+
403
+ this.setUserMetadata(metadata);
404
+
405
+ // 5) Start polling for new tracks
406
+ this._startPolling();
407
+ }
408
+
409
+ /**
410
+ * Cleans up ended tracks in localStream
411
+ * @async
412
+ * @private
413
+ * @returns {void}
414
+ */
415
+ async _cleanupEndedTracks() {
416
+ // Clear local media devices (readyState == 'ended', so they can't be reused)
417
+ if (this.localStream) {
418
+ for (const track of this.localStream.getTracks()) {
419
+ if (track.readyState === 'ended') {
420
+ this.localStream.removeTrack(track);
421
+ track.stop();
422
+ }
423
+ }
424
+ }
425
+
426
+ // If no tracks remain, clear the stream
427
+ if (this.localStream && !this.localStream.getTracks().length) {
428
+ this.localStream = null;
429
+ }
430
+ }
431
+
432
+ /**
433
+ * Leaves the current room and cleans up connections.
434
+ * @async
435
+ * @returns {Promise<void>}
436
+ */
437
+ async leaveRoom() {
438
+ if (!this.roomId || !this.sessionId) return;
439
+
440
+ // Clean up published tracks (if applicable)
441
+ const senders = this.peerConnection.getSenders();
442
+ if (senders && senders.length) {
443
+ await this.unpublishAllTracks();
444
+ }
445
+
446
+ try {
447
+ await this._fetch(`${this.backendUrl}/api/rooms/${this.roomId}/leave`, {
448
+ method: 'POST',
449
+ headers: { 'Content-Type': 'application/json' },
450
+ body: JSON.stringify({ sessionId: this.sessionId })
451
+ });
452
+ } catch (error) {
453
+ this._warn('Error leaving room:', error);
454
+ }
455
+
456
+ // Clean up WebSocket
457
+ if (this.ws) {
458
+ this.ws.close();
459
+ this.ws = null;
460
+ }
461
+
462
+ // Clean up PeerConnection
463
+ if (this.peerConnection) {
464
+ this.peerConnection.close();
465
+ this.peerConnection = null;
466
+ }
467
+
468
+ await this._cleanupEndedTracks();
469
+
470
+ this._log('Left room, closed PC & WS');
471
+
472
+ // Reset room state
473
+ this.roomId = null;
474
+ this.sessionId = null;
475
+ this.pulledTracks.clear();
476
+ this.midToSessionId.clear();
477
+ this.midToTrackName.clear();
478
+ this.publishedTracks.clear();
479
+ }
480
+
481
+ /************************************************
482
+ * Publish & Pull
483
+ ***********************************************/
484
+
485
+ /**
486
+ * Publishes the local media tracks to the room.
487
+ * @async
488
+ * @returns {Promise<void>}
489
+ * @throws {Error} If there is no local media stream to publish.
490
+ */
491
+ async publishTracks() {
492
+ if (!this.localStream) {
493
+ return this._warn('No local media stream to publish.');
494
+ }
495
+ await this._publishTracks();
496
+ }
497
+
498
+ // /**
499
+ // * Unpublishes a specific local media track (audio or video).
500
+ // * @async
501
+ // * @param {string} trackKind - The kind of track to unpublish ('audio' or 'video').
502
+ // * @param {boolean} [force=false] - If true, forces track closure without renegotiation.
503
+ // * @returns {Promise<Object>} Result object from the Cloudflare API.
504
+ // * @throws {Error} If PeerConnection is not established or track is not found.
505
+ // */
506
+ // // Todo: I don't think this method works
507
+ // async unpublishTrack(trackKind, force = false) {
508
+ // if (!this.peerConnection) {
509
+ // return this._warn('PeerConnection is not established.');
510
+ // }
511
+ //
512
+ // const sender = this.peerConnection.getSenders().find(s => s.track?.kind === trackKind);
513
+ // if (!sender) {
514
+ // return this._warn(`No ${trackKind} track found to unpublish.`);
515
+ // }
516
+ //
517
+ // const transceiver = this.peerConnection.getTransceivers().find(t => t.sender === sender);
518
+ // if (!transceiver?.mid) {
519
+ // throw new Error('Could not find transceiver mid for track');
520
+ // }
521
+ //
522
+ // try {
523
+ // // Create an offer for the updated state
524
+ // const offer = await this.peerConnection.createOffer();
525
+ // await this.peerConnection.setLocalDescription(offer);
526
+ //
527
+ // const unpublishUrl = `${this.backendUrl}/api/rooms/${this.roomId}/sessions/${this.sessionId}/unpublish`;
528
+ // const response = await this._fetch(unpublishUrl, {
529
+ // method: 'POST',
530
+ // headers: { 'Content-Type': 'application/json' },
531
+ // body: JSON.stringify({
532
+ // trackName: sender.track.id,
533
+ // mid: transceiver.mid,
534
+ // force,
535
+ // sessionDescription: {
536
+ // type: offer.type,
537
+ // sdp: offer.sdp
538
+ // }
539
+ // })
540
+ // });
541
+ //
542
+ // if (!response || !response.ok) return false;
543
+ // const result = await response.json();
544
+ //
545
+ // // Stop the track
546
+ // sender.track.stop();
547
+ //
548
+ // // Remove from PeerConnection after server confirms
549
+ // this.peerConnection.removeTrack(sender);
550
+ //
551
+ // // Remove from our tracked set
552
+ // this.publishedTracks.delete(sender.track.id);
553
+ //
554
+ // return result;
555
+ // } catch (error) {
556
+ // this._warn(`Error unpublishing ${trackKind} track:`, error);
557
+ // return false;
558
+ // }
559
+ // }
560
+
561
+ /**
562
+ * Initiates renegotiation of the PeerConnection.
563
+ * @async
564
+ * @private
565
+ * @returns {Promise<void>}
566
+ */
567
+ async _renegotiate() {
568
+ if (!this.peerConnection) return;
569
+
570
+ if (this._renegotiateTimeout) {
571
+ clearTimeout(this._renegotiateTimeout);
572
+ }
573
+
574
+ this._renegotiateTimeout = setTimeout(async () => {
575
+ try {
576
+ this._log('Starting renegotiation process...');
577
+ const answer = await this.peerConnection.createAnswer();
578
+ this._log('Created renegotiation answer:', answer.sdp);
579
+ await this.peerConnection.setLocalDescription(answer);
580
+
581
+ const renegotiateUrl = `${this.backendUrl}/api/rooms/${this.roomId}/sessions/${this.sessionId}/renegotiate`;
582
+ const body = { sdp: answer.sdp, type: answer.type };
583
+ this._log(`Sending renegotiate request to ${renegotiateUrl} with body:`, body);
584
+
585
+ const response = await this._fetch(renegotiateUrl, {
586
+ method: 'PUT',
587
+ headers: { 'Content-Type': 'application/json' },
588
+ body: JSON.stringify(body)
589
+ }).then(r => r.json());
590
+
591
+ if (response.errorCode) {
592
+ this._warn('Renegotiation failed:', response.errorDescription);
593
+ return;
594
+ }
595
+
596
+ await this.peerConnection.setRemoteDescription(response.sessionDescription);
597
+ this._log('Renegotiation successful. Applied SFU response.');
598
+ } catch (error) {
599
+ this._error('Error during renegotiation:', error);
600
+ }
601
+ }, 500);
602
+ }
603
+
604
+ /**
605
+ * Updates the published media tracks.
606
+ * @async
607
+ * @returns {Promise<void>}
608
+ * @throws {Error} If the PeerConnection is not established.
609
+ */
610
+ // Todo: I don't know what this was supposed to accomplish
611
+ // Possibly unpublish and re-publish tracks to solve some lifecycle issue
612
+ async updatePublishedTracks() {
613
+ if (!this.peerConnection) {
614
+ return this._warn('PeerConnection is not established.');
615
+ }
616
+
617
+ // Remove existing senders
618
+ const senders = this.peerConnection.getSenders();
619
+ for (const sender of senders) {
620
+ this.peerConnection.removeTrack(sender);
621
+ }
622
+
623
+ // Add updated tracks
624
+ await this._publishTracks();
625
+ }
626
+
627
+ /**
628
+ * Publishes the local media tracks to the PeerConnection and server.
629
+ * @async
630
+ * @private
631
+ * @returns {Promise<void>}
632
+ */
633
+ async _publishTracks() {
634
+ if (!this.localStream || !this.peerConnection) return;
635
+
636
+ const transceivers = [];
637
+ for (const track of this.localStream.getTracks()) {
638
+ // Check if we've already published this track
639
+ if (this.publishedTracks.has(track.id)) continue;
640
+ if (track.readyState !== 'live') continue;
641
+
642
+ const tx = this.peerConnection.addTransceiver(track, { direction: 'sendonly' });
643
+
644
+ // Apply any pending quality settings to video tracks
645
+ if (this.pendingQualitySettings && track.kind === 'video') {
646
+ const params = tx.sender.getParameters();
647
+ params.encodings = [{
648
+ maxBitrate: this.pendingQualitySettings.video.maxBitrate
649
+ }];
650
+ tx.sender.setParameters(params);
651
+ }
652
+
653
+ transceivers.push(tx);
654
+ this.publishedTracks.add(track.id);
655
+ }
656
+
657
+ if (transceivers.length === 0) return; // No new tracks to publish
658
+
659
+ const offer = await this.peerConnection.createOffer();
660
+ this._log('SDP Offer:', offer.sdp);
661
+ await this.peerConnection.setLocalDescription(offer);
662
+
663
+ const trackInfos = transceivers.map(({ sender, mid }) => ({
664
+ location: 'local',
665
+ mid,
666
+ trackName: sender.track.id
667
+ }));
668
+
669
+ const body = {
670
+ offer: { sdp: offer.sdp, type: offer.type },
671
+ tracks: trackInfos,
672
+ metadata: this.userMetadata
673
+ };
674
+ const publishUrl = `${this.backendUrl}/api/rooms/${this.roomId}/sessions/${this.sessionId}/publish`;
675
+ const resp = await this._fetch(publishUrl, {
676
+ method: 'POST',
677
+ headers: { 'Content-Type': 'application/json' },
678
+ body: JSON.stringify(body)
679
+ }).then(r => r.json());
680
+
681
+ if (resp.errorCode) {
682
+ this._error('Publish error:', resp.errorDescription);
683
+ return;
684
+ }
685
+ // The SFU's answer
686
+ const answer = resp.sessionDescription;
687
+ await this.peerConnection.setRemoteDescription(answer);
688
+ this._log('Publish => success. Applied SFU answer.');
689
+ }
690
+
691
+ /**
692
+ * Pulls a specific track from a remote session.
693
+ * @async
694
+ * @private
695
+ * @param {string} remoteSessionId - The session ID of the remote participant.
696
+ * @param {string} trackName - The name of the track to pull.
697
+ * @returns {Promise<void>}
698
+ */
699
+ async _pullTracks(remoteSessionId, trackName) {
700
+ this._log(`Pulling track '${trackName}' from session ${remoteSessionId}`);
701
+ const pullUrl = `${this.backendUrl}/api/rooms/${this.roomId}/sessions/${this.sessionId}/pull`;
702
+ const body = { remoteSessionId, trackName };
703
+
704
+ const resp = await this._fetch(pullUrl, {
705
+ method: 'POST',
706
+ headers: { 'Content-Type': 'application/json' },
707
+ body: JSON.stringify(body)
708
+ }).then(r => r.json());
709
+
710
+ if (resp.errorCode) {
711
+ this._error('Pull error:', resp.errorDescription);
712
+ return;
713
+ }
714
+
715
+ if (resp.requiresImmediateRenegotiation) {
716
+ this._log('Pull => requires renegotiation');
717
+
718
+ // Set up both mappings from the SDP
719
+ const pendingMids = new Set();
720
+ resp.sessionDescription.sdp.split('\n').forEach(line => {
721
+ if (line.startsWith('a=mid:')) {
722
+ const mid = line.split(':')[1].trim();
723
+ pendingMids.add(mid);
724
+ this.midToSessionId.set(mid, remoteSessionId);
725
+ this.midToTrackName.set(mid, trackName);
726
+ this._log('Pre-mapped MID:', {
727
+ mid,
728
+ sessionId: remoteSessionId,
729
+ trackName
730
+ });
731
+ }
732
+ });
733
+
734
+ // Now set the remote description
735
+ await this.peerConnection.setRemoteDescription(resp.sessionDescription);
736
+
737
+ // Create and set local answer
738
+ const localAnswer = await this.peerConnection.createAnswer();
739
+ await this.peerConnection.setLocalDescription(localAnswer);
740
+
741
+ // Verify mappings are still correct
742
+ const transceivers = this.peerConnection.getTransceivers();
743
+ transceivers.forEach(transceiver => {
744
+ if (transceiver.mid && pendingMids.has(transceiver.mid)) {
745
+ this._log('Verified MID mapping:', {
746
+ mid: transceiver.mid,
747
+ sessionId: remoteSessionId,
748
+ direction: transceiver.direction
749
+ });
750
+ }
751
+ });
752
+
753
+ await this._fetch(`${this.backendUrl}/api/rooms/${this.roomId}/sessions/${this.sessionId}/renegotiate`, {
754
+ method: 'PUT',
755
+ headers: { 'Content-Type': 'application/json' },
756
+ body: JSON.stringify({ sdp: localAnswer.sdp, type: localAnswer.type })
757
+ });
758
+ }
759
+
760
+ this._log(`Pulled trackName="${trackName}" from session ${remoteSessionId}`);
761
+ this._log('Current MID mappings:', Array.from(this.midToSessionId.entries()));
762
+
763
+ // Record the pulled track
764
+ if (!this.pulledTracks.has(remoteSessionId)) {
765
+ this.pulledTracks.set(remoteSessionId, new Set());
766
+ }
767
+ this.pulledTracks.get(remoteSessionId).add(trackName);
768
+ }
769
+
770
+ /************************************************
771
+ * PeerConnection & WebSocket
772
+ ***********************************************/
773
+
774
+ /**
775
+ * Creates and configures a new RTCPeerConnection.
776
+ * @async
777
+ * @private
778
+ * @returns {Promise<RTCPeerConnection>} The configured RTCPeerConnection instance.
779
+ */
780
+ async _attemptIceServersUpdate() {
781
+ let iceServers = [{ urls: 'stun:stun.cloudflare.com:3478' }];
782
+
783
+ try {
784
+ const response = await this._fetch(`${this.backendUrl}/api/ice-servers`);
785
+ if (!response.ok) {
786
+ this._warn(`Failed to fetch ICE servers: ${response.status} ${response.statusText}`);
787
+ return false;
788
+ }
789
+
790
+ const data = await response.json();
791
+
792
+ // Validate and process the fetched ICE servers
793
+ if (data.iceServers && Array.isArray(data.iceServers)) {
794
+ iceServers = data.iceServers.map(server => {
795
+ // Ensure each server has the required fields
796
+ const iceServer = { urls: server.urls };
797
+ if (server.username && server.credential) {
798
+ iceServer.username = server.username;
799
+ iceServer.credential = server.credential;
800
+ }
801
+ return iceServer;
802
+ });
803
+ this._log('Fetched ICE servers:', iceServers);
804
+ } else {
805
+ return iceServers;
806
+ }
807
+ } catch (error) {
808
+ this._warn('Error fetching ICE servers:', error);
809
+ // Fallback to default ICE servers if fetching fails
810
+ return false;
811
+ }
812
+ }
813
+ async _createPeerConnection() {
814
+ let iceServers = await this._attemptIceServersUpdate() || [{ urls: 'stun:stun.cloudflare.com:3478' }];
815
+
816
+ const pc = new RTCPeerConnection({
817
+ iceServers: iceServers,
818
+ bundlePolicy: 'max-bundle',
819
+ sdpSemantics: 'unified-plan'
820
+ });
821
+
822
+ pc.onicecandidate = (evt) => {
823
+ if (evt.candidate) {
824
+ this._log('New ICE candidate:', evt.candidate.candidate);
825
+ } else {
826
+ this._log('All ICE candidates have been sent');
827
+ }
828
+ };
829
+
830
+ pc.oniceconnectionstatechange = () => {
831
+ this._log('ICE Connection State:', pc.iceConnectionState);
832
+ if (pc.iceConnectionState === 'disconnected' || pc.iceConnectionState === 'failed') {
833
+ this.leaveRoom();
834
+ }
835
+ };
836
+
837
+ pc.onconnectionstatechange = () => {
838
+ this._log('Connection State:', pc.connectionState);
839
+ if (pc.connectionState === 'connected') {
840
+ this._log('Peer connection fully established');
841
+ } else if (pc.connectionState === 'disconnected' || pc.connectionState === 'failed') {
842
+ this._log('Peer connection disconnected or failed');
843
+ this.leaveRoom();
844
+ }
845
+ };
846
+
847
+ pc.ontrack = (evt) => {
848
+ this._log('ontrack event:', {
849
+ kind: evt.track.kind,
850
+ webrtcTrackId: evt.track.id,
851
+ mid: evt.transceiver?.mid
852
+ });
853
+
854
+ if (this._onRemoteTrackCallback) {
855
+ const mid = evt.transceiver?.mid;
856
+ const sessionId = this.midToSessionId.get(mid);
857
+ const trackName = this.midToTrackName.get(mid);
858
+
859
+ this._log('Track mapping lookup:', {
860
+ mid,
861
+ sessionId,
862
+ trackName,
863
+ webrtcTrackId: evt.track.id,
864
+ availableMappings: {
865
+ sessions: Array.from(this.midToSessionId.entries()),
866
+ tracks: Array.from(this.midToTrackName.entries())
867
+ }
868
+ });
869
+
870
+ if (!sessionId) {
871
+ this._warn('No sessionId found for mid:', mid);
872
+ if (!this.pendingTracks) this.pendingTracks = [];
873
+ this.pendingTracks.push({ evt, mid });
874
+ return;
875
+ }
876
+
877
+ const wrappedTrack = evt.track;
878
+ wrappedTrack.sessionId = sessionId;
879
+ wrappedTrack.mid = mid;
880
+ wrappedTrack.trackName = trackName;
881
+
882
+ this._log('Sending track to callback:', {
883
+ webrtcTrackId: wrappedTrack.id,
884
+ trackName: wrappedTrack.trackName,
885
+ sessionId: wrappedTrack.sessionId,
886
+ mid: wrappedTrack.mid
887
+ });
888
+
889
+ this._onRemoteTrackCallback(wrappedTrack);
890
+ }
891
+ };
892
+
893
+ return pc;
894
+ }
895
+
896
+ /**
897
+ * Initializes the WebSocket connection.
898
+ * @async
899
+ * @private
900
+ * @returns {Promise<void>}
901
+ */
902
+ async _initWebSocket() {
903
+ if (this.ws && this.ws.readyState === WebSocket.OPEN) return;
904
+
905
+ return new Promise((resolve, reject) => {
906
+ this.ws = new WebSocket(this.websocketUrl);
907
+
908
+ this.ws.onopen = () => {
909
+ this._log('WebSocket open');
910
+ this.ws.send(JSON.stringify({
911
+ type: 'join-websocket',
912
+ payload: {
913
+ roomId: this.roomId,
914
+ userId: this.userId,
915
+ token: this.token
916
+ }
917
+ }));
918
+ resolve();
919
+ };
920
+
921
+ this.ws.onmessage = (event) => {
922
+ try {
923
+ const message = JSON.parse(event.data);
924
+ this._log('WebSocket message received:', message);
925
+
926
+ // Handle specific message types
927
+ switch (message.type) {
928
+ case 'participant-joined':
929
+ if (this._onParticipantJoinedCallback) {
930
+ this._onParticipantJoinedCallback(message.payload);
931
+ }
932
+ break;
933
+
934
+ case 'participant-left':
935
+ if (this._onParticipantLeftCallback) {
936
+ this._onParticipantLeftCallback(message.payload);
937
+ }
938
+ break;
939
+
940
+ case 'track-published':
941
+ if (this._onRemoteTrackCallback) {
942
+ // Handle track published event
943
+ this._onRemoteTrackCallback(message.payload);
944
+ }
945
+ break;
946
+
947
+ case 'track-unpublished':
948
+ if (this._onRemoteTrackUnpublishedCallback) {
949
+ this._onRemoteTrackUnpublishedCallback(
950
+ message.payload.sessionId,
951
+ message.payload.trackName
952
+ );
953
+ }
954
+ break;
955
+
956
+ case 'track-status-changed':
957
+ if (this._onTrackStatusChangedCallback) {
958
+ this._onTrackStatusChangedCallback(message.payload);
959
+ }
960
+ break;
961
+
962
+ case 'data-message':
963
+ if (this._onDataMessageCallback) {
964
+ this._onDataMessageCallback(message.payload);
965
+ }
966
+ break;
967
+
968
+ case 'room-metadata-updated':
969
+ if (this._onRoomMetadataUpdatedCallback) {
970
+ this._onRoomMetadataUpdatedCallback(message.payload);
971
+ }
972
+ break;
973
+
974
+ default:
975
+ this._log('Unhandled message type:', message.type);
976
+ }
977
+
978
+ // Notify generic handlers
979
+ this._wsMessageHandlers.forEach(handler => handler(message));
980
+ } catch (error) {
981
+ this._error('Error processing WebSocket message:', error);
982
+ }
983
+ };
984
+
985
+ this.ws.onerror = (err) => {
986
+ this._error('WebSocket error:', err);
987
+ reject(err);
988
+ };
989
+
990
+ this.ws.onclose = () => {
991
+ this._log('WebSocket connection closed');
992
+ };
993
+ });
994
+ }
995
+
996
+ /************************************************
997
+ * Polling for New Tracks
998
+ ***********************************************/
999
+
1000
+ /**
1001
+ * Starts polling the server for new tracks every 10 seconds.
1002
+ * @private
1003
+ * @returns {void}
1004
+ */
1005
+ _startPolling() {
1006
+ this.pollingInterval = setInterval(async () => {
1007
+ if (!this.roomId) return;
1008
+
1009
+ try {
1010
+ const resp = await this._fetch(`${this.backendUrl}/api/rooms/${this.roomId}/participants`)
1011
+ .then(r => r.json());
1012
+ const participants = resp.participants || [];
1013
+
1014
+ for (const participant of participants) {
1015
+ const { sessionId, publishedTracks } = participant;
1016
+ if (sessionId === this.sessionId) continue; // Skip self
1017
+
1018
+ if (!this.pulledTracks.has(sessionId)) {
1019
+ this.pulledTracks.set(sessionId, new Set());
1020
+ }
1021
+
1022
+ for (const trackName of publishedTracks) {
1023
+ if (!this.pulledTracks.get(sessionId).has(trackName)) {
1024
+ this._log(`[Polling] New track detected: ${trackName} from session ${sessionId}`);
1025
+ await this._pullTracks(sessionId, trackName);
1026
+ }
1027
+ }
1028
+ }
1029
+ } catch (err) {
1030
+ this._error('Polling error:', err);
1031
+ }
1032
+ }, 10000);
1033
+ }
1034
+
1035
+ /************************************************
1036
+ * Device Management
1037
+ ***********************************************/
1038
+
1039
+ /**
1040
+ * Retrieves the list of available media devices.
1041
+ * @async
1042
+ * @returns {Promise<Object>} An object containing arrays of audio input, video input, and audio output devices.
1043
+ */
1044
+ async getAvailableDevices() {
1045
+ const devices = await navigator.mediaDevices.enumerateDevices();
1046
+ this.availableAudioInputDevices = devices.filter(device => device.kind === 'audioinput');
1047
+ this.availableVideoInputDevices = devices.filter(device => device.kind === 'videoinput');
1048
+ this.availableAudioOutputDevices = devices.filter(device => device.kind === 'audiooutput');
1049
+
1050
+ return {
1051
+ audioInput: this.availableAudioInputDevices,
1052
+ videoInput: this.availableVideoInputDevices,
1053
+ audioOutput: this.availableAudioOutputDevices
1054
+ };
1055
+ }
1056
+
1057
+ /**
1058
+ * Selects a specific audio input device.
1059
+ * @async
1060
+ * @param {string} deviceId - The ID of the audio input device to select.
1061
+ * @returns {Promise<void>}
1062
+ */
1063
+ async selectAudioInputDevice(deviceId) {
1064
+ if (!deviceId) {
1065
+ this._warn('No deviceId provided for audio input.');
1066
+ return;
1067
+ }
1068
+
1069
+ const constraints = {
1070
+ audio: { deviceId: { exact: deviceId } },
1071
+ video: false
1072
+ };
1073
+
1074
+ try {
1075
+ const newStream = await navigator.mediaDevices.getUserMedia(constraints);
1076
+ const newAudioTrack = newStream.getAudioTracks()[0];
1077
+ const sender = this.peerConnection.getSenders().find(s => s.track.kind === 'audio');
1078
+ if (sender) {
1079
+ sender.replaceTrack(newAudioTrack);
1080
+ const oldTrack = sender.track;
1081
+ oldTrack.stop();
1082
+ } else {
1083
+ this.localStream.addTrack(newAudioTrack);
1084
+ await this._publishTracks();
1085
+ }
1086
+
1087
+ this._log(`Switched to audio input device: ${deviceId}`);
1088
+ } catch (error) {
1089
+ this._error('Error switching audio input device:', error);
1090
+ }
1091
+ }
1092
+
1093
+ /**
1094
+ * Selects a specific video input device.
1095
+ * @async
1096
+ * @param {string} deviceId - The ID of the video input device to select.
1097
+ * @returns {Promise<void>}
1098
+ */
1099
+ async selectVideoInputDevice(deviceId) {
1100
+ if (!deviceId) {
1101
+ this._warn('No deviceId provided for video input.');
1102
+ return;
1103
+ }
1104
+
1105
+ const constraints = {
1106
+ video: { deviceId: { exact: deviceId } },
1107
+ audio: false
1108
+ };
1109
+
1110
+ try {
1111
+ const newStream = await navigator.mediaDevices.getUserMedia(constraints);
1112
+ const newVideoTrack = newStream.getVideoTracks()[0];
1113
+ const sender = this.peerConnection.getSenders().find(s => s.track.kind === 'video');
1114
+ if (sender) {
1115
+ sender.replaceTrack(newVideoTrack);
1116
+ const oldTrack = sender.track;
1117
+ oldTrack.stop();
1118
+ } else {
1119
+ this.localStream.addTrack(newVideoTrack);
1120
+ await this._publishTracks();
1121
+ }
1122
+
1123
+ this._log(`Switched to video input device: ${deviceId}`);
1124
+ } catch (error) {
1125
+ this._error('Error switching video input device:', error);
1126
+ }
1127
+ }
1128
+
1129
+ /**
1130
+ * Selects a specific audio output device.
1131
+ * @async
1132
+ * @param {string} deviceId - The ID of the audio output device to select.
1133
+ * @returns {Promise<void>}
1134
+ */
1135
+ async selectAudioOutputDevice(deviceId) {
1136
+ if (!deviceId) {
1137
+ this._warn('No deviceId provided for audio output.');
1138
+ return;
1139
+ }
1140
+
1141
+ try {
1142
+ const audioElements = document.querySelectorAll('audio');
1143
+ for (const audio of audioElements) {
1144
+ await audio.setSinkId(deviceId);
1145
+ }
1146
+ this.currentAudioOutputDeviceId = deviceId;
1147
+ this._log(`Switched to audio output device: ${deviceId}`);
1148
+ } catch (error) {
1149
+ this._error('Error switching audio output device:', error);
1150
+ }
1151
+ }
1152
+
1153
+ /**
1154
+ * Previews media streams with specified device IDs.
1155
+ * @async
1156
+ * @param {Object} params - Parameters for media preview.
1157
+ * @param {string} [params.audioDeviceId] - The ID of the audio input device to use.
1158
+ * @param {string} [params.videoDeviceId] - The ID of the video input device to use.
1159
+ * @param {HTMLMediaElement} [previewElement=null] - The media element to display the preview.
1160
+ * @returns {Promise<MediaStream>} The media stream being previewed.
1161
+ * @throws {Error} If there is an issue accessing the media devices.
1162
+ */
1163
+ async previewMedia({ audioDeviceId, videoDeviceId }, previewElement = null) {
1164
+ const constraints = {
1165
+ audio: audioDeviceId ? { deviceId: { exact: audioDeviceId } } : false,
1166
+ video: videoDeviceId ? { deviceId: { exact: videoDeviceId } } : false
1167
+ };
1168
+
1169
+ try {
1170
+ const stream = await navigator.mediaDevices.getUserMedia(constraints);
1171
+ if (previewElement) {
1172
+ previewElement.srcObject = stream;
1173
+ }
1174
+ return stream;
1175
+ } catch (error) {
1176
+ this._error('Error previewing media:', error);
1177
+ throw error;
1178
+ }
1179
+ }
1180
+
1181
+ /************************************************
1182
+ * Media Controls
1183
+ ***********************************************/
1184
+
1185
+ /**
1186
+ * Toggles the enabled state of video and/or audio tracks.
1187
+ * @param {Object} options - Options to toggle media tracks.
1188
+ * @param {boolean} [options.video=null] - Whether to toggle video tracks.
1189
+ * @param {boolean} [options.audio=null] - Whether to toggle audio tracks.
1190
+ * @returns {void}
1191
+ */
1192
+ toggleMedia({ video = null, audio = null }) {
1193
+ if (!this.localStream) return;
1194
+
1195
+ if (video !== null) {
1196
+ const videoTracks = this.localStream.getVideoTracks();
1197
+ videoTracks.forEach(track => {
1198
+ track.enabled = video;
1199
+ // Find the corresponding sender and update the track status
1200
+ const sender = this.peerConnection?.getSenders().find(s => s.track === track);
1201
+ if (sender) {
1202
+ // Send track status update to SFU
1203
+ this._updateTrackStatus(sender.track.id, 'video', video);
1204
+ }
1205
+ });
1206
+ }
1207
+
1208
+ if (audio !== null) {
1209
+ const audioTracks = this.localStream.getAudioTracks();
1210
+ audioTracks.forEach(track => {
1211
+ track.enabled = audio;
1212
+ // Find the corresponding sender and update the track status
1213
+ const sender = this.peerConnection?.getSenders().find(s => s.track === track);
1214
+ if (sender) {
1215
+ // Send track status update to SFU
1216
+ this._updateTrackStatus(sender.track.id, 'audio', audio);
1217
+ }
1218
+ });
1219
+ }
1220
+ }
1221
+
1222
+ /**
1223
+ * Starts screen sharing.
1224
+ * @async
1225
+ * @returns {Promise<void>}
1226
+ */
1227
+ async shareScreen() {
1228
+ try {
1229
+ // Stop any existing video tracks (Todo: breaks the addTrack)
1230
+ await this.unpublishAllTracks('video');
1231
+
1232
+ const screenStream = await navigator.mediaDevices.getDisplayMedia({
1233
+ video: true,
1234
+ audio: false // Most browsers don't support screen audio yet
1235
+ });
1236
+
1237
+ const screenTrack = screenStream.getVideoTracks()[0];
1238
+
1239
+ // Add the new screen track
1240
+ this.localStream.addTrack(screenTrack);
1241
+
1242
+ // Publish the new track
1243
+ await this._publishTracks();
1244
+
1245
+ // Handle the user stopping screen share
1246
+ screenTrack.onended = async () => {
1247
+ await this.unpublishAllTracks();
1248
+ await this._cleanupEndedTracks();
1249
+
1250
+ this.localStream = await navigator.mediaDevices.getUserMedia({ video: true, audio: true });
1251
+ this._log('Re-acquired local media');
1252
+ await this._publishTracks();
1253
+ };
1254
+ } catch (err) {
1255
+ this._error('Error sharing screen:', err);
1256
+ throw err;
1257
+ }
1258
+ }
1259
+
1260
+ /************************************************
1261
+ * WebSocket-Based Data Communication
1262
+ ***********************************************/
1263
+
1264
+ /**
1265
+ * Internal method to send a message via WebSocket.
1266
+ * @private
1267
+ * @param {Object} data - The data object to send.
1268
+ * @returns {void}
1269
+ */
1270
+ _sendWebSocketMessage(data) {
1271
+ if (!this.ws || this.ws.readyState !== WebSocket.OPEN) {
1272
+ this._warn('WebSocket is not open. Cannot send message.');
1273
+ return;
1274
+ }
1275
+ this.ws.send(JSON.stringify(data));
1276
+ this._log('Sent WebSocket message:', data);
1277
+ }
1278
+
1279
+ /************************************************
1280
+ * Participant Management
1281
+ ***********************************************/
1282
+
1283
+ /**
1284
+ * Lists all participants currently in the room.
1285
+ * @async
1286
+ * @returns {Promise<Array<Object>>} An array of participant objects.
1287
+ * @throws {Error} If not connected to any room.
1288
+ */
1289
+ async listParticipants() {
1290
+ if (!this.roomId) {
1291
+ return this._warn('Not connected to any room.');
1292
+ }
1293
+
1294
+ const resp = await this._fetch(`${this.backendUrl}/api/rooms/${this.roomId}/participants`)
1295
+ .then(r => r.json());
1296
+
1297
+ return resp.participants || [];
1298
+ }
1299
+
1300
+ /************************************************
1301
+ * Helpers & Placeholders
1302
+ ***********************************************/
1303
+
1304
+ /**
1305
+ * Generates a simple UUID.
1306
+ * @private
1307
+ * @returns {string} A generated UUID string.
1308
+ */
1309
+ _generateUUID() {
1310
+ // Simple placeholder generator
1311
+ return 'xxxx-xxxx-xxxx-xxxx'.replace(/[x]/g, () =>
1312
+ ((Math.random() * 16) | 0).toString(16)
1313
+ );
1314
+ }
1315
+
1316
+ /**
1317
+ * Unpublishes all currently published tracks (with filters for type)
1318
+ * @async
1319
+ * @param {string} trackKind - The kind of track to unpublish ('audio' or 'video').
1320
+ * @param {boolean} [force=false] - If true, forces track closure without renegotiation.
1321
+ * @returns {Promise<void>}
1322
+ */
1323
+ async unpublishAllTracks(trackKind, force = false) {
1324
+ if (!this.peerConnection) {
1325
+ this._warn('PeerConnection is not established.');
1326
+ return;
1327
+ }
1328
+
1329
+ let senders = this.peerConnection.getSenders();
1330
+ if (trackKind) {
1331
+ senders = senders.filter(s => s.track && s.track.kind === trackKind);
1332
+ }
1333
+ this._log('Unpublishing all tracks:', senders.length);
1334
+
1335
+ // Create an offer for the updated state
1336
+ const offer = await this.peerConnection.createOffer();
1337
+ await this.peerConnection.setLocalDescription(offer);
1338
+
1339
+ for (const sender of senders) {
1340
+ if (sender.track) {
1341
+ try {
1342
+ const trackId = sender.track.id;
1343
+ const transceiver = this.peerConnection.getTransceivers().find(t => t.sender === sender);
1344
+ const mid = transceiver ? transceiver.mid : null;
1345
+
1346
+ this._log('Unpublishing track:', { trackId, mid });
1347
+
1348
+ if (!mid) {
1349
+ this._warn('No mid found for track:', trackId);
1350
+ continue;
1351
+ }
1352
+
1353
+ // Stop the track first
1354
+ sender.track.stop();
1355
+
1356
+ // Notify server
1357
+ await this._fetch(`${this.backendUrl}/api/rooms/${this.roomId}/sessions/${this.sessionId}/unpublish`, {
1358
+ method: 'POST',
1359
+ headers: { 'Content-Type': 'application/json' },
1360
+ body: JSON.stringify({
1361
+ trackName: trackId,
1362
+ mid: mid,
1363
+ force,
1364
+ sessionDescription: {
1365
+ type: offer.type,
1366
+ sdp: offer.sdp
1367
+ }
1368
+ })
1369
+ });
1370
+
1371
+ // Remove from PeerConnection after server confirms
1372
+ this.peerConnection.removeTrack(sender);
1373
+
1374
+ // Remove from our tracked set
1375
+ this.publishedTracks.delete(trackId);
1376
+
1377
+ // Since we're unpublishing we need to stop local streams
1378
+ await this._cleanupEndedTracks();
1379
+
1380
+ this._log(`Successfully unpublished track: ${trackId}`);
1381
+ } catch (error) {
1382
+ this._error(`Error unpublishing track:`, error);
1383
+ }
1384
+ }
1385
+ }
1386
+ }
1387
+
1388
+ /**
1389
+ * Gets the session state
1390
+ * @async
1391
+ * @returns {Promise<Object>} The session state
1392
+ */
1393
+ async getSessionState() {
1394
+ if (!this.sessionId) {
1395
+ return this._warn('No active session');
1396
+ }
1397
+
1398
+ try {
1399
+ const response = await this._fetch(`${this.backendUrl}/api/rooms/${this.roomId}/sessions/${this.sessionId}/state`);
1400
+ const state = await response.json();
1401
+
1402
+ // Store track states internally
1403
+ if (state.tracks) {
1404
+ this.trackStates = new Map(
1405
+ state.tracks.map(track => [track.trackName, track.status])
1406
+ );
1407
+ }
1408
+
1409
+ return state;
1410
+ } catch (error) {
1411
+ this._error('Error getting session state:', error);
1412
+ throw error;
1413
+ }
1414
+ }
1415
+
1416
+ /**
1417
+ * Gets the track status
1418
+ * @async
1419
+ * @param {string} trackName - The track name
1420
+ * @returns {Promise<string>} The track status
1421
+ */
1422
+ async getTrackStatus(trackName) {
1423
+ const state = await this.getSessionState();
1424
+ return state.tracks.find(t => t.trackName === trackName)?.status;
1425
+ }
1426
+
1427
+ /**
1428
+ * Updates the track status
1429
+ * @async
1430
+ * @private
1431
+ * @param {string} trackId - The track ID
1432
+ * @param {string} kind - The track kind
1433
+ * @param {boolean} enabled - Whether the track is enabled
1434
+ * @returns {Promise<Object>} The updated track status
1435
+ */
1436
+ async _updateTrackStatus(trackId, kind, enabled) {
1437
+ try {
1438
+ const updateUrl = `${this.backendUrl}/api/rooms/${this.roomId}/sessions/${this.sessionId}/track-status`;
1439
+ const response = await this._fetch(updateUrl, {
1440
+ method: 'POST',
1441
+ headers: { 'Content-Type': 'application/json' },
1442
+ body: JSON.stringify({
1443
+ trackId,
1444
+ kind,
1445
+ enabled,
1446
+ force: false // Allow proper renegotiation
1447
+ })
1448
+ });
1449
+
1450
+ const result = await response.json();
1451
+ if (result.errorCode) {
1452
+ throw new Error(result.errorDescription || 'Unknown error updating track status');
1453
+ }
1454
+
1455
+ // If renegotiation is needed, handle it
1456
+ if (result.requiresImmediateRenegotiation) {
1457
+ await this._renegotiate();
1458
+ }
1459
+
1460
+ if (!result.errorCode) {
1461
+ this._updateTrackState(trackId, enabled ? 'enabled' : 'disabled');
1462
+ }
1463
+
1464
+ return result;
1465
+ } catch (error) {
1466
+ this._error(`Error updating track status:`, error);
1467
+ throw error;
1468
+ }
1469
+ }
1470
+
1471
+ /**
1472
+ * Handles errors
1473
+ * @private
1474
+ * @param {Object} response - The response object
1475
+ * @returns {Object} The response object
1476
+ */
1477
+ _handleError(response) {
1478
+ if (response.errorCode) {
1479
+ const error = new Error(response.errorDescription || 'Unknown error');
1480
+ error.code = response.errorCode;
1481
+ throw error;
1482
+ }
1483
+ return response;
1484
+ }
1485
+
1486
+ /**
1487
+ * Gets information about a user
1488
+ * @async
1489
+ * @param {string} [userId] - Optional user ID. If omitted, returns current user's info
1490
+ * @returns {Promise<Object>} User information including moderator status
1491
+ */
1492
+ async getUserInfo(userId = null) {
1493
+ try {
1494
+ const response = await this._fetch(
1495
+ `${this.backendUrl}/api/users/${userId || 'me'}`
1496
+ );
1497
+ return await response.json();
1498
+ } catch (error) {
1499
+ this._error('Error getting user info:', error);
1500
+ throw error;
1501
+ }
1502
+ }
1503
+
1504
+ /**
1505
+ * Handles WebSocket messages
1506
+ * @private
1507
+ * @param {MessageEvent} event - The WebSocket message event
1508
+ * @returns {void}
1509
+ */
1510
+ _handleWebSocketMessage(event) {
1511
+ try {
1512
+ const message = JSON.parse(event.data);
1513
+ this._log('WebSocket message received:', message);
1514
+
1515
+ // First, notify generic handlers
1516
+ this._wsMessageHandlers.forEach(handler => {
1517
+ try {
1518
+ handler(message);
1519
+ } catch (err) {
1520
+ this._error('Error in WebSocket message handler:', err);
1521
+ }
1522
+ });
1523
+
1524
+ // Then handle specific message types
1525
+ switch (message.type) {
1526
+ case 'participant-joined':
1527
+ if (this._onParticipantJoinedCallback) {
1528
+ this._onParticipantJoinedCallback(message.payload);
1529
+ }
1530
+ break;
1531
+
1532
+ case 'participant-left':
1533
+ if (this._onParticipantLeftCallback) {
1534
+ this._onParticipantLeftCallback(message.payload.sessionId);
1535
+ }
1536
+ break;
1537
+
1538
+ case 'track-published':
1539
+ if (this._onRemoteTrackCallback) {
1540
+ // Handle track published event
1541
+ this._onRemoteTrackCallback(message.payload);
1542
+ }
1543
+ break;
1544
+
1545
+ case 'track-unpublished':
1546
+ if (this._onRemoteTrackUnpublishedCallback) {
1547
+ this._onRemoteTrackUnpublishedCallback(
1548
+ message.payload.sessionId,
1549
+ message.payload.trackName
1550
+ );
1551
+ }
1552
+ break;
1553
+
1554
+ case 'track-status-changed':
1555
+ if (this._onTrackStatusChangedCallback) {
1556
+ this._onTrackStatusChangedCallback(message.payload);
1557
+ }
1558
+ break;
1559
+
1560
+ case 'data-message':
1561
+ if (this._onDataMessageCallback) {
1562
+ this._onDataMessageCallback(message.payload);
1563
+ }
1564
+ break;
1565
+
1566
+ case 'room-metadata-updated':
1567
+ if (this._onRoomMetadataUpdatedCallback) {
1568
+ this._onRoomMetadataUpdatedCallback(message.payload);
1569
+ }
1570
+ break;
1571
+
1572
+ default:
1573
+ this._log('Unhandled message type:', message.type);
1574
+ }
1575
+ } catch (error) {
1576
+ this._error('Error handling WebSocket message:', error);
1577
+ }
1578
+ }
1579
+
1580
+ /**
1581
+ * Updates track state in internal tracking
1582
+ * @private
1583
+ * @param {string} trackName - The track name
1584
+ * @param {string} status - The new status
1585
+ */
1586
+ _updateTrackState(trackName, status) {
1587
+ if (!this.trackStates) {
1588
+ this.trackStates = new Map();
1589
+ }
1590
+ this.trackStates.set(trackName, status);
1591
+ }
1592
+
1593
+ /**
1594
+ * Lists all available rooms.
1595
+ * @async
1596
+ * @returns {Promise<Array>} List of rooms
1597
+ */
1598
+ async listRooms() {
1599
+ const resp = await this._fetch(`${this.backendUrl}/api/rooms`)
1600
+ .then(r => r.json());
1601
+ return resp.rooms;
1602
+ }
1603
+
1604
+ /**
1605
+ * Updates room metadata.
1606
+ * @async
1607
+ * @param {Object} updates Metadata updates
1608
+ * @param {string} [updates.name] New room name
1609
+ * @param {Object} [updates.metadata] New room metadata
1610
+ * @returns {Promise<Object>} Updated room information
1611
+ */
1612
+ async updateRoomMetadata(updates) {
1613
+ if (!this.roomId) {
1614
+ return this._warn('Not connected to any room');
1615
+ }
1616
+
1617
+ return await this._fetch(`${this.backendUrl}/api/rooms/${this.roomId}/metadata`, {
1618
+ method: 'PUT',
1619
+ headers: { 'Content-Type': 'application/json' },
1620
+ body: JSON.stringify(updates)
1621
+ }).then(r => r.json());
1622
+ }
1623
+
1624
+ /**
1625
+ * Send a data message to all participants in the room via WebSocket.
1626
+ * @param {Object} data - The JSON object to send.
1627
+ * @returns {void}
1628
+ */
1629
+ async sendDataToAll(data) {
1630
+ if (!this.roomId || !this.sessionId) {
1631
+ throw new Error('Must be in a room to send data');
1632
+ }
1633
+
1634
+ // Send via WebSocket instead of HTTP
1635
+ if (this.ws && this.ws.readyState === WebSocket.OPEN) {
1636
+ this.ws.send(JSON.stringify({
1637
+ type: 'data-message',
1638
+ payload: {
1639
+ from: this.sessionId,
1640
+ message: data
1641
+ }
1642
+ }));
1643
+ } else {
1644
+ throw new Error('WebSocket connection not available');
1645
+ }
1646
+ }
1647
+
1648
+ /**
1649
+ * Sets the media quality for audio and video tracks
1650
+ * @param {string|QualityPreset} quality - Either a preset name ('high', 'medium', 'low') or a custom quality object
1651
+ * @param {VideoQualitySettings} [quality.video] - Video quality settings
1652
+ * @param {AudioQualitySettings} [quality.audio] - Audio quality settings
1653
+ * @throws {Error} If preset name is invalid
1654
+ */
1655
+ setMediaQuality(quality) {
1656
+ // If quality is a string, use the preset
1657
+ if (typeof quality === 'string') {
1658
+ const preset = CloudflareCalls.QUALITY_PRESETS[quality];
1659
+ if (!preset) {
1660
+ return this._warn(`Unknown quality preset: ${quality}`);
1661
+ }
1662
+ this.mediaQuality = quality;
1663
+ quality = preset;
1664
+ }
1665
+
1666
+ this.mediaQuality = {
1667
+ video: { ...this.mediaQuality.video, ...quality.video },
1668
+ audio: { ...this.mediaQuality.audio, ...quality.audio }
1669
+ };
1670
+
1671
+ // Store settings to apply to future tracks
1672
+ this.pendingQualitySettings = this.mediaQuality;
1673
+
1674
+ // If we're already in a call, update existing tracks
1675
+ if (this.peerConnection) {
1676
+ this._applyQualitySettings();
1677
+ }
1678
+ }
1679
+
1680
+ /**
1681
+ * Applies quality settings to all tracks
1682
+ * @private
1683
+ */
1684
+ async _applyQualitySettings() {
1685
+ if (!this.peerConnection) return;
1686
+
1687
+ const senders = this.peerConnection.getSenders();
1688
+ for (const sender of senders) {
1689
+ if (!sender.track) continue;
1690
+
1691
+ const params = sender.getParameters();
1692
+ if (!params.encodings) {
1693
+ params.encodings = [{}];
1694
+ }
1695
+
1696
+ const kind = sender.track.kind;
1697
+ const qualitySettings = this.mediaQuality[kind];
1698
+
1699
+ // Update bitrate
1700
+ if (qualitySettings.maxBitrate) {
1701
+ params.encodings[0].maxBitrate = qualitySettings.maxBitrate;
1702
+ }
1703
+
1704
+ // Update resolution/framerate for video
1705
+ if (kind === 'video') {
1706
+ const constraints = {
1707
+ width: qualitySettings.width,
1708
+ height: qualitySettings.height,
1709
+ frameRate: qualitySettings.frameRate
1710
+ };
1711
+ await sender.track.applyConstraints(constraints);
1712
+ }
1713
+
1714
+ await sender.setParameters(params);
1715
+ }
1716
+ }
1717
+
1718
+ /**
1719
+ * Start monitoring connection statistics
1720
+ * @param {number} [interval=1000] - How often to gather stats in milliseconds
1721
+ */
1722
+ startStatsMonitoring(interval = 1000) {
1723
+ if (this.statsMonitoringState === 'monitoring') return;
1724
+
1725
+ this.statsMonitoringState = 'monitoring';
1726
+ this.statsInterval = setInterval(async () => {
1727
+ if (!this.peerConnection) return;
1728
+
1729
+ const stats = await this._gatherConnectionStats();
1730
+ const streamStats = await this._gatherStreamStats();
1731
+
1732
+ if (this._onConnectionStatsCallback) {
1733
+ this._onConnectionStatsCallback(stats, streamStats);
1734
+ }
1735
+ }, interval);
1736
+ }
1737
+
1738
+ /**
1739
+ * Stop monitoring connection statistics
1740
+ */
1741
+ stopStatsMonitoring() {
1742
+ if (this.statsInterval) {
1743
+ clearInterval(this.statsInterval);
1744
+ this.statsInterval = null;
1745
+ // + this.previousStats = null; // Clear previous stats
1746
+ }
1747
+ this.statsMonitoringState = 'stopped';
1748
+ }
1749
+
1750
+ /**
1751
+ * Register a callback to receive connection statistics
1752
+ * @param {function(ConnectionStats): void} callback - Function to receive stats updates
1753
+ */
1754
+ onConnectionStats(callback) {
1755
+ this._onConnectionStatsCallback = callback;
1756
+ }
1757
+
1758
+ /**
1759
+ * Gather current connection statistics
1760
+ * @private
1761
+ * @returns {Promise<ConnectionStats>} Current connection statistics
1762
+ */
1763
+ async _gatherConnectionStats() {
1764
+ if (!this.peerConnection) {
1765
+ return this._warn('No active connection');
1766
+ }
1767
+
1768
+ const stats = await this.peerConnection.getStats();
1769
+ const result = {
1770
+ outbound: {
1771
+ bitrate: 0,
1772
+ packetLoss: 0,
1773
+ qualityLimitation: 'none'
1774
+ },
1775
+ inbound: {
1776
+ bitrate: 0,
1777
+ packetLoss: 0,
1778
+ jitter: 0
1779
+ },
1780
+ connection: {
1781
+ roundTripTime: 0,
1782
+ state: this.peerConnection.connectionState
1783
+ }
1784
+ };
1785
+
1786
+ let outboundStats = null;
1787
+ let inboundStats = null;
1788
+
1789
+ // Process each stat
1790
+ stats.forEach(stat => {
1791
+ switch (stat.type) {
1792
+ case 'outbound-rtp':
1793
+ if (stat.kind === 'video') {
1794
+ outboundStats = stat;
1795
+ result.outbound.qualityLimitation = stat.qualityLimitationReason;
1796
+ }
1797
+ break;
1798
+
1799
+ case 'inbound-rtp':
1800
+ if (stat.kind === 'video') {
1801
+ inboundStats = stat;
1802
+ result.inbound.jitter = stat.jitter;
1803
+ if (stat.packetsLost > 0) {
1804
+ result.inbound.packetLoss =
1805
+ (stat.packetsLost / (stat.packetsReceived + stat.packetsLost)) * 100;
1806
+ }
1807
+ }
1808
+ break;
1809
+
1810
+ case 'candidate-pair':
1811
+ if (stat.state === 'succeeded') {
1812
+ result.connection.roundTripTime = stat.currentRoundTripTime;
1813
+ }
1814
+ break;
1815
+ }
1816
+ });
1817
+
1818
+ // Calculate bitrates using previous stats
1819
+ if (this.previousStats && outboundStats && inboundStats) {
1820
+ const timeDelta = (outboundStats.timestamp - this.previousStats.outboundTimestamp) / 1000; // Convert to seconds
1821
+
1822
+ if (timeDelta > 0) {
1823
+ // Calculate outbound bitrate
1824
+ const bytesSentDelta = outboundStats.bytesSent - this.previousStats.bytesSent;
1825
+ result.outbound.bitrate = (bytesSentDelta * 8) / timeDelta; // Convert to bits per second
1826
+
1827
+ // Calculate inbound bitrate
1828
+ const bytesReceivedDelta = inboundStats.bytesReceived - this.previousStats.bytesReceived;
1829
+ result.inbound.bitrate = (bytesReceivedDelta * 8) / timeDelta; // Convert to bits per second
1830
+ }
1831
+ }
1832
+
1833
+ // Store current stats for next calculation
1834
+ if (outboundStats && inboundStats) {
1835
+ this.previousStats = {
1836
+ outboundTimestamp: outboundStats.timestamp,
1837
+ bytesSent: outboundStats.bytesSent,
1838
+ bytesReceived: inboundStats.bytesReceived
1839
+ };
1840
+ }
1841
+
1842
+ return result;
1843
+ }
1844
+
1845
+ /**
1846
+ * Get a snapshot of current connection statistics
1847
+ * @returns {Promise<ConnectionStats>} Current connection statistics
1848
+ */
1849
+ async getConnectionStats() {
1850
+ return this._gatherConnectionStats();
1851
+ }
1852
+
1853
+ /**
1854
+ * Gather current connection statistics per stream
1855
+ * @private
1856
+ * @returns {Promise<Map<string, StreamStats>>} Map of session IDs to stream stats
1857
+ */
1858
+ async _gatherStreamStats() {
1859
+ if (!this.peerConnection) return new Map();
1860
+
1861
+ const stats = await this.peerConnection.getStats();
1862
+ const streamStats = new Map();
1863
+
1864
+ // Initialize local stats
1865
+ if (this.sessionId) {
1866
+ streamStats.set(this.sessionId, {
1867
+ sessionId: this.sessionId,
1868
+ packetLoss: 0,
1869
+ qualityLimitation: 'none',
1870
+ bitrate: 0
1871
+ });
1872
+ }
1873
+
1874
+ stats.forEach(stat => {
1875
+ if (stat.type === 'outbound-rtp' && stat.kind === 'video') {
1876
+ // Update local stream stats
1877
+ const localStats = streamStats.get(this.sessionId);
1878
+ if (localStats) {
1879
+ localStats.qualityLimitation = stat.qualityLimitationReason;
1880
+ localStats.bitrate = stat.bytesSent * 8 / stat.timestamp;
1881
+ }
1882
+ }
1883
+ else if (stat.type === 'inbound-rtp' && stat.kind === 'video') {
1884
+ // Get sessionId from mid mapping
1885
+ const mid = stat.mid;
1886
+ const sessionId = this.midToSessionId.get(mid);
1887
+
1888
+ if (sessionId) {
1889
+ streamStats.set(sessionId, {
1890
+ sessionId,
1891
+ packetLoss: stat.packetsLost > 0
1892
+ ? (stat.packetsLost / (stat.packetsReceived + stat.packetsLost)) * 100
1893
+ : 0,
1894
+ qualityLimitation: 'none',
1895
+ bitrate: stat.bytesReceived * 8 / stat.timestamp
1896
+ });
1897
+ }
1898
+ }
1899
+ });
1900
+
1901
+ return streamStats;
1902
+ }
1903
+
1904
+ // Add static QUALITY_PRESETS
1905
+ static QUALITY_PRESETS = {
1906
+ // 16:9 Presets
1907
+ high_16x9_xl: { // 1080p
1908
+ video: {
1909
+ width: { ideal: 1920 },
1910
+ height: { ideal: 1080 },
1911
+ frameRate: { ideal: 30 },
1912
+ maxBitrate: 2_500_000
1913
+ },
1914
+ audio: { maxBitrate: 128000, sampleRate: 48000, channelCount: 2 }
1915
+ },
1916
+ high_16x9_lg: { // 720p
1917
+ video: {
1918
+ width: { ideal: 1280 },
1919
+ height: { ideal: 720 },
1920
+ frameRate: { ideal: 30 },
1921
+ maxBitrate: 1_500_000
1922
+ },
1923
+ audio: { maxBitrate: 96000, sampleRate: 48000, channelCount: 2 }
1924
+ },
1925
+ high_16x9_md: { // 480p
1926
+ video: {
1927
+ width: { ideal: 854 },
1928
+ height: { ideal: 480 },
1929
+ frameRate: { ideal: 30 },
1930
+ maxBitrate: 800_000
1931
+ },
1932
+ audio: { maxBitrate: 96000, sampleRate: 48000, channelCount: 1 }
1933
+ },
1934
+ high_16x9_sm: { // 360p
1935
+ video: {
1936
+ width: { ideal: 640 },
1937
+ height: { ideal: 360 },
1938
+ frameRate: { ideal: 30 },
1939
+ maxBitrate: 600_000
1940
+ },
1941
+ audio: { maxBitrate: 64000, sampleRate: 44100, channelCount: 1 }
1942
+ },
1943
+ high_16x9_xs: { // 270p
1944
+ video: {
1945
+ width: { ideal: 480 },
1946
+ height: { ideal: 270 },
1947
+ frameRate: { ideal: 30 },
1948
+ maxBitrate: 400_000
1949
+ },
1950
+ audio: { maxBitrate: 64000, sampleRate: 44100, channelCount: 1 }
1951
+ },
1952
+
1953
+ // 16:9 Medium Quality Presets (reduced framerate & bitrate)
1954
+ medium_16x9_xl: {
1955
+ video: {
1956
+ width: { ideal: 1920 },
1957
+ height: { ideal: 1080 },
1958
+ frameRate: { ideal: 24 },
1959
+ maxBitrate: 2_000_000
1960
+ },
1961
+ audio: { maxBitrate: 96000, sampleRate: 48000, channelCount: 2 }
1962
+ },
1963
+ medium_16x9_lg: {
1964
+ video: {
1965
+ width: { ideal: 1280 },
1966
+ height: { ideal: 720 },
1967
+ frameRate: { ideal: 24 },
1968
+ maxBitrate: 1_200_000
1969
+ },
1970
+ audio: { maxBitrate: 96000, sampleRate: 48000, channelCount: 1 }
1971
+ },
1972
+ medium_16x9_md: {
1973
+ video: {
1974
+ width: { ideal: 854 },
1975
+ height: { ideal: 480 },
1976
+ frameRate: { ideal: 24 },
1977
+ maxBitrate: 600_000
1978
+ },
1979
+ audio: { maxBitrate: 64000, sampleRate: 44100, channelCount: 1 }
1980
+ },
1981
+ medium_16x9_sm: {
1982
+ video: {
1983
+ width: { ideal: 640 },
1984
+ height: { ideal: 360 },
1985
+ frameRate: { ideal: 20 },
1986
+ maxBitrate: 400_000
1987
+ },
1988
+ audio: { maxBitrate: 48000, sampleRate: 44100, channelCount: 1 }
1989
+ },
1990
+ medium_16x9_xs: {
1991
+ video: {
1992
+ width: { ideal: 480 },
1993
+ height: { ideal: 270 },
1994
+ frameRate: { ideal: 20 },
1995
+ maxBitrate: 300_000
1996
+ },
1997
+ audio: { maxBitrate: 48000, sampleRate: 44100, channelCount: 1 }
1998
+ },
1999
+
2000
+ // 16:9 Low Quality Presets (minimum viable quality)
2001
+ low_16x9_xl: {
2002
+ video: {
2003
+ width: { ideal: 1920 },
2004
+ height: { ideal: 1080 },
2005
+ frameRate: { ideal: 15 },
2006
+ maxBitrate: 1_500_000
2007
+ },
2008
+ audio: { maxBitrate: 64000, sampleRate: 44100, channelCount: 1 }
2009
+ },
2010
+ low_16x9_lg: {
2011
+ video: {
2012
+ width: { ideal: 1280 },
2013
+ height: { ideal: 720 },
2014
+ frameRate: { ideal: 15 },
2015
+ maxBitrate: 800_000
2016
+ },
2017
+ audio: { maxBitrate: 48000, sampleRate: 44100, channelCount: 1 }
2018
+ },
2019
+ low_16x9_md: {
2020
+ video: {
2021
+ width: { ideal: 854 },
2022
+ height: { ideal: 480 },
2023
+ frameRate: { ideal: 15 },
2024
+ maxBitrate: 400_000
2025
+ },
2026
+ audio: { maxBitrate: 32000, sampleRate: 44100, channelCount: 1 }
2027
+ },
2028
+ low_16x9_sm: {
2029
+ video: {
2030
+ width: { ideal: 640 },
2031
+ height: { ideal: 360 },
2032
+ frameRate: { ideal: 12 },
2033
+ maxBitrate: 250_000
2034
+ },
2035
+ audio: { maxBitrate: 32000, sampleRate: 22050, channelCount: 1 }
2036
+ },
2037
+ low_16x9_xs: {
2038
+ video: {
2039
+ width: { ideal: 480 },
2040
+ height: { ideal: 270 },
2041
+ frameRate: { ideal: 10 },
2042
+ maxBitrate: 150_000
2043
+ },
2044
+ audio: { maxBitrate: 24000, sampleRate: 22050, channelCount: 1 }
2045
+ },
2046
+
2047
+ // 4:3 High Quality Presets (existing)
2048
+ high_4x3_xl: { // 960x720
2049
+ video: {
2050
+ width: { ideal: 960 },
2051
+ height: { ideal: 720 },
2052
+ frameRate: { ideal: 30 },
2053
+ maxBitrate: 1_500_000
2054
+ },
2055
+ audio: { maxBitrate: 128000, sampleRate: 48000, channelCount: 2 }
2056
+ },
2057
+ high_4x3_lg: { // 640x480
2058
+ video: {
2059
+ width: { ideal: 640 },
2060
+ height: { ideal: 480 },
2061
+ frameRate: { ideal: 30 },
2062
+ maxBitrate: 800_000
2063
+ },
2064
+ audio: { maxBitrate: 96000, sampleRate: 48000, channelCount: 1 }
2065
+ },
2066
+ high_4x3_md: { // 480x360
2067
+ video: {
2068
+ width: { ideal: 480 },
2069
+ height: { ideal: 360 },
2070
+ frameRate: { ideal: 30 },
2071
+ maxBitrate: 600_000
2072
+ },
2073
+ audio: { maxBitrate: 96000, sampleRate: 44100, channelCount: 1 }
2074
+ },
2075
+ high_4x3_sm: { // 320x240
2076
+ video: {
2077
+ width: { ideal: 320 },
2078
+ height: { ideal: 240 },
2079
+ frameRate: { ideal: 30 },
2080
+ maxBitrate: 400_000
2081
+ },
2082
+ audio: { maxBitrate: 64000, sampleRate: 44100, channelCount: 1 }
2083
+ },
2084
+ high_4x3_xs: { // 240x180 (perfect for 300x225 container)
2085
+ video: {
2086
+ width: { ideal: 240 },
2087
+ height: { ideal: 180 },
2088
+ frameRate: { ideal: 30 },
2089
+ maxBitrate: 250_000
2090
+ },
2091
+ audio: { maxBitrate: 64000, sampleRate: 44100, channelCount: 1 }
2092
+ },
2093
+
2094
+ // 4:3 Medium Quality Presets
2095
+ medium_4x3_xl: {
2096
+ video: {
2097
+ width: { ideal: 960 },
2098
+ height: { ideal: 720 },
2099
+ frameRate: { ideal: 24 },
2100
+ maxBitrate: 1_200_000
2101
+ },
2102
+ audio: { maxBitrate: 96000, sampleRate: 48000, channelCount: 1 }
2103
+ },
2104
+ medium_4x3_lg: {
2105
+ video: {
2106
+ width: { ideal: 640 },
2107
+ height: { ideal: 480 },
2108
+ frameRate: { ideal: 24 },
2109
+ maxBitrate: 600_000
2110
+ },
2111
+ audio: { maxBitrate: 64000, sampleRate: 44100, channelCount: 1 }
2112
+ },
2113
+ medium_4x3_md: {
2114
+ video: {
2115
+ width: { ideal: 480 },
2116
+ height: { ideal: 360 },
2117
+ frameRate: { ideal: 20 },
2118
+ maxBitrate: 400_000
2119
+ },
2120
+ audio: { maxBitrate: 48000, sampleRate: 44100, channelCount: 1 }
2121
+ },
2122
+ medium_4x3_sm: {
2123
+ video: {
2124
+ width: { ideal: 320 },
2125
+ height: { ideal: 240 },
2126
+ frameRate: { ideal: 20 },
2127
+ maxBitrate: 300_000
2128
+ },
2129
+ audio: { maxBitrate: 48000, sampleRate: 44100, channelCount: 1 }
2130
+ },
2131
+ medium_4x3_xs: {
2132
+ video: {
2133
+ width: { ideal: 240 },
2134
+ height: { ideal: 180 },
2135
+ frameRate: { ideal: 20 },
2136
+ maxBitrate: 200_000
2137
+ },
2138
+ audio: { maxBitrate: 48000, sampleRate: 44100, channelCount: 1 }
2139
+ },
2140
+
2141
+ // 4:3 Low Quality Presets
2142
+ low_4x3_xl: {
2143
+ video: {
2144
+ width: { ideal: 960 },
2145
+ height: { ideal: 720 },
2146
+ frameRate: { ideal: 15 },
2147
+ maxBitrate: 800_000
2148
+ },
2149
+ audio: { maxBitrate: 48000, sampleRate: 44100, channelCount: 1 }
2150
+ },
2151
+ low_4x3_lg: {
2152
+ video: {
2153
+ width: { ideal: 640 },
2154
+ height: { ideal: 480 },
2155
+ frameRate: { ideal: 15 },
2156
+ maxBitrate: 400_000
2157
+ },
2158
+ audio: { maxBitrate: 32000, sampleRate: 44100, channelCount: 1 }
2159
+ },
2160
+ low_4x3_md: {
2161
+ video: {
2162
+ width: { ideal: 480 },
2163
+ height: { ideal: 360 },
2164
+ frameRate: { ideal: 12 },
2165
+ maxBitrate: 250_000
2166
+ },
2167
+ audio: { maxBitrate: 32000, sampleRate: 22050, channelCount: 1 }
2168
+ },
2169
+ low_4x3_sm: {
2170
+ video: {
2171
+ width: { ideal: 320 },
2172
+ height: { ideal: 240 },
2173
+ frameRate: { ideal: 10 },
2174
+ maxBitrate: 150_000
2175
+ },
2176
+ audio: { maxBitrate: 24000, sampleRate: 22050, channelCount: 1 }
2177
+ },
2178
+ low_4x3_xs: {
2179
+ video: {
2180
+ width: { ideal: 240 },
2181
+ height: { ideal: 180 },
2182
+ frameRate: { ideal: 10 },
2183
+ maxBitrate: 100_000
2184
+ },
2185
+ audio: { maxBitrate: 24000, sampleRate: 22050, channelCount: 1 }
2186
+ },
2187
+
2188
+ // 1:1 High Quality Presets
2189
+ high_1x1_xl: { // 720x720
2190
+ video: {
2191
+ width: { ideal: 720 },
2192
+ height: { ideal: 720 },
2193
+ frameRate: { ideal: 30 },
2194
+ maxBitrate: 1_500_000
2195
+ },
2196
+ audio: { maxBitrate: 128000, sampleRate: 48000, channelCount: 2 }
2197
+ },
2198
+ high_1x1_lg: { // 480x480
2199
+ video: {
2200
+ width: { ideal: 480 },
2201
+ height: { ideal: 480 },
2202
+ frameRate: { ideal: 30 },
2203
+ maxBitrate: 800_000
2204
+ },
2205
+ audio: { maxBitrate: 96000, sampleRate: 48000, channelCount: 1 }
2206
+ },
2207
+ high_1x1_md: { // 360x360
2208
+ video: {
2209
+ width: { ideal: 360 },
2210
+ height: { ideal: 360 },
2211
+ frameRate: { ideal: 30 },
2212
+ maxBitrate: 600_000
2213
+ },
2214
+ audio: { maxBitrate: 96000, sampleRate: 44100, channelCount: 1 }
2215
+ },
2216
+ high_1x1_sm: { // 240x240
2217
+ video: {
2218
+ width: { ideal: 240 },
2219
+ height: { ideal: 240 },
2220
+ frameRate: { ideal: 30 },
2221
+ maxBitrate: 400_000
2222
+ },
2223
+ audio: { maxBitrate: 64000, sampleRate: 44100, channelCount: 1 }
2224
+ },
2225
+ high_1x1_xs: { // 180x180
2226
+ video: {
2227
+ width: { ideal: 180 },
2228
+ height: { ideal: 180 },
2229
+ frameRate: { ideal: 30 },
2230
+ maxBitrate: 250_000
2231
+ },
2232
+ audio: { maxBitrate: 64000, sampleRate: 44100, channelCount: 1 }
2233
+ },
2234
+
2235
+ // 1:1 Medium Quality Presets
2236
+ medium_1x1_xl: {
2237
+ video: {
2238
+ width: { ideal: 720 },
2239
+ height: { ideal: 720 },
2240
+ frameRate: { ideal: 24 },
2241
+ maxBitrate: 1_200_000
2242
+ },
2243
+ audio: { maxBitrate: 96000, sampleRate: 48000, channelCount: 1 }
2244
+ },
2245
+ medium_1x1_lg: {
2246
+ video: {
2247
+ width: { ideal: 480 },
2248
+ height: { ideal: 480 },
2249
+ frameRate: { ideal: 24 },
2250
+ maxBitrate: 600_000
2251
+ },
2252
+ audio: { maxBitrate: 64000, sampleRate: 44100, channelCount: 1 }
2253
+ },
2254
+ medium_1x1_md: {
2255
+ video: {
2256
+ width: { ideal: 360 },
2257
+ height: { ideal: 360 },
2258
+ frameRate: { ideal: 20 },
2259
+ maxBitrate: 400_000
2260
+ },
2261
+ audio: { maxBitrate: 48000, sampleRate: 44100, channelCount: 1 }
2262
+ },
2263
+ medium_1x1_sm: {
2264
+ video: {
2265
+ width: { ideal: 240 },
2266
+ height: { ideal: 240 },
2267
+ frameRate: { ideal: 20 },
2268
+ maxBitrate: 300_000
2269
+ },
2270
+ audio: { maxBitrate: 48000, sampleRate: 44100, channelCount: 1 }
2271
+ },
2272
+ medium_1x1_xs: {
2273
+ video: {
2274
+ width: { ideal: 180 },
2275
+ height: { ideal: 180 },
2276
+ frameRate: { ideal: 20 },
2277
+ maxBitrate: 200_000
2278
+ },
2279
+ audio: { maxBitrate: 48000, sampleRate: 44100, channelCount: 1 }
2280
+ },
2281
+
2282
+ // 1:1 Low Quality Presets
2283
+ low_1x1_xl: {
2284
+ video: {
2285
+ width: { ideal: 720 },
2286
+ height: { ideal: 720 },
2287
+ frameRate: { ideal: 15 },
2288
+ maxBitrate: 800_000
2289
+ },
2290
+ audio: { maxBitrate: 48000, sampleRate: 44100, channelCount: 1 }
2291
+ },
2292
+ low_1x1_lg: {
2293
+ video: {
2294
+ width: { ideal: 480 },
2295
+ height: { ideal: 480 },
2296
+ frameRate: { ideal: 15 },
2297
+ maxBitrate: 400_000
2298
+ },
2299
+ audio: { maxBitrate: 32000, sampleRate: 44100, channelCount: 1 }
2300
+ },
2301
+ low_1x1_md: {
2302
+ video: {
2303
+ width: { ideal: 360 },
2304
+ height: { ideal: 360 },
2305
+ frameRate: { ideal: 12 },
2306
+ maxBitrate: 250_000
2307
+ },
2308
+ audio: { maxBitrate: 32000, sampleRate: 22050, channelCount: 1 }
2309
+ },
2310
+ low_1x1_sm: {
2311
+ video: {
2312
+ width: { ideal: 240 },
2313
+ height: { ideal: 240 },
2314
+ frameRate: { ideal: 10 },
2315
+ maxBitrate: 150_000
2316
+ },
2317
+ audio: { maxBitrate: 24000, sampleRate: 22050, channelCount: 1 }
2318
+ },
2319
+ low_1x1_xs: {
2320
+ video: {
2321
+ width: { ideal: 180 },
2322
+ height: { ideal: 180 },
2323
+ frameRate: { ideal: 10 },
2324
+ maxBitrate: 100_000
2325
+ },
2326
+ audio: { maxBitrate: 24000, sampleRate: 22050, channelCount: 1 }
2327
+ },
2328
+
2329
+ // 9:16 High Quality Presets (Portrait/Mobile)
2330
+ high_9x16_xl: { // 1080x1920
2331
+ video: {
2332
+ width: { ideal: 1080 },
2333
+ height: { ideal: 1920 },
2334
+ frameRate: { ideal: 30 },
2335
+ maxBitrate: 2_500_000
2336
+ },
2337
+ audio: { maxBitrate: 128000, sampleRate: 48000, channelCount: 2 }
2338
+ },
2339
+ high_9x16_lg: { // 720x1280
2340
+ video: {
2341
+ width: { ideal: 720 },
2342
+ height: { ideal: 1280 },
2343
+ frameRate: { ideal: 30 },
2344
+ maxBitrate: 1_500_000
2345
+ },
2346
+ audio: { maxBitrate: 96000, sampleRate: 48000, channelCount: 1 }
2347
+ },
2348
+ high_9x16_md: { // 480x854
2349
+ video: {
2350
+ width: { ideal: 480 },
2351
+ height: { ideal: 854 },
2352
+ frameRate: { ideal: 30 },
2353
+ maxBitrate: 800_000
2354
+ },
2355
+ audio: { maxBitrate: 96000, sampleRate: 44100, channelCount: 1 }
2356
+ },
2357
+ high_9x16_sm: { // 360x640
2358
+ video: {
2359
+ width: { ideal: 360 },
2360
+ height: { ideal: 640 },
2361
+ frameRate: { ideal: 30 },
2362
+ maxBitrate: 600_000
2363
+ },
2364
+ audio: { maxBitrate: 64000, sampleRate: 44100, channelCount: 1 }
2365
+ },
2366
+ high_9x16_xs: { // 270x480
2367
+ video: {
2368
+ width: { ideal: 270 },
2369
+ height: { ideal: 480 },
2370
+ frameRate: { ideal: 30 },
2371
+ maxBitrate: 400_000
2372
+ },
2373
+ audio: { maxBitrate: 64000, sampleRate: 44100, channelCount: 1 }
2374
+ },
2375
+
2376
+ // 9:16 Medium Quality Presets
2377
+ medium_9x16_xl: {
2378
+ video: {
2379
+ width: { ideal: 1080 },
2380
+ height: { ideal: 1920 },
2381
+ frameRate: { ideal: 24 },
2382
+ maxBitrate: 2_000_000
2383
+ },
2384
+ audio: { maxBitrate: 96000, sampleRate: 48000, channelCount: 1 }
2385
+ },
2386
+ medium_9x16_lg: {
2387
+ video: {
2388
+ width: { ideal: 720 },
2389
+ height: { ideal: 1280 },
2390
+ frameRate: { ideal: 24 },
2391
+ maxBitrate: 1_200_000
2392
+ },
2393
+ audio: { maxBitrate: 64000, sampleRate: 44100, channelCount: 1 }
2394
+ },
2395
+ medium_9x16_md: {
2396
+ video: {
2397
+ width: { ideal: 480 },
2398
+ height: { ideal: 854 },
2399
+ frameRate: { ideal: 20 },
2400
+ maxBitrate: 600_000
2401
+ },
2402
+ audio: { maxBitrate: 48000, sampleRate: 44100, channelCount: 1 }
2403
+ },
2404
+ medium_9x16_sm: {
2405
+ video: {
2406
+ width: { ideal: 360 },
2407
+ height: { ideal: 640 },
2408
+ frameRate: { ideal: 20 },
2409
+ maxBitrate: 400_000
2410
+ },
2411
+ audio: { maxBitrate: 48000, sampleRate: 44100, channelCount: 1 }
2412
+ },
2413
+ medium_9x16_xs: {
2414
+ video: {
2415
+ width: { ideal: 270 },
2416
+ height: { ideal: 480 },
2417
+ frameRate: { ideal: 20 },
2418
+ maxBitrate: 300_000
2419
+ },
2420
+ audio: { maxBitrate: 48000, sampleRate: 44100, channelCount: 1 }
2421
+ },
2422
+
2423
+ // 9:16 Low Quality Presets
2424
+ low_9x16_xl: {
2425
+ video: {
2426
+ width: { ideal: 1080 },
2427
+ height: { ideal: 1920 },
2428
+ frameRate: { ideal: 15 },
2429
+ maxBitrate: 1_500_000
2430
+ },
2431
+ audio: { maxBitrate: 48000, sampleRate: 44100, channelCount: 1 }
2432
+ },
2433
+ low_9x16_lg: {
2434
+ video: {
2435
+ width: { ideal: 720 },
2436
+ height: { ideal: 1280 },
2437
+ frameRate: { ideal: 15 },
2438
+ maxBitrate: 800_000
2439
+ },
2440
+ audio: { maxBitrate: 32000, sampleRate: 44100, channelCount: 1 }
2441
+ },
2442
+ low_9x16_md: {
2443
+ video: {
2444
+ width: { ideal: 480 },
2445
+ height: { ideal: 854 },
2446
+ frameRate: { ideal: 12 },
2447
+ maxBitrate: 400_000
2448
+ },
2449
+ audio: { maxBitrate: 32000, sampleRate: 22050, channelCount: 1 }
2450
+ },
2451
+ low_9x16_sm: {
2452
+ video: {
2453
+ width: { ideal: 360 },
2454
+ height: { ideal: 640 },
2455
+ frameRate: { ideal: 10 },
2456
+ maxBitrate: 250_000
2457
+ },
2458
+ audio: { maxBitrate: 24000, sampleRate: 22050, channelCount: 1 }
2459
+ },
2460
+ low_9x16_xs: {
2461
+ video: {
2462
+ width: { ideal: 270 },
2463
+ height: { ideal: 480 },
2464
+ frameRate: { ideal: 10 },
2465
+ maxBitrate: 150_000
2466
+ },
2467
+ audio: { maxBitrate: 24000, sampleRate: 22050, channelCount: 1 }
2468
+ }
2469
+ };
2470
+ }
2471
+
2472
+ export default CloudflareCalls;
public/js/CloudflareCalls.min.js ADDED
@@ -0,0 +1 @@
 
 
1
+ !function(e,t){"object"==typeof exports&&"undefined"!=typeof module?module.exports=t():"function"==typeof define&&define.amd?define(t):(e="undefined"!=typeof globalThis?globalThis:e||self).CloudflareCalls=t()}(this,(function(){"use strict";class e{constructor(t={}){this.backendUrl=t.backendUrl||"",this.websocketUrl=t.websocketUrl||"",this.debug=t.debug||!1,this.token=null,this.roomId=null,this.sessionId=null,this.userId=this._generateUUID(),this.userMetadata={},this.localStream=null,this.peerConnection=null,this.ws=null,this._onParticipantJoinedCallback=null,this._onParticipantLeftCallback=null,this._onRemoteTrackCallback=null,this._onRemoteTrackUnpublishedCallback=null,this._onTrackStatusChangedCallback=null,this._onDataMessageCallback=null,this._onConnectionStatsCallback=null,this._wsMessageHandlers=new Set,this.pulledTracks=new Map,this.pollingInterval=null,this.availableAudioInputDevices=[],this.availableVideoInputDevices=[],this.availableAudioOutputDevices=[],this.currentAudioOutputDeviceId=null,this._renegotiateTimeout=null,this.publishedTracks=new Set,this.midToSessionId=new Map,this.midToTrackName=new Map,this._onRoomMetadataUpdatedCallback=null,this.pendingQualitySettings=null,this.mediaQuality=e.QUALITY_PRESETS.medium_16x9_md,this.QUALITY_PRESETS=e.QUALITY_PRESETS,this.statsInterval=null,this.previousStats=null,this.statsMonitoringState="stopped"}_log(...e){this.debug&&console.log("[CloudflareCalls]",...e)}_warn(...e){this.debug&&console.warn("[CloudflareCalls]",...e)}_error(...e){console.error("[CloudflareCalls]",...e)}setDebugMode(e){this.debug=Boolean(e)}async _fetch(e,t={}){t.headers=t.headers||{},this.token&&(t.headers.Authorization=`Bearer ${this.token}`);try{const a=await fetch(e,t);return a.ok||this._warn(`HTTP error! status: ${a.status}`),a}catch(t){return this._warn(`Fetch error for ${e}:`,t),!1}}onRemoteTrack(e){this._onRemoteTrackCallback=e}onRemoteTrackUnpublished(e){this._onRemoteTrackUnpublishedCallback=e}onDataMessage(e){this._onDataMessageCallback=e}onParticipantJoined(e){this._onParticipantJoinedCallback=e}onParticipantLeft(e){this._onParticipantLeftCallback=e}onTrackStatusChanged(e){this._onTrackStatusChangedCallback=e}onWebSocketMessage(e){return this._wsMessageHandlers.add(e),()=>this._wsMessageHandlers.delete(e)}setToken(e){this.token=e}onRoomMetadataUpdated(e){this._onRoomMetadataUpdatedCallback=e}setUserMetadata(e){this.userMetadata=e,this._updateUserMetadataOnServer()}getUserMetadata(){return this.userMetadata}async _updateUserMetadataOnServer(){if(this.roomId&&this.sessionId)try{const e=`${this.backendUrl}/api/rooms/${this.roomId}/metadata`;(await this._fetch(e,{method:"PUT",headers:{"Content-Type":"application/json"},body:JSON.stringify(this.userMetadata)})).ok?this._log("User metadata updated on server."):this._error("Failed to update user metadata on server.")}catch(e){throw this._error("Error updating user metadata:",e),e}else this._warn("Cannot update metadata before joining a room.")}async createRoom(e={}){const t=await this._fetch(`${this.backendUrl}/api/rooms`,{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify(e)}).then((e=>e.json()));return this.roomId=t.roomId,t}async joinRoom(e,t={}){this.roomId=e;const a=await this._fetch(`${this.backendUrl}/api/rooms/${e}/join`,{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify({userId:this.userId,metadata:this.userMetadata})}).then((e=>e.json()));if(await this._initWebSocket(),!a.sessionId)throw new Error("Failed to join room or retrieve sessionId");this.sessionId=a.sessionId,this.pulledTracks.set(this.sessionId,new Set),this.peerConnection=await this._createPeerConnection(),this.localStream||(this.localStream=await navigator.mediaDevices.getUserMedia({video:!0,audio:!0}),this._log("Acquired local media")),await this._publishTracks();const i=a.otherSessions||[];for(const e of i){this.pulledTracks.set(e.sessionId,new Set);for(const t of e.publishedTracks||[])await this._pullTracks(e.sessionId,t)}this._log("Joined room",e,"my session:",this.sessionId),this.setUserMetadata(t),this._startPolling()}async _cleanupEndedTracks(){if(this.localStream)for(const e of this.localStream.getTracks())"ended"===e.readyState&&(this.localStream.removeTrack(e),e.stop());this.localStream&&!this.localStream.getTracks().length&&(this.localStream=null)}async leaveRoom(){if(!this.roomId||!this.sessionId)return;const e=this.peerConnection.getSenders();e&&e.length&&await this.unpublishAllTracks();try{await this._fetch(`${this.backendUrl}/api/rooms/${this.roomId}/leave`,{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify({sessionId:this.sessionId})})}catch(e){this._warn("Error leaving room:",e)}this.ws&&(this.ws.close(),this.ws=null),this.peerConnection&&(this.peerConnection.close(),this.peerConnection=null),await this._cleanupEndedTracks(),this._log("Left room, closed PC & WS"),this.roomId=null,this.sessionId=null,this.pulledTracks.clear(),this.midToSessionId.clear(),this.midToTrackName.clear(),this.publishedTracks.clear()}async publishTracks(){if(!this.localStream)return this._warn("No local media stream to publish.");await this._publishTracks()}async _renegotiate(){this.peerConnection&&(this._renegotiateTimeout&&clearTimeout(this._renegotiateTimeout),this._renegotiateTimeout=setTimeout((async()=>{try{this._log("Starting renegotiation process...");const e=await this.peerConnection.createAnswer();this._log("Created renegotiation answer:",e.sdp),await this.peerConnection.setLocalDescription(e);const t=`${this.backendUrl}/api/rooms/${this.roomId}/sessions/${this.sessionId}/renegotiate`,a={sdp:e.sdp,type:e.type};this._log(`Sending renegotiate request to ${t} with body:`,a);const i=await this._fetch(t,{method:"PUT",headers:{"Content-Type":"application/json"},body:JSON.stringify(a)}).then((e=>e.json()));if(i.errorCode)return void this._warn("Renegotiation failed:",i.errorDescription);await this.peerConnection.setRemoteDescription(i.sessionDescription),this._log("Renegotiation successful. Applied SFU response.")}catch(e){this._error("Error during renegotiation:",e)}}),500))}async updatePublishedTracks(){if(!this.peerConnection)return this._warn("PeerConnection is not established.");const e=this.peerConnection.getSenders();for(const t of e)this.peerConnection.removeTrack(t);await this._publishTracks()}async _publishTracks(){if(!this.localStream||!this.peerConnection)return;const e=[];for(const t of this.localStream.getTracks()){if(this.publishedTracks.has(t.id))continue;if("live"!==t.readyState)continue;const a=this.peerConnection.addTransceiver(t,{direction:"sendonly"});if(this.pendingQualitySettings&&"video"===t.kind){const e=a.sender.getParameters();e.encodings=[{maxBitrate:this.pendingQualitySettings.video.maxBitrate}],a.sender.setParameters(e)}e.push(a),this.publishedTracks.add(t.id)}if(0===e.length)return;const t=await this.peerConnection.createOffer();this._log("SDP Offer:",t.sdp),await this.peerConnection.setLocalDescription(t);const a=e.map((({sender:e,mid:t})=>({location:"local",mid:t,trackName:e.track.id}))),i={offer:{sdp:t.sdp,type:t.type},tracks:a,metadata:this.userMetadata},s=`${this.backendUrl}/api/rooms/${this.roomId}/sessions/${this.sessionId}/publish`,o=await this._fetch(s,{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify(i)}).then((e=>e.json()));if(o.errorCode)return void this._error("Publish error:",o.errorDescription);const n=o.sessionDescription;await this.peerConnection.setRemoteDescription(n),this._log("Publish => success. Applied SFU answer.")}async _pullTracks(e,t){this._log(`Pulling track '${t}' from session ${e}`);const a=`${this.backendUrl}/api/rooms/${this.roomId}/sessions/${this.sessionId}/pull`,i={remoteSessionId:e,trackName:t},s=await this._fetch(a,{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify(i)}).then((e=>e.json()));if(s.errorCode)this._error("Pull error:",s.errorDescription);else{if(s.requiresImmediateRenegotiation){this._log("Pull => requires renegotiation");const a=new Set;s.sessionDescription.sdp.split("\n").forEach((i=>{if(i.startsWith("a=mid:")){const s=i.split(":")[1].trim();a.add(s),this.midToSessionId.set(s,e),this.midToTrackName.set(s,t),this._log("Pre-mapped MID:",{mid:s,sessionId:e,trackName:t})}})),await this.peerConnection.setRemoteDescription(s.sessionDescription);const i=await this.peerConnection.createAnswer();await this.peerConnection.setLocalDescription(i);this.peerConnection.getTransceivers().forEach((t=>{t.mid&&a.has(t.mid)&&this._log("Verified MID mapping:",{mid:t.mid,sessionId:e,direction:t.direction})})),await this._fetch(`${this.backendUrl}/api/rooms/${this.roomId}/sessions/${this.sessionId}/renegotiate`,{method:"PUT",headers:{"Content-Type":"application/json"},body:JSON.stringify({sdp:i.sdp,type:i.type})})}this._log(`Pulled trackName="${t}" from session ${e}`),this._log("Current MID mappings:",Array.from(this.midToSessionId.entries())),this.pulledTracks.has(e)||this.pulledTracks.set(e,new Set),this.pulledTracks.get(e).add(t)}}async _attemptIceServersUpdate(){let e=[{urls:"stun:stun.cloudflare.com:3478"}];try{const t=await this._fetch(`${this.backendUrl}/api/ice-servers`);if(!t.ok)return this._warn(`Failed to fetch ICE servers: ${t.status} ${t.statusText}`),!1;const a=await t.json();if(!a.iceServers||!Array.isArray(a.iceServers))return e;e=a.iceServers.map((e=>{const t={urls:e.urls};return e.username&&e.credential&&(t.username=e.username,t.credential=e.credential),t})),this._log("Fetched ICE servers:",e)}catch(e){return this._warn("Error fetching ICE servers:",e),!1}}async _createPeerConnection(){let e=await this._attemptIceServersUpdate()||[{urls:"stun:stun.cloudflare.com:3478"}];const t=new RTCPeerConnection({iceServers:e,bundlePolicy:"max-bundle",sdpSemantics:"unified-plan"});return t.onicecandidate=e=>{e.candidate?this._log("New ICE candidate:",e.candidate.candidate):this._log("All ICE candidates have been sent")},t.oniceconnectionstatechange=()=>{this._log("ICE Connection State:",t.iceConnectionState),"disconnected"!==t.iceConnectionState&&"failed"!==t.iceConnectionState||this.leaveRoom()},t.onconnectionstatechange=()=>{this._log("Connection State:",t.connectionState),"connected"===t.connectionState?this._log("Peer connection fully established"):"disconnected"!==t.connectionState&&"failed"!==t.connectionState||(this._log("Peer connection disconnected or failed"),this.leaveRoom())},t.ontrack=e=>{if(this._log("ontrack event:",{kind:e.track.kind,webrtcTrackId:e.track.id,mid:e.transceiver?.mid}),this._onRemoteTrackCallback){const t=e.transceiver?.mid,a=this.midToSessionId.get(t),i=this.midToTrackName.get(t);if(this._log("Track mapping lookup:",{mid:t,sessionId:a,trackName:i,webrtcTrackId:e.track.id,availableMappings:{sessions:Array.from(this.midToSessionId.entries()),tracks:Array.from(this.midToTrackName.entries())}}),!a)return this._warn("No sessionId found for mid:",t),this.pendingTracks||(this.pendingTracks=[]),void this.pendingTracks.push({evt:e,mid:t});const s=e.track;s.sessionId=a,s.mid=t,s.trackName=i,this._log("Sending track to callback:",{webrtcTrackId:s.id,trackName:s.trackName,sessionId:s.sessionId,mid:s.mid}),this._onRemoteTrackCallback(s)}},t}async _initWebSocket(){if(!this.ws||this.ws.readyState!==WebSocket.OPEN)return new Promise(((e,t)=>{this.ws=new WebSocket(this.websocketUrl),this.ws.onopen=()=>{this._log("WebSocket open"),this.ws.send(JSON.stringify({type:"join-websocket",payload:{roomId:this.roomId,userId:this.userId,token:this.token}})),e()},this.ws.onmessage=e=>{try{const t=JSON.parse(e.data);switch(this._log("WebSocket message received:",t),t.type){case"participant-joined":this._onParticipantJoinedCallback&&this._onParticipantJoinedCallback(t.payload);break;case"participant-left":this._onParticipantLeftCallback&&this._onParticipantLeftCallback(t.payload);break;case"track-published":this._onRemoteTrackCallback&&this._onRemoteTrackCallback(t.payload);break;case"track-unpublished":this._onRemoteTrackUnpublishedCallback&&this._onRemoteTrackUnpublishedCallback(t.payload.sessionId,t.payload.trackName);break;case"track-status-changed":this._onTrackStatusChangedCallback&&this._onTrackStatusChangedCallback(t.payload);break;case"data-message":this._onDataMessageCallback&&this._onDataMessageCallback(t.payload);break;case"room-metadata-updated":this._onRoomMetadataUpdatedCallback&&this._onRoomMetadataUpdatedCallback(t.payload);break;default:this._log("Unhandled message type:",t.type)}this._wsMessageHandlers.forEach((e=>e(t)))}catch(e){this._error("Error processing WebSocket message:",e)}},this.ws.onerror=e=>{this._error("WebSocket error:",e),t(e)},this.ws.onclose=()=>{this._log("WebSocket connection closed")}}))}_startPolling(){this.pollingInterval=setInterval((async()=>{if(this.roomId)try{const e=(await this._fetch(`${this.backendUrl}/api/rooms/${this.roomId}/participants`).then((e=>e.json()))).participants||[];for(const t of e){const{sessionId:e,publishedTracks:a}=t;if(e!==this.sessionId){this.pulledTracks.has(e)||this.pulledTracks.set(e,new Set);for(const t of a)this.pulledTracks.get(e).has(t)||(this._log(`[Polling] New track detected: ${t} from session ${e}`),await this._pullTracks(e,t))}}}catch(e){this._error("Polling error:",e)}}),1e4)}async getAvailableDevices(){const e=await navigator.mediaDevices.enumerateDevices();return this.availableAudioInputDevices=e.filter((e=>"audioinput"===e.kind)),this.availableVideoInputDevices=e.filter((e=>"videoinput"===e.kind)),this.availableAudioOutputDevices=e.filter((e=>"audiooutput"===e.kind)),{audioInput:this.availableAudioInputDevices,videoInput:this.availableVideoInputDevices,audioOutput:this.availableAudioOutputDevices}}async selectAudioInputDevice(e){if(!e)return void this._warn("No deviceId provided for audio input.");const t={audio:{deviceId:{exact:e}},video:!1};try{const a=(await navigator.mediaDevices.getUserMedia(t)).getAudioTracks()[0],i=this.peerConnection.getSenders().find((e=>"audio"===e.track.kind));if(i){i.replaceTrack(a);i.track.stop()}else this.localStream.addTrack(a),await this._publishTracks();this._log(`Switched to audio input device: ${e}`)}catch(e){this._error("Error switching audio input device:",e)}}async selectVideoInputDevice(e){if(!e)return void this._warn("No deviceId provided for video input.");const t={video:{deviceId:{exact:e}},audio:!1};try{const a=(await navigator.mediaDevices.getUserMedia(t)).getVideoTracks()[0],i=this.peerConnection.getSenders().find((e=>"video"===e.track.kind));if(i){i.replaceTrack(a);i.track.stop()}else this.localStream.addTrack(a),await this._publishTracks();this._log(`Switched to video input device: ${e}`)}catch(e){this._error("Error switching video input device:",e)}}async selectAudioOutputDevice(e){if(e)try{const t=document.querySelectorAll("audio");for(const a of t)await a.setSinkId(e);this.currentAudioOutputDeviceId=e,this._log(`Switched to audio output device: ${e}`)}catch(e){this._error("Error switching audio output device:",e)}else this._warn("No deviceId provided for audio output.")}async previewMedia({audioDeviceId:e,videoDeviceId:t},a=null){const i={audio:!!e&&{deviceId:{exact:e}},video:!!t&&{deviceId:{exact:t}}};try{const e=await navigator.mediaDevices.getUserMedia(i);return a&&(a.srcObject=e),e}catch(e){throw this._error("Error previewing media:",e),e}}toggleMedia({video:e=null,audio:t=null}){if(this.localStream){if(null!==e){this.localStream.getVideoTracks().forEach((t=>{t.enabled=e;const a=this.peerConnection?.getSenders().find((e=>e.track===t));a&&this._updateTrackStatus(a.track.id,"video",e)}))}if(null!==t){this.localStream.getAudioTracks().forEach((e=>{e.enabled=t;const a=this.peerConnection?.getSenders().find((t=>t.track===e));a&&this._updateTrackStatus(a.track.id,"audio",t)}))}}}async shareScreen(){try{await this.unpublishAllTracks("video");const e=(await navigator.mediaDevices.getDisplayMedia({video:!0,audio:!1})).getVideoTracks()[0];this.localStream.addTrack(e),await this._publishTracks(),e.onended=async()=>{await this.unpublishAllTracks(),await this._cleanupEndedTracks(),this.localStream=await navigator.mediaDevices.getUserMedia({video:!0,audio:!0}),this._log("Re-acquired local media"),await this._publishTracks()}}catch(e){throw this._error("Error sharing screen:",e),e}}_sendWebSocketMessage(e){this.ws&&this.ws.readyState===WebSocket.OPEN?(this.ws.send(JSON.stringify(e)),this._log("Sent WebSocket message:",e)):this._warn("WebSocket is not open. Cannot send message.")}async listParticipants(){if(!this.roomId)return this._warn("Not connected to any room.");return(await this._fetch(`${this.backendUrl}/api/rooms/${this.roomId}/participants`).then((e=>e.json()))).participants||[]}_generateUUID(){return"xxxx-xxxx-xxxx-xxxx".replace(/[x]/g,(()=>(16*Math.random()|0).toString(16)))}async unpublishAllTracks(e,t=!1){if(!this.peerConnection)return void this._warn("PeerConnection is not established.");let a=this.peerConnection.getSenders();e&&(a=a.filter((t=>t.track&&t.track.kind===e))),this._log("Unpublishing all tracks:",a.length);const i=await this.peerConnection.createOffer();await this.peerConnection.setLocalDescription(i);for(const e of a)if(e.track)try{const a=e.track.id,s=this.peerConnection.getTransceivers().find((t=>t.sender===e)),o=s?s.mid:null;if(this._log("Unpublishing track:",{trackId:a,mid:o}),!o){this._warn("No mid found for track:",a);continue}e.track.stop(),await this._fetch(`${this.backendUrl}/api/rooms/${this.roomId}/sessions/${this.sessionId}/unpublish`,{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify({trackName:a,mid:o,force:t,sessionDescription:{type:i.type,sdp:i.sdp}})}),this.peerConnection.removeTrack(e),this.publishedTracks.delete(a),await this._cleanupEndedTracks(),this._log(`Successfully unpublished track: ${a}`)}catch(e){this._error("Error unpublishing track:",e)}}async getSessionState(){if(!this.sessionId)return this._warn("No active session");try{const e=await this._fetch(`${this.backendUrl}/api/rooms/${this.roomId}/sessions/${this.sessionId}/state`),t=await e.json();return t.tracks&&(this.trackStates=new Map(t.tracks.map((e=>[e.trackName,e.status])))),t}catch(e){throw this._error("Error getting session state:",e),e}}async getTrackStatus(e){const t=await this.getSessionState();return t.tracks.find((t=>t.trackName===e))?.status}async _updateTrackStatus(e,t,a){try{const i=`${this.backendUrl}/api/rooms/${this.roomId}/sessions/${this.sessionId}/track-status`,s=await this._fetch(i,{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify({trackId:e,kind:t,enabled:a,force:!1})}),o=await s.json();if(o.errorCode)throw new Error(o.errorDescription||"Unknown error updating track status");return o.requiresImmediateRenegotiation&&await this._renegotiate(),o.errorCode||this._updateTrackState(e,a?"enabled":"disabled"),o}catch(e){throw this._error("Error updating track status:",e),e}}_handleError(e){if(e.errorCode){const t=new Error(e.errorDescription||"Unknown error");throw t.code=e.errorCode,t}return e}async getUserInfo(e=null){try{const t=await this._fetch(`${this.backendUrl}/api/users/${e||"me"}`);return await t.json()}catch(e){throw this._error("Error getting user info:",e),e}}_handleWebSocketMessage(e){try{const t=JSON.parse(e.data);switch(this._log("WebSocket message received:",t),this._wsMessageHandlers.forEach((e=>{try{e(t)}catch(e){this._error("Error in WebSocket message handler:",e)}})),t.type){case"participant-joined":this._onParticipantJoinedCallback&&this._onParticipantJoinedCallback(t.payload);break;case"participant-left":this._onParticipantLeftCallback&&this._onParticipantLeftCallback(t.payload.sessionId);break;case"track-published":this._onRemoteTrackCallback&&this._onRemoteTrackCallback(t.payload);break;case"track-unpublished":this._onRemoteTrackUnpublishedCallback&&this._onRemoteTrackUnpublishedCallback(t.payload.sessionId,t.payload.trackName);break;case"track-status-changed":this._onTrackStatusChangedCallback&&this._onTrackStatusChangedCallback(t.payload);break;case"data-message":this._onDataMessageCallback&&this._onDataMessageCallback(t.payload);break;case"room-metadata-updated":this._onRoomMetadataUpdatedCallback&&this._onRoomMetadataUpdatedCallback(t.payload);break;default:this._log("Unhandled message type:",t.type)}}catch(e){this._error("Error handling WebSocket message:",e)}}_updateTrackState(e,t){this.trackStates||(this.trackStates=new Map),this.trackStates.set(e,t)}async listRooms(){return(await this._fetch(`${this.backendUrl}/api/rooms`).then((e=>e.json()))).rooms}async updateRoomMetadata(e){return this.roomId?await this._fetch(`${this.backendUrl}/api/rooms/${this.roomId}/metadata`,{method:"PUT",headers:{"Content-Type":"application/json"},body:JSON.stringify(e)}).then((e=>e.json())):this._warn("Not connected to any room")}async sendDataToAll(e){if(!this.roomId||!this.sessionId)throw new Error("Must be in a room to send data");if(!this.ws||this.ws.readyState!==WebSocket.OPEN)throw new Error("WebSocket connection not available");this.ws.send(JSON.stringify({type:"data-message",payload:{from:this.sessionId,message:e}}))}setMediaQuality(t){if("string"==typeof t){const a=e.QUALITY_PRESETS[t];if(!a)return this._warn(`Unknown quality preset: ${t}`);this.mediaQuality=t,t=a}this.mediaQuality={video:{...this.mediaQuality.video,...t.video},audio:{...this.mediaQuality.audio,...t.audio}},this.pendingQualitySettings=this.mediaQuality,this.peerConnection&&this._applyQualitySettings()}async _applyQualitySettings(){if(!this.peerConnection)return;const e=this.peerConnection.getSenders();for(const t of e){if(!t.track)continue;const e=t.getParameters();e.encodings||(e.encodings=[{}]);const a=t.track.kind,i=this.mediaQuality[a];if(i.maxBitrate&&(e.encodings[0].maxBitrate=i.maxBitrate),"video"===a){const e={width:i.width,height:i.height,frameRate:i.frameRate};await t.track.applyConstraints(e)}await t.setParameters(e)}}startStatsMonitoring(e=1e3){"monitoring"!==this.statsMonitoringState&&(this.statsMonitoringState="monitoring",this.statsInterval=setInterval((async()=>{if(!this.peerConnection)return;const e=await this._gatherConnectionStats(),t=await this._gatherStreamStats();this._onConnectionStatsCallback&&this._onConnectionStatsCallback(e,t)}),e))}stopStatsMonitoring(){this.statsInterval&&(clearInterval(this.statsInterval),this.statsInterval=null),this.statsMonitoringState="stopped"}onConnectionStats(e){this._onConnectionStatsCallback=e}async _gatherConnectionStats(){if(!this.peerConnection)return this._warn("No active connection");const e=await this.peerConnection.getStats(),t={outbound:{bitrate:0,packetLoss:0,qualityLimitation:"none"},inbound:{bitrate:0,packetLoss:0,jitter:0},connection:{roundTripTime:0,state:this.peerConnection.connectionState}};let a=null,i=null;if(e.forEach((e=>{switch(e.type){case"outbound-rtp":"video"===e.kind&&(a=e,t.outbound.qualityLimitation=e.qualityLimitationReason);break;case"inbound-rtp":"video"===e.kind&&(i=e,t.inbound.jitter=e.jitter,e.packetsLost>0&&(t.inbound.packetLoss=e.packetsLost/(e.packetsReceived+e.packetsLost)*100));break;case"candidate-pair":"succeeded"===e.state&&(t.connection.roundTripTime=e.currentRoundTripTime)}})),this.previousStats&&a&&i){const e=(a.timestamp-this.previousStats.outboundTimestamp)/1e3;if(e>0){const s=a.bytesSent-this.previousStats.bytesSent;t.outbound.bitrate=8*s/e;const o=i.bytesReceived-this.previousStats.bytesReceived;t.inbound.bitrate=8*o/e}}return a&&i&&(this.previousStats={outboundTimestamp:a.timestamp,bytesSent:a.bytesSent,bytesReceived:i.bytesReceived}),t}async getConnectionStats(){return this._gatherConnectionStats()}async _gatherStreamStats(){if(!this.peerConnection)return new Map;const e=await this.peerConnection.getStats(),t=new Map;return this.sessionId&&t.set(this.sessionId,{sessionId:this.sessionId,packetLoss:0,qualityLimitation:"none",bitrate:0}),e.forEach((e=>{if("outbound-rtp"===e.type&&"video"===e.kind){const a=t.get(this.sessionId);a&&(a.qualityLimitation=e.qualityLimitationReason,a.bitrate=8*e.bytesSent/e.timestamp)}else if("inbound-rtp"===e.type&&"video"===e.kind){const a=e.mid,i=this.midToSessionId.get(a);i&&t.set(i,{sessionId:i,packetLoss:e.packetsLost>0?e.packetsLost/(e.packetsReceived+e.packetsLost)*100:0,qualityLimitation:"none",bitrate:8*e.bytesReceived/e.timestamp})}})),t}static QUALITY_PRESETS={high_16x9_xl:{video:{width:{ideal:1920},height:{ideal:1080},frameRate:{ideal:30},maxBitrate:25e5},audio:{maxBitrate:128e3,sampleRate:48e3,channelCount:2}},high_16x9_lg:{video:{width:{ideal:1280},height:{ideal:720},frameRate:{ideal:30},maxBitrate:15e5},audio:{maxBitrate:96e3,sampleRate:48e3,channelCount:2}},high_16x9_md:{video:{width:{ideal:854},height:{ideal:480},frameRate:{ideal:30},maxBitrate:8e5},audio:{maxBitrate:96e3,sampleRate:48e3,channelCount:1}},high_16x9_sm:{video:{width:{ideal:640},height:{ideal:360},frameRate:{ideal:30},maxBitrate:6e5},audio:{maxBitrate:64e3,sampleRate:44100,channelCount:1}},high_16x9_xs:{video:{width:{ideal:480},height:{ideal:270},frameRate:{ideal:30},maxBitrate:4e5},audio:{maxBitrate:64e3,sampleRate:44100,channelCount:1}},medium_16x9_xl:{video:{width:{ideal:1920},height:{ideal:1080},frameRate:{ideal:24},maxBitrate:2e6},audio:{maxBitrate:96e3,sampleRate:48e3,channelCount:2}},medium_16x9_lg:{video:{width:{ideal:1280},height:{ideal:720},frameRate:{ideal:24},maxBitrate:12e5},audio:{maxBitrate:96e3,sampleRate:48e3,channelCount:1}},medium_16x9_md:{video:{width:{ideal:854},height:{ideal:480},frameRate:{ideal:24},maxBitrate:6e5},audio:{maxBitrate:64e3,sampleRate:44100,channelCount:1}},medium_16x9_sm:{video:{width:{ideal:640},height:{ideal:360},frameRate:{ideal:20},maxBitrate:4e5},audio:{maxBitrate:48e3,sampleRate:44100,channelCount:1}},medium_16x9_xs:{video:{width:{ideal:480},height:{ideal:270},frameRate:{ideal:20},maxBitrate:3e5},audio:{maxBitrate:48e3,sampleRate:44100,channelCount:1}},low_16x9_xl:{video:{width:{ideal:1920},height:{ideal:1080},frameRate:{ideal:15},maxBitrate:15e5},audio:{maxBitrate:64e3,sampleRate:44100,channelCount:1}},low_16x9_lg:{video:{width:{ideal:1280},height:{ideal:720},frameRate:{ideal:15},maxBitrate:8e5},audio:{maxBitrate:48e3,sampleRate:44100,channelCount:1}},low_16x9_md:{video:{width:{ideal:854},height:{ideal:480},frameRate:{ideal:15},maxBitrate:4e5},audio:{maxBitrate:32e3,sampleRate:44100,channelCount:1}},low_16x9_sm:{video:{width:{ideal:640},height:{ideal:360},frameRate:{ideal:12},maxBitrate:25e4},audio:{maxBitrate:32e3,sampleRate:22050,channelCount:1}},low_16x9_xs:{video:{width:{ideal:480},height:{ideal:270},frameRate:{ideal:10},maxBitrate:15e4},audio:{maxBitrate:24e3,sampleRate:22050,channelCount:1}},high_4x3_xl:{video:{width:{ideal:960},height:{ideal:720},frameRate:{ideal:30},maxBitrate:15e5},audio:{maxBitrate:128e3,sampleRate:48e3,channelCount:2}},high_4x3_lg:{video:{width:{ideal:640},height:{ideal:480},frameRate:{ideal:30},maxBitrate:8e5},audio:{maxBitrate:96e3,sampleRate:48e3,channelCount:1}},high_4x3_md:{video:{width:{ideal:480},height:{ideal:360},frameRate:{ideal:30},maxBitrate:6e5},audio:{maxBitrate:96e3,sampleRate:44100,channelCount:1}},high_4x3_sm:{video:{width:{ideal:320},height:{ideal:240},frameRate:{ideal:30},maxBitrate:4e5},audio:{maxBitrate:64e3,sampleRate:44100,channelCount:1}},high_4x3_xs:{video:{width:{ideal:240},height:{ideal:180},frameRate:{ideal:30},maxBitrate:25e4},audio:{maxBitrate:64e3,sampleRate:44100,channelCount:1}},medium_4x3_xl:{video:{width:{ideal:960},height:{ideal:720},frameRate:{ideal:24},maxBitrate:12e5},audio:{maxBitrate:96e3,sampleRate:48e3,channelCount:1}},medium_4x3_lg:{video:{width:{ideal:640},height:{ideal:480},frameRate:{ideal:24},maxBitrate:6e5},audio:{maxBitrate:64e3,sampleRate:44100,channelCount:1}},medium_4x3_md:{video:{width:{ideal:480},height:{ideal:360},frameRate:{ideal:20},maxBitrate:4e5},audio:{maxBitrate:48e3,sampleRate:44100,channelCount:1}},medium_4x3_sm:{video:{width:{ideal:320},height:{ideal:240},frameRate:{ideal:20},maxBitrate:3e5},audio:{maxBitrate:48e3,sampleRate:44100,channelCount:1}},medium_4x3_xs:{video:{width:{ideal:240},height:{ideal:180},frameRate:{ideal:20},maxBitrate:2e5},audio:{maxBitrate:48e3,sampleRate:44100,channelCount:1}},low_4x3_xl:{video:{width:{ideal:960},height:{ideal:720},frameRate:{ideal:15},maxBitrate:8e5},audio:{maxBitrate:48e3,sampleRate:44100,channelCount:1}},low_4x3_lg:{video:{width:{ideal:640},height:{ideal:480},frameRate:{ideal:15},maxBitrate:4e5},audio:{maxBitrate:32e3,sampleRate:44100,channelCount:1}},low_4x3_md:{video:{width:{ideal:480},height:{ideal:360},frameRate:{ideal:12},maxBitrate:25e4},audio:{maxBitrate:32e3,sampleRate:22050,channelCount:1}},low_4x3_sm:{video:{width:{ideal:320},height:{ideal:240},frameRate:{ideal:10},maxBitrate:15e4},audio:{maxBitrate:24e3,sampleRate:22050,channelCount:1}},low_4x3_xs:{video:{width:{ideal:240},height:{ideal:180},frameRate:{ideal:10},maxBitrate:1e5},audio:{maxBitrate:24e3,sampleRate:22050,channelCount:1}},high_1x1_xl:{video:{width:{ideal:720},height:{ideal:720},frameRate:{ideal:30},maxBitrate:15e5},audio:{maxBitrate:128e3,sampleRate:48e3,channelCount:2}},high_1x1_lg:{video:{width:{ideal:480},height:{ideal:480},frameRate:{ideal:30},maxBitrate:8e5},audio:{maxBitrate:96e3,sampleRate:48e3,channelCount:1}},high_1x1_md:{video:{width:{ideal:360},height:{ideal:360},frameRate:{ideal:30},maxBitrate:6e5},audio:{maxBitrate:96e3,sampleRate:44100,channelCount:1}},high_1x1_sm:{video:{width:{ideal:240},height:{ideal:240},frameRate:{ideal:30},maxBitrate:4e5},audio:{maxBitrate:64e3,sampleRate:44100,channelCount:1}},high_1x1_xs:{video:{width:{ideal:180},height:{ideal:180},frameRate:{ideal:30},maxBitrate:25e4},audio:{maxBitrate:64e3,sampleRate:44100,channelCount:1}},medium_1x1_xl:{video:{width:{ideal:720},height:{ideal:720},frameRate:{ideal:24},maxBitrate:12e5},audio:{maxBitrate:96e3,sampleRate:48e3,channelCount:1}},medium_1x1_lg:{video:{width:{ideal:480},height:{ideal:480},frameRate:{ideal:24},maxBitrate:6e5},audio:{maxBitrate:64e3,sampleRate:44100,channelCount:1}},medium_1x1_md:{video:{width:{ideal:360},height:{ideal:360},frameRate:{ideal:20},maxBitrate:4e5},audio:{maxBitrate:48e3,sampleRate:44100,channelCount:1}},medium_1x1_sm:{video:{width:{ideal:240},height:{ideal:240},frameRate:{ideal:20},maxBitrate:3e5},audio:{maxBitrate:48e3,sampleRate:44100,channelCount:1}},medium_1x1_xs:{video:{width:{ideal:180},height:{ideal:180},frameRate:{ideal:20},maxBitrate:2e5},audio:{maxBitrate:48e3,sampleRate:44100,channelCount:1}},low_1x1_xl:{video:{width:{ideal:720},height:{ideal:720},frameRate:{ideal:15},maxBitrate:8e5},audio:{maxBitrate:48e3,sampleRate:44100,channelCount:1}},low_1x1_lg:{video:{width:{ideal:480},height:{ideal:480},frameRate:{ideal:15},maxBitrate:4e5},audio:{maxBitrate:32e3,sampleRate:44100,channelCount:1}},low_1x1_md:{video:{width:{ideal:360},height:{ideal:360},frameRate:{ideal:12},maxBitrate:25e4},audio:{maxBitrate:32e3,sampleRate:22050,channelCount:1}},low_1x1_sm:{video:{width:{ideal:240},height:{ideal:240},frameRate:{ideal:10},maxBitrate:15e4},audio:{maxBitrate:24e3,sampleRate:22050,channelCount:1}},low_1x1_xs:{video:{width:{ideal:180},height:{ideal:180},frameRate:{ideal:10},maxBitrate:1e5},audio:{maxBitrate:24e3,sampleRate:22050,channelCount:1}},high_9x16_xl:{video:{width:{ideal:1080},height:{ideal:1920},frameRate:{ideal:30},maxBitrate:25e5},audio:{maxBitrate:128e3,sampleRate:48e3,channelCount:2}},high_9x16_lg:{video:{width:{ideal:720},height:{ideal:1280},frameRate:{ideal:30},maxBitrate:15e5},audio:{maxBitrate:96e3,sampleRate:48e3,channelCount:1}},high_9x16_md:{video:{width:{ideal:480},height:{ideal:854},frameRate:{ideal:30},maxBitrate:8e5},audio:{maxBitrate:96e3,sampleRate:44100,channelCount:1}},high_9x16_sm:{video:{width:{ideal:360},height:{ideal:640},frameRate:{ideal:30},maxBitrate:6e5},audio:{maxBitrate:64e3,sampleRate:44100,channelCount:1}},high_9x16_xs:{video:{width:{ideal:270},height:{ideal:480},frameRate:{ideal:30},maxBitrate:4e5},audio:{maxBitrate:64e3,sampleRate:44100,channelCount:1}},medium_9x16_xl:{video:{width:{ideal:1080},height:{ideal:1920},frameRate:{ideal:24},maxBitrate:2e6},audio:{maxBitrate:96e3,sampleRate:48e3,channelCount:1}},medium_9x16_lg:{video:{width:{ideal:720},height:{ideal:1280},frameRate:{ideal:24},maxBitrate:12e5},audio:{maxBitrate:64e3,sampleRate:44100,channelCount:1}},medium_9x16_md:{video:{width:{ideal:480},height:{ideal:854},frameRate:{ideal:20},maxBitrate:6e5},audio:{maxBitrate:48e3,sampleRate:44100,channelCount:1}},medium_9x16_sm:{video:{width:{ideal:360},height:{ideal:640},frameRate:{ideal:20},maxBitrate:4e5},audio:{maxBitrate:48e3,sampleRate:44100,channelCount:1}},medium_9x16_xs:{video:{width:{ideal:270},height:{ideal:480},frameRate:{ideal:20},maxBitrate:3e5},audio:{maxBitrate:48e3,sampleRate:44100,channelCount:1}},low_9x16_xl:{video:{width:{ideal:1080},height:{ideal:1920},frameRate:{ideal:15},maxBitrate:15e5},audio:{maxBitrate:48e3,sampleRate:44100,channelCount:1}},low_9x16_lg:{video:{width:{ideal:720},height:{ideal:1280},frameRate:{ideal:15},maxBitrate:8e5},audio:{maxBitrate:32e3,sampleRate:44100,channelCount:1}},low_9x16_md:{video:{width:{ideal:480},height:{ideal:854},frameRate:{ideal:12},maxBitrate:4e5},audio:{maxBitrate:32e3,sampleRate:22050,channelCount:1}},low_9x16_sm:{video:{width:{ideal:360},height:{ideal:640},frameRate:{ideal:10},maxBitrate:25e4},audio:{maxBitrate:24e3,sampleRate:22050,channelCount:1}},low_9x16_xs:{video:{width:{ideal:270},height:{ideal:480},frameRate:{ideal:10},maxBitrate:15e4},audio:{maxBitrate:24e3,sampleRate:22050,channelCount:1}}}}return e}));
public/js/FaceMaskFilter.js ADDED
@@ -0,0 +1,203 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ class FaceMaskFilter {
2
+ constructor(videoElement, canvasElement, maskImageElement) {
3
+ this.video = videoElement;
4
+ this.canvas = canvasElement;
5
+ this.context = this.canvas.getContext('2d');
6
+ this.maskImage = maskImageElement;
7
+ this.faceMesh = null;
8
+ this.camera = null;
9
+ this.isProcessing = false;
10
+ this.frameRequestId = null;
11
+ }
12
+
13
+ async initialize() {
14
+ this.initFaceMesh();
15
+ // await this.startCamera();
16
+ }
17
+
18
+ initFaceMesh() {
19
+ this.faceMesh = new FaceMesh({
20
+ locateFile: (file) => {
21
+ return `https://cdn.jsdelivr.net/npm/@mediapipe/face_mesh/${file}`;
22
+ }
23
+ });
24
+
25
+ this.faceMesh.setOptions({
26
+ maxNumFaces: 1,
27
+ refineLandmarks: true,
28
+ minDetectionConfidence: 0.5,
29
+ minTrackingConfidence: 0.5
30
+ });
31
+
32
+ this.faceMesh.onResults(this.onResults.bind(this));
33
+ }
34
+
35
+ // async startCamera() {
36
+ // this.camera = new Camera(this.video, {
37
+ // onFrame: async () => {
38
+ // await this.faceMesh.send({image: this.video});
39
+ // },
40
+ // width: 640,
41
+ // height: 480
42
+ // });
43
+ // await this.camera.start();
44
+ // }
45
+
46
+ async processFrame(inputStream) {
47
+ if (this.frameRequestId) {
48
+ cancelAnimationFrame(this.frameRequestId);
49
+ }
50
+
51
+ this.video.srcObject = inputStream;
52
+ this.video.width = this.canvas.width;
53
+ this.video.height = this.canvas.height;
54
+
55
+ // Wait for video to be ready
56
+ await new Promise((resolve) => {
57
+ this.video.onloadedmetadata = () => {
58
+ this.video.play();
59
+ resolve();
60
+ };
61
+ });
62
+
63
+ const processFrameLoop = async () => {
64
+ if (this.video.readyState === 4) {
65
+ await this.faceMesh.send({ image: this.video });
66
+ }
67
+ this.frameRequestId = requestAnimationFrame(processFrameLoop);
68
+ };
69
+
70
+ processFrameLoop();
71
+ return this.canvas.captureStream();
72
+ }
73
+
74
+ onResults(results) {
75
+ this.context.save();
76
+ this.context.clearRect(0, 0, this.canvas.width, this.canvas.height);
77
+ this.context.drawImage(results.image, 0, 0, this.canvas.width, this.canvas.height);
78
+
79
+ if (results.multiFaceLandmarks) {
80
+ for (const landmarks of results.multiFaceLandmarks) {
81
+ this.drawMask(landmarks);
82
+ }
83
+ }
84
+ this.context.restore();
85
+ }
86
+
87
+ drawMask(landmarks) {
88
+ // Check if current mask is medical type
89
+ const isMedicalMask = this.maskImage.src.includes('medicel/');
90
+
91
+ if (isMedicalMask) {
92
+ this.drawMedicalMask(landmarks);
93
+ } else {
94
+ this.drawBasicMask(landmarks);
95
+ }
96
+ }
97
+
98
+ // Existing mask drawing logic renamed to drawBasicMask
99
+ drawBasicMask(landmarks) {
100
+ const leftEar = landmarks[234];
101
+ const rightEar = landmarks[454];
102
+ const chin = landmarks[199];
103
+ const bottomChin = landmarks[175];
104
+ const noseBridge = landmarks[6];
105
+ const foreHead = landmarks[10];
106
+
107
+ const faceWidth = Math.sqrt(
108
+ Math.pow((rightEar.x - leftEar.x) * this.canvas.width, 2) +
109
+ Math.pow((rightEar.y - leftEar.y) * this.canvas.height, 2)
110
+ );
111
+
112
+ const faceHeight = Math.sqrt(
113
+ Math.pow((bottomChin.y - foreHead.y) * this.canvas.height, 2) +
114
+ Math.pow((bottomChin.x - foreHead.x) * this.canvas.width, 2)
115
+ );
116
+
117
+ const angle = Math.atan2(
118
+ (rightEar.y - leftEar.y) * this.canvas.height,
119
+ (rightEar.x - leftEar.x) * this.canvas.width
120
+ );
121
+
122
+ this.context.save();
123
+
124
+ const centerX = noseBridge.x * this.canvas.width;
125
+ const centerY = (noseBridge.y * 0.6 + bottomChin.y * 0.4) * this.canvas.height;
126
+ this.context.translate(centerX, centerY);
127
+ this.context.rotate(angle);
128
+
129
+ const maskWidth = faceWidth * 1.4;
130
+ const maskHeight = faceHeight * 1.5;
131
+ this.context.drawImage(
132
+ this.maskImage,
133
+ -maskWidth / 2,
134
+ -maskHeight / 2,
135
+ maskWidth,
136
+ maskHeight
137
+ );
138
+
139
+ this.context.restore();
140
+ }
141
+
142
+ // Add new method for medical mask
143
+ drawMedicalMask(landmarks) {
144
+ // Landmarks for medical mask positioning
145
+ const noseTip = landmarks[1]; // Tip of nose
146
+ const leftCheek = landmarks[234]; // Left cheek
147
+ const rightCheek = landmarks[454]; // Right cheek
148
+ const chin = landmarks[152]; // Bottom of chin
149
+
150
+ // Calculate mask dimensions
151
+ const faceWidth = Math.sqrt(
152
+ Math.pow((rightCheek.x - leftCheek.x) * this.canvas.width, 2) +
153
+ Math.pow((rightCheek.y - leftCheek.y) * this.canvas.height, 2)
154
+ );
155
+
156
+ const maskHeight = Math.sqrt(
157
+ Math.pow((chin.y - noseTip.y) * this.canvas.height, 2) +
158
+ Math.pow((chin.x - noseTip.x) * this.canvas.width, 2)
159
+ ) * 1.2; // Slightly larger than nose-to-chin distance
160
+
161
+ // Calculate angle for mask rotation
162
+ const angle = Math.atan2(
163
+ (rightCheek.y - leftCheek.y) * this.canvas.height,
164
+ (rightCheek.x - leftCheek.x) * this.canvas.width
165
+ );
166
+
167
+ this.context.save();
168
+
169
+ // Position mask centered on nose tip
170
+ const centerX = noseTip.x * this.canvas.width;
171
+ const centerY = noseTip.y * this.canvas.height;
172
+
173
+ this.context.translate(centerX, centerY);
174
+ this.context.rotate(angle);
175
+
176
+ // Draw mask slightly wider than face width
177
+ const maskWidth = faceWidth * 1.2;
178
+ this.context.drawImage(
179
+ this.maskImage,
180
+ -maskWidth / 2,
181
+ 0, // Start from nose tip
182
+ maskWidth,
183
+ maskHeight
184
+ );
185
+
186
+ this.context.restore();
187
+ }
188
+ }
189
+
190
+ // // Khởi tạo khi bấm nút Start
191
+ // document.getElementById('startButton').addEventListener('click', async () => {
192
+ // document.getElementById('startButton').style.display = 'none';
193
+ // document.getElementById('video').style.display = 'block';
194
+ // document.getElementById('canvas').style.display = 'block';
195
+
196
+ // const faceMaskFilter = new FaceMaskFilter(
197
+ // document.getElementById('video'),
198
+ // document.getElementById('canvas'),
199
+ // document.getElementById('maskImage')
200
+ // );
201
+
202
+ // await faceMaskFilter.initialize();
203
+ // });
public/js/backgroundBlur.js ADDED
@@ -0,0 +1,99 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ class BackgroundBlur {
2
+ constructor(videoElement, canvasElement) {
3
+ if (!canvasElement) {
4
+ throw new Error('Canvas element is required for BackgroundBlur');
5
+ }
6
+
7
+ this.video = videoElement;
8
+ this.canvas = canvasElement;
9
+ this.context = this.canvas.getContext('2d');
10
+
11
+ if (!this.context) {
12
+ throw new Error('Failed to get canvas context');
13
+ }
14
+
15
+ this.selfieSegmentation = null;
16
+
17
+ // Set canvas dimensions
18
+ this.canvas.width = 640;
19
+ this.canvas.height = 480;
20
+ }
21
+
22
+ // Add method to update input stream
23
+ async updateInputStream(stream) {
24
+ this.video.srcObject = stream;
25
+ this.video.width = this.canvas.width;
26
+ this.video.height = this.canvas.height;
27
+
28
+ // Wait for video to be ready
29
+ await new Promise((resolve) => {
30
+ this.video.onloadedmetadata = () => {
31
+ this.video.play();
32
+ resolve();
33
+ };
34
+ });
35
+
36
+ return this.canvas.captureStream();
37
+ }
38
+
39
+ async initialize() {
40
+ this.initSelfieSegmentation();
41
+ await this.startCamera();
42
+ }
43
+
44
+ initSelfieSegmentation() {
45
+ this.selfieSegmentation = new SelfieSegmentation({
46
+ locateFile: (file) => `https://cdn.jsdelivr.net/npm/@mediapipe/selfie_segmentation/${file}`,
47
+ });
48
+
49
+ this.selfieSegmentation.setOptions({ modelSelection: 1 }); // Chọn model phân tách nền
50
+ this.selfieSegmentation.onResults(this.onResults.bind(this));
51
+ }
52
+
53
+ async startCamera() {
54
+ const stream = await navigator.mediaDevices.getUserMedia({ video: true });
55
+ this.video.srcObject = stream;
56
+ this.video.play();
57
+
58
+ requestAnimationFrame(this.processFrame.bind(this));
59
+ }
60
+
61
+ async processFrame() {
62
+ if (this.selfieSegmentation && this.video.readyState === 4) {
63
+ await this.selfieSegmentation.send({ image: this.video });
64
+ }
65
+ requestAnimationFrame(this.processFrame.bind(this));
66
+ }
67
+
68
+ // Modify onResults to accept custom input
69
+ onResults(results) {
70
+ if (!results || !results.segmentationMask) return;
71
+
72
+ this.context.save();
73
+ this.context.clearRect(0, 0, this.canvas.width, this.canvas.height);
74
+
75
+ // Draw input image (could be masked or original)
76
+ this.context.drawImage(results.image, 0, 0, this.canvas.width, this.canvas.height);
77
+
78
+ // Create temp canvas for blur
79
+ const tempCanvas = document.createElement('canvas');
80
+ tempCanvas.width = this.canvas.width;
81
+ tempCanvas.height = this.canvas.height;
82
+ const tempCtx = tempCanvas.getContext('2d');
83
+
84
+ // Apply blur
85
+ tempCtx.filter = 'blur(10px)';
86
+ tempCtx.drawImage(results.image, 0, 0, this.canvas.width, this.canvas.height);
87
+
88
+ // Apply mask
89
+ this.context.globalCompositeOperation = 'destination-in';
90
+ this.context.drawImage(results.segmentationMask, 0, 0, this.canvas.width, this.canvas.height);
91
+
92
+ // Draw blurred background
93
+ this.context.globalCompositeOperation = 'destination-over';
94
+ this.context.drawImage(tempCanvas, 0, 0);
95
+
96
+ this.context.restore();
97
+ }
98
+ }
99
+
public/js/room-oldcode.js ADDED
@@ -0,0 +1,2231 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ // Add utility function to escape HTML
2
+ function escapeHtml(unsafe) {
3
+ return unsafe
4
+ .replace(/&/g, "&amp;")
5
+ .replace(/</g, "&lt;")
6
+ .replace(/>/g, "&gt;")
7
+ .replace(/"/g, "&quot;")
8
+ .replace(/'/g, "&#039;");
9
+ }
10
+
11
+ // Update API URL detection logic
12
+ const isLocalhost = window.location.hostname === 'localhost' || window.location.hostname === '127.0.0.1';
13
+ const API_BASE = isLocalhost
14
+ ? 'http://127.0.0.1:7860'
15
+ : 'https://manhteky123-dapp-meeting.hf.space';
16
+
17
+ let APP_ID;
18
+ let APP_TOKEN;
19
+
20
+ let localStream;
21
+ let localPeerConnection;
22
+ let participants = new Map(); // Store participant connections
23
+ let ws; // WebSocket connection
24
+
25
+ const urlParams = new URLSearchParams(window.location.search);
26
+ const roomId = urlParams.get('roomId');
27
+ const username = urlParams.get('username');
28
+
29
+ // Get stored device preferences
30
+ const devicePrefs = JSON.parse(localStorage.getItem('selectedDevices') || '{}');
31
+
32
+ // Add at the top with other global variables
33
+ let faceMaskFilter = null;
34
+ let processedStream = null;
35
+
36
+ // Add after other global variables
37
+ let currentMask = 'default.png';
38
+ let masksList = [];
39
+
40
+ // Add at the top with other global variables
41
+ let backgroundBlur = null;
42
+ let isBlurEnabled = false;
43
+ let blurCanvas = null;
44
+
45
+ async function initializeRoom() {
46
+ try {
47
+ await loadAvailableMasks();
48
+ // Fetch Cloudflare credentials first
49
+ const credentialsResponse = await fetch(`${API_BASE}/cloudflare/credentials`);
50
+ if (!credentialsResponse.ok) {
51
+ throw new Error('Failed to fetch Cloudflare credentials');
52
+ }
53
+ const credentials = await credentialsResponse.json();
54
+ APP_ID = credentials.appId;
55
+ APP_TOKEN = credentials.token;
56
+ console.log("AppID: ", APP_ID);
57
+ console.log("SessionID: ", APP_TOKEN);
58
+
59
+ // Initialize local media with stored preferences
60
+ localStream = await navigator.mediaDevices.getUserMedia({
61
+ audio: { deviceId: devicePrefs.audioDeviceId },
62
+ video: { deviceId: devicePrefs.videoDeviceId }
63
+ });
64
+
65
+ // Initialize face mask filter
66
+ const maskCanvas = document.getElementById('maskCanvas');
67
+ const maskImage = document.getElementById('maskImage');
68
+ faceMaskFilter = new FaceMaskFilter(
69
+ document.createElement('video'), // Create temporary video element
70
+ maskCanvas,
71
+ maskImage
72
+ );
73
+ await faceMaskFilter.initialize();
74
+
75
+ // Create processed stream from canvas
76
+ processedStream = maskCanvas.captureStream();
77
+ // Add audio track from original stream
78
+ localStream.getAudioTracks().forEach(track => {
79
+ processedStream.addTrack(track);
80
+ });
81
+
82
+ // Setup audio detection after stream is initialized
83
+ setupAudioDetection();
84
+
85
+ // Apply stored enable/disable states
86
+ localStream.getAudioTracks()[0].enabled = devicePrefs.audioEnabled;
87
+ localStream.getVideoTracks()[0].enabled = devicePrefs.videoEnabled;
88
+
89
+ // Add local video to grid
90
+ addVideoStream('local', username, localStream);
91
+ updateControls();
92
+
93
+ // Get session info from backend
94
+ const response = await fetch(`${API_BASE}/meetings/${roomId}/info`);
95
+ if (!response.ok) {
96
+ throw new Error('Failed to fetch meeting info');
97
+ }
98
+
99
+ const meetingInfo = await response.json();
100
+ console.log('Meeting info received:', meetingInfo);
101
+
102
+ if (!meetingInfo.sessions || meetingInfo.sessions.length === 0) {
103
+ throw new Error('No sessions array in meeting info');
104
+ }
105
+
106
+ // Find current user's session
107
+ const userSession = meetingInfo.sessions.find(s => s.username === username);
108
+ if (!userSession) {
109
+ throw new Error('No session found for user: ' + username);
110
+ }
111
+
112
+ // Initialize WebSocket connection
113
+ await setupWebSocket();
114
+
115
+ // Initialize WebRTC with user's session
116
+ await setupCloudflareRTC(userSession.session_id);
117
+
118
+ // After setting up our connection, handle existing participants
119
+ await handleExistingParticipants(meetingInfo.sessions);
120
+
121
+ // Add after localStream initialization
122
+ // Setup blur canvas
123
+ blurCanvas = document.createElement('canvas');
124
+ blurCanvas.id = 'blurCanvas';
125
+ blurCanvas.width = 640;
126
+ blurCanvas.height = 480;
127
+ document.body.appendChild(blurCanvas);
128
+
129
+ // Initialize background blur
130
+ backgroundBlur = new BackgroundBlur(
131
+ document.createElement('video'), // Create temporary video element
132
+ blurCanvas
133
+ );
134
+ await backgroundBlur.initialize();
135
+
136
+ } catch (error) {
137
+ console.error('Error initializing room:', error);
138
+ alert('Failed to initialize meeting room: ' + error.message);
139
+ }
140
+ }
141
+
142
+ // Add new function to handle existing participants
143
+ async function handleExistingParticipants(sessions) {
144
+ console.log('Handling existing participants:', sessions);
145
+
146
+ const existingParticipants = sessions.filter(s => s.username !== username);
147
+
148
+ for (const participant of existingParticipants) {
149
+ try {
150
+ // Get session state from Cloudflare
151
+ const sessionState = await fetch(
152
+ `https://rtc.live.cloudflare.com/v1/apps/${APP_ID}/sessions/${participant.session_id}`,
153
+ {
154
+ headers: {
155
+ "Authorization": `Bearer ${APP_TOKEN}`
156
+ }
157
+ }
158
+ ).then(res => res.json());
159
+
160
+ console.log('Session state for existing participant:', participant.username, sessionState);
161
+
162
+ if (sessionState.errorCode) {
163
+ console.warn(`Error getting session state for ${participant.username}:`, sessionState.errorDescription);
164
+ continue;
165
+ }
166
+
167
+ // Add to participants map first
168
+ participants.set(participant.session_id, {
169
+ username: participant.username,
170
+ stream: null,
171
+ sessionId: participant.session_id
172
+ });
173
+
174
+ // Pull tracks if available
175
+ if (sessionState.tracks && sessionState.tracks.length > 0) {
176
+ const activeTracks = sessionState.tracks.filter(track => track.status === 'active');
177
+ if (activeTracks.length > 0) {
178
+ console.log('Found active tracks for', participant.username, ':', activeTracks);
179
+
180
+ // Use retryOperation for pulling tracks
181
+ await retryOperation(
182
+ () => pullParticipantTracks(activeTracks, {
183
+ session_id: participant.session_id,
184
+ username: participant.username
185
+ }),
186
+ 5, // max retries
187
+ 1000, // initial delay
188
+ 'Pulling tracks for ' + participant.username
189
+ );
190
+ }
191
+ }
192
+ } catch (err) {
193
+ console.error(`Error handling existing participant ${participant.username}:`, err);
194
+ }
195
+ }
196
+
197
+ // Update UI after handling all participants
198
+ updateParticipantsList();
199
+ updateGridLayout();
200
+ }
201
+
202
+ // Add utility function for retrying operations
203
+ async function retryOperation(operation, maxRetries, initialDelay, operationName) {
204
+ let lastError;
205
+
206
+ for (let attempt = 0; attempt < maxRetries; attempt++) {
207
+ try {
208
+ const result = await operation();
209
+ console.log(`${operationName} succeeded on attempt ${attempt + 1}`);
210
+ return result;
211
+ } catch (error) {
212
+ lastError = error;
213
+ console.warn(`${operationName} failed attempt ${attempt + 1}:`, error);
214
+
215
+ if (attempt < maxRetries - 1) {
216
+ const delay = initialDelay * Math.pow(2, attempt) * (1 + Math.random() * 0.1);
217
+ console.log(`Retrying ${operationName} in ${delay}ms...`);
218
+ await new Promise(resolve => setTimeout(resolve, delay));
219
+ }
220
+ }
221
+ }
222
+
223
+ throw lastError;
224
+ }
225
+
226
+ // Update pullParticipantTracks to use timeout and better error handling
227
+ async function pullParticipantTracks(tracks, participant) {
228
+ const maxRetries = 5;
229
+ const baseDelay = 1000;
230
+ const localSessionId = localPeerConnection.sessionId;
231
+
232
+ // Prevent pulling tracks for our own session
233
+ if (localSessionId === participant.session_id) {
234
+ console.log('Skipping track pull for local session');
235
+ return;
236
+ }
237
+
238
+ for (let attempt = 0; attempt < maxRetries; attempt++) {
239
+ try {
240
+ // Only check existing stream for non-screen share participants
241
+ if (!participant.isScreenShare && participants.get(participant.session_id)?.stream) {
242
+ console.log('Participant already has stream:', participant.username);
243
+ return;
244
+ }
245
+
246
+ console.log(`Pulling tracks attempt ${attempt + 1}/${maxRetries} for participant:`, participant.username, tracks);
247
+
248
+ // Set up track reception promise with timeout
249
+ const receivedTracksPromise = new Promise((resolve, reject) => {
250
+ const receivedTracks = new Map();
251
+ const timeout = setTimeout(() => {
252
+ reject(new Error("Track reception timeout"));
253
+ }, 15000);
254
+
255
+ // Store the track IDs we're expecting for this participant
256
+ participant.pendingTracks = new Set(tracks.map(track => track.trackName));
257
+ console.log('Expecting tracks for participant:', participant.username, participant.pendingTracks);
258
+
259
+ const trackHandler = (event) => {
260
+ const track = event.track;
261
+ if (participant.pendingTracks.has(track.id)) {
262
+ console.log(`Received expected ${track.kind} track for ${participant.username}:`, track.id);
263
+ receivedTracks.set(track.id, track);
264
+
265
+ if (receivedTracks.size >= tracks.length) {
266
+ clearTimeout(timeout);
267
+ localPeerConnection.removeEventListener('track', trackHandler);
268
+ resolve(Array.from(receivedTracks.values()));
269
+ }
270
+ }
271
+ };
272
+
273
+ localPeerConnection.addEventListener('track', trackHandler);
274
+ });
275
+
276
+ // Pull remote tracks
277
+ console.log(`Pulling tracks for participant ${participant.username} using local session ${localSessionId}`);
278
+ const pullResponse = await fetch(
279
+ `https://rtc.live.cloudflare.com/v1/apps/${APP_ID}/sessions/${localSessionId}/tracks/new`,
280
+ {
281
+ method: "POST",
282
+ headers: {
283
+ "Authorization": `Bearer ${APP_TOKEN}`,
284
+ "Content-Type": "application/json"
285
+ },
286
+ body: JSON.stringify({
287
+ tracks: tracks.map(track => ({
288
+ location: "remote",
289
+ sessionId: participant.session_id,
290
+ trackName: track.trackName
291
+ }))
292
+ })
293
+ }
294
+ );
295
+
296
+ if (!pullResponse.ok) {
297
+ throw new Error(`Failed to pull tracks: ${pullResponse.status}`);
298
+ }
299
+
300
+ const pullData = await pullResponse.json();
301
+ console.log('Pull response:', pullData);
302
+
303
+ if (pullData.requiresImmediateRenegotiation) {
304
+ await handleRenegotiation(pullData, localPeerConnection);
305
+ }
306
+
307
+ const receivedTracks = await receivedTracksPromise;
308
+
309
+ if (receivedTracks.length > 0) {
310
+ const remoteStream = new MediaStream(receivedTracks);
311
+
312
+ participants.set(participant.session_id, {
313
+ ...participants.get(participant.session_id),
314
+ stream: remoteStream
315
+ });
316
+
317
+ addVideoStream(participant.session_id, participant.username, remoteStream);
318
+ console.log(`Successfully added video stream for ${participant.username}`);
319
+ return; // Success - exit retry loop
320
+ }
321
+
322
+ } catch (err) {
323
+ console.error(`Attempt ${attempt + 1} failed for ${participant.username}:`, err);
324
+
325
+ const delay = Math.min(baseDelay * Math.pow(2, attempt) * (1 + Math.random() * 0.1), 10000);
326
+
327
+ if (attempt === maxRetries - 1) {
328
+ throw err;
329
+ }
330
+
331
+ if (err.message.includes("500") ||
332
+ err.message.includes("Session is not ready") ||
333
+ err.message.includes("Track reception timeout") ||
334
+ err.message.includes("Invalid state")) {
335
+
336
+ console.log(`Retrying in ${delay}ms (attempt ${attempt + 1}/${maxRetries})`);
337
+ await new Promise(resolve => setTimeout(resolve, delay));
338
+ continue;
339
+ }
340
+
341
+ throw err;
342
+ }
343
+ }
344
+ }
345
+
346
+ async function setupWebSocket() {
347
+ try {
348
+ if (ws) {
349
+ // Properly close existing connection if any
350
+ if (ws.readyState === WebSocket.OPEN || ws.readyState === WebSocket.CONNECTING) {
351
+ ws.close(1000, 'Intentional close for reconnection');
352
+ }
353
+ }
354
+
355
+ const wsProtocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:';
356
+ const wsBaseUrl = isLocalhost
357
+ ? 'localhost:7860'
358
+ : 'manhteky123-dapp-meeting.hf.space';
359
+ const wsUrl = `${wsProtocol}//${wsBaseUrl}/ws/meetings/${roomId}?username=${encodeURIComponent(username)}`;
360
+
361
+ console.log('Connecting to WebSocket:', wsUrl);
362
+
363
+ ws = new WebSocket(wsUrl);
364
+
365
+ // Add connection timeout
366
+ const connectionTimeout = setTimeout(() => {
367
+ if (ws.readyState !== WebSocket.OPEN) {
368
+ ws.close();
369
+ throw new Error('WebSocket connection timeout');
370
+ }
371
+ }, 15000);
372
+
373
+ await new Promise((resolve, reject) => {
374
+ ws.onopen = () => {
375
+ clearTimeout(connectionTimeout);
376
+ console.log('WebSocket connected successfully');
377
+ resolve();
378
+ };
379
+
380
+ ws.onerror = (error) => {
381
+ clearTimeout(connectionTimeout);
382
+ console.error('WebSocket error:', error);
383
+ reject(error);
384
+ };
385
+
386
+ ws.onclose = (event) => {
387
+ clearTimeout(connectionTimeout);
388
+ console.log('WebSocket closed:', {
389
+ code: event.code,
390
+ reason: event.reason,
391
+ wasClean: event.wasClean,
392
+ timestamp: new Date().toISOString()
393
+ });
394
+
395
+ // Only attempt to reconnect on abnormal closure
396
+ if (event.code === 1006) {
397
+ console.log('Abnormal closure detected, attempting to reconnect...');
398
+ setTimeout(() => {
399
+ if (!ws || ws.readyState === WebSocket.CLOSED) {
400
+ setupWebSocket().catch(err => {
401
+ console.error('Reconnection failed:', err);
402
+ });
403
+ }
404
+ }, 3000);
405
+ }
406
+ };
407
+
408
+ // Setup message handler
409
+ ws.onmessage = (event) => {
410
+ try {
411
+ const data = typeof event.data === 'string' ? JSON.parse(event.data) : event.data;
412
+ if (data === 'ping') {
413
+ ws.send('pong');
414
+ return;
415
+ }
416
+ handleWebSocketMessage(data);
417
+ } catch (e) {
418
+ console.warn('Error handling WebSocket message:', e);
419
+ console.warn('Received invalid message:', event.data);
420
+ }
421
+ };
422
+ });
423
+
424
+ // Setup periodic ping to keep connection alive
425
+ const pingInterval = setInterval(() => {
426
+ if (ws.readyState === WebSocket.OPEN) {
427
+ safeSendWebSocketMessage({ type: 'ping' }).catch(err => {
428
+ console.error('Failed to send ping:', err);
429
+ });
430
+ } else {
431
+ clearInterval(pingInterval);
432
+ }
433
+ }, 30000);
434
+
435
+ } catch (error) {
436
+ console.error('Error setting up WebSocket:', error);
437
+ throw error;
438
+ }
439
+ }
440
+
441
+ // Update handleWebSocketMessage function
442
+ function handleWebSocketMessage(message) {
443
+ console.log('WebSocket message received:', message);
444
+
445
+ if (['participant_joined', 'participant_left', 'tracks_ready', 'wave'].includes(message.type)) {
446
+ showNotification(message.type, message.payload);
447
+ }
448
+
449
+ switch (message.type) {
450
+ case 'room_state':
451
+ console.log('Room state update received:', message.payload);
452
+ updateParticipants(message.payload);
453
+ break;
454
+ case 'participant_left':
455
+ console.log('Participant left:', message.payload);
456
+ handleParticipantLeft(message.payload);
457
+ break;
458
+ case 'participant_joined':
459
+ console.log('New participant joined:', message.payload);
460
+ handleNewParticipant(message.payload);
461
+ break;
462
+ case 'tracks_ready':
463
+ handleTracksReady(message.payload);
464
+ break;
465
+ case 'room_updated':
466
+ console.log('Room updated:', message.payload);
467
+ updateParticipants(message.payload);
468
+ break;
469
+ case 'wave':
470
+ handleWaveNotification(message.payload);
471
+ break;
472
+ case 'speaking_state':
473
+ handleSpeakingState(message.payload);
474
+ break;
475
+ case 'chat_message':
476
+ handleChatMessage(message.payload);
477
+ break;
478
+ }
479
+ }
480
+
481
+ // Add chat message handler
482
+ function handleChatMessage(data) {
483
+ const messages = document.getElementById('chatMessages');
484
+ const messageDiv = document.createElement('div');
485
+ messageDiv.className = `chat-message ${data.username === username ? 'own-message' : ''}`;
486
+
487
+ messageDiv.innerHTML = `
488
+ <div class="message-header">
489
+ <span class="message-username">${escapeHtml(data.username)}</span>
490
+ <span class="message-time">${new Date(data.timestamp).toLocaleTimeString()}</span>
491
+ </div>
492
+ <div class="message-content">${escapeHtml(data.content)}</div>
493
+ `;
494
+
495
+ messages.appendChild(messageDiv);
496
+ messages.scrollTop = messages.scrollHeight;
497
+
498
+ // Show notification if chat is minimized
499
+ if (!document.getElementById('chatContainer').classList.contains('show')) {
500
+ showNotification('chat', data);
501
+ }
502
+ }
503
+
504
+ // Add chat controls
505
+ document.addEventListener('DOMContentLoaded', () => {
506
+ const chatContainer = document.getElementById('chatContainer');
507
+ const chatBtn = document.getElementById('chatBtn');
508
+ const chatInput = document.getElementById('chatInput');
509
+ const sendMessageBtn = document.getElementById('sendMessageBtn');
510
+
511
+ // Toggle chat visibility
512
+ chatBtn.onclick = () => {
513
+ chatContainer.classList.toggle('show');
514
+ chatBtn.classList.toggle('active');
515
+ if (chatContainer.classList.contains('show')) {
516
+ chatInput.focus();
517
+ }
518
+ };
519
+
520
+ // Send message handler
521
+ function sendChatMessage() {
522
+ const content = chatInput.value.trim();
523
+ if (!content) return;
524
+
525
+ const message = {
526
+ type: 'chat_message',
527
+ payload: {
528
+ username: username,
529
+ content: content,
530
+ timestamp: new Date().toISOString()
531
+ }
532
+ };
533
+
534
+ safeSendWebSocketMessage(message);
535
+ chatInput.value = '';
536
+ }
537
+
538
+ // Send on button click
539
+ sendMessageBtn.onclick = sendChatMessage;
540
+
541
+ // Send on Enter key
542
+ chatInput.onkeypress = (e) => {
543
+ if (e.key === 'Enter') {
544
+ sendChatMessage();
545
+ }
546
+ };
547
+ });
548
+ // Update wave notification handler
549
+ function handleWaveNotification(data) {
550
+ console.log('Wave notification received:', data);
551
+ // showNotification('wave', data);
552
+ }
553
+
554
+ async function handleNewParticipant(data) {
555
+ console.log('New participant joined:', data);
556
+
557
+ // Skip if this is ourselves
558
+ if (data.username === username) {
559
+ console.log('Skipping self participant');
560
+ return;
561
+ }
562
+
563
+ try {
564
+ // Wait a bit to ensure the session is ready
565
+ await new Promise(resolve => setTimeout(resolve, 2000));
566
+
567
+ // Get session state from Cloudflare
568
+ const sessionState = await fetch(
569
+ `https://rtc.live.cloudflare.com/v1/apps/${APP_ID}/sessions/${data.session_id}`, {
570
+ headers: {
571
+ "Authorization": `Bearer ${APP_TOKEN}`
572
+ }
573
+ }).then(res => res.json());
574
+
575
+ console.log('New participant session state:', sessionState);
576
+
577
+ if (sessionState.errorCode) {
578
+ if (sessionState.errorDescription === 'Session is not ready yet') {
579
+ // Retry after a delay
580
+ console.log('Session not ready, retrying...');
581
+ setTimeout(() => handleNewParticipant(data), 3000);
582
+ return;
583
+ }
584
+ throw new Error(`Cloudflare error: ${sessionState.errorDescription}`);
585
+ }
586
+
587
+ // Lưu thông tin participant
588
+ if (!participants.has(data.session_id)) {
589
+ participants.set(data.session_id, {
590
+ username: data.username,
591
+ stream: null,
592
+ sessionId: data.session_id
593
+ });
594
+
595
+ // Pull tracks nếu có
596
+ if (sessionState.tracks && sessionState.tracks.length > 0) {
597
+ const activeTracks = sessionState.tracks.filter(track => track.status === 'active');
598
+ console.log('Active tracks found for new participant:', activeTracks);
599
+ if (activeTracks.length > 0) {
600
+ await pullParticipantTracks(activeTracks, {
601
+ session_id: data.session_id,
602
+ username: data.username
603
+ });
604
+ }
605
+ }
606
+
607
+ // Update UI
608
+ updateParticipantsList();
609
+ }
610
+
611
+ } catch (err) {
612
+ console.error("Error handling new participant:", err);
613
+ if (err.message.includes("Session is not ready")) {
614
+ setTimeout(() => handleNewParticipant(data), 3000);
615
+ }
616
+ }
617
+ }
618
+
619
+ async function handleTracksReady(data) {
620
+ console.log('Tracks ready for participant:', data);
621
+ if (data.username === username) return; // Skip if it's our own tracks
622
+
623
+ try {
624
+ const isScreenShare = data.username.endsWith('_screen');
625
+ const sessionState = await fetch(
626
+ `https://rtc.live.cloudflare.com/v1/apps/${APP_ID}/sessions/${data.session_id}`, {
627
+ headers: {
628
+ "Authorization": `Bearer ${APP_TOKEN}`
629
+ }
630
+ }).then(res => res.json());
631
+
632
+ if (sessionState.tracks && sessionState.tracks.length > 0) {
633
+ const activeTracks = sessionState.tracks.filter(track => track.status === 'active');
634
+ if (activeTracks.length > 0) {
635
+ // Force update stream if it's screen share
636
+ if (isScreenShare) {
637
+ const participant = participants.get(data.session_id);
638
+ if (participant) {
639
+ // Reset stream to force new pull
640
+ participant.stream = null;
641
+ }
642
+ }
643
+
644
+ await pullParticipantTracks(activeTracks, {
645
+ session_id: data.session_id,
646
+ username: data.username,
647
+ isScreenShare: isScreenShare
648
+ });
649
+ }
650
+ }
651
+ } catch (err) {
652
+ console.error('Error handling tracks ready:', err);
653
+ }
654
+ }
655
+
656
+ async function setupCloudflareRTC(sessionId) {
657
+ const maxRetries = 5;
658
+ const baseDelay = 1000; // Start with 1 second delay
659
+
660
+ for (let attempt = 0; attempt < maxRetries; attempt++) {
661
+ try {
662
+ if (!localPeerConnection || localPeerConnection.connectionState === 'failed') {
663
+ const iceServers = [
664
+ { urls: 'stun:stun.cloudflare.com:3478' },
665
+ {
666
+ urls: 'turn:turn.cloudflare.com:3478?transport=udp', // TURN URL
667
+ username: 'turn-relay', // TURN Username (Name bạn đặt)
668
+ credential: '761d341add57239b706f846d6f000625cf077d6d4650c0240733852256e7d2a4',
669
+ },
670
+ {
671
+ urls: 'turn:turn.cloudflare.com:3478?transport=tcp', // TURN URL (TCP)
672
+ username: 'turn-relay', // TURN Username (Name bạn đặt)
673
+ credential: '761d341add57239b706f846d6f000625cf077d6d4650c0240733852256e7d2a4',
674
+ },
675
+ {
676
+ urls: 'turns:turn.cloudflare.com:5349?transport=tcp', // TURN URL (TLS - turns)
677
+ username: 'turn-relay', // TURN Username (Name bạn đặt)
678
+ credential: '761d341add57239b706f846d6f000625cf077d6d4650c0240733852256e7d2a4',
679
+ }
680
+ ];
681
+
682
+
683
+ // localPeerConnection = new RTCPeerConnection({
684
+ // iceServers: iceServers, // Sử dụng iceServers đã cấu hình trực tiếp
685
+ // bundlePolicy: 'max-bundle'
686
+ // });
687
+ localPeerConnection = new RTCPeerConnection({
688
+ iceServers: [{ urls: 'stun:stun.cloudflare.com:3478' }], // **CHỈ STUN SERVER**
689
+ bundlePolicy: 'max-bundle'
690
+ });
691
+
692
+ localPeerConnection.sessionId = sessionId;
693
+
694
+ localPeerConnection.onicecandidate = (event) => {
695
+ if (event.candidate) {
696
+ console.log("New ICE candidate:", event.candidate);
697
+ }
698
+ };
699
+
700
+ localPeerConnection.onconnectionstatechange = (event) => {
701
+ console.log("Connection state changed:", localPeerConnection.connectionState);
702
+ };
703
+
704
+ localPeerConnection.ontrack = handleRemoteTrack;
705
+ }
706
+
707
+ // Create transceivers for local stream
708
+ console.log(`Creating transceivers for local stream (attempt ${attempt + 1})`);
709
+ const streamToUse = isMaskEnabled ? processedStream : localStream;
710
+ const transceivers = streamToUse.getTracks().map(track =>
711
+ localPeerConnection.addTransceiver(track, {
712
+ direction: 'sendonly',
713
+ streams: [streamToUse]
714
+ })
715
+ );
716
+
717
+ const offer = await localPeerConnection.createOffer();
718
+ await localPeerConnection.setLocalDescription(offer);
719
+
720
+ // Send local tracks to server with retry logic
721
+ console.log(`Sending local tracks to server (attempt ${attempt + 1})`);
722
+ const requestPayload = {
723
+ method: 'POST',
724
+ headers: {
725
+ 'Authorization': `Bearer ${APP_TOKEN}`,
726
+ 'Content-Type': 'application/json'
727
+ },
728
+ body: JSON.stringify({
729
+ sessionDescription: {
730
+ sdp: offer.sdp,
731
+ type: offer.type
732
+ },
733
+ tracks: transceivers.map(({ mid, sender }) => ({
734
+ location: 'local',
735
+ mid: mid,
736
+ trackName: sender.track?.id || 'anonymous'
737
+ }))
738
+ })
739
+ };
740
+ console.log("Request payload to /tracks/new:", JSON.stringify(requestPayload, null, 2)); // Log request payload
741
+ const response = await fetch(`https://rtc.live.cloudflare.com/v1/apps/${APP_ID}/sessions/${sessionId}/tracks/new`, {
742
+ method: 'POST',
743
+ headers: {
744
+ 'Authorization': `Bearer ${APP_TOKEN}`,
745
+ 'Content-Type': 'application/json'
746
+ },
747
+ body: JSON.stringify({
748
+ sessionDescription: {
749
+ sdp: offer.sdp,
750
+ type: offer.type
751
+ },
752
+ tracks: transceivers.map(({ mid, sender }) => ({
753
+ location: 'local',
754
+ mid: mid,
755
+ trackName: sender.track?.id || 'anonymous'
756
+ }))
757
+ })
758
+ });
759
+
760
+ console.log("Response from /tracks/new:", response); // Log response object
761
+ if (!response.ok) {
762
+ if (response.status === 500) {
763
+ const delay = Math.min(baseDelay * Math.pow(2, attempt), 10000);
764
+ console.log(`Server returned 500, retrying in ${delay}ms (attempt ${attempt + 1}/${maxRetries})`);
765
+ await new Promise(resolve => setTimeout(resolve, delay));
766
+
767
+ // Reset PeerConnection for next attempt
768
+ if (localPeerConnection) {
769
+ localPeerConnection.close();
770
+ localPeerConnection = null;
771
+ }
772
+
773
+ continue; // Try again
774
+ }
775
+ throw new Error(`Cloudflare API error: ${response.status}`);
776
+ }
777
+
778
+ const data = await response.json();
779
+
780
+ if (!data.sessionDescription || !data.sessionDescription.type || !data.sessionDescription.sdp) {
781
+ throw new Error('Invalid response format from Cloudflare API');
782
+ }
783
+
784
+ await localPeerConnection.setRemoteDescription(
785
+ new RTCSessionDescription(data.sessionDescription)
786
+ );
787
+
788
+ // Wait for connection to be established
789
+ await waitForConnectionState(localPeerConnection, 'connected', 15000);
790
+
791
+ // Only proceed after connection is confirmed
792
+ console.log('WebRTC connection established successfully');
793
+
794
+ // Notify that our tracks are ready
795
+ const notifyResponse = await fetch(`${API_BASE}/meetings/${roomId}/notify-tracks-ready`, {
796
+ method: 'POST',
797
+ headers: { 'Content-Type': 'application/json' },
798
+ body: JSON.stringify({
799
+ session_id: sessionId,
800
+ username: username
801
+ })
802
+ });
803
+
804
+ if (!notifyResponse.ok) {
805
+ console.error('Failed to notify tracks ready');
806
+ }
807
+
808
+ // If we get here, everything succeeded
809
+ return;
810
+
811
+ } catch (error) {
812
+ console.error(`Error in setupCloudflareRTC attempt ${attempt + 1}:`, error);
813
+
814
+ // Clean up the failed connection
815
+ if (localPeerConnection) {
816
+ localPeerConnection.close();
817
+ localPeerConnection = null;
818
+ }
819
+
820
+ // If this was our last attempt, throw the error
821
+ if (attempt === maxRetries - 1) {
822
+ throw error;
823
+ }
824
+
825
+ // Wait before retrying
826
+ const delay = Math.min(baseDelay * Math.pow(2, attempt), 10000);
827
+ console.log(`Retrying setupCloudflareRTC in ${delay}ms...`);
828
+ await new Promise(resolve => setTimeout(resolve, delay));
829
+ }
830
+ }
831
+ }
832
+
833
+ // Add new utility function to wait for connection state
834
+ async function waitForConnectionState(peerConnection, desiredState, timeout = 15000) {
835
+ return new Promise((resolve, reject) => {
836
+ if (peerConnection.connectionState === desiredState) {
837
+ resolve();
838
+ return;
839
+ }
840
+
841
+ const timer = setTimeout(() => {
842
+ peerConnection.removeEventListener('connectionstatechange', checkState);
843
+ reject(new Error(`Connection state timeout: waited ${timeout}ms for '${desiredState}' state`));
844
+ }, timeout);
845
+
846
+ function checkState() {
847
+ if (peerConnection.connectionState === desiredState) {
848
+ clearTimeout(timer);
849
+ peerConnection.removeEventListener('connectionstatechange', checkState);
850
+ resolve();
851
+ } else if (peerConnection.connectionState === 'failed') {
852
+ clearTimeout(timer);
853
+ peerConnection.removeEventListener('connectionstatechange', checkState);
854
+ reject(new Error('Connection failed while waiting for desired state'));
855
+ }
856
+ }
857
+
858
+ peerConnection.addEventListener('connectionstatechange', checkState);
859
+ });
860
+ }
861
+
862
+ function handleRemoteTrack(event) {
863
+ const stream = event.streams[0];
864
+ if (!stream) {
865
+ console.warn('No stream in remote track event');
866
+ return;
867
+ }
868
+
869
+ // Log the received track and stream details for debugging
870
+ console.log('Received remote track:', {
871
+ trackKind: event.track.kind,
872
+ trackId: event.track.id,
873
+ streamId: stream.id
874
+ });
875
+
876
+ // Find the participant this stream belongs to by checking track IDs
877
+ let matchingParticipant = null;
878
+ for (const [sessionId, participant] of participants) {
879
+ if (participant.pendingTracks && participant.pendingTracks.has(event.track.id)) {
880
+ console.log('Found matching participant for track:', sessionId);
881
+ matchingParticipant = participant;
882
+ break;
883
+ }
884
+ }
885
+
886
+ if (matchingParticipant) {
887
+ // If we already have a stream for this participant, add the track to it
888
+ if (matchingParticipant.stream) {
889
+ if (!matchingParticipant.stream.getTracks().find(t => t.id === event.track.id)) {
890
+ matchingParticipant.stream.addTrack(event.track);
891
+ }
892
+ } else {
893
+ // Create new stream if this is the first track
894
+ matchingParticipant.stream = new MediaStream([event.track]);
895
+ }
896
+
897
+ // Update the video element with the correct stream
898
+ const videoElement = document.getElementById(`video-${matchingParticipant.sessionId}`);
899
+ if (videoElement) {
900
+ const videoTag = videoElement.querySelector('video');
901
+ if (videoTag && videoTag.srcObject !== matchingParticipant.stream) {
902
+ console.log('Updating video source for participant:', matchingParticipant.username);
903
+ videoTag.srcObject = matchingParticipant.stream;
904
+ }
905
+ }
906
+
907
+ // Setup audio detection if this is an audio track
908
+ if (event.track.kind === 'audio') {
909
+ setupRemoteAudioDetection(matchingParticipant.stream, matchingParticipant.sessionId);
910
+ }
911
+ } else {
912
+ console.log('No matching participant found for track, waiting for participant info');
913
+ }
914
+ }
915
+
916
+ function addVideoStream(id, username, stream) {
917
+ // Remove existing video element if it exists
918
+ const existingVideo = document.getElementById(`video-${id}`);
919
+ if (existingVideo) {
920
+ existingVideo.remove();
921
+ }
922
+
923
+ console.log('Adding video stream:', { id, username, streamId: stream.id });
924
+
925
+ // Create and setup video element
926
+ const videoWrapper = document.createElement('div');
927
+ videoWrapper.className = 'video-wrapper';
928
+ videoWrapper.id = `video-${id}`;
929
+
930
+ const video = document.createElement('video');
931
+ video.autoplay = true;
932
+ video.playsInline = true;
933
+
934
+ // Special handling for screen share video
935
+ if (username.endsWith('_screen')) {
936
+ video.style.objectFit = 'contain'; // Better for screen sharing
937
+ videoWrapper.classList.add('screen-share');
938
+ }
939
+
940
+ // Add error handling
941
+ video.onerror = (e) => {
942
+ console.error('Video error:', e);
943
+ };
944
+
945
+ video.onloadedmetadata = () => {
946
+ console.log(`Video metadata loaded for ${username}`);
947
+ video.play().catch(e => console.error('Error playing video:', e));
948
+ };
949
+
950
+ try {
951
+ video.srcObject = stream;
952
+ } catch (e) {
953
+ console.error('Error setting srcObject:', e);
954
+ return;
955
+ }
956
+
957
+ if (id === 'local') {
958
+ video.muted = true;
959
+ }
960
+
961
+ const nameTag = document.createElement('div');
962
+ nameTag.className = 'participant-name';
963
+ nameTag.textContent = username;
964
+
965
+ videoWrapper.appendChild(video);
966
+ videoWrapper.appendChild(nameTag);
967
+
968
+ // Add to video grid
969
+ const videoGrid = document.getElementById('videoGrid');
970
+ if (videoGrid) {
971
+ videoGrid.appendChild(videoWrapper);
972
+ console.log('Video element added to grid for:', username);
973
+ }
974
+
975
+ // Add or update participant in the participants map if not local user
976
+ if (id !== 'local') {
977
+ participants.set(id, {
978
+ username: username,
979
+ stream: stream,
980
+ sessionId: id
981
+ });
982
+ }
983
+
984
+ // Update both layouts
985
+ updateGridLayout();
986
+ updateParticipantsList();
987
+
988
+ console.log('Updated participants after adding video:', Array.from(participants.entries()));
989
+ }
990
+
991
+ // Update the updateParticipantsList function to be more reliable
992
+ function updateParticipantsList() {
993
+ const participantsList = document.getElementById('participantsList');
994
+ if (!participantsList) {
995
+ console.error('Participants list element not found');
996
+ return;
997
+ }
998
+
999
+ // Clear current list
1000
+ participantsList.innerHTML = '';
1001
+
1002
+ // Add local user first
1003
+ const localLi = document.createElement('li');
1004
+ localLi.textContent = `${username} (You)`;
1005
+ participantsList.appendChild(localLi);
1006
+
1007
+ // Sort participants by username for consistent ordering
1008
+ const sortedParticipants = Array.from(participants.entries())
1009
+ .sort((a, b) => a[1].username.localeCompare(b[1].username));
1010
+
1011
+ // Add remote participants
1012
+ for (const [sessionId, participant] of sortedParticipants) {
1013
+ if (participant.username !== username) {
1014
+ const li = document.createElement('li');
1015
+ li.textContent = participant.username;
1016
+ li.setAttribute('data-session-id', sessionId);
1017
+ participantsList.appendChild(li);
1018
+ }
1019
+ }
1020
+
1021
+ console.log('Participants list updated with', participants.size + 1, 'participants');
1022
+ }
1023
+
1024
+ async function updateParticipants(meetingInfo) {
1025
+ if (!meetingInfo || !meetingInfo.sessions) {
1026
+ console.warn('Invalid meeting info:', meetingInfo);
1027
+ return;
1028
+ }
1029
+
1030
+ console.log('Current participants:', Array.from(participants.entries()));
1031
+ console.log('Updating with new sessions:', meetingInfo.sessions);
1032
+
1033
+ // Create a set of current session IDs for removal tracking
1034
+ const currentSessionIds = new Set(participants.keys());
1035
+
1036
+ // Process each session from the meeting info
1037
+ for (const session of meetingInfo.sessions) {
1038
+ // Skip local user
1039
+ if (session.username === username) {
1040
+ currentSessionIds.delete(session.session_id);
1041
+ continue;
1042
+ }
1043
+
1044
+ // Update or add participant
1045
+ if (!participants.has(session.session_id)) {
1046
+ // New participant
1047
+ console.log('Adding new participant:', session.username);
1048
+ participants.set(session.session_id, {
1049
+ username: session.username,
1050
+ stream: null,
1051
+ sessionId: session.session_id
1052
+ });
1053
+ } else {
1054
+ // Existing participant - update info
1055
+ console.log('Updating existing participant:', session.username);
1056
+ const existingParticipant = participants.get(session.session_id);
1057
+ existingParticipant.username = session.username;
1058
+ }
1059
+
1060
+ // Remove from tracking set since we've processed it
1061
+ currentSessionIds.delete(session.session_id);
1062
+ }
1063
+
1064
+ // Remove participants that are no longer in the meeting
1065
+ for (const sessionId of currentSessionIds) {
1066
+ console.log('Removing participant with session ID:', sessionId);
1067
+ removeParticipant(sessionId);
1068
+ }
1069
+
1070
+ // Update the UI
1071
+ updateParticipantsList();
1072
+ updateGridLayout();
1073
+
1074
+ console.log('Updated participants map:', Array.from(participants.entries()));
1075
+ }
1076
+
1077
+ function updateParticipantsList() {
1078
+ const participantsList = document.getElementById('participantsList');
1079
+ if (!participantsList) {
1080
+ console.error('Participants list element not found');
1081
+ return;
1082
+ }
1083
+
1084
+ console.log('Updating participants list UI');
1085
+ participantsList.innerHTML = '';
1086
+
1087
+ // Add local user
1088
+ const localLi = document.createElement('li');
1089
+ localLi.textContent = `${username} (You)`;
1090
+ participantsList.appendChild(localLi);
1091
+
1092
+ // Add all remote participants
1093
+ for (const [_, participant] of participants) {
1094
+ if (participant.username !== username) {
1095
+ const li = document.createElement('li');
1096
+ li.textContent = participant.username;
1097
+ participantsList.appendChild(li);
1098
+ }
1099
+ }
1100
+
1101
+ console.log('Participants list updated with', participants.size + 1, 'participants');
1102
+ }
1103
+
1104
+ async function pullParticipantTracks(tracks, participant) {
1105
+ const maxRetries = 5;
1106
+ const baseDelay = 1000; // Start with 1 second delay
1107
+
1108
+ for (let attempt = 0; attempt < maxRetries; attempt++) {
1109
+ try {
1110
+ // Only check existing stream for non-screen share participants
1111
+ if (!participant.isScreenShare && participants.get(participant.session_id)?.stream) {
1112
+ console.log('Participant already has stream:', participant.username);
1113
+ return;
1114
+ }
1115
+
1116
+ console.log(`Pulling tracks attempt ${attempt + 1}/${maxRetries} for participant:`, participant.username, tracks);
1117
+
1118
+ // Rest of the existing pullParticipantTracks code...
1119
+ const localSessionId = localPeerConnection.sessionId;
1120
+
1121
+ if (localPeerConnection.connectionState === 'failed') {
1122
+ console.log('Connection failed, attempting to restart...');
1123
+ const offer = await localPeerConnection.createOffer({ iceRestart: true });
1124
+ await localPeerConnection.setLocalDescription(offer);
1125
+ }
1126
+
1127
+ // Set up track reception promise
1128
+ const receivedTracksPromise = new Promise((resolve, reject) => {
1129
+ // ...existing promise setup code...
1130
+ const receivedTracks = new Map();
1131
+ const timeout = setTimeout(() => {
1132
+ if (receivedTracks.size === 0) {
1133
+ reject(new Error("Track reception timeout"));
1134
+ } else {
1135
+ resolve(Array.from(receivedTracks.values()));
1136
+ }
1137
+ }, 15000);
1138
+
1139
+ const trackHandler = (event) => {
1140
+ const track = event.track;
1141
+ console.log("Received track:", track.kind, track.id);
1142
+
1143
+ // Check if this track belongs to the participant we're pulling for
1144
+ if (event.streams[0]) {
1145
+ receivedTracks.set(track.id, track);
1146
+
1147
+ if (receivedTracks.size >= tracks.length) {
1148
+ clearTimeout(timeout);
1149
+ resolve(Array.from(receivedTracks.values()));
1150
+ }
1151
+ }
1152
+
1153
+ // Add track removal handler
1154
+ if (event.streams && event.streams[0]) {
1155
+ event.streams[0].onremovetrack = () => {
1156
+ console.log('Track removed:', track.id);
1157
+ const participant = Array.from(participants.entries())
1158
+ .find(([_, p]) => p.stream?.id === event.streams[0].id);
1159
+ if (participant) {
1160
+ console.log('Removing track from participant:', participant[1].username);
1161
+ }
1162
+ };
1163
+ }
1164
+ };
1165
+
1166
+ localPeerConnection.addEventListener('track', trackHandler);
1167
+ setTimeout(() => {
1168
+ localPeerConnection.removeEventListener('track', trackHandler);
1169
+ }, 15000);
1170
+ });
1171
+
1172
+ // Pull remote tracks
1173
+ console.log('Sending pull tracks request for:', participant.username);
1174
+ const pullResponse = await fetch(
1175
+ `https://rtc.live.cloudflare.com/v1/apps/${APP_ID}/sessions/${localSessionId}/tracks/new`,
1176
+ {
1177
+ method: "POST",
1178
+ headers: {
1179
+ "Authorization": `Bearer ${APP_TOKEN}`,
1180
+ "Content-Type": "application/json"
1181
+ },
1182
+ body: JSON.stringify({
1183
+ tracks: tracks.map(track => ({
1184
+ location: "remote",
1185
+ sessionId: participant.session_id,
1186
+ trackName: track.trackName
1187
+ }))
1188
+ })
1189
+ }
1190
+ );
1191
+
1192
+ if (!pullResponse.ok) {
1193
+ throw new Error(`Failed to pull tracks: ${pullResponse.status}`);
1194
+ }
1195
+
1196
+ // If successful, process the response and return
1197
+ const pullData = await pullResponse.json();
1198
+ console.log('Pull response for', participant.username, ':', pullData);
1199
+
1200
+ if (pullData.requiresImmediateRenegotiation) {
1201
+ console.log('Renegotiation required for:', participant.username);
1202
+ await handleRenegotiation(pullData, localPeerConnection);
1203
+ }
1204
+
1205
+ const receivedTracks = await receivedTracksPromise;
1206
+ if (receivedTracks.length > 0) {
1207
+ // Process received tracks...
1208
+ const existingVideo = document.getElementById(`video-${participant.session_id}`);
1209
+ if (existingVideo) {
1210
+ existingVideo.remove();
1211
+ }
1212
+
1213
+ const remoteStream = new MediaStream();
1214
+ receivedTracks.forEach(track => {
1215
+ console.log('Adding track to stream:', track.kind, track.id);
1216
+ remoteStream.addTrack(track);
1217
+ });
1218
+
1219
+ // Update participant with new stream
1220
+ const existingParticipant = participants.get(participant.session_id);
1221
+ if (existingParticipant) {
1222
+ // Stop old stream tracks if they exist
1223
+ if (existingParticipant.stream) {
1224
+ existingParticipant.stream.getTracks().forEach(track => track.stop());
1225
+ }
1226
+ existingParticipant.stream = remoteStream;
1227
+ } else {
1228
+ participants.set(participant.session_id, {
1229
+ username: participant.username,
1230
+ stream: remoteStream,
1231
+ sessionId: participant.session_id,
1232
+ isScreenShare: participant.isScreenShare
1233
+ });
1234
+ }
1235
+
1236
+ // Always add/update video element for screen shares
1237
+ addVideoStream(participant.session_id, participant.username, remoteStream);
1238
+ }
1239
+
1240
+ // If we get here, we succeeded, so break the retry loop
1241
+ return;
1242
+
1243
+ } catch (err) {
1244
+ console.error(`Attempt ${attempt + 1} failed for ${participant.username}:`, err);
1245
+
1246
+ // Calculate exponential backoff delay with jitter
1247
+ const delay = Math.min(baseDelay * Math.pow(2, attempt) * (1 + Math.random() * 0.1), 10000);
1248
+
1249
+ // If this was our last attempt, throw the error
1250
+ if (attempt === maxRetries - 1) {
1251
+ throw err;
1252
+ }
1253
+
1254
+ // If we get specific errors that we want to retry
1255
+ if (err.message.includes("500") ||
1256
+ err.message.includes("Session is not ready") ||
1257
+ err.message.includes("Track reception timeout") ||
1258
+ err.message.includes("Invalid state")) {
1259
+
1260
+ console.log(`Retrying in ${delay}ms (attempt ${attempt + 1}/${maxRetries})`);
1261
+ await new Promise(resolve => setTimeout(resolve, delay));
1262
+ continue;
1263
+ }
1264
+
1265
+ // For other types of errors, throw immediately
1266
+ throw err;
1267
+ }
1268
+ }
1269
+ }
1270
+
1271
+ async function handleRenegotiation(pullData, peerConnection) {
1272
+ try {
1273
+ console.log('Starting renegotiation with data:', pullData);
1274
+
1275
+ // Check if connection is closed or failing
1276
+ if (peerConnection.connectionState === 'closed' || peerConnection.connectionState === 'failed') {
1277
+ console.log('Connection is closed/failed, creating new connection...');
1278
+ await setupCloudflareRTC(peerConnection.sessionId);
1279
+ return;
1280
+ }
1281
+
1282
+ // Wait for stable state with timeout
1283
+ await waitForSignalingState(peerConnection, 'stable', 5000);
1284
+
1285
+ try {
1286
+ await peerConnection.setRemoteDescription(
1287
+ new RTCSessionDescription(pullData.sessionDescription)
1288
+ );
1289
+
1290
+ const answer = await peerConnection.createAnswer();
1291
+ await peerConnection.setLocalDescription(answer);
1292
+
1293
+ const renegotiateResponse = await fetch(
1294
+ `https://rtc.live.cloudflare.com/v1/apps/${APP_ID}/sessions/${peerConnection.sessionId}/renegotiate`,
1295
+ {
1296
+ method: "PUT",
1297
+ headers: {
1298
+ "Authorization": `Bearer ${APP_TOKEN}`,
1299
+ "Content-Type": "application/json"
1300
+ },
1301
+ body: JSON.stringify({
1302
+ sessionDescription: {
1303
+ sdp: answer.sdp,
1304
+ type: "answer"
1305
+ }
1306
+ })
1307
+ }
1308
+ );
1309
+
1310
+ if (!renegotiateResponse.ok) {
1311
+ throw new Error(`Renegotiation failed: ${renegotiateResponse.status}`);
1312
+ }
1313
+
1314
+ } catch (error) {
1315
+ console.error('Error during renegotiation:', error);
1316
+ if (error.name === 'InvalidStateError') {
1317
+ // If we get invalid state, try recreating the connection
1318
+ await setupCloudflareRTC(peerConnection.sessionId);
1319
+ }
1320
+ throw error;
1321
+ }
1322
+ } catch (error) {
1323
+ console.error('Renegotiation error:', error);
1324
+ throw error;
1325
+ }
1326
+ }
1327
+
1328
+ // Add new utility function to wait for signaling state
1329
+ function waitForSignalingState(peerConnection, desiredState, timeout) {
1330
+ return new Promise((resolve, reject) => {
1331
+ if (peerConnection.signalingState === desiredState) {
1332
+ resolve();
1333
+ return;
1334
+ }
1335
+
1336
+ const timer = setTimeout(() => {
1337
+ peerConnection.removeEventListener('signalingstatechange', checkState);
1338
+ reject(new Error('Signaling state timeout'));
1339
+ }, timeout);
1340
+
1341
+ function checkState() {
1342
+ if (peerConnection.signalingState === desiredState) {
1343
+ clearTimeout(timer);
1344
+ peerConnection.removeEventListener('signalingstatechange', checkState);
1345
+ resolve();
1346
+ }
1347
+ }
1348
+
1349
+ peerConnection.addEventListener('signalingstatechange', checkState);
1350
+ });
1351
+ }
1352
+
1353
+ function handleParticipantLeft(data) {
1354
+ console.log('Handling participant left:', data);
1355
+
1356
+ if (data.session_id) {
1357
+ // Direct removal using session_id
1358
+ removeParticipant(data.session_id);
1359
+ } else if (data.username) {
1360
+ // Find session_id by username if session_id not provided
1361
+ const participantEntry = Array.from(participants.entries())
1362
+ .find(([_, p]) => p.username === data.username);
1363
+
1364
+ if (participantEntry) {
1365
+ const [sessionId, participant] = participantEntry;
1366
+ console.log('Found session ID for leaving user:', sessionId);
1367
+ removeParticipant(sessionId);
1368
+
1369
+ // Also check and remove any associated screen share
1370
+ const screenShareEntry = Array.from(participants.entries())
1371
+ .find(([_, p]) => p.username === `${data.username}_screen`);
1372
+
1373
+ if (screenShareEntry) {
1374
+ removeParticipant(screenShareEntry[0]);
1375
+ }
1376
+ } else {
1377
+ console.warn('Could not find participant with username:', data.username);
1378
+ }
1379
+ } else {
1380
+ console.error('Invalid participant_left payload:', data);
1381
+ }
1382
+ }
1383
+
1384
+ function removeParticipant(sessionId) {
1385
+ const participant = participants.get(sessionId);
1386
+ if (!participant) {
1387
+ console.log('Participant not found for removal:', sessionId);
1388
+ return;
1389
+ }
1390
+
1391
+ console.log('Removing participant:', sessionId, participant.username);
1392
+
1393
+ // Get video element
1394
+ const videoElement = document.getElementById(`video-${sessionId}`);
1395
+ if (videoElement) {
1396
+ // Add animation class
1397
+ videoElement.classList.add('removing');
1398
+
1399
+ // Wait for animation to complete before removing
1400
+ setTimeout(() => {
1401
+ const video = videoElement.querySelector('video');
1402
+ if (video) {
1403
+ video.srcObject = null;
1404
+ }
1405
+ videoElement.remove();
1406
+
1407
+ // Update layout after removal
1408
+ updateGridLayout();
1409
+ }, 300);
1410
+ }
1411
+
1412
+ // Stop all tracks in participant's stream
1413
+ if (participant.stream) {
1414
+ participant.stream.getTracks().forEach(track => {
1415
+ track.stop();
1416
+ track.enabled = false;
1417
+ });
1418
+ participant.stream = null; // Clear stream reference
1419
+ }
1420
+
1421
+ // Clean up peer connection if it exists
1422
+ if (participant.peerConnection) {
1423
+ participant.peerConnection.close();
1424
+ participant.peerConnection = null;
1425
+ }
1426
+
1427
+ // Remove from participants map
1428
+ participants.delete(sessionId);
1429
+
1430
+ // Update UI
1431
+ updateParticipantsList();
1432
+ updateGridLayout();
1433
+
1434
+ console.log('Participant removed successfully:', sessionId);
1435
+ console.log('Remaining participants:', Array.from(participants.keys()));
1436
+ }
1437
+
1438
+ // Control handlers
1439
+ document.getElementById('toggleMicBtn').onclick = () => {
1440
+ const audioTrack = localStream.getAudioTracks()[0];
1441
+ audioTrack.enabled = !audioTrack.enabled;
1442
+ updateControls();
1443
+ };
1444
+
1445
+ document.getElementById('toggleVideoBtn').onclick = () => {
1446
+ const videoTrack = localStream.getVideoTracks()[0];
1447
+ videoTrack.enabled = !videoTrack.enabled;
1448
+ updateControls();
1449
+ };
1450
+
1451
+ // Add new constant for screen share identifier
1452
+ const SCREEN_SHARE_PREFIX = '_screen';
1453
+
1454
+ document.getElementById('shareScreenBtn').onclick = async () => {
1455
+ try {
1456
+ const existingScreenShare = [...participants.values()].some(obj => obj.username?.endsWith("_screen"));
1457
+ if (existingScreenShare) {
1458
+ const screenShareParticipant = Array.from(participants.entries())
1459
+ .find(([_, obj]) => obj.username === `${username}_screen`);
1460
+
1461
+ if (screenShareParticipant) {
1462
+ const [sessionId, participant] = screenShareParticipant;
1463
+
1464
+ // Dừng streams và đóng kết nối trước
1465
+ if (participant.stream) {
1466
+ participant.stream.getTracks().forEach(track => {
1467
+ track.stop();
1468
+ participant.stream.removeTrack(track);
1469
+ });
1470
+ }
1471
+ if (participant.peerConnection) {
1472
+ participant.peerConnection.getSenders().forEach(sender => {
1473
+ if (sender.track) {
1474
+ sender.track.stop();
1475
+ participant.peerConnection.removeTrack(sender);
1476
+ }
1477
+ });
1478
+ participant.peerConnection.close();
1479
+ }
1480
+
1481
+ // Gửi WebSocket message trước khi xóa
1482
+ if (ws && ws.readyState === WebSocket.OPEN) {
1483
+ ws.send(JSON.stringify({
1484
+ type: 'participant_left',
1485
+ payload: {
1486
+ session_id: sessionId,
1487
+ username: `${username}_screen`
1488
+ }
1489
+ }));
1490
+
1491
+ // Đợi một chút để đảm bảo message được gửi
1492
+ await new Promise(resolve => setTimeout(resolve, 100));
1493
+ }
1494
+
1495
+ // Sau đó mới xóa khỏi local state
1496
+ removeParticipant(sessionId);
1497
+
1498
+ // Reset button state
1499
+ const shareBtn = document.getElementById('shareScreenBtn');
1500
+ shareBtn.querySelector('.material-icons').textContent = 'screen_share';
1501
+ shareBtn.classList.remove('active');
1502
+ return;
1503
+ }
1504
+ alert("Another participant is already sharing their screen");
1505
+ return;
1506
+ }
1507
+
1508
+ // Start new screen share
1509
+ const screenStream = await navigator.mediaDevices.getDisplayMedia({
1510
+ video: true,
1511
+ audio: false
1512
+ });
1513
+
1514
+ // Create screen share username
1515
+ const screenShareUsername = `${username}_screen`;
1516
+
1517
+ // Join meeting as new participant for screen share
1518
+ const joinResponse = await fetch(
1519
+ `${API_BASE}/meetings/${roomId}?username=${encodeURIComponent(screenShareUsername)}`,
1520
+ {
1521
+ method: 'GET',
1522
+ headers: {
1523
+ 'Content-Type': 'application/json',
1524
+ 'Accept': 'application/json',
1525
+ 'Origin': window.location.origin
1526
+ },
1527
+ credentials: 'include'
1528
+ }
1529
+ );
1530
+
1531
+ if (!joinResponse.ok) {
1532
+ throw new Error('Failed to create screen share session');
1533
+ }
1534
+
1535
+ // Get session ID for screen share
1536
+ const screenSession = await joinResponse.json();
1537
+ const screenSessionId = screenSession.session_id;
1538
+
1539
+ // Create peer connection and setup WebRTC for screen share
1540
+ await setupScreenShare(screenSessionId, screenStream);
1541
+
1542
+ // Handle stream ending
1543
+ screenStream.getVideoTracks()[0].onended = async () => {
1544
+ try {
1545
+ await fetch(`${API_BASE}/meetings/${roomId}/leave`, {
1546
+ method: 'POST',
1547
+ headers: {
1548
+ 'Content-Type': 'application/json'
1549
+ },
1550
+ body: JSON.stringify({
1551
+ session_id: screenSessionId
1552
+ })
1553
+ });
1554
+ // WebSocket will handle cleanup via participant_left event
1555
+ } catch (err) {
1556
+ console.error('Error handling screen share end:', err);
1557
+ }
1558
+ };
1559
+
1560
+ // Update button state
1561
+ const shareBtn = document.getElementById('shareScreenBtn');
1562
+ shareBtn.querySelector('.material-icons').textContent = 'stop_screen_share';
1563
+ shareBtn.classList.add('active');
1564
+
1565
+ } catch (error) {
1566
+ console.error('Error sharing screen:', error);
1567
+ alert('Failed to share screen: ' + error.message);
1568
+ }
1569
+ };
1570
+
1571
+ async function setupScreenShare(sessionId, screenStream) {
1572
+ // Create new peer connection for screen share
1573
+ const screenPeerConnection = new RTCPeerConnection({
1574
+ iceServers: [{ urls: 'stun:stun.cloudflare.com:3478' }],
1575
+ bundlePolicy: 'max-bundle'
1576
+ });
1577
+
1578
+ screenPeerConnection.sessionId = sessionId;
1579
+
1580
+ // Add screen track to peer connection
1581
+ const screenTrack = screenStream.getVideoTracks()[0];
1582
+ const transceiver = screenPeerConnection.addTransceiver(screenTrack, {
1583
+ direction: 'sendrecv',
1584
+ streams: [screenStream]
1585
+ });
1586
+
1587
+ // Create and send offer
1588
+ const offer = await screenPeerConnection.createOffer();
1589
+ await screenPeerConnection.setLocalDescription(offer);
1590
+
1591
+ // Send tracks to Cloudflare
1592
+ const cloudflareResponse = await fetch(
1593
+ `https://rtc.live.cloudflare.com/v1/apps/${APP_ID}/sessions/${sessionId}/tracks/new`,
1594
+ {
1595
+ method: 'POST',
1596
+ headers: {
1597
+ 'Authorization': `Bearer ${APP_TOKEN}`,
1598
+ 'Content-Type': 'application/json'
1599
+ },
1600
+ body: JSON.stringify({
1601
+ sessionDescription: {
1602
+ sdp: offer.sdp,
1603
+ type: offer.type
1604
+ },
1605
+ tracks: [{
1606
+ location: 'local',
1607
+ mid: transceiver.mid,
1608
+ trackName: screenTrack.id
1609
+ }]
1610
+ })
1611
+ }
1612
+ );
1613
+
1614
+ if (!cloudflareResponse.ok) {
1615
+ throw new Error('Failed to setup screen share tracks');
1616
+ }
1617
+
1618
+ const data = await cloudflareResponse.json();
1619
+ await screenPeerConnection.setRemoteDescription(
1620
+ new RTCSessionDescription(data.sessionDescription)
1621
+ );
1622
+
1623
+ // Add to participants map
1624
+ participants.set(sessionId, {
1625
+ username: `${username}'s Screen`,
1626
+ stream: screenStream,
1627
+ sessionId: sessionId,
1628
+ isScreenShare: true,
1629
+ peerConnection: screenPeerConnection
1630
+ });
1631
+
1632
+ // Notify that tracks are ready
1633
+ await fetch(`${API_BASE}/meetings/${roomId}/notify-tracks-ready`, {
1634
+ method: 'POST',
1635
+ headers: { 'Content-Type': 'application/json' },
1636
+ body: JSON.stringify({
1637
+ session_id: sessionId,
1638
+ username: `${username}_screen`
1639
+ })
1640
+ });
1641
+
1642
+ // Update stream end handler
1643
+ screenStream.getVideoTracks()[0].onended = () => {
1644
+ handleScreenShareEnd(sessionId, screenStream, screenPeerConnection);
1645
+ };
1646
+
1647
+ // Add track ended listener for each track
1648
+ screenStream.getTracks().forEach(track => {
1649
+ track.addEventListener('ended', () => {
1650
+ handleScreenShareEnd(sessionId, screenStream, screenPeerConnection);
1651
+ });
1652
+ });
1653
+ }
1654
+
1655
+ // Add new helper function to handle screen share cleanup
1656
+ async function handleScreenShareEnd(sessionId, stream, peerConnection) {
1657
+ try {
1658
+ // Dừng streams và đóng kết nối trước
1659
+ if (stream) {
1660
+ stream.getTracks().forEach(track => {
1661
+ track.stop();
1662
+ stream.removeTrack(track);
1663
+ });
1664
+ }
1665
+
1666
+ if (peerConnection) {
1667
+ peerConnection.getSenders().forEach(sender => {
1668
+ if (sender.track) {
1669
+ sender.track.stop();
1670
+ peerConnection.removeTrack(sender);
1671
+ }
1672
+ });
1673
+ peerConnection.close();
1674
+ }
1675
+
1676
+ // Gửi WebSocket message trước khi xóa
1677
+ if (ws && ws.readyState === WebSocket.OPEN) {
1678
+ ws.send(JSON.stringify({
1679
+ type: 'participant_left',
1680
+ payload: {
1681
+ session_id: sessionId,
1682
+ username: `${username}_screen`
1683
+ }
1684
+ }));
1685
+
1686
+ // Đợi một chút để đảm bảo message được gửi
1687
+ await new Promise(resolve => setTimeout(resolve, 100));
1688
+ }
1689
+
1690
+ // Sau đó mới xóa video element và local state
1691
+ const videoElement = document.getElementById(`video-${sessionId}`);
1692
+ if (videoElement) {
1693
+ const video = videoElement.querySelector('video');
1694
+ if (video) {
1695
+ video.srcObject = null;
1696
+ video.load();
1697
+ }
1698
+ videoElement.remove();
1699
+ }
1700
+
1701
+ removeParticipant(sessionId);
1702
+
1703
+ // Reset button state
1704
+ const shareBtn = document.getElementById('shareScreenBtn');
1705
+ shareBtn.querySelector('.material-icons').textContent = 'screen_share';
1706
+ shareBtn.classList.remove('active');
1707
+
1708
+ // Force update layout
1709
+ updateGridLayout();
1710
+ } catch (err) {
1711
+ console.error('Error handling screen share end:', err);
1712
+ }
1713
+ }
1714
+
1715
+ document.getElementById('leaveBtn').onclick = async () => {
1716
+ try {
1717
+ if (ws) {
1718
+ ws.close();
1719
+ }
1720
+ if (localPeerConnection) {
1721
+ localPeerConnection.close();
1722
+ }
1723
+ if (localStream) {
1724
+ localStream.getTracks().forEach(track => track.stop());
1725
+ }
1726
+ window.location.href = 'index.html';
1727
+ } catch (error) {
1728
+ console.error('Error leaving meeting:', error);
1729
+ }
1730
+ };
1731
+
1732
+ function updateControls() {
1733
+ const micBtn = document.getElementById('toggleMicBtn');
1734
+ const videoBtn = document.getElementById('toggleVideoBtn');
1735
+
1736
+ const audioTrack = localStream.getAudioTracks()[0];
1737
+ const videoTrack = localStream.getVideoTracks()[0];
1738
+
1739
+ micBtn.querySelector('.material-icons').textContent = audioTrack.enabled ? 'mic' : 'mic_off';
1740
+ videoBtn.querySelector('.material-icons').textContent = videoTrack.enabled ? 'videocam' : 'videocam_off';
1741
+
1742
+ micBtn.classList.toggle('active', !audioTrack.enabled);
1743
+ videoBtn.classList.toggle('active', !videoTrack.enabled);
1744
+ }
1745
+
1746
+ // Add this function to update grid layout based on participant count
1747
+ function updateGridLayout() {
1748
+ const grid = document.getElementById('videoGrid');
1749
+ const participantCount = participants.size + 1; // +1 for local user
1750
+
1751
+ // Remove all existing layout classes
1752
+ grid.classList.remove(
1753
+ 'single-participant',
1754
+ 'two-participants',
1755
+ 'few-participants',
1756
+ 'many-participants'
1757
+ );
1758
+
1759
+ // Add appropriate layout class based on participant count
1760
+ if (participantCount === 1) {
1761
+ grid.classList.add('single-participant');
1762
+ } else if (participantCount === 2) {
1763
+ grid.classList.add('two-participants');
1764
+ } else if (participantCount <= 4) {
1765
+ grid.classList.add('few-participants');
1766
+ } else {
1767
+ grid.classList.add('many-participants');
1768
+ }
1769
+
1770
+ // Force grid reflow for smoother transitions
1771
+ grid.style.display = 'none';
1772
+ grid.offsetHeight; // Trigger reflow
1773
+ grid.style.display = 'grid';
1774
+ }
1775
+
1776
+ // Add this helper function to verify stream mappings
1777
+ function verifyStreamMappings() {
1778
+ console.log('Verifying stream mappings:');
1779
+ for (const [sessionId, participant] of participants) {
1780
+ const videoElement = document.getElementById(`video-${sessionId}`);
1781
+ if (videoElement) {
1782
+ const videoTag = videoElement.querySelector('video');
1783
+ console.log('Participant:', participant.username, {
1784
+ sessionId,
1785
+ hasStream: !!participant.stream,
1786
+ streamId: participant.stream?.id,
1787
+ videoSrcObject: videoTag?.srcObject?.id,
1788
+ matches: videoTag?.srcObject === participant.stream
1789
+ });
1790
+ }
1791
+ }
1792
+ }
1793
+
1794
+ // Call this periodically or after significant events
1795
+ setInterval(verifyStreamMappings, 10000);
1796
+
1797
+ // Add after localStream initialization in initializeRoom()
1798
+ function setupAudioDetection() {
1799
+ try {
1800
+ const audioContext = new (window.AudioContext || window.webkitAudioContext)();
1801
+ const analyzer = audioContext.createAnalyser();
1802
+ const microphone = audioContext.createMediaStreamSource(localStream);
1803
+ const scriptProcessor = audioContext.createScriptProcessor(2048, 1, 1);
1804
+
1805
+ analyzer.smoothingTimeConstant = 0.3; // Make it more responsive
1806
+ analyzer.fftSize = 1024;
1807
+
1808
+ microphone.connect(analyzer);
1809
+ analyzer.connect(scriptProcessor);
1810
+ scriptProcessor.connect(audioContext.destination);
1811
+
1812
+ const speakingThreshold = -30; // Lower threshold to detect more subtle sounds
1813
+ let speakingIndicatorTimeout;
1814
+ let lastSpeakingState = false; // Track speaking state
1815
+
1816
+ scriptProcessor.onaudioprocess = function () {
1817
+ const array = new Uint8Array(analyzer.frequencyBinCount);
1818
+ analyzer.getByteFrequencyData(array);
1819
+ const arrayAverage = array.reduce((a, value) => a + value, 0) / array.length;
1820
+ const volume = 20 * Math.log10(arrayAverage / 255);
1821
+
1822
+ const isSpeaking = volume > speakingThreshold;
1823
+
1824
+ // Only send update when speaking state changes
1825
+ if (isSpeaking !== lastSpeakingState) {
1826
+ lastSpeakingState = isSpeaking;
1827
+
1828
+ // Send speaking state update via WebSocket
1829
+ if (ws && ws.readyState === WebSocket.OPEN) {
1830
+ safeSendWebSocketMessage({
1831
+ type: 'speaking_state',
1832
+ payload: {
1833
+ username: username,
1834
+ isSpeaking: isSpeaking
1835
+ }
1836
+ });
1837
+ }
1838
+ }
1839
+
1840
+ const localVideo = document.getElementById('video-local');
1841
+ if (localVideo) {
1842
+ if (isSpeaking) {
1843
+ if (!localVideo.classList.contains('speaking')) {
1844
+ localVideo.classList.add('speaking');
1845
+ }
1846
+ clearTimeout(speakingIndicatorTimeout);
1847
+ } else {
1848
+ speakingIndicatorTimeout = setTimeout(() => {
1849
+ localVideo.classList.remove('speaking');
1850
+ }, 300); // Shorter timeout for more responsive UI
1851
+ }
1852
+ }
1853
+ };
1854
+ } catch (error) {
1855
+ console.error('Error setting up audio detection:', error);
1856
+ }
1857
+ }
1858
+
1859
+ function setupRemoteAudioDetection(stream, sessionId) {
1860
+ try {
1861
+ const audioContext = new (window.AudioContext || window.webkitAudioContext)();
1862
+ const analyzer = audioContext.createAnalyser();
1863
+ const microphone = audioContext.createMediaStreamSource(stream);
1864
+ const scriptProcessor = audioContext.createScriptProcessor(2048, 1, 1);
1865
+
1866
+ analyzer.smoothingTimeConstant = 0.3; // Make it more responsive
1867
+ analyzer.fftSize = 1024;
1868
+
1869
+ microphone.connect(analyzer);
1870
+ analyzer.connect(scriptProcessor);
1871
+ scriptProcessor.connect(audioContext.destination);
1872
+
1873
+ const speakingThreshold = -30; // Lower threshold to detect more subtle sounds
1874
+ let speakingIndicatorTimeout;
1875
+
1876
+ scriptProcessor.onaudioprocess = function () {
1877
+ const array = new Uint8Array(analyzer.frequencyBinCount);
1878
+ analyzer.getByteFrequencyData(array);
1879
+ const arrayAverage = array.reduce((a, value) => a + value, 0) / array.length;
1880
+ const volume = 20 * Math.log10(arrayAverage / 255);
1881
+
1882
+ const videoElement = document.getElementById(`video-${sessionId}`);
1883
+ if (videoElement) {
1884
+ if (volume > speakingThreshold) {
1885
+ if (!videoElement.classList.contains('speaking')) {
1886
+ videoElement.classList.add('speaking');
1887
+ }
1888
+ clearTimeout(speakingIndicatorTimeout);
1889
+ speakingIndicatorTimeout = setTimeout(() => {
1890
+ videoElement.classList.remove('speaking');
1891
+ }, 300); // Shorter timeout for more responsive UI
1892
+ }
1893
+ }
1894
+ };
1895
+ } catch (error) {
1896
+ console.error('Error setting up remote audio detection:', error);
1897
+ }
1898
+ }
1899
+
1900
+ // Add after other control handlers
1901
+ document.getElementById('waveBtn').onclick = () => {
1902
+ if (!ws) {
1903
+ console.error('WebSocket connection not initialized, attempting to reconnect...');
1904
+ setupWebSocket().catch(err => {
1905
+ console.error('Failed to reconnect WebSocket:', err);
1906
+ });
1907
+ return;
1908
+ }
1909
+
1910
+ // Check WebSocket state
1911
+ if (ws.readyState !== WebSocket.OPEN) {
1912
+ console.log('WebSocket is not open, current state:', ws.readyState);
1913
+ return;
1914
+ }
1915
+
1916
+ try {
1917
+ // Add message type validation
1918
+ const waveMessage = {
1919
+ type: 'wave',
1920
+ payload: {
1921
+ username: username,
1922
+ timestamp: new Date().toISOString()
1923
+ }
1924
+ };
1925
+
1926
+ // Use a safe send method
1927
+ safeSendWebSocketMessage(waveMessage);
1928
+
1929
+ } catch (error) {
1930
+ console.error('Error sending wave message:', error);
1931
+ }
1932
+ };
1933
+
1934
+ // Add new utility function for safe WebSocket sending
1935
+ function safeSendWebSocketMessage(message) {
1936
+ return new Promise((resolve, reject) => {
1937
+ if (!ws || ws.readyState !== WebSocket.OPEN) {
1938
+ reject(new Error('WebSocket is not connected'));
1939
+ return;
1940
+ }
1941
+
1942
+ try {
1943
+ const messageString = JSON.stringify(message);
1944
+ ws.send(messageString);
1945
+ console.log('Message sent successfully:', messageString);
1946
+ resolve();
1947
+ } catch (error) {
1948
+ console.error('Error sending message:', error);
1949
+ reject(error);
1950
+ }
1951
+ });
1952
+ }
1953
+
1954
+ // Update showNotification function
1955
+ function showNotification(type, data) {
1956
+ // Check if notifications container exists
1957
+ let notificationsContainer = document.getElementById('notificationsContainer');
1958
+ if (!notificationsContainer) {
1959
+ notificationsContainer = document.createElement('div');
1960
+ notificationsContainer.id = 'notificationsContainer';
1961
+ notificationsContainer.className = 'notifications-container';
1962
+ document.body.appendChild(notificationsContainer);
1963
+ }
1964
+
1965
+ const notification = document.createElement('div');
1966
+ notification.className = 'notification';
1967
+
1968
+ // Add different text for own notifications
1969
+ const isOwnAction = data.username === username;
1970
+
1971
+ let content = '';
1972
+ switch (type) {
1973
+ case 'wave':
1974
+ notification.classList.add('wave');
1975
+ content = `
1976
+ <span class="material-icons">👋</span>
1977
+ <div class="notification-content">
1978
+ <span class="notification-username">${isOwnAction ? 'You' : escapeHtml(data.username)}</span>
1979
+ <span>${isOwnAction ? 'waved' : 'is waving'}</span>
1980
+ </div>
1981
+ `;
1982
+ break;
1983
+ case 'participant_joined':
1984
+ notification.classList.add('join');
1985
+ content = `
1986
+ <span class="material-icons">person_add</span>
1987
+ <div class="notification-content">
1988
+ <span class="notification-username">${escapeHtml(data.username)}</span>
1989
+ <span>joined the meeting</span>
1990
+ </div>
1991
+ `;
1992
+ break;
1993
+ case 'participant_left':
1994
+ notification.classList.add('leave');
1995
+ content = `
1996
+ <span class="material-icons">person_remove</span>
1997
+ <div class="notification-content">
1998
+ <span class="notification-username">${escapeHtml(data.username)}</span>
1999
+ <span>left the meeting</span>
2000
+ </div>
2001
+ `;
2002
+ break;
2003
+ case 'tracks_ready':
2004
+ notification.classList.add('media');
2005
+ content = `
2006
+ <span class="material-icons">videocam</span>
2007
+ <div class="notification-content">
2008
+ <span class="notification-username">${escapeHtml(data.username)}</span>
2009
+ <span>turned on their media</span>
2010
+ </div>
2011
+ `;
2012
+ break;
2013
+ case 'chat':
2014
+ if (data.username === username) return; // Don't show notifications for own messages
2015
+ notification.classList.add('chat');
2016
+ content = `
2017
+ <span class="material-icons">chat</span>
2018
+ <div class="notification-content">
2019
+ <span class="notification-username">${escapeHtml(data.username)}</span>
2020
+ <span>sent a message</span>
2021
+ </div>
2022
+ `;
2023
+ break;
2024
+ }
2025
+
2026
+ notification.innerHTML = content;
2027
+ notificationsContainer.appendChild(notification);
2028
+ console.log('Notification added:', type, data); // Debug log
2029
+
2030
+ // Remove notification after 5 seconds with animation
2031
+ setTimeout(() => {
2032
+ notification.classList.add('removing');
2033
+ setTimeout(() => {
2034
+ if (notification.parentElement) {
2035
+ notification.remove();
2036
+ }
2037
+ }, 300); // Match animation duration
2038
+ }, 5000);
2039
+ }
2040
+
2041
+ // Add new function to handle speaking state updates
2042
+ function handleSpeakingState(data) {
2043
+ // Find video element for the speaker
2044
+ let videoElement;
2045
+ if (data.username === username) {
2046
+ videoElement = document.getElementById('video-local');
2047
+ } else {
2048
+ // Find participant session ID by username
2049
+ const participant = Array.from(participants.entries())
2050
+ .find(([_, p]) => p.username === data.username);
2051
+ if (participant) {
2052
+ videoElement = document.getElementById(`video-${participant[0]}`);
2053
+ }
2054
+ }
2055
+
2056
+ if (videoElement) {
2057
+ if (data.isSpeaking) {
2058
+ videoElement.classList.add('speaking');
2059
+ } else {
2060
+ videoElement.classList.remove('speaking');
2061
+ }
2062
+ }
2063
+ }
2064
+
2065
+ // Add mask toggle handler
2066
+ let isMaskEnabled = false;
2067
+ document.getElementById('toggleMaskBtn').onclick = () => {
2068
+ if (!isMaskEnabled) {
2069
+ showMaskModal();
2070
+ } else {
2071
+ isMaskEnabled = false;
2072
+ updateMaskState();
2073
+ }
2074
+ };
2075
+
2076
+ // Add modal functions
2077
+ function showMaskModal() {
2078
+ const modal = document.getElementById('maskModal');
2079
+ const maskGrid = document.getElementById('maskGrid');
2080
+ maskGrid.innerHTML = '';
2081
+
2082
+ // Add mask options
2083
+ masksList.forEach(maskFile => {
2084
+ const maskOption = document.createElement('div');
2085
+ maskOption.className = `mask-option ${maskFile === currentMask ? 'selected' : ''}`;
2086
+
2087
+ const maskName = maskFile.replace('.png', '').replace(/-/g, ' ');
2088
+
2089
+ maskOption.innerHTML = `
2090
+ <img src="assets/mask/${maskFile}" alt="${maskName}">
2091
+ <div class="mask-name">${maskName}</div>
2092
+ `;
2093
+
2094
+ maskOption.onclick = () => {
2095
+ document.querySelectorAll('.mask-option').forEach(opt =>
2096
+ opt.classList.remove('selected')
2097
+ );
2098
+ maskOption.classList.add('selected');
2099
+ currentMask = maskFile;
2100
+ isMaskEnabled = true;
2101
+ updateMaskState();
2102
+ };
2103
+
2104
+ maskGrid.appendChild(maskOption);
2105
+ });
2106
+
2107
+ modal.classList.add('show');
2108
+
2109
+ // Close button handler
2110
+ modal.querySelector('.close-btn').onclick = () => {
2111
+ modal.classList.remove('show');
2112
+ };
2113
+
2114
+ // Close on outside click
2115
+ modal.onclick = (e) => {
2116
+ if (e.target === modal) {
2117
+ modal.classList.remove('show');
2118
+ }
2119
+ };
2120
+ }
2121
+
2122
+ // Update mask state function
2123
+ function updateMaskState() {
2124
+ const maskBtn = document.getElementById('toggleMaskBtn');
2125
+ maskBtn.classList.toggle('active', isMaskEnabled);
2126
+
2127
+ const maskImage = document.getElementById('maskImage');
2128
+ maskImage.src = `assets/mask/${currentMask}`;
2129
+
2130
+ // Close modal if open
2131
+ document.getElementById('maskModal').classList.remove('show');
2132
+
2133
+ // Call combined effects instead of individual updates
2134
+ combineEffects();
2135
+ }
2136
+
2137
+ // Add function to load available masks
2138
+ async function loadAvailableMasks() {
2139
+ masksList = [
2140
+ 'basic/mask1.png',
2141
+ 'basic/mask2.png',
2142
+ 'basic/mask3.png',
2143
+ 'medicel/mask1.png',
2144
+ 'medicel/mask2.png',
2145
+ 'medicel/mask3.png',
2146
+ ];
2147
+ console.log('Available masks:', masksList);
2148
+ }
2149
+
2150
+ // Add blur toggle handler after other control handlers
2151
+ document.getElementById('toggleBlurBtn').onclick = async () => {
2152
+ isBlurEnabled = !isBlurEnabled;
2153
+ updateBlurState();
2154
+ };
2155
+
2156
+ // Add new function to update blur state
2157
+ async function updateBlurState() {
2158
+ const blurBtn = document.getElementById('toggleBlurBtn');
2159
+ blurBtn.classList.toggle('active', isBlurEnabled);
2160
+
2161
+ // Call combined effects instead of individual updates
2162
+ await combineEffects();
2163
+ }
2164
+
2165
+ // Update function to combine mask and blur effects
2166
+ async function combineEffects() {
2167
+ try {
2168
+ let finalStream = localStream;
2169
+
2170
+ if (isMaskEnabled && isBlurEnabled) {
2171
+ // Create temporary video with proper dimensions
2172
+ const tempVideo = document.createElement('video');
2173
+ tempVideo.width = 640;
2174
+ tempVideo.height = 480;
2175
+ tempVideo.srcObject = localStream;
2176
+
2177
+ // Wait for video to be ready
2178
+ await new Promise((resolve) => {
2179
+ tempVideo.onloadedmetadata = () => {
2180
+ tempVideo.play();
2181
+ resolve();
2182
+ };
2183
+ });
2184
+
2185
+ // Apply mask first
2186
+ const maskedStream = await faceMaskFilter.processFrame(localStream);
2187
+
2188
+ // Then apply blur to masked stream
2189
+ const blurredAndMaskedStream = await backgroundBlur.updateInputStream(maskedStream);
2190
+
2191
+ // Create final stream
2192
+ finalStream = new MediaStream();
2193
+ blurredAndMaskedStream.getVideoTracks().forEach(track => {
2194
+ finalStream.addTrack(track);
2195
+ });
2196
+
2197
+ // Add audio
2198
+ const audioTrack = localStream.getAudioTracks()[0];
2199
+ if (audioTrack) {
2200
+ finalStream.addTrack(audioTrack);
2201
+ }
2202
+ } else if (isMaskEnabled) {
2203
+ finalStream = processedStream;
2204
+ } else if (isBlurEnabled) {
2205
+ const blurStream = await backgroundBlur.updateInputStream(localStream);
2206
+ const audioTrack = localStream.getAudioTracks()[0];
2207
+ if (audioTrack) {
2208
+ blurStream.addTrack(audioTrack);
2209
+ }
2210
+ finalStream = blurStream;
2211
+ }
2212
+
2213
+ // Update streams
2214
+ if (localPeerConnection) {
2215
+ const videoSender = localPeerConnection.getSenders()
2216
+ .find(sender => sender.track?.kind === 'video');
2217
+ if (videoSender) {
2218
+ await videoSender.replaceTrack(finalStream.getVideoTracks()[0]);
2219
+ }
2220
+ }
2221
+
2222
+ const localVideo = document.getElementById('video-local').querySelector('video');
2223
+ localVideo.srcObject = finalStream;
2224
+
2225
+ } catch (error) {
2226
+ console.error('Error in combineEffects:', error);
2227
+ }
2228
+ }
2229
+
2230
+ // Initialize the room
2231
+ initializeRoom();
public/js/room.js ADDED
@@ -0,0 +1,691 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import CloudflareCalls from './CloudflareCalls.js';
2
+
3
+ const calls = new CloudflareCalls({
4
+ backendUrl: 'http://localhost:50000',
5
+ websocketUrl: 'ws://localhost:50000'
6
+ });
7
+
8
+ let currentRoom = null;
9
+ let screenShareCalls = null;
10
+
11
+ // Add at the top with other globals
12
+ let isMaskEnabled = false;
13
+ let isBlurEnabled = false;
14
+ let currentMask = 'basic/mask1.png';
15
+ let masksList = [];
16
+ let faceMaskFilter = null;
17
+ let backgroundBlur = null;
18
+ let processedStream = null;
19
+
20
+
21
+ // DOM Elements
22
+ const videoGrid = document.getElementById('videoGrid');
23
+ const controls = {
24
+ toggleMic: document.getElementById('toggleMicBtn'),
25
+ toggleVideo: document.getElementById('toggleVideoBtn'),
26
+ shareScreen: document.getElementById('shareScreenBtn'),
27
+ toggleMask: document.getElementById('toggleMaskBtn'),
28
+ wave: document.getElementById('waveBtn'),
29
+ leave: document.getElementById('leaveBtn')
30
+ };
31
+ const participantsList = document.getElementById('participantsList');
32
+ const notificationsContainer = document.getElementById('notificationsContainer');
33
+
34
+ // Get stored data from localStorage
35
+ const username = localStorage.getItem('username');
36
+ const roomId = localStorage.getItem('roomId');
37
+
38
+ // Get token and initialize calls
39
+ async function ensureInitialized() {
40
+ if (!calls.token) {
41
+ try {
42
+ const response = await fetch('/auth/token', {
43
+ method: 'POST',
44
+ headers: { 'Content-Type': 'application/json' },
45
+ body: JSON.stringify({ username })
46
+ });
47
+
48
+ const { token } = await response.json();
49
+ calls.setToken(token);
50
+ showNotification('Successfully initialized');
51
+ return true;
52
+ } catch (err) {
53
+ console.error('Error getting token:', err);
54
+ showNotification('Failed to initialize', 'error');
55
+ return false;
56
+ }
57
+ }
58
+ return true;
59
+ }
60
+
61
+ async function setupLocalVideo() {
62
+ try {
63
+ const stream = await navigator.mediaDevices.getUserMedia({
64
+ video: true,
65
+ audio: true
66
+ });
67
+
68
+ // Tìm hoặc tạo container cho local video
69
+ let container = document.querySelector('.local-video');
70
+ if (!container) {
71
+ container = document.createElement('div');
72
+ container.className = 'video-container local-video';
73
+
74
+ // Tạo video element cho preview local
75
+ const video = document.createElement('video');
76
+ video.autoplay = true;
77
+ video.playsInline = true;
78
+ video.muted = true; // Đảm bảo video local luôn bị mute
79
+
80
+ const nameLabel = document.createElement('div');
81
+ nameLabel.className = 'participant-name';
82
+ nameLabel.textContent = username || 'You';
83
+
84
+ container.appendChild(video);
85
+ container.appendChild(nameLabel);
86
+ videoGrid.appendChild(container);
87
+ }
88
+
89
+ // Set stream nhưng đảm bảo audio luôn bị mute cho local preview
90
+ const video = container.querySelector('video');
91
+ video.srcObject = stream;
92
+ video.volume = 0; // Thêm dòng này để đảm bảo volume = 0
93
+ video.muted = true; // Thêm dòng này để doubly sure
94
+
95
+ // Lưu stream cho WebRTC
96
+ calls.localStream = stream;
97
+ console.log('Local video setup complete');
98
+ } catch (err) {
99
+ console.error('Error accessing media devices:', err);
100
+ showNotification('Failed to access camera/microphone', 'error');
101
+ }
102
+ }
103
+
104
+ function getParticipantDisplayName(participant) {
105
+ return participant.name || `User-${participant.userId.slice(0, 6)}`;
106
+ }
107
+
108
+ async function joinRoom() {
109
+ try {
110
+ // 1. Join room và lấy session
111
+ await calls.joinRoom(roomId, { name: username });
112
+ currentRoom = roomId;
113
+ showNotification(`Joined room: ${roomId}`);
114
+
115
+ // 2. Setup handlers trước khi pull tracks
116
+ setupCallbacks();
117
+
118
+ // 3. Lấy danh sách người tham gia
119
+ const participants = await calls.listParticipants();
120
+ console.log('Current participants:', participants);
121
+
122
+ // 4. Set up remote streams for existing participants
123
+ for (const participant of participants) {
124
+ // Skip if it's our own session
125
+ if (participant.sessionId === calls.sessionId) continue;
126
+
127
+ console.log('Processing participant:', participant);
128
+
129
+ // Create container for this participant if not exists
130
+ const containerId = `participant-${participant.sessionId}`;
131
+ if (!document.getElementById(containerId)) {
132
+ const container = document.createElement('div');
133
+ container.id = containerId;
134
+ container.className = 'video-container';
135
+
136
+ const video = document.createElement('video');
137
+ video.autoplay = true;
138
+ video.playsInline = true;
139
+
140
+ const name = document.createElement('div');
141
+ name.className = 'participant-name';
142
+ name.textContent = participant.name || `participant-${participant.sessionId}`;
143
+
144
+ container.appendChild(video);
145
+ container.appendChild(name);
146
+ videoGrid.appendChild(container);
147
+
148
+ // Set up MediaStream for this participant
149
+ video.srcObject = new MediaStream();
150
+ }
151
+
152
+ // Pull each track from the participant
153
+ for (const trackName of participant.publishedTracks) {
154
+ console.log(`Pulling track ${trackName} from session ${participant.sessionId}`);
155
+ await calls._pullTracks(participant.sessionId, trackName);
156
+ }
157
+ }
158
+
159
+ // 5. Start monitoring stats sau khi mọi thứ đã setup
160
+ calls.startStatsMonitoring(1000);
161
+ } catch (err) {
162
+ console.error('Error joining room:', err);
163
+ showNotification('Failed to join room: ' + err.message, 'error');
164
+ }
165
+ }
166
+
167
+ async function setupScreenShare() {
168
+
169
+ // Tạo một instance CloudflareCalls mới cho screen share
170
+ screenShareCalls = new CloudflareCalls({
171
+ backendUrl: 'http://localhost:50000',
172
+ websocketUrl: 'ws://localhost:50000'
173
+ });
174
+
175
+ // Khởi tạo token cho screen share
176
+ const response = await fetch('/auth/token', {
177
+ method: 'POST',
178
+ headers: { 'Content-Type': 'application/json' },
179
+ body: JSON.stringify({ username: username + '-screen' })
180
+ });
181
+
182
+ const { token } = await response.json();
183
+ screenShareCalls.setToken(token);
184
+
185
+ // Lấy screen share stream
186
+ const screenStream = await navigator.mediaDevices.getDisplayMedia({
187
+ video: true,
188
+ audio: false
189
+ });
190
+
191
+ // Lưu stream và join room
192
+ screenShareCalls.localStream = screenStream;
193
+ await screenShareCalls.joinRoom(roomId, { name: username + "'s Screen" });
194
+
195
+ // THÊM: Publish tracks sau khi join room
196
+ await screenShareCalls.publishTracks();
197
+
198
+ // Setup callback cho screen share để nhận remote tracks
199
+ screenShareCalls.onRemoteTrack((track) => {
200
+ console.log('Screen share track received:', track);
201
+ const containerId = `participant-${track.sessionId}`;
202
+ let container = document.getElementById(containerId);
203
+
204
+ if (!container) {
205
+ container = document.createElement('div');
206
+ container.id = containerId;
207
+ container.className = 'video-container';
208
+
209
+ const video = document.createElement('video');
210
+ video.autoplay = true;
211
+ video.playsInline = true;
212
+
213
+ const name = document.createElement('div');
214
+ name.className = 'participant-name';
215
+ name.textContent = username + "'s Screen";
216
+
217
+ container.appendChild(video);
218
+ container.appendChild(name);
219
+ videoGrid.appendChild(container);
220
+
221
+ video.srcObject = new MediaStream();
222
+ }
223
+
224
+ const video = container.querySelector('video');
225
+ video.srcObject.addTrack(track);
226
+ });
227
+
228
+ // Lắng nghe sự kiện kết thúc screen share
229
+ const screenTrack = screenStream.getVideoTracks()[0];
230
+ screenTrack.onended = async () => {
231
+ await stopScreenShare();
232
+ };
233
+
234
+ // Cập nhật icon
235
+ controls.shareScreen.querySelector('.material-icons').textContent = 'stop_screen_share';
236
+ showNotification('Screen sharing started');
237
+
238
+
239
+ }
240
+
241
+ async function stopScreenShare() {
242
+ if (screenShareCalls) {
243
+ // Dọn dẹp stream
244
+ if (screenShareCalls.localStream) {
245
+ screenShareCalls.localStream.getTracks().forEach(track => track.stop());
246
+ }
247
+
248
+ // Rời phòng và đóng kết nối
249
+ await screenShareCalls.leaveRoom();
250
+ screenShareCalls = null;
251
+
252
+ // Cập nhật UI
253
+ controls.shareScreen.querySelector('.material-icons').textContent = 'screen_share';
254
+ showNotification('Screen sharing ended');
255
+ }
256
+ }
257
+
258
+ function setupCallbacks() {
259
+ calls.onRemoteTrack((track) => {
260
+ console.log('Remote track received:', track);
261
+ const containerId = `participant-${track.sessionId}`;
262
+ let container = document.getElementById(containerId);
263
+
264
+ if (!container) {
265
+ container = document.createElement('div');
266
+ container.id = containerId;
267
+ container.className = 'video-container';
268
+
269
+ const video = document.createElement('video');
270
+ video.autoplay = true;
271
+ video.playsInline = true;
272
+
273
+ const name = document.createElement('div');
274
+ name.className = 'participant-name';
275
+ name.textContent = 'Participant ' + track.sessionId;
276
+
277
+ container.appendChild(video);
278
+ container.appendChild(name);
279
+ videoGrid.appendChild(container);
280
+ }
281
+
282
+ const video = container.querySelector('video');
283
+ if (!video.srcObject) {
284
+ video.srcObject = new MediaStream();
285
+ }
286
+ video.srcObject.addTrack(track);
287
+ });
288
+
289
+ calls.onRemoteTrackUnpublished((sessionId, trackName) => {
290
+ console.log('Remote track unpublished:', { sessionId, trackName });
291
+ const containerId = `participant-${sessionId}`;
292
+ const container = document.getElementById(containerId);
293
+
294
+ if (container) {
295
+ const video = container.querySelector('video');
296
+ if (video && video.srcObject) {
297
+ // Tìm và xóa track khỏi MediaStream
298
+ const stream = video.srcObject;
299
+ const tracks = stream.getTracks();
300
+ tracks.forEach(track => {
301
+ if (track.id === trackName) {
302
+ stream.removeTrack(track);
303
+ track.stop();
304
+ }
305
+ });
306
+
307
+ // Nếu không còn track nào, xóa container
308
+ if (stream.getTracks().length === 0) {
309
+ container.remove();
310
+ }
311
+ }
312
+ }
313
+ });
314
+
315
+ // Thêm callback xử lý khi track status thay đổi
316
+ calls.onTrackStatusChanged(async ({ sessionId, trackName, status }) => {
317
+ console.log('Track status changed:', { sessionId, trackName, status });
318
+
319
+ // Tìm container của participant
320
+ const containerId = `participant-${sessionId}`;
321
+ const container = document.getElementById(containerId);
322
+
323
+ if (container) {
324
+ const video = container.querySelector('video');
325
+ if (video && video.srcObject) {
326
+ // Tìm track cần cập nhật
327
+ const mediaStream = video.srcObject;
328
+ const tracks = mediaStream.getTracks();
329
+
330
+ // Nếu track bị disabled, pull lại track mới
331
+ if (status === 'disabled') {
332
+ // Xóa track cũ
333
+ tracks.forEach(track => {
334
+ if (track.id === trackName) {
335
+ mediaStream.removeTrack(track);
336
+ track.stop();
337
+ }
338
+ });
339
+
340
+ // Pull track mới
341
+ try {
342
+ await calls._pullTracks(sessionId, trackName);
343
+ console.log(`Re-pulled track ${trackName} for session ${sessionId}`);
344
+ } catch (error) {
345
+ console.error('Error re-pulling track:', error);
346
+ }
347
+ }
348
+ }
349
+ }
350
+ });
351
+
352
+ calls.onParticipantLeft((participant) => {
353
+ const container = document.getElementById(`participant-${participant.sessionId}`);
354
+ if (container) {
355
+ container.remove();
356
+ }
357
+ showNotification(`${participant.name || 'A participant'} left the room`);
358
+
359
+ });
360
+
361
+ // Sửa lại handler cho data messages
362
+ calls.onDataMessage(async (data) => {
363
+ console.log('Received data message:', data);
364
+ showNotification(`👋 ${data.data.fromName} vẫy tay chào!`);
365
+ });
366
+
367
+ // Sửa lại handler cho nút vẫy tay
368
+ controls.wave.onclick = async () => {
369
+ try {
370
+ // Gửi tin nhắn vẫy tay với đầy đủ thông tin
371
+ await calls.sendDataToAll({
372
+ type: 'wave',
373
+ fromSession: calls.sessionId,
374
+ fromName: username,
375
+ timestamp: Date.now() // Thêm timestamp để tránh trùng lặp
376
+ });
377
+
378
+ // Hiển thị thông báo local
379
+ showNotification('Bạn đã vẫy tay chào mọi người! 👋');
380
+
381
+ } catch (error) {
382
+ console.error('Error sending wave:', error);
383
+ showNotification('Không thể gửi vẫy tay', 'error');
384
+ }
385
+ };
386
+
387
+ // Control buttons
388
+ controls.toggleMic.onclick = () => {
389
+ const audioTrack = calls.localStream?.getAudioTracks()[0];
390
+ if (audioTrack) {
391
+ audioTrack.enabled = !audioTrack.enabled;
392
+ controls.toggleMic.querySelector('.material-icons').textContent =
393
+ audioTrack.enabled ? 'mic' : 'mic_off';
394
+ }
395
+ };
396
+
397
+ controls.toggleVideo.onclick = () => {
398
+ const videoTrack = calls.localStream?.getVideoTracks()[0];
399
+ if (videoTrack) {
400
+ videoTrack.enabled = !videoTrack.enabled;
401
+ controls.toggleVideo.querySelector('.material-icons').textContent =
402
+ videoTrack.enabled ? 'videocam' : 'videocam_off';
403
+ }
404
+ };
405
+
406
+ controls.shareScreen.onclick = async () => {
407
+ if (!screenShareCalls) {
408
+ await setupScreenShare();
409
+ } else {
410
+ await stopScreenShare();
411
+ }
412
+ };
413
+
414
+ controls.leave.onclick = async () => {
415
+ if (currentRoom) {
416
+ await calls.leaveRoom();
417
+ window.location.href = 'index.html';
418
+ }
419
+ };
420
+
421
+ controls.toggleMask.onclick = () => {
422
+ if (!isMaskEnabled) {
423
+ showMaskModal();
424
+ } else {
425
+ isMaskEnabled = false;
426
+ const maskBtn = document.getElementById('toggleMaskBtn');
427
+ maskBtn.classList.remove('active');
428
+ combineEffects();
429
+ }
430
+ };
431
+
432
+ // Add blur toggle handler
433
+ document.getElementById('toggleBlurBtn').onclick = async () => {
434
+ isBlurEnabled = !isBlurEnabled;
435
+ const blurBtn = document.getElementById('toggleBlurBtn');
436
+ blurBtn.classList.toggle('active', isBlurEnabled);
437
+ await combineEffects();
438
+ };
439
+ }
440
+
441
+ // Thêm CSS styles cho hiệu ứng vẫy tay
442
+ const style = document.createElement('style');
443
+ style.textContent = `
444
+ .wave-effect {
445
+ position: absolute;
446
+ top: 10px;
447
+ right: 10px;
448
+ font-size: 24px;
449
+ animation: wave 1s infinite;
450
+ }
451
+
452
+ @keyframes wave {
453
+ 0% { transform: rotate(0deg); }
454
+ 25% { transform: rotate(-20deg); }
455
+ 75% { transform: rotate(20deg); }
456
+ 100% { transform: rotate(0deg); }
457
+ }
458
+ `;
459
+ document.head.appendChild(style);
460
+
461
+ function showNotification(message, type = 'info') {
462
+ const notification = document.createElement('div');
463
+ notification.className = `notification ${type}`;
464
+ notification.textContent = message;
465
+ notificationsContainer.appendChild(notification);
466
+ setTimeout(() => notification.remove(), 3000);
467
+ }
468
+
469
+ // Initialize when page loads
470
+ async function initialize() {
471
+ if (!username || !roomId) {
472
+ window.location.href = 'index.html';
473
+ return;
474
+ }
475
+
476
+ // Initialize background blur with existing canvas element
477
+ const blurCanvas = document.getElementById('blurCanvas');
478
+ if (!blurCanvas) {
479
+ console.error('Blur canvas element not found');
480
+ return;
481
+ }
482
+
483
+ backgroundBlur = new BackgroundBlur(
484
+ document.createElement('video'),
485
+ blurCanvas
486
+ );
487
+ await backgroundBlur.initialize();
488
+
489
+ // Load available masks
490
+ await loadAvailableMasks();
491
+
492
+ // Initialize face mask filter
493
+ const maskCanvas = document.getElementById('maskCanvas');
494
+ const maskImage = document.getElementById('maskImage');
495
+ if (maskCanvas && maskImage) {
496
+ faceMaskFilter = new FaceMaskFilter(
497
+ document.createElement('video'),
498
+ maskCanvas,
499
+ maskImage
500
+ );
501
+ await faceMaskFilter.initialize();
502
+ }
503
+
504
+ if (await ensureInitialized()) {
505
+ await setupLocalVideo();
506
+ await joinRoom();
507
+ setupCallbacks();
508
+ }
509
+ }
510
+
511
+ document.addEventListener('DOMContentLoaded', initialize);
512
+
513
+ window.addEventListener('beforeunload', () => {
514
+ if (currentRoom) {
515
+ calls.leaveRoom();
516
+ if (screenShareCalls) {
517
+ screenShareCalls.leaveRoom();
518
+ }
519
+ }
520
+ });
521
+
522
+ // Add mask-related functions
523
+ async function loadAvailableMasks() {
524
+ masksList = [
525
+ 'basic/mask1.png',
526
+ 'basic/mask2.png',
527
+ 'basic/mask3.png',
528
+ 'medicel/mask1.png',
529
+ 'medicel/mask2.png',
530
+ 'medicel/mask3.png'
531
+ ];
532
+ console.log('Available masks:', masksList);
533
+ }
534
+
535
+ // Add modal close button handler
536
+ document.querySelector('#maskModal .close-btn').onclick = () => {
537
+ document.getElementById('maskModal').classList.remove('show');
538
+ };
539
+
540
+ function showMaskModal() {
541
+ const modal = document.getElementById('maskModal');
542
+ const maskGrid = document.getElementById('maskGrid');
543
+ maskGrid.innerHTML = '';
544
+
545
+ // Group masks by category
546
+ const masksByCategory = masksList.reduce((acc, maskFile) => {
547
+ const category = maskFile.split('/')[0];
548
+ if (!acc[category]) {
549
+ acc[category] = [];
550
+ }
551
+ acc[category].push(maskFile);
552
+ return acc;
553
+ }, {});
554
+
555
+ // Create mask options for each category
556
+ Object.entries(masksByCategory).forEach(([category, masks]) => {
557
+ masks.forEach(maskFile => {
558
+ const maskOption = document.createElement('div');
559
+ maskOption.className = `mask-option ${maskFile === currentMask ? 'selected' : ''}`;
560
+
561
+ const maskName = maskFile.split('/')[1].replace('.png', '');
562
+
563
+ maskOption.innerHTML = `
564
+ <img src="assets/mask/${maskFile}" alt="${maskName}">
565
+ <div class="mask-name">${maskName}</div>
566
+ <div class="mask-category">${category}</div>
567
+ `;
568
+
569
+ maskOption.onclick = () => {
570
+ document.querySelectorAll('.mask-option').forEach(opt =>
571
+ opt.classList.remove('selected')
572
+ );
573
+ maskOption.classList.add('selected');
574
+ currentMask = maskFile;
575
+ isMaskEnabled = true;
576
+ updateMaskState();
577
+ };
578
+
579
+ maskGrid.appendChild(maskOption);
580
+ });
581
+ });
582
+
583
+ // Show modal with animation
584
+ modal.classList.add('show');
585
+ setTimeout(() => modal.querySelector('.modal-content').classList.add('show'), 50);
586
+ }
587
+
588
+ // Update modal close handler
589
+ document.querySelector('#maskModal .close-btn').onclick = () => {
590
+ const modal = document.getElementById('maskModal');
591
+ modal.querySelector('.modal-content').classList.remove('show');
592
+ setTimeout(() => modal.classList.remove('show'), 300);
593
+ };
594
+
595
+ // Add click outside to close
596
+ document.getElementById('maskModal').onclick = (e) => {
597
+ if (e.target.id === 'maskModal') {
598
+ e.target.querySelector('.modal-content').classList.remove('show');
599
+ setTimeout(() => e.target.classList.remove('show'), 300);
600
+ }
601
+ };
602
+
603
+ async function updateMaskState() {
604
+ const maskBtn = document.getElementById('toggleMaskBtn');
605
+ maskBtn.classList.toggle('active', isMaskEnabled);
606
+
607
+ const maskImage = document.getElementById('maskImage');
608
+ maskImage.src = `assets/mask/${currentMask}`;
609
+
610
+ document.getElementById('maskModal').classList.remove('show');
611
+
612
+ await combineEffects();
613
+ }
614
+
615
+ async function updateBlurState() {
616
+ const blurBtn = document.getElementById('toggleBlurBtn');
617
+ blurBtn.classList.toggle('active', isBlurEnabled);
618
+
619
+ await combineEffects();
620
+ }
621
+
622
+ async function combineEffects() {
623
+ try {
624
+ let finalStream = calls.localStream;
625
+
626
+ if (isMaskEnabled && isBlurEnabled) {
627
+ // Apply mask first
628
+ const maskedStream = await faceMaskFilter.processFrame(calls.localStream);
629
+ await new Promise(resolve => setTimeout(resolve, 100)); // Wait for mask to initialize
630
+
631
+ // Then apply blur
632
+ const blurredAndMaskedStream = await backgroundBlur.updateInputStream(maskedStream);
633
+
634
+ // Create final stream with both effects
635
+ finalStream = new MediaStream();
636
+ blurredAndMaskedStream.getVideoTracks().forEach(track => {
637
+ finalStream.addTrack(track);
638
+ });
639
+
640
+ // Add audio track
641
+ const audioTrack = calls.localStream.getAudioTracks()[0];
642
+ if (audioTrack) {
643
+ finalStream.addTrack(audioTrack);
644
+ }
645
+
646
+ } else if (isMaskEnabled) {
647
+ finalStream = await faceMaskFilter.processFrame(calls.localStream);
648
+ // Ensure we have audio
649
+ const audioTrack = calls.localStream.getAudioTracks()[0];
650
+ if (audioTrack && !finalStream.getAudioTracks().length) {
651
+ finalStream.addTrack(audioTrack);
652
+ }
653
+
654
+ } else if (isBlurEnabled) {
655
+ const blurredStream = await backgroundBlur.updateInputStream(calls.localStream);
656
+ finalStream = new MediaStream();
657
+ blurredStream.getVideoTracks().forEach(track => {
658
+ finalStream.addTrack(track);
659
+ });
660
+
661
+ // Add audio track
662
+ const audioTrack = calls.localStream.getAudioTracks()[0];
663
+ if (audioTrack) {
664
+ finalStream.addTrack(audioTrack);
665
+ }
666
+ }
667
+
668
+ // Update local video display với muted audio
669
+ const localVideo = document.querySelector('.local-video video');
670
+ if (localVideo) {
671
+ localVideo.srcObject = finalStream;
672
+ localVideo.muted = true; // Đảm bảo local preview luôn mute
673
+ localVideo.volume = 0;
674
+ }
675
+
676
+ // Update WebRTC stream
677
+ if (calls.peerConnection) {
678
+ const videoSender = calls.peerConnection.getSenders()
679
+ .find(sender => sender.track?.kind === 'video');
680
+ if (videoSender) {
681
+ const videoTrack = finalStream.getVideoTracks()[0];
682
+ if (videoTrack) {
683
+ await videoSender.replaceTrack(videoTrack);
684
+ }
685
+ }
686
+ }
687
+
688
+ } catch (error) {
689
+ console.error('Error in combineEffects:', error);
690
+ }
691
+ }
public/room-old.html ADDED
@@ -0,0 +1,104 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ <!DOCTYPE html>
2
+ <html lang="en">
3
+ <head>
4
+ <meta charset="UTF-8">
5
+ <meta name="viewport" content="width=device-width, initial-scale=1.0">
6
+ <title>Meeting Room</title>
7
+ <script src="https://cdnjs.cloudflare.com/ajax/libs/webrtc-adapter/8.1.2/adapter.min.js"></script>
8
+ <link href="https://fonts.googleapis.com/icon?family=Material+Icons" rel="stylesheet">
9
+ <link rel="stylesheet" href="css/style.css">
10
+ </head>
11
+ <body>
12
+ <div id="notificationsContainer" class="notifications-container"></div>
13
+ <div class="room-container">
14
+ <div class="main-video" id="mainVideo">
15
+ <!-- Spotlight video goes here -->
16
+ </div>
17
+ <div class="thumbnail-grid" id="thumbnailGrid">
18
+ <!-- Small participant videos go here -->
19
+ </div>
20
+ <div id="videoGrid" class="video-grid">
21
+ <!-- Video elements will be added here dynamically -->
22
+ </div>
23
+
24
+ <!-- Add chat container -->
25
+ <div class="chat-container" id="chatContainer">
26
+ <div class="chat-header">
27
+ <h3>Chat</h3>
28
+ <button id="toggleChatBtn" class="chat-toggle-btn">
29
+ <span class="material-icons">chat</span>
30
+ </button>
31
+ </div>
32
+ <div class="chat-messages" id="chatMessages">
33
+ <!-- Chat messages will be added here -->
34
+ </div>
35
+ <div class="chat-input-container">
36
+ <input type="text" id="chatInput" placeholder="Type a message...">
37
+ <button id="sendMessageBtn">
38
+ <span class="material-icons">send</span>
39
+ </button>
40
+ </div>
41
+ </div>
42
+
43
+ <div class="controls-bar">
44
+ <button id="toggleMicBtn" class="control-btn">
45
+ <span class="material-icons">mic</span>
46
+ </button>
47
+ <button id="toggleVideoBtn" class="control-btn">
48
+ <span class="material-icons">videocam</span>
49
+ </button>
50
+ <button id="shareScreenBtn" class="control-btn">
51
+ <span class="material-icons">screen_share</span>
52
+ </button>
53
+ <!-- Add mask toggle button -->
54
+ <button id="toggleMaskBtn" class="control-btn">
55
+ <span class="material-icons">face</span>
56
+ </button>
57
+ <!-- Add blur toggle button -->
58
+ <button id="toggleBlurBtn" class="control-btn">
59
+ <span class="material-icons">blur_on</span>
60
+ </button>
61
+ <button id="waveBtn" class="control-btn">
62
+ <span class="material-icons">👋</span>
63
+ </button>
64
+ <button id="leaveBtn" class="control-btn leave-btn">
65
+ <span class="material-icons">call_end</span>
66
+ </button>
67
+ <!-- Add chat toggle button to controls -->
68
+ <button id="chatBtn" class="control-btn">
69
+ <span class="material-icons">chat</span>
70
+ </button>
71
+ </div>
72
+ </div>
73
+
74
+ <!-- Add hidden elements for face mask filter -->
75
+ <canvas id="maskCanvas" style="display: none;"></canvas>
76
+ <img id="maskImage" src="assets/mask/mask.png" style="display: none;" crossorigin="anonymous">
77
+
78
+ <!-- Add mask selection modal -->
79
+ <div id="maskModal" class="modal">
80
+ <div class="modal-content">
81
+ <div class="modal-header">
82
+ <h3>Chọn mặt nạ</h3>
83
+ <button class="close-btn">&times;</button>
84
+ </div>
85
+ <div id="maskGrid" class="mask-grid">
86
+ <!-- Mask options will be added dynamically -->
87
+ </div>
88
+ </div>
89
+ </div>
90
+
91
+ <div class="participant-list">
92
+ <h3>Participants</h3>
93
+ <ul id="participantsList"></ul>
94
+ </div>
95
+
96
+ <!-- Update script imports -->
97
+ <script src="https://cdn.jsdelivr.net/npm/@mediapipe/face_mesh/face_mesh.js"></script>
98
+ <script src="https://cdn.jsdelivr.net/npm/@mediapipe/camera_utils/camera_utils.js"></script>
99
+ <script src="js/FaceMaskFilter.js"></script>
100
+ <script src="https://cdn.jsdelivr.net/npm/@mediapipe/selfie_segmentation"></script>
101
+ <script src="js/backgroundBlur.js"></script>
102
+ <script src="js/room.js"></script>
103
+ </body>
104
+ </html>
public/room.html ADDED
@@ -0,0 +1,124 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ <!DOCTYPE html>
2
+ <html lang="en">
3
+
4
+ <head>
5
+ <meta charset="UTF-8">
6
+ <meta name="viewport" content="width=device-width, initial-scale=1.0">
7
+ <title>Meeting Room</title>
8
+ <script src="https://cdnjs.cloudflare.com/ajax/libs/webrtc-adapter/8.1.2/adapter.min.js"></script>
9
+ <link href="https://fonts.googleapis.com/icon?family=Material+Icons" rel="stylesheet">
10
+ <link rel="stylesheet" href="css/style.css">
11
+ <script>
12
+ window.onerror = function (msg, url, lineNo, columnNo, error) {
13
+ console.error('Window Error:', {
14
+ message: msg,
15
+ url: url,
16
+ lineNo: lineNo,
17
+ columnNo: columnNo,
18
+ error: error
19
+ });
20
+ return false;
21
+ };
22
+
23
+ window.addEventListener('unhandledrejection', function (event) {
24
+ console.error('Unhandled Promise Rejection:', event.reason);
25
+ });
26
+ </script>
27
+ </head>
28
+
29
+ <body>
30
+ <div id="notificationsContainer" class="notifications-container"></div>
31
+ <div class="room-container">
32
+ <div class="main-video" id="mainVideo">
33
+ <!-- Spotlight video goes here -->
34
+ </div>
35
+ <div class="thumbnail-grid" id="thumbnailGrid">
36
+ <!-- Small participant videos go here -->
37
+ </div>
38
+ <div id="videoGrid" class="video-grid">
39
+ <!-- Video elements will be added here dynamically -->
40
+ </div>
41
+ <canvas id="blurCanvas" width="640" height="480" style="display: none;"></canvas>
42
+ <div class="controls-bar">
43
+ <button id="toggleMicBtn" class="control-btn">
44
+ <span class="material-icons">mic</span>
45
+ </button>
46
+ <button id="toggleVideoBtn" class="control-btn">
47
+ <span class="material-icons">videocam</span>
48
+ </button>
49
+ <button id="shareScreenBtn" class="control-btn">
50
+ <span class="material-icons">screen_share</span>
51
+ </button>
52
+ <!-- Add mask toggle button -->
53
+ <button id="toggleMaskBtn" class="control-btn">
54
+ <span class="material-icons">face</span>
55
+ </button>
56
+ <button id="toggleBlurBtn" class="control-btn">
57
+ <span class="material-icons">blur_on</span>
58
+ </button>
59
+ <button id="waveBtn" class="control-btn">
60
+ <span class="material-icons">👋</span>
61
+ </button>
62
+ <button id="leaveBtn" class="control-btn leave-btn">
63
+ <span class="material-icons">call_end</span>
64
+ </button>
65
+ </div>
66
+ </div>
67
+
68
+ <!-- Hidden elements for mask filter -->
69
+ <canvas id="maskCanvas" width="640" height="480" style="display: none;"></canvas>
70
+ <img id="maskImage" src="assets/mask/basic/mask1.png" style="display: none;" crossorigin="anonymous">
71
+
72
+ <!-- Mask selection modal -->
73
+ <div id="maskModal" class="modal">
74
+ <div class="modal-content">
75
+ <div class="modal-header">
76
+ <h3>Choose Your Mask</h3>
77
+ <button class="close-btn">&times;</button>
78
+ </div>
79
+ <div id="maskGrid" class="mask-grid">
80
+ <!-- Mask options will be added dynamically -->
81
+ </div>
82
+ </div>
83
+ </div>
84
+
85
+ <div class="participant-list">
86
+ <h3>Participants</h3>
87
+ <ul id="participantsList"></ul>
88
+ </div>
89
+
90
+ <!-- Update script imports -->
91
+ <script src="https://cdn.jsdelivr.net/npm/@mediapipe/face_mesh@0.4.1633559619/face_mesh.js"></script>
92
+ <script src="https://cdn.jsdelivr.net/npm/@mediapipe/drawing_utils@0.3.1633559619/drawing_utils.js"></script>
93
+ <script src="https://cdn.jsdelivr.net/npm/@mediapipe/camera_utils@0.3.1633559619/camera_utils.js"></script>
94
+
95
+ <!-- Load selfie segmentation separately -->
96
+ <script
97
+ src="https://cdn.jsdelivr.net/npm/@mediapipe/selfie_segmentation@0.1.1632777926/selfie_segmentation.js"></script>
98
+
99
+ <!-- Add error handling for WASM loading -->
100
+ <script>
101
+ // Check WebAssembly support
102
+ if (!WebAssembly.instantiateStreaming) {
103
+ WebAssembly.instantiateStreaming = async (resp, importObject) => {
104
+ const source = await (await resp).arrayBuffer();
105
+ return await WebAssembly.instantiate(source, importObject);
106
+ };
107
+ }
108
+
109
+ // Add error handler for WASM loading
110
+ window.addEventListener('unhandledrejection', function (event) {
111
+ if (event.reason.toString().includes('wasm')) {
112
+ console.error('WASM loading error:', event.reason);
113
+ alert('Failed to load face effects. Please check your internet connection.');
114
+ }
115
+ });
116
+ </script>
117
+
118
+ <!-- Load your custom scripts -->
119
+ <script src="js/backgroundBlur.js"></script>
120
+ <script src="js/FaceMaskFilter.js"></script>
121
+ <script type="module" src="js/room.js"></script>
122
+ </body>
123
+
124
+ </html>
public/temp/CloudflareCalls.js ADDED
@@ -0,0 +1,2472 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ /**
2
+ * CloudflareCalls.js
3
+ *
4
+ * High-level library for Cloudflare Calls using SFU,
5
+ * now leveraging WebSocket for data message publish/subscribe flow.
6
+ */
7
+
8
+ /**
9
+ * Represents the CloudflareCalls library for managing real-time communications.
10
+ */
11
+ class CloudflareCalls {
12
+ /**
13
+ * @typedef {Object} VideoQualitySettings
14
+ * @property {Object} width - Video width settings
15
+ * @property {number} width.ideal - Ideal video width in pixels
16
+ * @property {Object} height - Video height settings
17
+ * @property {number} height.ideal - Ideal video height in pixels
18
+ * @property {Object} frameRate - Frame rate settings
19
+ * @property {number} frameRate.ideal - Ideal frame rate in fps
20
+ * @property {number} maxBitrate - Maximum video bitrate in bps
21
+ */
22
+
23
+ /**
24
+ * @typedef {Object} AudioQualitySettings
25
+ * @property {number} maxBitrate - Maximum audio bitrate in bps
26
+ * @property {number} sampleRate - Audio sample rate in Hz
27
+ * @property {number} channelCount - Number of audio channels (1=mono, 2=stereo)
28
+ */
29
+
30
+ /**
31
+ * @typedef {Object} QualityPreset
32
+ * @property {VideoQualitySettings} video - Video quality settings
33
+ * @property {AudioQualitySettings} audio - Audio quality settings
34
+ */
35
+
36
+ /**
37
+ * @typedef {Object} ConnectionStats
38
+ * @property {Object} outbound - Outbound (sending) statistics
39
+ * @property {number} outbound.bitrate - Current outbound bitrate in bits/s
40
+ * @property {number} outbound.packetLoss - Percentage of packets lost
41
+ * @property {string} outbound.qualityLimitation - Reason for quality limitations (if any)
42
+ * @property {Object} inbound - Inbound (receiving) statistics per track
43
+ * @property {number} inbound.bitrate - Current inbound bitrate in bits/s
44
+ * @property {number} inbound.packetLoss - Percentage of packets lost
45
+ * @property {number} inbound.jitter - Current jitter in seconds
46
+ * @property {Object} connection - Overall connection statistics
47
+ * @property {number} connection.roundTripTime - Current round trip time in seconds
48
+ * @property {string} connection.state - Current connection state
49
+ */
50
+
51
+ /**
52
+ * @typedef {Object} StreamStats
53
+ * @property {string} sessionId - Session ID of the stream
54
+ * @property {number} packetLoss - Packet loss percentage
55
+ * @property {string} qualityLimitation - Quality limitation reason
56
+ * @property {number} bitrate - Current bitrate
57
+ */
58
+
59
+ /**
60
+ * Creates an instance of CloudflareCalls.
61
+ * @param {Object} config - Configuration object.
62
+ * @param {string} config.backendUrl - The backend server URL.
63
+ * @param {string} config.websocketUrl - The WebSocket server URL.
64
+ */
65
+ constructor(config = {}) {
66
+ this.backendUrl = config.backendUrl || '';
67
+ this.websocketUrl = config.websocketUrl || '';
68
+ this.debug = config.debug || false;
69
+
70
+ this.token = null;
71
+ this.roomId = null;
72
+ this.sessionId = null;
73
+ this.userId = this._generateUUID();
74
+
75
+ this.userMetadata = {};
76
+
77
+ this.localStream = null;
78
+ this.peerConnection = null;
79
+ this.ws = null;
80
+
81
+ // Specific message handlers
82
+ this._onParticipantJoinedCallback = null;
83
+ this._onParticipantLeftCallback = null;
84
+ this._onRemoteTrackCallback = null;
85
+ this._onRemoteTrackUnpublishedCallback = null;
86
+ this._onTrackStatusChangedCallback = null;
87
+ this._onDataMessageCallback = null;
88
+ this._onConnectionStatsCallback = null;
89
+
90
+ // Generic message handlers
91
+ this._wsMessageHandlers = new Set();
92
+
93
+ // Track management
94
+ this.pulledTracks = new Map(); // Map<sessionId, Set<trackName>>
95
+ this.pollingInterval = null; // Reference to the polling interval
96
+
97
+ // Device management
98
+ this.availableAudioInputDevices = [];
99
+ this.availableVideoInputDevices = [];
100
+ this.availableAudioOutputDevices = [];
101
+ this.currentAudioOutputDeviceId = null;
102
+
103
+ this._renegotiateTimeout = null;
104
+ this.publishedTracks = new Set();
105
+
106
+ this.midToSessionId = new Map();
107
+ this.midToTrackName = new Map();
108
+
109
+ this._onRoomMetadataUpdatedCallback = null;
110
+
111
+ // Store initial quality settings
112
+ /** @type {QualityPreset} */
113
+ this.pendingQualitySettings = null;
114
+
115
+ this.mediaQuality = CloudflareCalls.QUALITY_PRESETS.medium_16x9_md;
116
+
117
+ /** @type {Object.<string, QualityPreset>} */
118
+ this.QUALITY_PRESETS = CloudflareCalls.QUALITY_PRESETS;
119
+
120
+ // Stats monitoring
121
+ this.statsInterval = null;
122
+ this.previousStats = null;
123
+
124
+ /** @type {'stopped'|'monitoring'} */
125
+ this.statsMonitoringState = 'stopped';
126
+ }
127
+
128
+ /**
129
+ * Internal logging method that only outputs when debug is enabled
130
+ * @private
131
+ * @param {...any} args - Arguments to pass to console.log
132
+ */
133
+ _log(...args) {
134
+ if (this.debug) {
135
+ console.log('[CloudflareCalls]', ...args);
136
+ }
137
+ }
138
+
139
+ /**
140
+ * Internal warning method that only outputs when debug is enabled
141
+ * @private
142
+ * @param {...any} args - Arguments to pass to console.warn
143
+ */
144
+ _warn(...args) {
145
+ if (this.debug) {
146
+ console.warn('[CloudflareCalls]', ...args);
147
+ }
148
+ }
149
+
150
+ /**
151
+ * Internal error method that always outputs (important for debugging)
152
+ * @private
153
+ * @param {...any} args - Arguments to pass to console.error
154
+ */
155
+ _error(...args) {
156
+ console.error('[CloudflareCalls]', ...args);
157
+ }
158
+
159
+ /**
160
+ * Enable or disable debug logging
161
+ * @param {boolean} enabled - Whether to enable debug logging
162
+ */
163
+ setDebugMode(enabled) {
164
+ this.debug = Boolean(enabled);
165
+ }
166
+
167
+ /**
168
+ * Internal method to perform fetch requests with automatic token inclusion and JSON parsing.
169
+ * @private
170
+ * @param {string} url - The full URL to fetch.
171
+ * @param {Object} options - Fetch options such as method, headers, body, etc.
172
+ * @returns {Promise<Object>} The parsed JSON response.
173
+ * @throws {Error} If the response is not OK.
174
+ */
175
+ async _fetch(url, options = {}) {
176
+ // Initialize headers if not provided
177
+ options.headers = options.headers || {};
178
+
179
+ // Add Authorization header if token is set
180
+ if (this.token) {
181
+ options.headers['Authorization'] = `Bearer ${this.token}`;
182
+ }
183
+
184
+ try {
185
+ const response = await fetch(url, options);
186
+
187
+ // Check if the response status is OK (status in the range 200-299)
188
+ if (!response.ok) {
189
+ this._warn(`HTTP error! status: ${response.status}`);
190
+ }
191
+
192
+ return response;
193
+ } catch (error) {
194
+ this._warn(`Fetch error for ${url}:`, error);
195
+ return false;
196
+ }
197
+ }
198
+
199
+
200
+ /************************************************
201
+ * Callback Registration
202
+ ***********************************************/
203
+
204
+ /**
205
+ * Registers a callback for remote track events.
206
+ * @param {Function} callback - The callback function to handle remote tracks.
207
+ */
208
+ onRemoteTrack(callback) {
209
+ this._onRemoteTrackCallback = callback;
210
+ }
211
+
212
+ /**
213
+ * Registers a callback for remote track unpublished events.
214
+ * @param {Function} callback - The callback function to handle track unpublished events.
215
+ */
216
+ onRemoteTrackUnpublished(callback) {
217
+ this._onRemoteTrackUnpublishedCallback = callback;
218
+ }
219
+
220
+ /**
221
+ * Registers a callback for incoming data messages.
222
+ * @param {Function} callback - The callback function to handle data messages.
223
+ */
224
+ onDataMessage(callback) {
225
+ this._onDataMessageCallback = callback;
226
+ }
227
+
228
+ /**
229
+ * Registers a callback for participant joined events.
230
+ * @param {Function} callback - The callback function to handle participant joins.
231
+ */
232
+ onParticipantJoined(callback) {
233
+ this._onParticipantJoinedCallback = callback;
234
+ }
235
+
236
+ /**
237
+ * Registers a callback for participant left events.
238
+ * @param {Function} callback - The callback function to handle participant departures.
239
+ */
240
+ onParticipantLeft(callback) {
241
+ this._onParticipantLeftCallback = callback;
242
+ }
243
+
244
+ /**
245
+ * Registers a callback for track status changed events.
246
+ * @param {Function} callback - The callback function to handle track status changes.
247
+ */
248
+ onTrackStatusChanged(callback) {
249
+ this._onTrackStatusChangedCallback = callback;
250
+ }
251
+
252
+ /**
253
+ * Registers a callback for WebSocket messages
254
+ * @param {Function} callback - Function to call when WebSocket messages are received
255
+ * @returns {Function} Function to unregister the callback
256
+ */
257
+ onWebSocketMessage(callback) {
258
+ this._wsMessageHandlers.add(callback);
259
+ return () => this._wsMessageHandlers.delete(callback);
260
+ }
261
+
262
+ /************************************************
263
+ * User Metadata Management
264
+ ***********************************************/
265
+
266
+ /**
267
+ * Sets the user token for server requests. This should be a JWT token, and will be delivered in Authorization headers (HTTP) and to authenticate websocket join requests.
268
+ * @param {String} token - The metadata to associate with the user.
269
+ */
270
+ setToken(token) {
271
+ this.token = token;
272
+ }
273
+
274
+ /**
275
+ * Register callback for room metadata updates
276
+ * @param {Function} callback Callback function
277
+ */
278
+ onRoomMetadataUpdated(callback) {
279
+ this._onRoomMetadataUpdatedCallback = callback;
280
+ }
281
+
282
+ /**
283
+ * Sets the user metadata and updates it on the server.
284
+ * @param {Object} metadata - The metadata to associate with the user.
285
+ */
286
+ setUserMetadata(metadata) {
287
+ this.userMetadata = metadata;
288
+ this._updateUserMetadataOnServer();
289
+ }
290
+
291
+ /**
292
+ * Retrieves the current user metadata.
293
+ * @returns {Object} The user metadata.
294
+ */
295
+ getUserMetadata() {
296
+ return this.userMetadata;
297
+ }
298
+
299
+ /**
300
+ * Updates user metadata on the server
301
+ * @private
302
+ * @async
303
+ * @returns {Promise<void>}
304
+ */
305
+ async _updateUserMetadataOnServer() {
306
+ if (!this.roomId || !this.sessionId) {
307
+ this._warn('Cannot update metadata before joining a room.');
308
+ return;
309
+ }
310
+
311
+ try {
312
+ const updateUrl = `${this.backendUrl}/api/rooms/${this.roomId}/metadata`;
313
+ const response = await this._fetch(updateUrl, {
314
+ method: 'PUT',
315
+ headers: { 'Content-Type': 'application/json' },
316
+ body: JSON.stringify(this.userMetadata)
317
+ });
318
+
319
+ if (!response.ok) {
320
+ this._error('Failed to update user metadata on server.');
321
+ } else {
322
+ this._log('User metadata updated on server.');
323
+ }
324
+ } catch (error) {
325
+ this._error('Error updating user metadata:', error);
326
+ throw error;
327
+ }
328
+ }
329
+
330
+ /************************************************
331
+ * Room & Session Management
332
+ ***********************************************/
333
+
334
+ /**
335
+ * Creates a new room with optional metadata.
336
+ * @async
337
+ * @param {Object} options Room creation options
338
+ * @param {string} [options.name] Room name
339
+ * @param {Object} [options.metadata] Room metadata
340
+ * @returns {Promise<Object>} Created room information including roomId, name, metadata, etc.
341
+ */
342
+ async createRoom(options = {}) {
343
+ const resp = await this._fetch(`${this.backendUrl}/api/rooms`, {
344
+ method: 'POST',
345
+ headers: { 'Content-Type': 'application/json' },
346
+ body: JSON.stringify(options)
347
+ }).then(r => r.json());
348
+
349
+ // Store the roomId
350
+ this.roomId = resp.roomId;
351
+
352
+ // Return the full room object
353
+ return resp;
354
+ }
355
+
356
+ /**
357
+ * Joins an existing room.
358
+ * @async
359
+ * @param {string} roomId - The ID of the room to join.
360
+ * @param {Object} [metadata={}] - Optional metadata for the user.
361
+ * @returns {Promise<void>}
362
+ */
363
+ async joinRoom(roomId, metadata = {}) {
364
+ this.roomId = roomId;
365
+
366
+ // 1) Ask server to create a CF Calls session
367
+ const joinResp = await this._fetch(`${this.backendUrl}/api/rooms/${roomId}/join`, {
368
+ method: 'POST',
369
+ headers: { 'Content-Type': 'application/json' },
370
+ body: JSON.stringify({ userId: this.userId, metadata: this.userMetadata })
371
+ }).then(r => r.json());
372
+
373
+ await this._initWebSocket();
374
+
375
+ if (!joinResp.sessionId) {
376
+ throw new Error('Failed to join room or retrieve sessionId');
377
+ }
378
+ this.sessionId = joinResp.sessionId;
379
+
380
+ // Initialize pulledTracks map
381
+ this.pulledTracks.set(this.sessionId, new Set());
382
+
383
+ // 2) Create RTCPeerConnection
384
+ this.peerConnection = await this._createPeerConnection();
385
+
386
+ // 3) Get Local Media and Publish Tracks
387
+ if (!this.localStream) {
388
+ this.localStream = await navigator.mediaDevices.getUserMedia({ video: true, audio: true });
389
+ this._log('Acquired local media');
390
+ }
391
+ await this._publishTracks();
392
+
393
+ // 4) Pull other participants' tracks
394
+ const otherSessions = joinResp.otherSessions || [];
395
+ for (const s of otherSessions) {
396
+ this.pulledTracks.set(s.sessionId, new Set());
397
+ for (const tName of s.publishedTracks || []) {
398
+ await this._pullTracks(s.sessionId, tName);
399
+ }
400
+ }
401
+ this._log('Joined room', roomId, 'my session:', this.sessionId);
402
+
403
+ this.setUserMetadata(metadata);
404
+
405
+ // 5) Start polling for new tracks
406
+ this._startPolling();
407
+ }
408
+
409
+ /**
410
+ * Cleans up ended tracks in localStream
411
+ * @async
412
+ * @private
413
+ * @returns {void}
414
+ */
415
+ async _cleanupEndedTracks() {
416
+ // Clear local media devices (readyState == 'ended', so they can't be reused)
417
+ if (this.localStream) {
418
+ for (const track of this.localStream.getTracks()) {
419
+ if (track.readyState === 'ended') {
420
+ this.localStream.removeTrack(track);
421
+ track.stop();
422
+ }
423
+ }
424
+ }
425
+
426
+ // If no tracks remain, clear the stream
427
+ if (this.localStream && !this.localStream.getTracks().length) {
428
+ this.localStream = null;
429
+ }
430
+ }
431
+
432
+ /**
433
+ * Leaves the current room and cleans up connections.
434
+ * @async
435
+ * @returns {Promise<void>}
436
+ */
437
+ async leaveRoom() {
438
+ if (!this.roomId || !this.sessionId) return;
439
+
440
+ // Clean up published tracks (if applicable)
441
+ const senders = this.peerConnection.getSenders();
442
+ if (senders && senders.length) {
443
+ await this.unpublishAllTracks();
444
+ }
445
+
446
+ try {
447
+ await this._fetch(`${this.backendUrl}/api/rooms/${this.roomId}/leave`, {
448
+ method: 'POST',
449
+ headers: { 'Content-Type': 'application/json' },
450
+ body: JSON.stringify({ sessionId: this.sessionId })
451
+ });
452
+ } catch (error) {
453
+ this._warn('Error leaving room:', error);
454
+ }
455
+
456
+ // Clean up WebSocket
457
+ if (this.ws) {
458
+ this.ws.close();
459
+ this.ws = null;
460
+ }
461
+
462
+ // Clean up PeerConnection
463
+ if (this.peerConnection) {
464
+ this.peerConnection.close();
465
+ this.peerConnection = null;
466
+ }
467
+
468
+ await this._cleanupEndedTracks();
469
+
470
+ this._log('Left room, closed PC & WS');
471
+
472
+ // Reset room state
473
+ this.roomId = null;
474
+ this.sessionId = null;
475
+ this.pulledTracks.clear();
476
+ this.midToSessionId.clear();
477
+ this.midToTrackName.clear();
478
+ this.publishedTracks.clear();
479
+ }
480
+
481
+ /************************************************
482
+ * Publish & Pull
483
+ ***********************************************/
484
+
485
+ /**
486
+ * Publishes the local media tracks to the room.
487
+ * @async
488
+ * @returns {Promise<void>}
489
+ * @throws {Error} If there is no local media stream to publish.
490
+ */
491
+ async publishTracks() {
492
+ if (!this.localStream) {
493
+ return this._warn('No local media stream to publish.');
494
+ }
495
+ await this._publishTracks();
496
+ }
497
+
498
+ // /**
499
+ // * Unpublishes a specific local media track (audio or video).
500
+ // * @async
501
+ // * @param {string} trackKind - The kind of track to unpublish ('audio' or 'video').
502
+ // * @param {boolean} [force=false] - If true, forces track closure without renegotiation.
503
+ // * @returns {Promise<Object>} Result object from the Cloudflare API.
504
+ // * @throws {Error} If PeerConnection is not established or track is not found.
505
+ // */
506
+ // // Todo: I don't think this method works
507
+ // async unpublishTrack(trackKind, force = false) {
508
+ // if (!this.peerConnection) {
509
+ // return this._warn('PeerConnection is not established.');
510
+ // }
511
+ //
512
+ // const sender = this.peerConnection.getSenders().find(s => s.track?.kind === trackKind);
513
+ // if (!sender) {
514
+ // return this._warn(`No ${trackKind} track found to unpublish.`);
515
+ // }
516
+ //
517
+ // const transceiver = this.peerConnection.getTransceivers().find(t => t.sender === sender);
518
+ // if (!transceiver?.mid) {
519
+ // throw new Error('Could not find transceiver mid for track');
520
+ // }
521
+ //
522
+ // try {
523
+ // // Create an offer for the updated state
524
+ // const offer = await this.peerConnection.createOffer();
525
+ // await this.peerConnection.setLocalDescription(offer);
526
+ //
527
+ // const unpublishUrl = `${this.backendUrl}/api/rooms/${this.roomId}/sessions/${this.sessionId}/unpublish`;
528
+ // const response = await this._fetch(unpublishUrl, {
529
+ // method: 'POST',
530
+ // headers: { 'Content-Type': 'application/json' },
531
+ // body: JSON.stringify({
532
+ // trackName: sender.track.id,
533
+ // mid: transceiver.mid,
534
+ // force,
535
+ // sessionDescription: {
536
+ // type: offer.type,
537
+ // sdp: offer.sdp
538
+ // }
539
+ // })
540
+ // });
541
+ //
542
+ // if (!response || !response.ok) return false;
543
+ // const result = await response.json();
544
+ //
545
+ // // Stop the track
546
+ // sender.track.stop();
547
+ //
548
+ // // Remove from PeerConnection after server confirms
549
+ // this.peerConnection.removeTrack(sender);
550
+ //
551
+ // // Remove from our tracked set
552
+ // this.publishedTracks.delete(sender.track.id);
553
+ //
554
+ // return result;
555
+ // } catch (error) {
556
+ // this._warn(`Error unpublishing ${trackKind} track:`, error);
557
+ // return false;
558
+ // }
559
+ // }
560
+
561
+ /**
562
+ * Initiates renegotiation of the PeerConnection.
563
+ * @async
564
+ * @private
565
+ * @returns {Promise<void>}
566
+ */
567
+ async _renegotiate() {
568
+ if (!this.peerConnection) return;
569
+
570
+ if (this._renegotiateTimeout) {
571
+ clearTimeout(this._renegotiateTimeout);
572
+ }
573
+
574
+ this._renegotiateTimeout = setTimeout(async () => {
575
+ try {
576
+ this._log('Starting renegotiation process...');
577
+ const answer = await this.peerConnection.createAnswer();
578
+ this._log('Created renegotiation answer:', answer.sdp);
579
+ await this.peerConnection.setLocalDescription(answer);
580
+
581
+ const renegotiateUrl = `${this.backendUrl}/api/rooms/${this.roomId}/sessions/${this.sessionId}/renegotiate`;
582
+ const body = { sdp: answer.sdp, type: answer.type };
583
+ this._log(`Sending renegotiate request to ${renegotiateUrl} with body:`, body);
584
+
585
+ const response = await this._fetch(renegotiateUrl, {
586
+ method: 'PUT',
587
+ headers: { 'Content-Type': 'application/json' },
588
+ body: JSON.stringify(body)
589
+ }).then(r => r.json());
590
+
591
+ if (response.errorCode) {
592
+ this._warn('Renegotiation failed:', response.errorDescription);
593
+ return;
594
+ }
595
+
596
+ await this.peerConnection.setRemoteDescription(response.sessionDescription);
597
+ this._log('Renegotiation successful. Applied SFU response.');
598
+ } catch (error) {
599
+ this._error('Error during renegotiation:', error);
600
+ }
601
+ }, 500);
602
+ }
603
+
604
+ /**
605
+ * Updates the published media tracks.
606
+ * @async
607
+ * @returns {Promise<void>}
608
+ * @throws {Error} If the PeerConnection is not established.
609
+ */
610
+ // Todo: I don't know what this was supposed to accomplish
611
+ // Possibly unpublish and re-publish tracks to solve some lifecycle issue
612
+ async updatePublishedTracks() {
613
+ if (!this.peerConnection) {
614
+ return this._warn('PeerConnection is not established.');
615
+ }
616
+
617
+ // Remove existing senders
618
+ const senders = this.peerConnection.getSenders();
619
+ for (const sender of senders) {
620
+ this.peerConnection.removeTrack(sender);
621
+ }
622
+
623
+ // Add updated tracks
624
+ await this._publishTracks();
625
+ }
626
+
627
+ /**
628
+ * Publishes the local media tracks to the PeerConnection and server.
629
+ * @async
630
+ * @private
631
+ * @returns {Promise<void>}
632
+ */
633
+ async _publishTracks() {
634
+ if (!this.localStream || !this.peerConnection) return;
635
+
636
+ const transceivers = [];
637
+ for (const track of this.localStream.getTracks()) {
638
+ // Check if we've already published this track
639
+ if (this.publishedTracks.has(track.id)) continue;
640
+ if (track.readyState !== 'live') continue;
641
+
642
+ const tx = this.peerConnection.addTransceiver(track, { direction: 'sendonly' });
643
+
644
+ // Apply any pending quality settings to video tracks
645
+ if (this.pendingQualitySettings && track.kind === 'video') {
646
+ const params = tx.sender.getParameters();
647
+ params.encodings = [{
648
+ maxBitrate: this.pendingQualitySettings.video.maxBitrate
649
+ }];
650
+ tx.sender.setParameters(params);
651
+ }
652
+
653
+ transceivers.push(tx);
654
+ this.publishedTracks.add(track.id);
655
+ }
656
+
657
+ if (transceivers.length === 0) return; // No new tracks to publish
658
+
659
+ const offer = await this.peerConnection.createOffer();
660
+ this._log('SDP Offer:', offer.sdp);
661
+ await this.peerConnection.setLocalDescription(offer);
662
+
663
+ const trackInfos = transceivers.map(({ sender, mid }) => ({
664
+ location: 'local',
665
+ mid,
666
+ trackName: sender.track.id
667
+ }));
668
+
669
+ const body = {
670
+ offer: { sdp: offer.sdp, type: offer.type },
671
+ tracks: trackInfos,
672
+ metadata: this.userMetadata
673
+ };
674
+ const publishUrl = `${this.backendUrl}/api/rooms/${this.roomId}/sessions/${this.sessionId}/publish`;
675
+ const resp = await this._fetch(publishUrl, {
676
+ method: 'POST',
677
+ headers: { 'Content-Type': 'application/json' },
678
+ body: JSON.stringify(body)
679
+ }).then(r => r.json());
680
+
681
+ if (resp.errorCode) {
682
+ this._error('Publish error:', resp.errorDescription);
683
+ return;
684
+ }
685
+ // The SFU's answer
686
+ const answer = resp.sessionDescription;
687
+ await this.peerConnection.setRemoteDescription(answer);
688
+ this._log('Publish => success. Applied SFU answer.');
689
+ }
690
+
691
+ /**
692
+ * Pulls a specific track from a remote session.
693
+ * @async
694
+ * @private
695
+ * @param {string} remoteSessionId - The session ID of the remote participant.
696
+ * @param {string} trackName - The name of the track to pull.
697
+ * @returns {Promise<void>}
698
+ */
699
+ async _pullTracks(remoteSessionId, trackName) {
700
+ this._log(`Pulling track '${trackName}' from session ${remoteSessionId}`);
701
+ const pullUrl = `${this.backendUrl}/api/rooms/${this.roomId}/sessions/${this.sessionId}/pull`;
702
+ const body = { remoteSessionId, trackName };
703
+
704
+ const resp = await this._fetch(pullUrl, {
705
+ method: 'POST',
706
+ headers: { 'Content-Type': 'application/json' },
707
+ body: JSON.stringify(body)
708
+ }).then(r => r.json());
709
+
710
+ if (resp.errorCode) {
711
+ this._error('Pull error:', resp.errorDescription);
712
+ return;
713
+ }
714
+
715
+ if (resp.requiresImmediateRenegotiation) {
716
+ this._log('Pull => requires renegotiation');
717
+
718
+ // Set up both mappings from the SDP
719
+ const pendingMids = new Set();
720
+ resp.sessionDescription.sdp.split('\n').forEach(line => {
721
+ if (line.startsWith('a=mid:')) {
722
+ const mid = line.split(':')[1].trim();
723
+ pendingMids.add(mid);
724
+ this.midToSessionId.set(mid, remoteSessionId);
725
+ this.midToTrackName.set(mid, trackName);
726
+ this._log('Pre-mapped MID:', {
727
+ mid,
728
+ sessionId: remoteSessionId,
729
+ trackName
730
+ });
731
+ }
732
+ });
733
+
734
+ // Now set the remote description
735
+ await this.peerConnection.setRemoteDescription(resp.sessionDescription);
736
+
737
+ // Create and set local answer
738
+ const localAnswer = await this.peerConnection.createAnswer();
739
+ await this.peerConnection.setLocalDescription(localAnswer);
740
+
741
+ // Verify mappings are still correct
742
+ const transceivers = this.peerConnection.getTransceivers();
743
+ transceivers.forEach(transceiver => {
744
+ if (transceiver.mid && pendingMids.has(transceiver.mid)) {
745
+ this._log('Verified MID mapping:', {
746
+ mid: transceiver.mid,
747
+ sessionId: remoteSessionId,
748
+ direction: transceiver.direction
749
+ });
750
+ }
751
+ });
752
+
753
+ await this._fetch(`${this.backendUrl}/api/rooms/${this.roomId}/sessions/${this.sessionId}/renegotiate`, {
754
+ method: 'PUT',
755
+ headers: { 'Content-Type': 'application/json' },
756
+ body: JSON.stringify({ sdp: localAnswer.sdp, type: localAnswer.type })
757
+ });
758
+ }
759
+
760
+ this._log(`Pulled trackName="${trackName}" from session ${remoteSessionId}`);
761
+ this._log('Current MID mappings:', Array.from(this.midToSessionId.entries()));
762
+
763
+ // Record the pulled track
764
+ if (!this.pulledTracks.has(remoteSessionId)) {
765
+ this.pulledTracks.set(remoteSessionId, new Set());
766
+ }
767
+ this.pulledTracks.get(remoteSessionId).add(trackName);
768
+ }
769
+
770
+ /************************************************
771
+ * PeerConnection & WebSocket
772
+ ***********************************************/
773
+
774
+ /**
775
+ * Creates and configures a new RTCPeerConnection.
776
+ * @async
777
+ * @private
778
+ * @returns {Promise<RTCPeerConnection>} The configured RTCPeerConnection instance.
779
+ */
780
+ async _attemptIceServersUpdate() {
781
+ let iceServers = [{ urls: 'stun:stun.cloudflare.com:3478' }];
782
+
783
+ try {
784
+ const response = await this._fetch(`${this.backendUrl}/api/ice-servers`);
785
+ if (!response.ok) {
786
+ this._warn(`Failed to fetch ICE servers: ${response.status} ${response.statusText}`);
787
+ return false;
788
+ }
789
+
790
+ const data = await response.json();
791
+
792
+ // Validate and process the fetched ICE servers
793
+ if (data.iceServers && Array.isArray(data.iceServers)) {
794
+ iceServers = data.iceServers.map(server => {
795
+ // Ensure each server has the required fields
796
+ const iceServer = { urls: server.urls };
797
+ if (server.username && server.credential) {
798
+ iceServer.username = server.username;
799
+ iceServer.credential = server.credential;
800
+ }
801
+ return iceServer;
802
+ });
803
+ this._log('Fetched ICE servers:', iceServers);
804
+ } else {
805
+ return iceServers;
806
+ }
807
+ } catch (error) {
808
+ this._warn('Error fetching ICE servers:', error);
809
+ // Fallback to default ICE servers if fetching fails
810
+ return false;
811
+ }
812
+ }
813
+ async _createPeerConnection() {
814
+ let iceServers = await this._attemptIceServersUpdate() || [{ urls: 'stun:stun.cloudflare.com:3478' }];
815
+
816
+ const pc = new RTCPeerConnection({
817
+ iceServers: iceServers,
818
+ bundlePolicy: 'max-bundle',
819
+ sdpSemantics: 'unified-plan'
820
+ });
821
+
822
+ pc.onicecandidate = (evt) => {
823
+ if (evt.candidate) {
824
+ this._log('New ICE candidate:', evt.candidate.candidate);
825
+ } else {
826
+ this._log('All ICE candidates have been sent');
827
+ }
828
+ };
829
+
830
+ pc.oniceconnectionstatechange = () => {
831
+ this._log('ICE Connection State:', pc.iceConnectionState);
832
+ if (pc.iceConnectionState === 'disconnected' || pc.iceConnectionState === 'failed') {
833
+ this.leaveRoom();
834
+ }
835
+ };
836
+
837
+ pc.onconnectionstatechange = () => {
838
+ this._log('Connection State:', pc.connectionState);
839
+ if (pc.connectionState === 'connected') {
840
+ this._log('Peer connection fully established');
841
+ } else if (pc.connectionState === 'disconnected' || pc.connectionState === 'failed') {
842
+ this._log('Peer connection disconnected or failed');
843
+ this.leaveRoom();
844
+ }
845
+ };
846
+
847
+ pc.ontrack = (evt) => {
848
+ this._log('ontrack event:', {
849
+ kind: evt.track.kind,
850
+ webrtcTrackId: evt.track.id,
851
+ mid: evt.transceiver?.mid
852
+ });
853
+
854
+ if (this._onRemoteTrackCallback) {
855
+ const mid = evt.transceiver?.mid;
856
+ const sessionId = this.midToSessionId.get(mid);
857
+ const trackName = this.midToTrackName.get(mid);
858
+
859
+ this._log('Track mapping lookup:', {
860
+ mid,
861
+ sessionId,
862
+ trackName,
863
+ webrtcTrackId: evt.track.id,
864
+ availableMappings: {
865
+ sessions: Array.from(this.midToSessionId.entries()),
866
+ tracks: Array.from(this.midToTrackName.entries())
867
+ }
868
+ });
869
+
870
+ if (!sessionId) {
871
+ this._warn('No sessionId found for mid:', mid);
872
+ if (!this.pendingTracks) this.pendingTracks = [];
873
+ this.pendingTracks.push({ evt, mid });
874
+ return;
875
+ }
876
+
877
+ const wrappedTrack = evt.track;
878
+ wrappedTrack.sessionId = sessionId;
879
+ wrappedTrack.mid = mid;
880
+ wrappedTrack.trackName = trackName;
881
+
882
+ this._log('Sending track to callback:', {
883
+ webrtcTrackId: wrappedTrack.id,
884
+ trackName: wrappedTrack.trackName,
885
+ sessionId: wrappedTrack.sessionId,
886
+ mid: wrappedTrack.mid
887
+ });
888
+
889
+ this._onRemoteTrackCallback(wrappedTrack);
890
+ }
891
+ };
892
+
893
+ return pc;
894
+ }
895
+
896
+ /**
897
+ * Initializes the WebSocket connection.
898
+ * @async
899
+ * @private
900
+ * @returns {Promise<void>}
901
+ */
902
+ async _initWebSocket() {
903
+ if (this.ws && this.ws.readyState === WebSocket.OPEN) return;
904
+
905
+ return new Promise((resolve, reject) => {
906
+ this.ws = new WebSocket(this.websocketUrl);
907
+
908
+ this.ws.onopen = () => {
909
+ this._log('WebSocket open');
910
+ this.ws.send(JSON.stringify({
911
+ type: 'join-websocket',
912
+ payload: {
913
+ roomId: this.roomId,
914
+ userId: this.userId,
915
+ token: this.token
916
+ }
917
+ }));
918
+ resolve();
919
+ };
920
+
921
+ this.ws.onmessage = (event) => {
922
+ try {
923
+ const message = JSON.parse(event.data);
924
+ this._log('WebSocket message received:', message);
925
+
926
+ // Handle specific message types
927
+ switch (message.type) {
928
+ case 'participant-joined':
929
+ if (this._onParticipantJoinedCallback) {
930
+ this._onParticipantJoinedCallback(message.payload);
931
+ }
932
+ break;
933
+
934
+ case 'participant-left':
935
+ if (this._onParticipantLeftCallback) {
936
+ this._onParticipantLeftCallback(message.payload);
937
+ }
938
+ break;
939
+
940
+ case 'track-published':
941
+ if (this._onRemoteTrackCallback) {
942
+ // Handle track published event
943
+ this._onRemoteTrackCallback(message.payload);
944
+ }
945
+ break;
946
+
947
+ case 'track-unpublished':
948
+ if (this._onRemoteTrackUnpublishedCallback) {
949
+ this._onRemoteTrackUnpublishedCallback(
950
+ message.payload.sessionId,
951
+ message.payload.trackName
952
+ );
953
+ }
954
+ break;
955
+
956
+ case 'track-status-changed':
957
+ if (this._onTrackStatusChangedCallback) {
958
+ this._onTrackStatusChangedCallback(message.payload);
959
+ }
960
+ break;
961
+
962
+ case 'data-message':
963
+ if (this._onDataMessageCallback) {
964
+ this._onDataMessageCallback(message.payload);
965
+ }
966
+ break;
967
+
968
+ case 'room-metadata-updated':
969
+ if (this._onRoomMetadataUpdatedCallback) {
970
+ this._onRoomMetadataUpdatedCallback(message.payload);
971
+ }
972
+ break;
973
+
974
+ default:
975
+ this._log('Unhandled message type:', message.type);
976
+ }
977
+
978
+ // Notify generic handlers
979
+ this._wsMessageHandlers.forEach(handler => handler(message));
980
+ } catch (error) {
981
+ this._error('Error processing WebSocket message:', error);
982
+ }
983
+ };
984
+
985
+ this.ws.onerror = (err) => {
986
+ this._error('WebSocket error:', err);
987
+ reject(err);
988
+ };
989
+
990
+ this.ws.onclose = () => {
991
+ this._log('WebSocket connection closed');
992
+ };
993
+ });
994
+ }
995
+
996
+ /************************************************
997
+ * Polling for New Tracks
998
+ ***********************************************/
999
+
1000
+ /**
1001
+ * Starts polling the server for new tracks every 10 seconds.
1002
+ * @private
1003
+ * @returns {void}
1004
+ */
1005
+ _startPolling() {
1006
+ this.pollingInterval = setInterval(async () => {
1007
+ if (!this.roomId) return;
1008
+
1009
+ try {
1010
+ const resp = await this._fetch(`${this.backendUrl}/api/rooms/${this.roomId}/participants`)
1011
+ .then(r => r.json());
1012
+ const participants = resp.participants || [];
1013
+
1014
+ for (const participant of participants) {
1015
+ const { sessionId, publishedTracks } = participant;
1016
+ if (sessionId === this.sessionId) continue; // Skip self
1017
+
1018
+ if (!this.pulledTracks.has(sessionId)) {
1019
+ this.pulledTracks.set(sessionId, new Set());
1020
+ }
1021
+
1022
+ for (const trackName of publishedTracks) {
1023
+ if (!this.pulledTracks.get(sessionId).has(trackName)) {
1024
+ this._log(`[Polling] New track detected: ${trackName} from session ${sessionId}`);
1025
+ await this._pullTracks(sessionId, trackName);
1026
+ }
1027
+ }
1028
+ }
1029
+ } catch (err) {
1030
+ this._error('Polling error:', err);
1031
+ }
1032
+ }, 10000);
1033
+ }
1034
+
1035
+ /************************************************
1036
+ * Device Management
1037
+ ***********************************************/
1038
+
1039
+ /**
1040
+ * Retrieves the list of available media devices.
1041
+ * @async
1042
+ * @returns {Promise<Object>} An object containing arrays of audio input, video input, and audio output devices.
1043
+ */
1044
+ async getAvailableDevices() {
1045
+ const devices = await navigator.mediaDevices.enumerateDevices();
1046
+ this.availableAudioInputDevices = devices.filter(device => device.kind === 'audioinput');
1047
+ this.availableVideoInputDevices = devices.filter(device => device.kind === 'videoinput');
1048
+ this.availableAudioOutputDevices = devices.filter(device => device.kind === 'audiooutput');
1049
+
1050
+ return {
1051
+ audioInput: this.availableAudioInputDevices,
1052
+ videoInput: this.availableVideoInputDevices,
1053
+ audioOutput: this.availableAudioOutputDevices
1054
+ };
1055
+ }
1056
+
1057
+ /**
1058
+ * Selects a specific audio input device.
1059
+ * @async
1060
+ * @param {string} deviceId - The ID of the audio input device to select.
1061
+ * @returns {Promise<void>}
1062
+ */
1063
+ async selectAudioInputDevice(deviceId) {
1064
+ if (!deviceId) {
1065
+ this._warn('No deviceId provided for audio input.');
1066
+ return;
1067
+ }
1068
+
1069
+ const constraints = {
1070
+ audio: { deviceId: { exact: deviceId } },
1071
+ video: false
1072
+ };
1073
+
1074
+ try {
1075
+ const newStream = await navigator.mediaDevices.getUserMedia(constraints);
1076
+ const newAudioTrack = newStream.getAudioTracks()[0];
1077
+ const sender = this.peerConnection.getSenders().find(s => s.track.kind === 'audio');
1078
+ if (sender) {
1079
+ sender.replaceTrack(newAudioTrack);
1080
+ const oldTrack = sender.track;
1081
+ oldTrack.stop();
1082
+ } else {
1083
+ this.localStream.addTrack(newAudioTrack);
1084
+ await this._publishTracks();
1085
+ }
1086
+
1087
+ this._log(`Switched to audio input device: ${deviceId}`);
1088
+ } catch (error) {
1089
+ this._error('Error switching audio input device:', error);
1090
+ }
1091
+ }
1092
+
1093
+ /**
1094
+ * Selects a specific video input device.
1095
+ * @async
1096
+ * @param {string} deviceId - The ID of the video input device to select.
1097
+ * @returns {Promise<void>}
1098
+ */
1099
+ async selectVideoInputDevice(deviceId) {
1100
+ if (!deviceId) {
1101
+ this._warn('No deviceId provided for video input.');
1102
+ return;
1103
+ }
1104
+
1105
+ const constraints = {
1106
+ video: { deviceId: { exact: deviceId } },
1107
+ audio: false
1108
+ };
1109
+
1110
+ try {
1111
+ const newStream = await navigator.mediaDevices.getUserMedia(constraints);
1112
+ const newVideoTrack = newStream.getVideoTracks()[0];
1113
+ const sender = this.peerConnection.getSenders().find(s => s.track.kind === 'video');
1114
+ if (sender) {
1115
+ sender.replaceTrack(newVideoTrack);
1116
+ const oldTrack = sender.track;
1117
+ oldTrack.stop();
1118
+ } else {
1119
+ this.localStream.addTrack(newVideoTrack);
1120
+ await this._publishTracks();
1121
+ }
1122
+
1123
+ this._log(`Switched to video input device: ${deviceId}`);
1124
+ } catch (error) {
1125
+ this._error('Error switching video input device:', error);
1126
+ }
1127
+ }
1128
+
1129
+ /**
1130
+ * Selects a specific audio output device.
1131
+ * @async
1132
+ * @param {string} deviceId - The ID of the audio output device to select.
1133
+ * @returns {Promise<void>}
1134
+ */
1135
+ async selectAudioOutputDevice(deviceId) {
1136
+ if (!deviceId) {
1137
+ this._warn('No deviceId provided for audio output.');
1138
+ return;
1139
+ }
1140
+
1141
+ try {
1142
+ const audioElements = document.querySelectorAll('audio');
1143
+ for (const audio of audioElements) {
1144
+ await audio.setSinkId(deviceId);
1145
+ }
1146
+ this.currentAudioOutputDeviceId = deviceId;
1147
+ this._log(`Switched to audio output device: ${deviceId}`);
1148
+ } catch (error) {
1149
+ this._error('Error switching audio output device:', error);
1150
+ }
1151
+ }
1152
+
1153
+ /**
1154
+ * Previews media streams with specified device IDs.
1155
+ * @async
1156
+ * @param {Object} params - Parameters for media preview.
1157
+ * @param {string} [params.audioDeviceId] - The ID of the audio input device to use.
1158
+ * @param {string} [params.videoDeviceId] - The ID of the video input device to use.
1159
+ * @param {HTMLMediaElement} [previewElement=null] - The media element to display the preview.
1160
+ * @returns {Promise<MediaStream>} The media stream being previewed.
1161
+ * @throws {Error} If there is an issue accessing the media devices.
1162
+ */
1163
+ async previewMedia({ audioDeviceId, videoDeviceId }, previewElement = null) {
1164
+ const constraints = {
1165
+ audio: audioDeviceId ? { deviceId: { exact: audioDeviceId } } : false,
1166
+ video: videoDeviceId ? { deviceId: { exact: videoDeviceId } } : false
1167
+ };
1168
+
1169
+ try {
1170
+ const stream = await navigator.mediaDevices.getUserMedia(constraints);
1171
+ if (previewElement) {
1172
+ previewElement.srcObject = stream;
1173
+ }
1174
+ return stream;
1175
+ } catch (error) {
1176
+ this._error('Error previewing media:', error);
1177
+ throw error;
1178
+ }
1179
+ }
1180
+
1181
+ /************************************************
1182
+ * Media Controls
1183
+ ***********************************************/
1184
+
1185
+ /**
1186
+ * Toggles the enabled state of video and/or audio tracks.
1187
+ * @param {Object} options - Options to toggle media tracks.
1188
+ * @param {boolean} [options.video=null] - Whether to toggle video tracks.
1189
+ * @param {boolean} [options.audio=null] - Whether to toggle audio tracks.
1190
+ * @returns {void}
1191
+ */
1192
+ toggleMedia({ video = null, audio = null }) {
1193
+ if (!this.localStream) return;
1194
+
1195
+ if (video !== null) {
1196
+ const videoTracks = this.localStream.getVideoTracks();
1197
+ videoTracks.forEach(track => {
1198
+ track.enabled = video;
1199
+ // Find the corresponding sender and update the track status
1200
+ const sender = this.peerConnection?.getSenders().find(s => s.track === track);
1201
+ if (sender) {
1202
+ // Send track status update to SFU
1203
+ this._updateTrackStatus(sender.track.id, 'video', video);
1204
+ }
1205
+ });
1206
+ }
1207
+
1208
+ if (audio !== null) {
1209
+ const audioTracks = this.localStream.getAudioTracks();
1210
+ audioTracks.forEach(track => {
1211
+ track.enabled = audio;
1212
+ // Find the corresponding sender and update the track status
1213
+ const sender = this.peerConnection?.getSenders().find(s => s.track === track);
1214
+ if (sender) {
1215
+ // Send track status update to SFU
1216
+ this._updateTrackStatus(sender.track.id, 'audio', audio);
1217
+ }
1218
+ });
1219
+ }
1220
+ }
1221
+
1222
+ /**
1223
+ * Starts screen sharing.
1224
+ * @async
1225
+ * @returns {Promise<void>}
1226
+ */
1227
+ async shareScreen() {
1228
+ try {
1229
+ // Stop any existing video tracks (Todo: breaks the addTrack)
1230
+ await this.unpublishAllTracks('video');
1231
+
1232
+ const screenStream = await navigator.mediaDevices.getDisplayMedia({
1233
+ video: true,
1234
+ audio: false // Most browsers don't support screen audio yet
1235
+ });
1236
+
1237
+ const screenTrack = screenStream.getVideoTracks()[0];
1238
+
1239
+ // Add the new screen track
1240
+ this.localStream.addTrack(screenTrack);
1241
+
1242
+ // Publish the new track
1243
+ await this._publishTracks();
1244
+
1245
+ // Handle the user stopping screen share
1246
+ screenTrack.onended = async () => {
1247
+ await this.unpublishAllTracks();
1248
+ await this._cleanupEndedTracks();
1249
+
1250
+ this.localStream = await navigator.mediaDevices.getUserMedia({ video: true, audio: true });
1251
+ this._log('Re-acquired local media');
1252
+ await this._publishTracks();
1253
+ };
1254
+ } catch (err) {
1255
+ this._error('Error sharing screen:', err);
1256
+ throw err;
1257
+ }
1258
+ }
1259
+
1260
+ /************************************************
1261
+ * WebSocket-Based Data Communication
1262
+ ***********************************************/
1263
+
1264
+ /**
1265
+ * Internal method to send a message via WebSocket.
1266
+ * @private
1267
+ * @param {Object} data - The data object to send.
1268
+ * @returns {void}
1269
+ */
1270
+ _sendWebSocketMessage(data) {
1271
+ if (!this.ws || this.ws.readyState !== WebSocket.OPEN) {
1272
+ this._warn('WebSocket is not open. Cannot send message.');
1273
+ return;
1274
+ }
1275
+ this.ws.send(JSON.stringify(data));
1276
+ this._log('Sent WebSocket message:', data);
1277
+ }
1278
+
1279
+ /************************************************
1280
+ * Participant Management
1281
+ ***********************************************/
1282
+
1283
+ /**
1284
+ * Lists all participants currently in the room.
1285
+ * @async
1286
+ * @returns {Promise<Array<Object>>} An array of participant objects.
1287
+ * @throws {Error} If not connected to any room.
1288
+ */
1289
+ async listParticipants() {
1290
+ if (!this.roomId) {
1291
+ return this._warn('Not connected to any room.');
1292
+ }
1293
+
1294
+ const resp = await this._fetch(`${this.backendUrl}/api/rooms/${this.roomId}/participants`)
1295
+ .then(r => r.json());
1296
+
1297
+ return resp.participants || [];
1298
+ }
1299
+
1300
+ /************************************************
1301
+ * Helpers & Placeholders
1302
+ ***********************************************/
1303
+
1304
+ /**
1305
+ * Generates a simple UUID.
1306
+ * @private
1307
+ * @returns {string} A generated UUID string.
1308
+ */
1309
+ _generateUUID() {
1310
+ // Simple placeholder generator
1311
+ return 'xxxx-xxxx-xxxx-xxxx'.replace(/[x]/g, () =>
1312
+ ((Math.random() * 16) | 0).toString(16)
1313
+ );
1314
+ }
1315
+
1316
+ /**
1317
+ * Unpublishes all currently published tracks (with filters for type)
1318
+ * @async
1319
+ * @param {string} trackKind - The kind of track to unpublish ('audio' or 'video').
1320
+ * @param {boolean} [force=false] - If true, forces track closure without renegotiation.
1321
+ * @returns {Promise<void>}
1322
+ */
1323
+ async unpublishAllTracks(trackKind, force = false) {
1324
+ if (!this.peerConnection) {
1325
+ this._warn('PeerConnection is not established.');
1326
+ return;
1327
+ }
1328
+
1329
+ let senders = this.peerConnection.getSenders();
1330
+ if (trackKind) {
1331
+ senders = senders.filter(s => s.track && s.track.kind === trackKind);
1332
+ }
1333
+ this._log('Unpublishing all tracks:', senders.length);
1334
+
1335
+ // Create an offer for the updated state
1336
+ const offer = await this.peerConnection.createOffer();
1337
+ await this.peerConnection.setLocalDescription(offer);
1338
+
1339
+ for (const sender of senders) {
1340
+ if (sender.track) {
1341
+ try {
1342
+ const trackId = sender.track.id;
1343
+ const transceiver = this.peerConnection.getTransceivers().find(t => t.sender === sender);
1344
+ const mid = transceiver ? transceiver.mid : null;
1345
+
1346
+ this._log('Unpublishing track:', { trackId, mid });
1347
+
1348
+ if (!mid) {
1349
+ this._warn('No mid found for track:', trackId);
1350
+ continue;
1351
+ }
1352
+
1353
+ // Stop the track first
1354
+ sender.track.stop();
1355
+
1356
+ // Notify server
1357
+ await this._fetch(`${this.backendUrl}/api/rooms/${this.roomId}/sessions/${this.sessionId}/unpublish`, {
1358
+ method: 'POST',
1359
+ headers: { 'Content-Type': 'application/json' },
1360
+ body: JSON.stringify({
1361
+ trackName: trackId,
1362
+ mid: mid,
1363
+ force,
1364
+ sessionDescription: {
1365
+ type: offer.type,
1366
+ sdp: offer.sdp
1367
+ }
1368
+ })
1369
+ });
1370
+
1371
+ // Remove from PeerConnection after server confirms
1372
+ this.peerConnection.removeTrack(sender);
1373
+
1374
+ // Remove from our tracked set
1375
+ this.publishedTracks.delete(trackId);
1376
+
1377
+ // Since we're unpublishing we need to stop local streams
1378
+ await this._cleanupEndedTracks();
1379
+
1380
+ this._log(`Successfully unpublished track: ${trackId}`);
1381
+ } catch (error) {
1382
+ this._error(`Error unpublishing track:`, error);
1383
+ }
1384
+ }
1385
+ }
1386
+ }
1387
+
1388
+ /**
1389
+ * Gets the session state
1390
+ * @async
1391
+ * @returns {Promise<Object>} The session state
1392
+ */
1393
+ async getSessionState() {
1394
+ if (!this.sessionId) {
1395
+ return this._warn('No active session');
1396
+ }
1397
+
1398
+ try {
1399
+ const response = await this._fetch(`${this.backendUrl}/api/rooms/${this.roomId}/sessions/${this.sessionId}/state`);
1400
+ const state = await response.json();
1401
+
1402
+ // Store track states internally
1403
+ if (state.tracks) {
1404
+ this.trackStates = new Map(
1405
+ state.tracks.map(track => [track.trackName, track.status])
1406
+ );
1407
+ }
1408
+
1409
+ return state;
1410
+ } catch (error) {
1411
+ this._error('Error getting session state:', error);
1412
+ throw error;
1413
+ }
1414
+ }
1415
+
1416
+ /**
1417
+ * Gets the track status
1418
+ * @async
1419
+ * @param {string} trackName - The track name
1420
+ * @returns {Promise<string>} The track status
1421
+ */
1422
+ async getTrackStatus(trackName) {
1423
+ const state = await this.getSessionState();
1424
+ return state.tracks.find(t => t.trackName === trackName)?.status;
1425
+ }
1426
+
1427
+ /**
1428
+ * Updates the track status
1429
+ * @async
1430
+ * @private
1431
+ * @param {string} trackId - The track ID
1432
+ * @param {string} kind - The track kind
1433
+ * @param {boolean} enabled - Whether the track is enabled
1434
+ * @returns {Promise<Object>} The updated track status
1435
+ */
1436
+ async _updateTrackStatus(trackId, kind, enabled) {
1437
+ try {
1438
+ const updateUrl = `${this.backendUrl}/api/rooms/${this.roomId}/sessions/${this.sessionId}/track-status`;
1439
+ const response = await this._fetch(updateUrl, {
1440
+ method: 'POST',
1441
+ headers: { 'Content-Type': 'application/json' },
1442
+ body: JSON.stringify({
1443
+ trackId,
1444
+ kind,
1445
+ enabled,
1446
+ force: false // Allow proper renegotiation
1447
+ })
1448
+ });
1449
+
1450
+ const result = await response.json();
1451
+ if (result.errorCode) {
1452
+ throw new Error(result.errorDescription || 'Unknown error updating track status');
1453
+ }
1454
+
1455
+ // If renegotiation is needed, handle it
1456
+ if (result.requiresImmediateRenegotiation) {
1457
+ await this._renegotiate();
1458
+ }
1459
+
1460
+ if (!result.errorCode) {
1461
+ this._updateTrackState(trackId, enabled ? 'enabled' : 'disabled');
1462
+ }
1463
+
1464
+ return result;
1465
+ } catch (error) {
1466
+ this._error(`Error updating track status:`, error);
1467
+ throw error;
1468
+ }
1469
+ }
1470
+
1471
+ /**
1472
+ * Handles errors
1473
+ * @private
1474
+ * @param {Object} response - The response object
1475
+ * @returns {Object} The response object
1476
+ */
1477
+ _handleError(response) {
1478
+ if (response.errorCode) {
1479
+ const error = new Error(response.errorDescription || 'Unknown error');
1480
+ error.code = response.errorCode;
1481
+ throw error;
1482
+ }
1483
+ return response;
1484
+ }
1485
+
1486
+ /**
1487
+ * Gets information about a user
1488
+ * @async
1489
+ * @param {string} [userId] - Optional user ID. If omitted, returns current user's info
1490
+ * @returns {Promise<Object>} User information including moderator status
1491
+ */
1492
+ async getUserInfo(userId = null) {
1493
+ try {
1494
+ const response = await this._fetch(
1495
+ `${this.backendUrl}/api/users/${userId || 'me'}`
1496
+ );
1497
+ return await response.json();
1498
+ } catch (error) {
1499
+ this._error('Error getting user info:', error);
1500
+ throw error;
1501
+ }
1502
+ }
1503
+
1504
+ /**
1505
+ * Handles WebSocket messages
1506
+ * @private
1507
+ * @param {MessageEvent} event - The WebSocket message event
1508
+ * @returns {void}
1509
+ */
1510
+ _handleWebSocketMessage(event) {
1511
+ try {
1512
+ const message = JSON.parse(event.data);
1513
+ this._log('WebSocket message received:', message);
1514
+
1515
+ // First, notify generic handlers
1516
+ this._wsMessageHandlers.forEach(handler => {
1517
+ try {
1518
+ handler(message);
1519
+ } catch (err) {
1520
+ this._error('Error in WebSocket message handler:', err);
1521
+ }
1522
+ });
1523
+
1524
+ // Then handle specific message types
1525
+ switch (message.type) {
1526
+ case 'participant-joined':
1527
+ if (this._onParticipantJoinedCallback) {
1528
+ this._onParticipantJoinedCallback(message.payload);
1529
+ }
1530
+ break;
1531
+
1532
+ case 'participant-left':
1533
+ if (this._onParticipantLeftCallback) {
1534
+ this._onParticipantLeftCallback(message.payload.sessionId);
1535
+ }
1536
+ break;
1537
+
1538
+ case 'track-published':
1539
+ if (this._onRemoteTrackCallback) {
1540
+ // Handle track published event
1541
+ this._onRemoteTrackCallback(message.payload);
1542
+ }
1543
+ break;
1544
+
1545
+ case 'track-unpublished':
1546
+ if (this._onRemoteTrackUnpublishedCallback) {
1547
+ this._onRemoteTrackUnpublishedCallback(
1548
+ message.payload.sessionId,
1549
+ message.payload.trackName
1550
+ );
1551
+ }
1552
+ break;
1553
+
1554
+ case 'track-status-changed':
1555
+ if (this._onTrackStatusChangedCallback) {
1556
+ this._onTrackStatusChangedCallback(message.payload);
1557
+ }
1558
+ break;
1559
+
1560
+ case 'data-message':
1561
+ if (this._onDataMessageCallback) {
1562
+ this._onDataMessageCallback(message.payload);
1563
+ }
1564
+ break;
1565
+
1566
+ case 'room-metadata-updated':
1567
+ if (this._onRoomMetadataUpdatedCallback) {
1568
+ this._onRoomMetadataUpdatedCallback(message.payload);
1569
+ }
1570
+ break;
1571
+
1572
+ default:
1573
+ this._log('Unhandled message type:', message.type);
1574
+ }
1575
+ } catch (error) {
1576
+ this._error('Error handling WebSocket message:', error);
1577
+ }
1578
+ }
1579
+
1580
+ /**
1581
+ * Updates track state in internal tracking
1582
+ * @private
1583
+ * @param {string} trackName - The track name
1584
+ * @param {string} status - The new status
1585
+ */
1586
+ _updateTrackState(trackName, status) {
1587
+ if (!this.trackStates) {
1588
+ this.trackStates = new Map();
1589
+ }
1590
+ this.trackStates.set(trackName, status);
1591
+ }
1592
+
1593
+ /**
1594
+ * Lists all available rooms.
1595
+ * @async
1596
+ * @returns {Promise<Array>} List of rooms
1597
+ */
1598
+ async listRooms() {
1599
+ const resp = await this._fetch(`${this.backendUrl}/api/rooms`)
1600
+ .then(r => r.json());
1601
+ return resp.rooms;
1602
+ }
1603
+
1604
+ /**
1605
+ * Updates room metadata.
1606
+ * @async
1607
+ * @param {Object} updates Metadata updates
1608
+ * @param {string} [updates.name] New room name
1609
+ * @param {Object} [updates.metadata] New room metadata
1610
+ * @returns {Promise<Object>} Updated room information
1611
+ */
1612
+ async updateRoomMetadata(updates) {
1613
+ if (!this.roomId) {
1614
+ return this._warn('Not connected to any room');
1615
+ }
1616
+
1617
+ return await this._fetch(`${this.backendUrl}/api/rooms/${this.roomId}/metadata`, {
1618
+ method: 'PUT',
1619
+ headers: { 'Content-Type': 'application/json' },
1620
+ body: JSON.stringify(updates)
1621
+ }).then(r => r.json());
1622
+ }
1623
+
1624
+ /**
1625
+ * Send a data message to all participants in the room via WebSocket.
1626
+ * @param {Object} data - The JSON object to send.
1627
+ * @returns {void}
1628
+ */
1629
+ async sendDataToAll(data) {
1630
+ if (!this.roomId || !this.sessionId) {
1631
+ throw new Error('Must be in a room to send data');
1632
+ }
1633
+
1634
+ // Send via WebSocket instead of HTTP
1635
+ if (this.ws && this.ws.readyState === WebSocket.OPEN) {
1636
+ this.ws.send(JSON.stringify({
1637
+ type: 'data-message',
1638
+ payload: {
1639
+ from: this.sessionId,
1640
+ message: data
1641
+ }
1642
+ }));
1643
+ } else {
1644
+ throw new Error('WebSocket connection not available');
1645
+ }
1646
+ }
1647
+
1648
+ /**
1649
+ * Sets the media quality for audio and video tracks
1650
+ * @param {string|QualityPreset} quality - Either a preset name ('high', 'medium', 'low') or a custom quality object
1651
+ * @param {VideoQualitySettings} [quality.video] - Video quality settings
1652
+ * @param {AudioQualitySettings} [quality.audio] - Audio quality settings
1653
+ * @throws {Error} If preset name is invalid
1654
+ */
1655
+ setMediaQuality(quality) {
1656
+ // If quality is a string, use the preset
1657
+ if (typeof quality === 'string') {
1658
+ const preset = CloudflareCalls.QUALITY_PRESETS[quality];
1659
+ if (!preset) {
1660
+ return this._warn(`Unknown quality preset: ${quality}`);
1661
+ }
1662
+ this.mediaQuality = quality;
1663
+ quality = preset;
1664
+ }
1665
+
1666
+ this.mediaQuality = {
1667
+ video: { ...this.mediaQuality.video, ...quality.video },
1668
+ audio: { ...this.mediaQuality.audio, ...quality.audio }
1669
+ };
1670
+
1671
+ // Store settings to apply to future tracks
1672
+ this.pendingQualitySettings = this.mediaQuality;
1673
+
1674
+ // If we're already in a call, update existing tracks
1675
+ if (this.peerConnection) {
1676
+ this._applyQualitySettings();
1677
+ }
1678
+ }
1679
+
1680
+ /**
1681
+ * Applies quality settings to all tracks
1682
+ * @private
1683
+ */
1684
+ async _applyQualitySettings() {
1685
+ if (!this.peerConnection) return;
1686
+
1687
+ const senders = this.peerConnection.getSenders();
1688
+ for (const sender of senders) {
1689
+ if (!sender.track) continue;
1690
+
1691
+ const params = sender.getParameters();
1692
+ if (!params.encodings) {
1693
+ params.encodings = [{}];
1694
+ }
1695
+
1696
+ const kind = sender.track.kind;
1697
+ const qualitySettings = this.mediaQuality[kind];
1698
+
1699
+ // Update bitrate
1700
+ if (qualitySettings.maxBitrate) {
1701
+ params.encodings[0].maxBitrate = qualitySettings.maxBitrate;
1702
+ }
1703
+
1704
+ // Update resolution/framerate for video
1705
+ if (kind === 'video') {
1706
+ const constraints = {
1707
+ width: qualitySettings.width,
1708
+ height: qualitySettings.height,
1709
+ frameRate: qualitySettings.frameRate
1710
+ };
1711
+ await sender.track.applyConstraints(constraints);
1712
+ }
1713
+
1714
+ await sender.setParameters(params);
1715
+ }
1716
+ }
1717
+
1718
+ /**
1719
+ * Start monitoring connection statistics
1720
+ * @param {number} [interval=1000] - How often to gather stats in milliseconds
1721
+ */
1722
+ startStatsMonitoring(interval = 1000) {
1723
+ if (this.statsMonitoringState === 'monitoring') return;
1724
+
1725
+ this.statsMonitoringState = 'monitoring';
1726
+ this.statsInterval = setInterval(async () => {
1727
+ if (!this.peerConnection) return;
1728
+
1729
+ const stats = await this._gatherConnectionStats();
1730
+ const streamStats = await this._gatherStreamStats();
1731
+
1732
+ if (this._onConnectionStatsCallback) {
1733
+ this._onConnectionStatsCallback(stats, streamStats);
1734
+ }
1735
+ }, interval);
1736
+ }
1737
+
1738
+ /**
1739
+ * Stop monitoring connection statistics
1740
+ */
1741
+ stopStatsMonitoring() {
1742
+ if (this.statsInterval) {
1743
+ clearInterval(this.statsInterval);
1744
+ this.statsInterval = null;
1745
+ // + this.previousStats = null; // Clear previous stats
1746
+ }
1747
+ this.statsMonitoringState = 'stopped';
1748
+ }
1749
+
1750
+ /**
1751
+ * Register a callback to receive connection statistics
1752
+ * @param {function(ConnectionStats): void} callback - Function to receive stats updates
1753
+ */
1754
+ onConnectionStats(callback) {
1755
+ this._onConnectionStatsCallback = callback;
1756
+ }
1757
+
1758
+ /**
1759
+ * Gather current connection statistics
1760
+ * @private
1761
+ * @returns {Promise<ConnectionStats>} Current connection statistics
1762
+ */
1763
+ async _gatherConnectionStats() {
1764
+ if (!this.peerConnection) {
1765
+ return this._warn('No active connection');
1766
+ }
1767
+
1768
+ const stats = await this.peerConnection.getStats();
1769
+ const result = {
1770
+ outbound: {
1771
+ bitrate: 0,
1772
+ packetLoss: 0,
1773
+ qualityLimitation: 'none'
1774
+ },
1775
+ inbound: {
1776
+ bitrate: 0,
1777
+ packetLoss: 0,
1778
+ jitter: 0
1779
+ },
1780
+ connection: {
1781
+ roundTripTime: 0,
1782
+ state: this.peerConnection.connectionState
1783
+ }
1784
+ };
1785
+
1786
+ let outboundStats = null;
1787
+ let inboundStats = null;
1788
+
1789
+ // Process each stat
1790
+ stats.forEach(stat => {
1791
+ switch (stat.type) {
1792
+ case 'outbound-rtp':
1793
+ if (stat.kind === 'video') {
1794
+ outboundStats = stat;
1795
+ result.outbound.qualityLimitation = stat.qualityLimitationReason;
1796
+ }
1797
+ break;
1798
+
1799
+ case 'inbound-rtp':
1800
+ if (stat.kind === 'video') {
1801
+ inboundStats = stat;
1802
+ result.inbound.jitter = stat.jitter;
1803
+ if (stat.packetsLost > 0) {
1804
+ result.inbound.packetLoss =
1805
+ (stat.packetsLost / (stat.packetsReceived + stat.packetsLost)) * 100;
1806
+ }
1807
+ }
1808
+ break;
1809
+
1810
+ case 'candidate-pair':
1811
+ if (stat.state === 'succeeded') {
1812
+ result.connection.roundTripTime = stat.currentRoundTripTime;
1813
+ }
1814
+ break;
1815
+ }
1816
+ });
1817
+
1818
+ // Calculate bitrates using previous stats
1819
+ if (this.previousStats && outboundStats && inboundStats) {
1820
+ const timeDelta = (outboundStats.timestamp - this.previousStats.outboundTimestamp) / 1000; // Convert to seconds
1821
+
1822
+ if (timeDelta > 0) {
1823
+ // Calculate outbound bitrate
1824
+ const bytesSentDelta = outboundStats.bytesSent - this.previousStats.bytesSent;
1825
+ result.outbound.bitrate = (bytesSentDelta * 8) / timeDelta; // Convert to bits per second
1826
+
1827
+ // Calculate inbound bitrate
1828
+ const bytesReceivedDelta = inboundStats.bytesReceived - this.previousStats.bytesReceived;
1829
+ result.inbound.bitrate = (bytesReceivedDelta * 8) / timeDelta; // Convert to bits per second
1830
+ }
1831
+ }
1832
+
1833
+ // Store current stats for next calculation
1834
+ if (outboundStats && inboundStats) {
1835
+ this.previousStats = {
1836
+ outboundTimestamp: outboundStats.timestamp,
1837
+ bytesSent: outboundStats.bytesSent,
1838
+ bytesReceived: inboundStats.bytesReceived
1839
+ };
1840
+ }
1841
+
1842
+ return result;
1843
+ }
1844
+
1845
+ /**
1846
+ * Get a snapshot of current connection statistics
1847
+ * @returns {Promise<ConnectionStats>} Current connection statistics
1848
+ */
1849
+ async getConnectionStats() {
1850
+ return this._gatherConnectionStats();
1851
+ }
1852
+
1853
+ /**
1854
+ * Gather current connection statistics per stream
1855
+ * @private
1856
+ * @returns {Promise<Map<string, StreamStats>>} Map of session IDs to stream stats
1857
+ */
1858
+ async _gatherStreamStats() {
1859
+ if (!this.peerConnection) return new Map();
1860
+
1861
+ const stats = await this.peerConnection.getStats();
1862
+ const streamStats = new Map();
1863
+
1864
+ // Initialize local stats
1865
+ if (this.sessionId) {
1866
+ streamStats.set(this.sessionId, {
1867
+ sessionId: this.sessionId,
1868
+ packetLoss: 0,
1869
+ qualityLimitation: 'none',
1870
+ bitrate: 0
1871
+ });
1872
+ }
1873
+
1874
+ stats.forEach(stat => {
1875
+ if (stat.type === 'outbound-rtp' && stat.kind === 'video') {
1876
+ // Update local stream stats
1877
+ const localStats = streamStats.get(this.sessionId);
1878
+ if (localStats) {
1879
+ localStats.qualityLimitation = stat.qualityLimitationReason;
1880
+ localStats.bitrate = stat.bytesSent * 8 / stat.timestamp;
1881
+ }
1882
+ }
1883
+ else if (stat.type === 'inbound-rtp' && stat.kind === 'video') {
1884
+ // Get sessionId from mid mapping
1885
+ const mid = stat.mid;
1886
+ const sessionId = this.midToSessionId.get(mid);
1887
+
1888
+ if (sessionId) {
1889
+ streamStats.set(sessionId, {
1890
+ sessionId,
1891
+ packetLoss: stat.packetsLost > 0
1892
+ ? (stat.packetsLost / (stat.packetsReceived + stat.packetsLost)) * 100
1893
+ : 0,
1894
+ qualityLimitation: 'none',
1895
+ bitrate: stat.bytesReceived * 8 / stat.timestamp
1896
+ });
1897
+ }
1898
+ }
1899
+ });
1900
+
1901
+ return streamStats;
1902
+ }
1903
+
1904
+ // Add static QUALITY_PRESETS
1905
+ static QUALITY_PRESETS = {
1906
+ // 16:9 Presets
1907
+ high_16x9_xl: { // 1080p
1908
+ video: {
1909
+ width: { ideal: 1920 },
1910
+ height: { ideal: 1080 },
1911
+ frameRate: { ideal: 30 },
1912
+ maxBitrate: 2_500_000
1913
+ },
1914
+ audio: { maxBitrate: 128000, sampleRate: 48000, channelCount: 2 }
1915
+ },
1916
+ high_16x9_lg: { // 720p
1917
+ video: {
1918
+ width: { ideal: 1280 },
1919
+ height: { ideal: 720 },
1920
+ frameRate: { ideal: 30 },
1921
+ maxBitrate: 1_500_000
1922
+ },
1923
+ audio: { maxBitrate: 96000, sampleRate: 48000, channelCount: 2 }
1924
+ },
1925
+ high_16x9_md: { // 480p
1926
+ video: {
1927
+ width: { ideal: 854 },
1928
+ height: { ideal: 480 },
1929
+ frameRate: { ideal: 30 },
1930
+ maxBitrate: 800_000
1931
+ },
1932
+ audio: { maxBitrate: 96000, sampleRate: 48000, channelCount: 1 }
1933
+ },
1934
+ high_16x9_sm: { // 360p
1935
+ video: {
1936
+ width: { ideal: 640 },
1937
+ height: { ideal: 360 },
1938
+ frameRate: { ideal: 30 },
1939
+ maxBitrate: 600_000
1940
+ },
1941
+ audio: { maxBitrate: 64000, sampleRate: 44100, channelCount: 1 }
1942
+ },
1943
+ high_16x9_xs: { // 270p
1944
+ video: {
1945
+ width: { ideal: 480 },
1946
+ height: { ideal: 270 },
1947
+ frameRate: { ideal: 30 },
1948
+ maxBitrate: 400_000
1949
+ },
1950
+ audio: { maxBitrate: 64000, sampleRate: 44100, channelCount: 1 }
1951
+ },
1952
+
1953
+ // 16:9 Medium Quality Presets (reduced framerate & bitrate)
1954
+ medium_16x9_xl: {
1955
+ video: {
1956
+ width: { ideal: 1920 },
1957
+ height: { ideal: 1080 },
1958
+ frameRate: { ideal: 24 },
1959
+ maxBitrate: 2_000_000
1960
+ },
1961
+ audio: { maxBitrate: 96000, sampleRate: 48000, channelCount: 2 }
1962
+ },
1963
+ medium_16x9_lg: {
1964
+ video: {
1965
+ width: { ideal: 1280 },
1966
+ height: { ideal: 720 },
1967
+ frameRate: { ideal: 24 },
1968
+ maxBitrate: 1_200_000
1969
+ },
1970
+ audio: { maxBitrate: 96000, sampleRate: 48000, channelCount: 1 }
1971
+ },
1972
+ medium_16x9_md: {
1973
+ video: {
1974
+ width: { ideal: 854 },
1975
+ height: { ideal: 480 },
1976
+ frameRate: { ideal: 24 },
1977
+ maxBitrate: 600_000
1978
+ },
1979
+ audio: { maxBitrate: 64000, sampleRate: 44100, channelCount: 1 }
1980
+ },
1981
+ medium_16x9_sm: {
1982
+ video: {
1983
+ width: { ideal: 640 },
1984
+ height: { ideal: 360 },
1985
+ frameRate: { ideal: 20 },
1986
+ maxBitrate: 400_000
1987
+ },
1988
+ audio: { maxBitrate: 48000, sampleRate: 44100, channelCount: 1 }
1989
+ },
1990
+ medium_16x9_xs: {
1991
+ video: {
1992
+ width: { ideal: 480 },
1993
+ height: { ideal: 270 },
1994
+ frameRate: { ideal: 20 },
1995
+ maxBitrate: 300_000
1996
+ },
1997
+ audio: { maxBitrate: 48000, sampleRate: 44100, channelCount: 1 }
1998
+ },
1999
+
2000
+ // 16:9 Low Quality Presets (minimum viable quality)
2001
+ low_16x9_xl: {
2002
+ video: {
2003
+ width: { ideal: 1920 },
2004
+ height: { ideal: 1080 },
2005
+ frameRate: { ideal: 15 },
2006
+ maxBitrate: 1_500_000
2007
+ },
2008
+ audio: { maxBitrate: 64000, sampleRate: 44100, channelCount: 1 }
2009
+ },
2010
+ low_16x9_lg: {
2011
+ video: {
2012
+ width: { ideal: 1280 },
2013
+ height: { ideal: 720 },
2014
+ frameRate: { ideal: 15 },
2015
+ maxBitrate: 800_000
2016
+ },
2017
+ audio: { maxBitrate: 48000, sampleRate: 44100, channelCount: 1 }
2018
+ },
2019
+ low_16x9_md: {
2020
+ video: {
2021
+ width: { ideal: 854 },
2022
+ height: { ideal: 480 },
2023
+ frameRate: { ideal: 15 },
2024
+ maxBitrate: 400_000
2025
+ },
2026
+ audio: { maxBitrate: 32000, sampleRate: 44100, channelCount: 1 }
2027
+ },
2028
+ low_16x9_sm: {
2029
+ video: {
2030
+ width: { ideal: 640 },
2031
+ height: { ideal: 360 },
2032
+ frameRate: { ideal: 12 },
2033
+ maxBitrate: 250_000
2034
+ },
2035
+ audio: { maxBitrate: 32000, sampleRate: 22050, channelCount: 1 }
2036
+ },
2037
+ low_16x9_xs: {
2038
+ video: {
2039
+ width: { ideal: 480 },
2040
+ height: { ideal: 270 },
2041
+ frameRate: { ideal: 10 },
2042
+ maxBitrate: 150_000
2043
+ },
2044
+ audio: { maxBitrate: 24000, sampleRate: 22050, channelCount: 1 }
2045
+ },
2046
+
2047
+ // 4:3 High Quality Presets (existing)
2048
+ high_4x3_xl: { // 960x720
2049
+ video: {
2050
+ width: { ideal: 960 },
2051
+ height: { ideal: 720 },
2052
+ frameRate: { ideal: 30 },
2053
+ maxBitrate: 1_500_000
2054
+ },
2055
+ audio: { maxBitrate: 128000, sampleRate: 48000, channelCount: 2 }
2056
+ },
2057
+ high_4x3_lg: { // 640x480
2058
+ video: {
2059
+ width: { ideal: 640 },
2060
+ height: { ideal: 480 },
2061
+ frameRate: { ideal: 30 },
2062
+ maxBitrate: 800_000
2063
+ },
2064
+ audio: { maxBitrate: 96000, sampleRate: 48000, channelCount: 1 }
2065
+ },
2066
+ high_4x3_md: { // 480x360
2067
+ video: {
2068
+ width: { ideal: 480 },
2069
+ height: { ideal: 360 },
2070
+ frameRate: { ideal: 30 },
2071
+ maxBitrate: 600_000
2072
+ },
2073
+ audio: { maxBitrate: 96000, sampleRate: 44100, channelCount: 1 }
2074
+ },
2075
+ high_4x3_sm: { // 320x240
2076
+ video: {
2077
+ width: { ideal: 320 },
2078
+ height: { ideal: 240 },
2079
+ frameRate: { ideal: 30 },
2080
+ maxBitrate: 400_000
2081
+ },
2082
+ audio: { maxBitrate: 64000, sampleRate: 44100, channelCount: 1 }
2083
+ },
2084
+ high_4x3_xs: { // 240x180 (perfect for 300x225 container)
2085
+ video: {
2086
+ width: { ideal: 240 },
2087
+ height: { ideal: 180 },
2088
+ frameRate: { ideal: 30 },
2089
+ maxBitrate: 250_000
2090
+ },
2091
+ audio: { maxBitrate: 64000, sampleRate: 44100, channelCount: 1 }
2092
+ },
2093
+
2094
+ // 4:3 Medium Quality Presets
2095
+ medium_4x3_xl: {
2096
+ video: {
2097
+ width: { ideal: 960 },
2098
+ height: { ideal: 720 },
2099
+ frameRate: { ideal: 24 },
2100
+ maxBitrate: 1_200_000
2101
+ },
2102
+ audio: { maxBitrate: 96000, sampleRate: 48000, channelCount: 1 }
2103
+ },
2104
+ medium_4x3_lg: {
2105
+ video: {
2106
+ width: { ideal: 640 },
2107
+ height: { ideal: 480 },
2108
+ frameRate: { ideal: 24 },
2109
+ maxBitrate: 600_000
2110
+ },
2111
+ audio: { maxBitrate: 64000, sampleRate: 44100, channelCount: 1 }
2112
+ },
2113
+ medium_4x3_md: {
2114
+ video: {
2115
+ width: { ideal: 480 },
2116
+ height: { ideal: 360 },
2117
+ frameRate: { ideal: 20 },
2118
+ maxBitrate: 400_000
2119
+ },
2120
+ audio: { maxBitrate: 48000, sampleRate: 44100, channelCount: 1 }
2121
+ },
2122
+ medium_4x3_sm: {
2123
+ video: {
2124
+ width: { ideal: 320 },
2125
+ height: { ideal: 240 },
2126
+ frameRate: { ideal: 20 },
2127
+ maxBitrate: 300_000
2128
+ },
2129
+ audio: { maxBitrate: 48000, sampleRate: 44100, channelCount: 1 }
2130
+ },
2131
+ medium_4x3_xs: {
2132
+ video: {
2133
+ width: { ideal: 240 },
2134
+ height: { ideal: 180 },
2135
+ frameRate: { ideal: 20 },
2136
+ maxBitrate: 200_000
2137
+ },
2138
+ audio: { maxBitrate: 48000, sampleRate: 44100, channelCount: 1 }
2139
+ },
2140
+
2141
+ // 4:3 Low Quality Presets
2142
+ low_4x3_xl: {
2143
+ video: {
2144
+ width: { ideal: 960 },
2145
+ height: { ideal: 720 },
2146
+ frameRate: { ideal: 15 },
2147
+ maxBitrate: 800_000
2148
+ },
2149
+ audio: { maxBitrate: 48000, sampleRate: 44100, channelCount: 1 }
2150
+ },
2151
+ low_4x3_lg: {
2152
+ video: {
2153
+ width: { ideal: 640 },
2154
+ height: { ideal: 480 },
2155
+ frameRate: { ideal: 15 },
2156
+ maxBitrate: 400_000
2157
+ },
2158
+ audio: { maxBitrate: 32000, sampleRate: 44100, channelCount: 1 }
2159
+ },
2160
+ low_4x3_md: {
2161
+ video: {
2162
+ width: { ideal: 480 },
2163
+ height: { ideal: 360 },
2164
+ frameRate: { ideal: 12 },
2165
+ maxBitrate: 250_000
2166
+ },
2167
+ audio: { maxBitrate: 32000, sampleRate: 22050, channelCount: 1 }
2168
+ },
2169
+ low_4x3_sm: {
2170
+ video: {
2171
+ width: { ideal: 320 },
2172
+ height: { ideal: 240 },
2173
+ frameRate: { ideal: 10 },
2174
+ maxBitrate: 150_000
2175
+ },
2176
+ audio: { maxBitrate: 24000, sampleRate: 22050, channelCount: 1 }
2177
+ },
2178
+ low_4x3_xs: {
2179
+ video: {
2180
+ width: { ideal: 240 },
2181
+ height: { ideal: 180 },
2182
+ frameRate: { ideal: 10 },
2183
+ maxBitrate: 100_000
2184
+ },
2185
+ audio: { maxBitrate: 24000, sampleRate: 22050, channelCount: 1 }
2186
+ },
2187
+
2188
+ // 1:1 High Quality Presets
2189
+ high_1x1_xl: { // 720x720
2190
+ video: {
2191
+ width: { ideal: 720 },
2192
+ height: { ideal: 720 },
2193
+ frameRate: { ideal: 30 },
2194
+ maxBitrate: 1_500_000
2195
+ },
2196
+ audio: { maxBitrate: 128000, sampleRate: 48000, channelCount: 2 }
2197
+ },
2198
+ high_1x1_lg: { // 480x480
2199
+ video: {
2200
+ width: { ideal: 480 },
2201
+ height: { ideal: 480 },
2202
+ frameRate: { ideal: 30 },
2203
+ maxBitrate: 800_000
2204
+ },
2205
+ audio: { maxBitrate: 96000, sampleRate: 48000, channelCount: 1 }
2206
+ },
2207
+ high_1x1_md: { // 360x360
2208
+ video: {
2209
+ width: { ideal: 360 },
2210
+ height: { ideal: 360 },
2211
+ frameRate: { ideal: 30 },
2212
+ maxBitrate: 600_000
2213
+ },
2214
+ audio: { maxBitrate: 96000, sampleRate: 44100, channelCount: 1 }
2215
+ },
2216
+ high_1x1_sm: { // 240x240
2217
+ video: {
2218
+ width: { ideal: 240 },
2219
+ height: { ideal: 240 },
2220
+ frameRate: { ideal: 30 },
2221
+ maxBitrate: 400_000
2222
+ },
2223
+ audio: { maxBitrate: 64000, sampleRate: 44100, channelCount: 1 }
2224
+ },
2225
+ high_1x1_xs: { // 180x180
2226
+ video: {
2227
+ width: { ideal: 180 },
2228
+ height: { ideal: 180 },
2229
+ frameRate: { ideal: 30 },
2230
+ maxBitrate: 250_000
2231
+ },
2232
+ audio: { maxBitrate: 64000, sampleRate: 44100, channelCount: 1 }
2233
+ },
2234
+
2235
+ // 1:1 Medium Quality Presets
2236
+ medium_1x1_xl: {
2237
+ video: {
2238
+ width: { ideal: 720 },
2239
+ height: { ideal: 720 },
2240
+ frameRate: { ideal: 24 },
2241
+ maxBitrate: 1_200_000
2242
+ },
2243
+ audio: { maxBitrate: 96000, sampleRate: 48000, channelCount: 1 }
2244
+ },
2245
+ medium_1x1_lg: {
2246
+ video: {
2247
+ width: { ideal: 480 },
2248
+ height: { ideal: 480 },
2249
+ frameRate: { ideal: 24 },
2250
+ maxBitrate: 600_000
2251
+ },
2252
+ audio: { maxBitrate: 64000, sampleRate: 44100, channelCount: 1 }
2253
+ },
2254
+ medium_1x1_md: {
2255
+ video: {
2256
+ width: { ideal: 360 },
2257
+ height: { ideal: 360 },
2258
+ frameRate: { ideal: 20 },
2259
+ maxBitrate: 400_000
2260
+ },
2261
+ audio: { maxBitrate: 48000, sampleRate: 44100, channelCount: 1 }
2262
+ },
2263
+ medium_1x1_sm: {
2264
+ video: {
2265
+ width: { ideal: 240 },
2266
+ height: { ideal: 240 },
2267
+ frameRate: { ideal: 20 },
2268
+ maxBitrate: 300_000
2269
+ },
2270
+ audio: { maxBitrate: 48000, sampleRate: 44100, channelCount: 1 }
2271
+ },
2272
+ medium_1x1_xs: {
2273
+ video: {
2274
+ width: { ideal: 180 },
2275
+ height: { ideal: 180 },
2276
+ frameRate: { ideal: 20 },
2277
+ maxBitrate: 200_000
2278
+ },
2279
+ audio: { maxBitrate: 48000, sampleRate: 44100, channelCount: 1 }
2280
+ },
2281
+
2282
+ // 1:1 Low Quality Presets
2283
+ low_1x1_xl: {
2284
+ video: {
2285
+ width: { ideal: 720 },
2286
+ height: { ideal: 720 },
2287
+ frameRate: { ideal: 15 },
2288
+ maxBitrate: 800_000
2289
+ },
2290
+ audio: { maxBitrate: 48000, sampleRate: 44100, channelCount: 1 }
2291
+ },
2292
+ low_1x1_lg: {
2293
+ video: {
2294
+ width: { ideal: 480 },
2295
+ height: { ideal: 480 },
2296
+ frameRate: { ideal: 15 },
2297
+ maxBitrate: 400_000
2298
+ },
2299
+ audio: { maxBitrate: 32000, sampleRate: 44100, channelCount: 1 }
2300
+ },
2301
+ low_1x1_md: {
2302
+ video: {
2303
+ width: { ideal: 360 },
2304
+ height: { ideal: 360 },
2305
+ frameRate: { ideal: 12 },
2306
+ maxBitrate: 250_000
2307
+ },
2308
+ audio: { maxBitrate: 32000, sampleRate: 22050, channelCount: 1 }
2309
+ },
2310
+ low_1x1_sm: {
2311
+ video: {
2312
+ width: { ideal: 240 },
2313
+ height: { ideal: 240 },
2314
+ frameRate: { ideal: 10 },
2315
+ maxBitrate: 150_000
2316
+ },
2317
+ audio: { maxBitrate: 24000, sampleRate: 22050, channelCount: 1 }
2318
+ },
2319
+ low_1x1_xs: {
2320
+ video: {
2321
+ width: { ideal: 180 },
2322
+ height: { ideal: 180 },
2323
+ frameRate: { ideal: 10 },
2324
+ maxBitrate: 100_000
2325
+ },
2326
+ audio: { maxBitrate: 24000, sampleRate: 22050, channelCount: 1 }
2327
+ },
2328
+
2329
+ // 9:16 High Quality Presets (Portrait/Mobile)
2330
+ high_9x16_xl: { // 1080x1920
2331
+ video: {
2332
+ width: { ideal: 1080 },
2333
+ height: { ideal: 1920 },
2334
+ frameRate: { ideal: 30 },
2335
+ maxBitrate: 2_500_000
2336
+ },
2337
+ audio: { maxBitrate: 128000, sampleRate: 48000, channelCount: 2 }
2338
+ },
2339
+ high_9x16_lg: { // 720x1280
2340
+ video: {
2341
+ width: { ideal: 720 },
2342
+ height: { ideal: 1280 },
2343
+ frameRate: { ideal: 30 },
2344
+ maxBitrate: 1_500_000
2345
+ },
2346
+ audio: { maxBitrate: 96000, sampleRate: 48000, channelCount: 1 }
2347
+ },
2348
+ high_9x16_md: { // 480x854
2349
+ video: {
2350
+ width: { ideal: 480 },
2351
+ height: { ideal: 854 },
2352
+ frameRate: { ideal: 30 },
2353
+ maxBitrate: 800_000
2354
+ },
2355
+ audio: { maxBitrate: 96000, sampleRate: 44100, channelCount: 1 }
2356
+ },
2357
+ high_9x16_sm: { // 360x640
2358
+ video: {
2359
+ width: { ideal: 360 },
2360
+ height: { ideal: 640 },
2361
+ frameRate: { ideal: 30 },
2362
+ maxBitrate: 600_000
2363
+ },
2364
+ audio: { maxBitrate: 64000, sampleRate: 44100, channelCount: 1 }
2365
+ },
2366
+ high_9x16_xs: { // 270x480
2367
+ video: {
2368
+ width: { ideal: 270 },
2369
+ height: { ideal: 480 },
2370
+ frameRate: { ideal: 30 },
2371
+ maxBitrate: 400_000
2372
+ },
2373
+ audio: { maxBitrate: 64000, sampleRate: 44100, channelCount: 1 }
2374
+ },
2375
+
2376
+ // 9:16 Medium Quality Presets
2377
+ medium_9x16_xl: {
2378
+ video: {
2379
+ width: { ideal: 1080 },
2380
+ height: { ideal: 1920 },
2381
+ frameRate: { ideal: 24 },
2382
+ maxBitrate: 2_000_000
2383
+ },
2384
+ audio: { maxBitrate: 96000, sampleRate: 48000, channelCount: 1 }
2385
+ },
2386
+ medium_9x16_lg: {
2387
+ video: {
2388
+ width: { ideal: 720 },
2389
+ height: { ideal: 1280 },
2390
+ frameRate: { ideal: 24 },
2391
+ maxBitrate: 1_200_000
2392
+ },
2393
+ audio: { maxBitrate: 64000, sampleRate: 44100, channelCount: 1 }
2394
+ },
2395
+ medium_9x16_md: {
2396
+ video: {
2397
+ width: { ideal: 480 },
2398
+ height: { ideal: 854 },
2399
+ frameRate: { ideal: 20 },
2400
+ maxBitrate: 600_000
2401
+ },
2402
+ audio: { maxBitrate: 48000, sampleRate: 44100, channelCount: 1 }
2403
+ },
2404
+ medium_9x16_sm: {
2405
+ video: {
2406
+ width: { ideal: 360 },
2407
+ height: { ideal: 640 },
2408
+ frameRate: { ideal: 20 },
2409
+ maxBitrate: 400_000
2410
+ },
2411
+ audio: { maxBitrate: 48000, sampleRate: 44100, channelCount: 1 }
2412
+ },
2413
+ medium_9x16_xs: {
2414
+ video: {
2415
+ width: { ideal: 270 },
2416
+ height: { ideal: 480 },
2417
+ frameRate: { ideal: 20 },
2418
+ maxBitrate: 300_000
2419
+ },
2420
+ audio: { maxBitrate: 48000, sampleRate: 44100, channelCount: 1 }
2421
+ },
2422
+
2423
+ // 9:16 Low Quality Presets
2424
+ low_9x16_xl: {
2425
+ video: {
2426
+ width: { ideal: 1080 },
2427
+ height: { ideal: 1920 },
2428
+ frameRate: { ideal: 15 },
2429
+ maxBitrate: 1_500_000
2430
+ },
2431
+ audio: { maxBitrate: 48000, sampleRate: 44100, channelCount: 1 }
2432
+ },
2433
+ low_9x16_lg: {
2434
+ video: {
2435
+ width: { ideal: 720 },
2436
+ height: { ideal: 1280 },
2437
+ frameRate: { ideal: 15 },
2438
+ maxBitrate: 800_000
2439
+ },
2440
+ audio: { maxBitrate: 32000, sampleRate: 44100, channelCount: 1 }
2441
+ },
2442
+ low_9x16_md: {
2443
+ video: {
2444
+ width: { ideal: 480 },
2445
+ height: { ideal: 854 },
2446
+ frameRate: { ideal: 12 },
2447
+ maxBitrate: 400_000
2448
+ },
2449
+ audio: { maxBitrate: 32000, sampleRate: 22050, channelCount: 1 }
2450
+ },
2451
+ low_9x16_sm: {
2452
+ video: {
2453
+ width: { ideal: 360 },
2454
+ height: { ideal: 640 },
2455
+ frameRate: { ideal: 10 },
2456
+ maxBitrate: 250_000
2457
+ },
2458
+ audio: { maxBitrate: 24000, sampleRate: 22050, channelCount: 1 }
2459
+ },
2460
+ low_9x16_xs: {
2461
+ video: {
2462
+ width: { ideal: 270 },
2463
+ height: { ideal: 480 },
2464
+ frameRate: { ideal: 10 },
2465
+ maxBitrate: 150_000
2466
+ },
2467
+ audio: { maxBitrate: 24000, sampleRate: 22050, channelCount: 1 }
2468
+ }
2469
+ };
2470
+ }
2471
+
2472
+ export default CloudflareCalls;
public/temp/CloudflareCalls.min.js ADDED
@@ -0,0 +1 @@
 
 
1
+ !function(e,t){"object"==typeof exports&&"undefined"!=typeof module?module.exports=t():"function"==typeof define&&define.amd?define(t):(e="undefined"!=typeof globalThis?globalThis:e||self).CloudflareCalls=t()}(this,(function(){"use strict";class e{constructor(t={}){this.backendUrl=t.backendUrl||"",this.websocketUrl=t.websocketUrl||"",this.debug=t.debug||!1,this.token=null,this.roomId=null,this.sessionId=null,this.userId=this._generateUUID(),this.userMetadata={},this.localStream=null,this.peerConnection=null,this.ws=null,this._onParticipantJoinedCallback=null,this._onParticipantLeftCallback=null,this._onRemoteTrackCallback=null,this._onRemoteTrackUnpublishedCallback=null,this._onTrackStatusChangedCallback=null,this._onDataMessageCallback=null,this._onConnectionStatsCallback=null,this._wsMessageHandlers=new Set,this.pulledTracks=new Map,this.pollingInterval=null,this.availableAudioInputDevices=[],this.availableVideoInputDevices=[],this.availableAudioOutputDevices=[],this.currentAudioOutputDeviceId=null,this._renegotiateTimeout=null,this.publishedTracks=new Set,this.midToSessionId=new Map,this.midToTrackName=new Map,this._onRoomMetadataUpdatedCallback=null,this.pendingQualitySettings=null,this.mediaQuality=e.QUALITY_PRESETS.medium_16x9_md,this.QUALITY_PRESETS=e.QUALITY_PRESETS,this.statsInterval=null,this.previousStats=null,this.statsMonitoringState="stopped"}_log(...e){this.debug&&console.log("[CloudflareCalls]",...e)}_warn(...e){this.debug&&console.warn("[CloudflareCalls]",...e)}_error(...e){console.error("[CloudflareCalls]",...e)}setDebugMode(e){this.debug=Boolean(e)}async _fetch(e,t={}){t.headers=t.headers||{},this.token&&(t.headers.Authorization=`Bearer ${this.token}`);try{const a=await fetch(e,t);return a.ok||this._warn(`HTTP error! status: ${a.status}`),a}catch(t){return this._warn(`Fetch error for ${e}:`,t),!1}}onRemoteTrack(e){this._onRemoteTrackCallback=e}onRemoteTrackUnpublished(e){this._onRemoteTrackUnpublishedCallback=e}onDataMessage(e){this._onDataMessageCallback=e}onParticipantJoined(e){this._onParticipantJoinedCallback=e}onParticipantLeft(e){this._onParticipantLeftCallback=e}onTrackStatusChanged(e){this._onTrackStatusChangedCallback=e}onWebSocketMessage(e){return this._wsMessageHandlers.add(e),()=>this._wsMessageHandlers.delete(e)}setToken(e){this.token=e}onRoomMetadataUpdated(e){this._onRoomMetadataUpdatedCallback=e}setUserMetadata(e){this.userMetadata=e,this._updateUserMetadataOnServer()}getUserMetadata(){return this.userMetadata}async _updateUserMetadataOnServer(){if(this.roomId&&this.sessionId)try{const e=`${this.backendUrl}/api/rooms/${this.roomId}/metadata`;(await this._fetch(e,{method:"PUT",headers:{"Content-Type":"application/json"},body:JSON.stringify(this.userMetadata)})).ok?this._log("User metadata updated on server."):this._error("Failed to update user metadata on server.")}catch(e){throw this._error("Error updating user metadata:",e),e}else this._warn("Cannot update metadata before joining a room.")}async createRoom(e={}){const t=await this._fetch(`${this.backendUrl}/api/rooms`,{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify(e)}).then((e=>e.json()));return this.roomId=t.roomId,t}async joinRoom(e,t={}){this.roomId=e;const a=await this._fetch(`${this.backendUrl}/api/rooms/${e}/join`,{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify({userId:this.userId,metadata:this.userMetadata})}).then((e=>e.json()));if(await this._initWebSocket(),!a.sessionId)throw new Error("Failed to join room or retrieve sessionId");this.sessionId=a.sessionId,this.pulledTracks.set(this.sessionId,new Set),this.peerConnection=await this._createPeerConnection(),this.localStream||(this.localStream=await navigator.mediaDevices.getUserMedia({video:!0,audio:!0}),this._log("Acquired local media")),await this._publishTracks();const i=a.otherSessions||[];for(const e of i){this.pulledTracks.set(e.sessionId,new Set);for(const t of e.publishedTracks||[])await this._pullTracks(e.sessionId,t)}this._log("Joined room",e,"my session:",this.sessionId),this.setUserMetadata(t),this._startPolling()}async _cleanupEndedTracks(){if(this.localStream)for(const e of this.localStream.getTracks())"ended"===e.readyState&&(this.localStream.removeTrack(e),e.stop());this.localStream&&!this.localStream.getTracks().length&&(this.localStream=null)}async leaveRoom(){if(!this.roomId||!this.sessionId)return;const e=this.peerConnection.getSenders();e&&e.length&&await this.unpublishAllTracks();try{await this._fetch(`${this.backendUrl}/api/rooms/${this.roomId}/leave`,{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify({sessionId:this.sessionId})})}catch(e){this._warn("Error leaving room:",e)}this.ws&&(this.ws.close(),this.ws=null),this.peerConnection&&(this.peerConnection.close(),this.peerConnection=null),await this._cleanupEndedTracks(),this._log("Left room, closed PC & WS"),this.roomId=null,this.sessionId=null,this.pulledTracks.clear(),this.midToSessionId.clear(),this.midToTrackName.clear(),this.publishedTracks.clear()}async publishTracks(){if(!this.localStream)return this._warn("No local media stream to publish.");await this._publishTracks()}async _renegotiate(){this.peerConnection&&(this._renegotiateTimeout&&clearTimeout(this._renegotiateTimeout),this._renegotiateTimeout=setTimeout((async()=>{try{this._log("Starting renegotiation process...");const e=await this.peerConnection.createAnswer();this._log("Created renegotiation answer:",e.sdp),await this.peerConnection.setLocalDescription(e);const t=`${this.backendUrl}/api/rooms/${this.roomId}/sessions/${this.sessionId}/renegotiate`,a={sdp:e.sdp,type:e.type};this._log(`Sending renegotiate request to ${t} with body:`,a);const i=await this._fetch(t,{method:"PUT",headers:{"Content-Type":"application/json"},body:JSON.stringify(a)}).then((e=>e.json()));if(i.errorCode)return void this._warn("Renegotiation failed:",i.errorDescription);await this.peerConnection.setRemoteDescription(i.sessionDescription),this._log("Renegotiation successful. Applied SFU response.")}catch(e){this._error("Error during renegotiation:",e)}}),500))}async updatePublishedTracks(){if(!this.peerConnection)return this._warn("PeerConnection is not established.");const e=this.peerConnection.getSenders();for(const t of e)this.peerConnection.removeTrack(t);await this._publishTracks()}async _publishTracks(){if(!this.localStream||!this.peerConnection)return;const e=[];for(const t of this.localStream.getTracks()){if(this.publishedTracks.has(t.id))continue;if("live"!==t.readyState)continue;const a=this.peerConnection.addTransceiver(t,{direction:"sendonly"});if(this.pendingQualitySettings&&"video"===t.kind){const e=a.sender.getParameters();e.encodings=[{maxBitrate:this.pendingQualitySettings.video.maxBitrate}],a.sender.setParameters(e)}e.push(a),this.publishedTracks.add(t.id)}if(0===e.length)return;const t=await this.peerConnection.createOffer();this._log("SDP Offer:",t.sdp),await this.peerConnection.setLocalDescription(t);const a=e.map((({sender:e,mid:t})=>({location:"local",mid:t,trackName:e.track.id}))),i={offer:{sdp:t.sdp,type:t.type},tracks:a,metadata:this.userMetadata},s=`${this.backendUrl}/api/rooms/${this.roomId}/sessions/${this.sessionId}/publish`,o=await this._fetch(s,{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify(i)}).then((e=>e.json()));if(o.errorCode)return void this._error("Publish error:",o.errorDescription);const n=o.sessionDescription;await this.peerConnection.setRemoteDescription(n),this._log("Publish => success. Applied SFU answer.")}async _pullTracks(e,t){this._log(`Pulling track '${t}' from session ${e}`);const a=`${this.backendUrl}/api/rooms/${this.roomId}/sessions/${this.sessionId}/pull`,i={remoteSessionId:e,trackName:t},s=await this._fetch(a,{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify(i)}).then((e=>e.json()));if(s.errorCode)this._error("Pull error:",s.errorDescription);else{if(s.requiresImmediateRenegotiation){this._log("Pull => requires renegotiation");const a=new Set;s.sessionDescription.sdp.split("\n").forEach((i=>{if(i.startsWith("a=mid:")){const s=i.split(":")[1].trim();a.add(s),this.midToSessionId.set(s,e),this.midToTrackName.set(s,t),this._log("Pre-mapped MID:",{mid:s,sessionId:e,trackName:t})}})),await this.peerConnection.setRemoteDescription(s.sessionDescription);const i=await this.peerConnection.createAnswer();await this.peerConnection.setLocalDescription(i);this.peerConnection.getTransceivers().forEach((t=>{t.mid&&a.has(t.mid)&&this._log("Verified MID mapping:",{mid:t.mid,sessionId:e,direction:t.direction})})),await this._fetch(`${this.backendUrl}/api/rooms/${this.roomId}/sessions/${this.sessionId}/renegotiate`,{method:"PUT",headers:{"Content-Type":"application/json"},body:JSON.stringify({sdp:i.sdp,type:i.type})})}this._log(`Pulled trackName="${t}" from session ${e}`),this._log("Current MID mappings:",Array.from(this.midToSessionId.entries())),this.pulledTracks.has(e)||this.pulledTracks.set(e,new Set),this.pulledTracks.get(e).add(t)}}async _attemptIceServersUpdate(){let e=[{urls:"stun:stun.cloudflare.com:3478"}];try{const t=await this._fetch(`${this.backendUrl}/api/ice-servers`);if(!t.ok)return this._warn(`Failed to fetch ICE servers: ${t.status} ${t.statusText}`),!1;const a=await t.json();if(!a.iceServers||!Array.isArray(a.iceServers))return e;e=a.iceServers.map((e=>{const t={urls:e.urls};return e.username&&e.credential&&(t.username=e.username,t.credential=e.credential),t})),this._log("Fetched ICE servers:",e)}catch(e){return this._warn("Error fetching ICE servers:",e),!1}}async _createPeerConnection(){let e=await this._attemptIceServersUpdate()||[{urls:"stun:stun.cloudflare.com:3478"}];const t=new RTCPeerConnection({iceServers:e,bundlePolicy:"max-bundle",sdpSemantics:"unified-plan"});return t.onicecandidate=e=>{e.candidate?this._log("New ICE candidate:",e.candidate.candidate):this._log("All ICE candidates have been sent")},t.oniceconnectionstatechange=()=>{this._log("ICE Connection State:",t.iceConnectionState),"disconnected"!==t.iceConnectionState&&"failed"!==t.iceConnectionState||this.leaveRoom()},t.onconnectionstatechange=()=>{this._log("Connection State:",t.connectionState),"connected"===t.connectionState?this._log("Peer connection fully established"):"disconnected"!==t.connectionState&&"failed"!==t.connectionState||(this._log("Peer connection disconnected or failed"),this.leaveRoom())},t.ontrack=e=>{if(this._log("ontrack event:",{kind:e.track.kind,webrtcTrackId:e.track.id,mid:e.transceiver?.mid}),this._onRemoteTrackCallback){const t=e.transceiver?.mid,a=this.midToSessionId.get(t),i=this.midToTrackName.get(t);if(this._log("Track mapping lookup:",{mid:t,sessionId:a,trackName:i,webrtcTrackId:e.track.id,availableMappings:{sessions:Array.from(this.midToSessionId.entries()),tracks:Array.from(this.midToTrackName.entries())}}),!a)return this._warn("No sessionId found for mid:",t),this.pendingTracks||(this.pendingTracks=[]),void this.pendingTracks.push({evt:e,mid:t});const s=e.track;s.sessionId=a,s.mid=t,s.trackName=i,this._log("Sending track to callback:",{webrtcTrackId:s.id,trackName:s.trackName,sessionId:s.sessionId,mid:s.mid}),this._onRemoteTrackCallback(s)}},t}async _initWebSocket(){if(!this.ws||this.ws.readyState!==WebSocket.OPEN)return new Promise(((e,t)=>{this.ws=new WebSocket(this.websocketUrl),this.ws.onopen=()=>{this._log("WebSocket open"),this.ws.send(JSON.stringify({type:"join-websocket",payload:{roomId:this.roomId,userId:this.userId,token:this.token}})),e()},this.ws.onmessage=e=>{try{const t=JSON.parse(e.data);switch(this._log("WebSocket message received:",t),t.type){case"participant-joined":this._onParticipantJoinedCallback&&this._onParticipantJoinedCallback(t.payload);break;case"participant-left":this._onParticipantLeftCallback&&this._onParticipantLeftCallback(t.payload);break;case"track-published":this._onRemoteTrackCallback&&this._onRemoteTrackCallback(t.payload);break;case"track-unpublished":this._onRemoteTrackUnpublishedCallback&&this._onRemoteTrackUnpublishedCallback(t.payload.sessionId,t.payload.trackName);break;case"track-status-changed":this._onTrackStatusChangedCallback&&this._onTrackStatusChangedCallback(t.payload);break;case"data-message":this._onDataMessageCallback&&this._onDataMessageCallback(t.payload);break;case"room-metadata-updated":this._onRoomMetadataUpdatedCallback&&this._onRoomMetadataUpdatedCallback(t.payload);break;default:this._log("Unhandled message type:",t.type)}this._wsMessageHandlers.forEach((e=>e(t)))}catch(e){this._error("Error processing WebSocket message:",e)}},this.ws.onerror=e=>{this._error("WebSocket error:",e),t(e)},this.ws.onclose=()=>{this._log("WebSocket connection closed")}}))}_startPolling(){this.pollingInterval=setInterval((async()=>{if(this.roomId)try{const e=(await this._fetch(`${this.backendUrl}/api/rooms/${this.roomId}/participants`).then((e=>e.json()))).participants||[];for(const t of e){const{sessionId:e,publishedTracks:a}=t;if(e!==this.sessionId){this.pulledTracks.has(e)||this.pulledTracks.set(e,new Set);for(const t of a)this.pulledTracks.get(e).has(t)||(this._log(`[Polling] New track detected: ${t} from session ${e}`),await this._pullTracks(e,t))}}}catch(e){this._error("Polling error:",e)}}),1e4)}async getAvailableDevices(){const e=await navigator.mediaDevices.enumerateDevices();return this.availableAudioInputDevices=e.filter((e=>"audioinput"===e.kind)),this.availableVideoInputDevices=e.filter((e=>"videoinput"===e.kind)),this.availableAudioOutputDevices=e.filter((e=>"audiooutput"===e.kind)),{audioInput:this.availableAudioInputDevices,videoInput:this.availableVideoInputDevices,audioOutput:this.availableAudioOutputDevices}}async selectAudioInputDevice(e){if(!e)return void this._warn("No deviceId provided for audio input.");const t={audio:{deviceId:{exact:e}},video:!1};try{const a=(await navigator.mediaDevices.getUserMedia(t)).getAudioTracks()[0],i=this.peerConnection.getSenders().find((e=>"audio"===e.track.kind));if(i){i.replaceTrack(a);i.track.stop()}else this.localStream.addTrack(a),await this._publishTracks();this._log(`Switched to audio input device: ${e}`)}catch(e){this._error("Error switching audio input device:",e)}}async selectVideoInputDevice(e){if(!e)return void this._warn("No deviceId provided for video input.");const t={video:{deviceId:{exact:e}},audio:!1};try{const a=(await navigator.mediaDevices.getUserMedia(t)).getVideoTracks()[0],i=this.peerConnection.getSenders().find((e=>"video"===e.track.kind));if(i){i.replaceTrack(a);i.track.stop()}else this.localStream.addTrack(a),await this._publishTracks();this._log(`Switched to video input device: ${e}`)}catch(e){this._error("Error switching video input device:",e)}}async selectAudioOutputDevice(e){if(e)try{const t=document.querySelectorAll("audio");for(const a of t)await a.setSinkId(e);this.currentAudioOutputDeviceId=e,this._log(`Switched to audio output device: ${e}`)}catch(e){this._error("Error switching audio output device:",e)}else this._warn("No deviceId provided for audio output.")}async previewMedia({audioDeviceId:e,videoDeviceId:t},a=null){const i={audio:!!e&&{deviceId:{exact:e}},video:!!t&&{deviceId:{exact:t}}};try{const e=await navigator.mediaDevices.getUserMedia(i);return a&&(a.srcObject=e),e}catch(e){throw this._error("Error previewing media:",e),e}}toggleMedia({video:e=null,audio:t=null}){if(this.localStream){if(null!==e){this.localStream.getVideoTracks().forEach((t=>{t.enabled=e;const a=this.peerConnection?.getSenders().find((e=>e.track===t));a&&this._updateTrackStatus(a.track.id,"video",e)}))}if(null!==t){this.localStream.getAudioTracks().forEach((e=>{e.enabled=t;const a=this.peerConnection?.getSenders().find((t=>t.track===e));a&&this._updateTrackStatus(a.track.id,"audio",t)}))}}}async shareScreen(){try{await this.unpublishAllTracks("video");const e=(await navigator.mediaDevices.getDisplayMedia({video:!0,audio:!1})).getVideoTracks()[0];this.localStream.addTrack(e),await this._publishTracks(),e.onended=async()=>{await this.unpublishAllTracks(),await this._cleanupEndedTracks(),this.localStream=await navigator.mediaDevices.getUserMedia({video:!0,audio:!0}),this._log("Re-acquired local media"),await this._publishTracks()}}catch(e){throw this._error("Error sharing screen:",e),e}}_sendWebSocketMessage(e){this.ws&&this.ws.readyState===WebSocket.OPEN?(this.ws.send(JSON.stringify(e)),this._log("Sent WebSocket message:",e)):this._warn("WebSocket is not open. Cannot send message.")}async listParticipants(){if(!this.roomId)return this._warn("Not connected to any room.");return(await this._fetch(`${this.backendUrl}/api/rooms/${this.roomId}/participants`).then((e=>e.json()))).participants||[]}_generateUUID(){return"xxxx-xxxx-xxxx-xxxx".replace(/[x]/g,(()=>(16*Math.random()|0).toString(16)))}async unpublishAllTracks(e,t=!1){if(!this.peerConnection)return void this._warn("PeerConnection is not established.");let a=this.peerConnection.getSenders();e&&(a=a.filter((t=>t.track&&t.track.kind===e))),this._log("Unpublishing all tracks:",a.length);const i=await this.peerConnection.createOffer();await this.peerConnection.setLocalDescription(i);for(const e of a)if(e.track)try{const a=e.track.id,s=this.peerConnection.getTransceivers().find((t=>t.sender===e)),o=s?s.mid:null;if(this._log("Unpublishing track:",{trackId:a,mid:o}),!o){this._warn("No mid found for track:",a);continue}e.track.stop(),await this._fetch(`${this.backendUrl}/api/rooms/${this.roomId}/sessions/${this.sessionId}/unpublish`,{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify({trackName:a,mid:o,force:t,sessionDescription:{type:i.type,sdp:i.sdp}})}),this.peerConnection.removeTrack(e),this.publishedTracks.delete(a),await this._cleanupEndedTracks(),this._log(`Successfully unpublished track: ${a}`)}catch(e){this._error("Error unpublishing track:",e)}}async getSessionState(){if(!this.sessionId)return this._warn("No active session");try{const e=await this._fetch(`${this.backendUrl}/api/rooms/${this.roomId}/sessions/${this.sessionId}/state`),t=await e.json();return t.tracks&&(this.trackStates=new Map(t.tracks.map((e=>[e.trackName,e.status])))),t}catch(e){throw this._error("Error getting session state:",e),e}}async getTrackStatus(e){const t=await this.getSessionState();return t.tracks.find((t=>t.trackName===e))?.status}async _updateTrackStatus(e,t,a){try{const i=`${this.backendUrl}/api/rooms/${this.roomId}/sessions/${this.sessionId}/track-status`,s=await this._fetch(i,{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify({trackId:e,kind:t,enabled:a,force:!1})}),o=await s.json();if(o.errorCode)throw new Error(o.errorDescription||"Unknown error updating track status");return o.requiresImmediateRenegotiation&&await this._renegotiate(),o.errorCode||this._updateTrackState(e,a?"enabled":"disabled"),o}catch(e){throw this._error("Error updating track status:",e),e}}_handleError(e){if(e.errorCode){const t=new Error(e.errorDescription||"Unknown error");throw t.code=e.errorCode,t}return e}async getUserInfo(e=null){try{const t=await this._fetch(`${this.backendUrl}/api/users/${e||"me"}`);return await t.json()}catch(e){throw this._error("Error getting user info:",e),e}}_handleWebSocketMessage(e){try{const t=JSON.parse(e.data);switch(this._log("WebSocket message received:",t),this._wsMessageHandlers.forEach((e=>{try{e(t)}catch(e){this._error("Error in WebSocket message handler:",e)}})),t.type){case"participant-joined":this._onParticipantJoinedCallback&&this._onParticipantJoinedCallback(t.payload);break;case"participant-left":this._onParticipantLeftCallback&&this._onParticipantLeftCallback(t.payload.sessionId);break;case"track-published":this._onRemoteTrackCallback&&this._onRemoteTrackCallback(t.payload);break;case"track-unpublished":this._onRemoteTrackUnpublishedCallback&&this._onRemoteTrackUnpublishedCallback(t.payload.sessionId,t.payload.trackName);break;case"track-status-changed":this._onTrackStatusChangedCallback&&this._onTrackStatusChangedCallback(t.payload);break;case"data-message":this._onDataMessageCallback&&this._onDataMessageCallback(t.payload);break;case"room-metadata-updated":this._onRoomMetadataUpdatedCallback&&this._onRoomMetadataUpdatedCallback(t.payload);break;default:this._log("Unhandled message type:",t.type)}}catch(e){this._error("Error handling WebSocket message:",e)}}_updateTrackState(e,t){this.trackStates||(this.trackStates=new Map),this.trackStates.set(e,t)}async listRooms(){return(await this._fetch(`${this.backendUrl}/api/rooms`).then((e=>e.json()))).rooms}async updateRoomMetadata(e){return this.roomId?await this._fetch(`${this.backendUrl}/api/rooms/${this.roomId}/metadata`,{method:"PUT",headers:{"Content-Type":"application/json"},body:JSON.stringify(e)}).then((e=>e.json())):this._warn("Not connected to any room")}async sendDataToAll(e){if(!this.roomId||!this.sessionId)throw new Error("Must be in a room to send data");if(!this.ws||this.ws.readyState!==WebSocket.OPEN)throw new Error("WebSocket connection not available");this.ws.send(JSON.stringify({type:"data-message",payload:{from:this.sessionId,message:e}}))}setMediaQuality(t){if("string"==typeof t){const a=e.QUALITY_PRESETS[t];if(!a)return this._warn(`Unknown quality preset: ${t}`);this.mediaQuality=t,t=a}this.mediaQuality={video:{...this.mediaQuality.video,...t.video},audio:{...this.mediaQuality.audio,...t.audio}},this.pendingQualitySettings=this.mediaQuality,this.peerConnection&&this._applyQualitySettings()}async _applyQualitySettings(){if(!this.peerConnection)return;const e=this.peerConnection.getSenders();for(const t of e){if(!t.track)continue;const e=t.getParameters();e.encodings||(e.encodings=[{}]);const a=t.track.kind,i=this.mediaQuality[a];if(i.maxBitrate&&(e.encodings[0].maxBitrate=i.maxBitrate),"video"===a){const e={width:i.width,height:i.height,frameRate:i.frameRate};await t.track.applyConstraints(e)}await t.setParameters(e)}}startStatsMonitoring(e=1e3){"monitoring"!==this.statsMonitoringState&&(this.statsMonitoringState="monitoring",this.statsInterval=setInterval((async()=>{if(!this.peerConnection)return;const e=await this._gatherConnectionStats(),t=await this._gatherStreamStats();this._onConnectionStatsCallback&&this._onConnectionStatsCallback(e,t)}),e))}stopStatsMonitoring(){this.statsInterval&&(clearInterval(this.statsInterval),this.statsInterval=null),this.statsMonitoringState="stopped"}onConnectionStats(e){this._onConnectionStatsCallback=e}async _gatherConnectionStats(){if(!this.peerConnection)return this._warn("No active connection");const e=await this.peerConnection.getStats(),t={outbound:{bitrate:0,packetLoss:0,qualityLimitation:"none"},inbound:{bitrate:0,packetLoss:0,jitter:0},connection:{roundTripTime:0,state:this.peerConnection.connectionState}};let a=null,i=null;if(e.forEach((e=>{switch(e.type){case"outbound-rtp":"video"===e.kind&&(a=e,t.outbound.qualityLimitation=e.qualityLimitationReason);break;case"inbound-rtp":"video"===e.kind&&(i=e,t.inbound.jitter=e.jitter,e.packetsLost>0&&(t.inbound.packetLoss=e.packetsLost/(e.packetsReceived+e.packetsLost)*100));break;case"candidate-pair":"succeeded"===e.state&&(t.connection.roundTripTime=e.currentRoundTripTime)}})),this.previousStats&&a&&i){const e=(a.timestamp-this.previousStats.outboundTimestamp)/1e3;if(e>0){const s=a.bytesSent-this.previousStats.bytesSent;t.outbound.bitrate=8*s/e;const o=i.bytesReceived-this.previousStats.bytesReceived;t.inbound.bitrate=8*o/e}}return a&&i&&(this.previousStats={outboundTimestamp:a.timestamp,bytesSent:a.bytesSent,bytesReceived:i.bytesReceived}),t}async getConnectionStats(){return this._gatherConnectionStats()}async _gatherStreamStats(){if(!this.peerConnection)return new Map;const e=await this.peerConnection.getStats(),t=new Map;return this.sessionId&&t.set(this.sessionId,{sessionId:this.sessionId,packetLoss:0,qualityLimitation:"none",bitrate:0}),e.forEach((e=>{if("outbound-rtp"===e.type&&"video"===e.kind){const a=t.get(this.sessionId);a&&(a.qualityLimitation=e.qualityLimitationReason,a.bitrate=8*e.bytesSent/e.timestamp)}else if("inbound-rtp"===e.type&&"video"===e.kind){const a=e.mid,i=this.midToSessionId.get(a);i&&t.set(i,{sessionId:i,packetLoss:e.packetsLost>0?e.packetsLost/(e.packetsReceived+e.packetsLost)*100:0,qualityLimitation:"none",bitrate:8*e.bytesReceived/e.timestamp})}})),t}static QUALITY_PRESETS={high_16x9_xl:{video:{width:{ideal:1920},height:{ideal:1080},frameRate:{ideal:30},maxBitrate:25e5},audio:{maxBitrate:128e3,sampleRate:48e3,channelCount:2}},high_16x9_lg:{video:{width:{ideal:1280},height:{ideal:720},frameRate:{ideal:30},maxBitrate:15e5},audio:{maxBitrate:96e3,sampleRate:48e3,channelCount:2}},high_16x9_md:{video:{width:{ideal:854},height:{ideal:480},frameRate:{ideal:30},maxBitrate:8e5},audio:{maxBitrate:96e3,sampleRate:48e3,channelCount:1}},high_16x9_sm:{video:{width:{ideal:640},height:{ideal:360},frameRate:{ideal:30},maxBitrate:6e5},audio:{maxBitrate:64e3,sampleRate:44100,channelCount:1}},high_16x9_xs:{video:{width:{ideal:480},height:{ideal:270},frameRate:{ideal:30},maxBitrate:4e5},audio:{maxBitrate:64e3,sampleRate:44100,channelCount:1}},medium_16x9_xl:{video:{width:{ideal:1920},height:{ideal:1080},frameRate:{ideal:24},maxBitrate:2e6},audio:{maxBitrate:96e3,sampleRate:48e3,channelCount:2}},medium_16x9_lg:{video:{width:{ideal:1280},height:{ideal:720},frameRate:{ideal:24},maxBitrate:12e5},audio:{maxBitrate:96e3,sampleRate:48e3,channelCount:1}},medium_16x9_md:{video:{width:{ideal:854},height:{ideal:480},frameRate:{ideal:24},maxBitrate:6e5},audio:{maxBitrate:64e3,sampleRate:44100,channelCount:1}},medium_16x9_sm:{video:{width:{ideal:640},height:{ideal:360},frameRate:{ideal:20},maxBitrate:4e5},audio:{maxBitrate:48e3,sampleRate:44100,channelCount:1}},medium_16x9_xs:{video:{width:{ideal:480},height:{ideal:270},frameRate:{ideal:20},maxBitrate:3e5},audio:{maxBitrate:48e3,sampleRate:44100,channelCount:1}},low_16x9_xl:{video:{width:{ideal:1920},height:{ideal:1080},frameRate:{ideal:15},maxBitrate:15e5},audio:{maxBitrate:64e3,sampleRate:44100,channelCount:1}},low_16x9_lg:{video:{width:{ideal:1280},height:{ideal:720},frameRate:{ideal:15},maxBitrate:8e5},audio:{maxBitrate:48e3,sampleRate:44100,channelCount:1}},low_16x9_md:{video:{width:{ideal:854},height:{ideal:480},frameRate:{ideal:15},maxBitrate:4e5},audio:{maxBitrate:32e3,sampleRate:44100,channelCount:1}},low_16x9_sm:{video:{width:{ideal:640},height:{ideal:360},frameRate:{ideal:12},maxBitrate:25e4},audio:{maxBitrate:32e3,sampleRate:22050,channelCount:1}},low_16x9_xs:{video:{width:{ideal:480},height:{ideal:270},frameRate:{ideal:10},maxBitrate:15e4},audio:{maxBitrate:24e3,sampleRate:22050,channelCount:1}},high_4x3_xl:{video:{width:{ideal:960},height:{ideal:720},frameRate:{ideal:30},maxBitrate:15e5},audio:{maxBitrate:128e3,sampleRate:48e3,channelCount:2}},high_4x3_lg:{video:{width:{ideal:640},height:{ideal:480},frameRate:{ideal:30},maxBitrate:8e5},audio:{maxBitrate:96e3,sampleRate:48e3,channelCount:1}},high_4x3_md:{video:{width:{ideal:480},height:{ideal:360},frameRate:{ideal:30},maxBitrate:6e5},audio:{maxBitrate:96e3,sampleRate:44100,channelCount:1}},high_4x3_sm:{video:{width:{ideal:320},height:{ideal:240},frameRate:{ideal:30},maxBitrate:4e5},audio:{maxBitrate:64e3,sampleRate:44100,channelCount:1}},high_4x3_xs:{video:{width:{ideal:240},height:{ideal:180},frameRate:{ideal:30},maxBitrate:25e4},audio:{maxBitrate:64e3,sampleRate:44100,channelCount:1}},medium_4x3_xl:{video:{width:{ideal:960},height:{ideal:720},frameRate:{ideal:24},maxBitrate:12e5},audio:{maxBitrate:96e3,sampleRate:48e3,channelCount:1}},medium_4x3_lg:{video:{width:{ideal:640},height:{ideal:480},frameRate:{ideal:24},maxBitrate:6e5},audio:{maxBitrate:64e3,sampleRate:44100,channelCount:1}},medium_4x3_md:{video:{width:{ideal:480},height:{ideal:360},frameRate:{ideal:20},maxBitrate:4e5},audio:{maxBitrate:48e3,sampleRate:44100,channelCount:1}},medium_4x3_sm:{video:{width:{ideal:320},height:{ideal:240},frameRate:{ideal:20},maxBitrate:3e5},audio:{maxBitrate:48e3,sampleRate:44100,channelCount:1}},medium_4x3_xs:{video:{width:{ideal:240},height:{ideal:180},frameRate:{ideal:20},maxBitrate:2e5},audio:{maxBitrate:48e3,sampleRate:44100,channelCount:1}},low_4x3_xl:{video:{width:{ideal:960},height:{ideal:720},frameRate:{ideal:15},maxBitrate:8e5},audio:{maxBitrate:48e3,sampleRate:44100,channelCount:1}},low_4x3_lg:{video:{width:{ideal:640},height:{ideal:480},frameRate:{ideal:15},maxBitrate:4e5},audio:{maxBitrate:32e3,sampleRate:44100,channelCount:1}},low_4x3_md:{video:{width:{ideal:480},height:{ideal:360},frameRate:{ideal:12},maxBitrate:25e4},audio:{maxBitrate:32e3,sampleRate:22050,channelCount:1}},low_4x3_sm:{video:{width:{ideal:320},height:{ideal:240},frameRate:{ideal:10},maxBitrate:15e4},audio:{maxBitrate:24e3,sampleRate:22050,channelCount:1}},low_4x3_xs:{video:{width:{ideal:240},height:{ideal:180},frameRate:{ideal:10},maxBitrate:1e5},audio:{maxBitrate:24e3,sampleRate:22050,channelCount:1}},high_1x1_xl:{video:{width:{ideal:720},height:{ideal:720},frameRate:{ideal:30},maxBitrate:15e5},audio:{maxBitrate:128e3,sampleRate:48e3,channelCount:2}},high_1x1_lg:{video:{width:{ideal:480},height:{ideal:480},frameRate:{ideal:30},maxBitrate:8e5},audio:{maxBitrate:96e3,sampleRate:48e3,channelCount:1}},high_1x1_md:{video:{width:{ideal:360},height:{ideal:360},frameRate:{ideal:30},maxBitrate:6e5},audio:{maxBitrate:96e3,sampleRate:44100,channelCount:1}},high_1x1_sm:{video:{width:{ideal:240},height:{ideal:240},frameRate:{ideal:30},maxBitrate:4e5},audio:{maxBitrate:64e3,sampleRate:44100,channelCount:1}},high_1x1_xs:{video:{width:{ideal:180},height:{ideal:180},frameRate:{ideal:30},maxBitrate:25e4},audio:{maxBitrate:64e3,sampleRate:44100,channelCount:1}},medium_1x1_xl:{video:{width:{ideal:720},height:{ideal:720},frameRate:{ideal:24},maxBitrate:12e5},audio:{maxBitrate:96e3,sampleRate:48e3,channelCount:1}},medium_1x1_lg:{video:{width:{ideal:480},height:{ideal:480},frameRate:{ideal:24},maxBitrate:6e5},audio:{maxBitrate:64e3,sampleRate:44100,channelCount:1}},medium_1x1_md:{video:{width:{ideal:360},height:{ideal:360},frameRate:{ideal:20},maxBitrate:4e5},audio:{maxBitrate:48e3,sampleRate:44100,channelCount:1}},medium_1x1_sm:{video:{width:{ideal:240},height:{ideal:240},frameRate:{ideal:20},maxBitrate:3e5},audio:{maxBitrate:48e3,sampleRate:44100,channelCount:1}},medium_1x1_xs:{video:{width:{ideal:180},height:{ideal:180},frameRate:{ideal:20},maxBitrate:2e5},audio:{maxBitrate:48e3,sampleRate:44100,channelCount:1}},low_1x1_xl:{video:{width:{ideal:720},height:{ideal:720},frameRate:{ideal:15},maxBitrate:8e5},audio:{maxBitrate:48e3,sampleRate:44100,channelCount:1}},low_1x1_lg:{video:{width:{ideal:480},height:{ideal:480},frameRate:{ideal:15},maxBitrate:4e5},audio:{maxBitrate:32e3,sampleRate:44100,channelCount:1}},low_1x1_md:{video:{width:{ideal:360},height:{ideal:360},frameRate:{ideal:12},maxBitrate:25e4},audio:{maxBitrate:32e3,sampleRate:22050,channelCount:1}},low_1x1_sm:{video:{width:{ideal:240},height:{ideal:240},frameRate:{ideal:10},maxBitrate:15e4},audio:{maxBitrate:24e3,sampleRate:22050,channelCount:1}},low_1x1_xs:{video:{width:{ideal:180},height:{ideal:180},frameRate:{ideal:10},maxBitrate:1e5},audio:{maxBitrate:24e3,sampleRate:22050,channelCount:1}},high_9x16_xl:{video:{width:{ideal:1080},height:{ideal:1920},frameRate:{ideal:30},maxBitrate:25e5},audio:{maxBitrate:128e3,sampleRate:48e3,channelCount:2}},high_9x16_lg:{video:{width:{ideal:720},height:{ideal:1280},frameRate:{ideal:30},maxBitrate:15e5},audio:{maxBitrate:96e3,sampleRate:48e3,channelCount:1}},high_9x16_md:{video:{width:{ideal:480},height:{ideal:854},frameRate:{ideal:30},maxBitrate:8e5},audio:{maxBitrate:96e3,sampleRate:44100,channelCount:1}},high_9x16_sm:{video:{width:{ideal:360},height:{ideal:640},frameRate:{ideal:30},maxBitrate:6e5},audio:{maxBitrate:64e3,sampleRate:44100,channelCount:1}},high_9x16_xs:{video:{width:{ideal:270},height:{ideal:480},frameRate:{ideal:30},maxBitrate:4e5},audio:{maxBitrate:64e3,sampleRate:44100,channelCount:1}},medium_9x16_xl:{video:{width:{ideal:1080},height:{ideal:1920},frameRate:{ideal:24},maxBitrate:2e6},audio:{maxBitrate:96e3,sampleRate:48e3,channelCount:1}},medium_9x16_lg:{video:{width:{ideal:720},height:{ideal:1280},frameRate:{ideal:24},maxBitrate:12e5},audio:{maxBitrate:64e3,sampleRate:44100,channelCount:1}},medium_9x16_md:{video:{width:{ideal:480},height:{ideal:854},frameRate:{ideal:20},maxBitrate:6e5},audio:{maxBitrate:48e3,sampleRate:44100,channelCount:1}},medium_9x16_sm:{video:{width:{ideal:360},height:{ideal:640},frameRate:{ideal:20},maxBitrate:4e5},audio:{maxBitrate:48e3,sampleRate:44100,channelCount:1}},medium_9x16_xs:{video:{width:{ideal:270},height:{ideal:480},frameRate:{ideal:20},maxBitrate:3e5},audio:{maxBitrate:48e3,sampleRate:44100,channelCount:1}},low_9x16_xl:{video:{width:{ideal:1080},height:{ideal:1920},frameRate:{ideal:15},maxBitrate:15e5},audio:{maxBitrate:48e3,sampleRate:44100,channelCount:1}},low_9x16_lg:{video:{width:{ideal:720},height:{ideal:1280},frameRate:{ideal:15},maxBitrate:8e5},audio:{maxBitrate:32e3,sampleRate:44100,channelCount:1}},low_9x16_md:{video:{width:{ideal:480},height:{ideal:854},frameRate:{ideal:12},maxBitrate:4e5},audio:{maxBitrate:32e3,sampleRate:22050,channelCount:1}},low_9x16_sm:{video:{width:{ideal:360},height:{ideal:640},frameRate:{ideal:10},maxBitrate:25e4},audio:{maxBitrate:24e3,sampleRate:22050,channelCount:1}},low_9x16_xs:{video:{width:{ideal:270},height:{ideal:480},frameRate:{ideal:10},maxBitrate:15e4},audio:{maxBitrate:24e3,sampleRate:22050,channelCount:1}}}}return e}));
public/temp/favicon.ico ADDED
public/temp/index.html ADDED
@@ -0,0 +1,1066 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ <!DOCTYPE html>
2
+ <html lang="en">
3
+ <head>
4
+ <meta charset="UTF-8">
5
+ <meta http-equiv="Content-Type" content="text/html; charset=utf-8" />
6
+ <meta name="viewport" content="width=device-width,initial-scale=1,maximum-scale=3,minimum-scale=1,user-scalable=yes,minimal-ui,viewport-fit=cover">
7
+ <meta name="apple-mobile-web-app-status-bar-style" content="black-translucent">
8
+ <meta name="apple-mobile-web-app-capable" content="yes" />
9
+ <meta http-equiv="X-UA-Compatible" content="IE=edge">
10
+ <meta name="mobile-web-app-capable" content="yes">
11
+ <meta name="theme-color" content="#000000">
12
+
13
+ <title>Cloudflare Calls SFU Demo</title>
14
+
15
+ <style>
16
+ body {
17
+ font-family: Arial, sans-serif;
18
+ }
19
+ #videos {
20
+ display: flex;
21
+ flex-wrap: wrap;
22
+ }
23
+ #videos audio {
24
+ /* Comment out to show audio and see how things work or debug */
25
+ display: none;
26
+ }
27
+ video, audio {
28
+ width: 300px;
29
+ height: 225px;
30
+ background-color: black;
31
+ /* margin: 5px; */
32
+ border: 1px solid #ccc;
33
+ object-fit: scale-down; /* Scale down for the demo, you would want the default behavior */
34
+ }
35
+ .participant-container {
36
+ width: 300px;
37
+ height: 225px;
38
+ position: relative;
39
+ margin: 5px;
40
+ }
41
+ #controls {
42
+ margin-top: 10px;
43
+ }
44
+ #controls button, #controls select {
45
+ margin-right: 5px;
46
+ padding: 10px 15px;
47
+ font-size: 14px;
48
+ margin-bottom: 15px;
49
+ }
50
+ #dataChannelMessages {
51
+ margin-top: 20px;
52
+ max-height: 200px;
53
+ overflow-y: auto;
54
+ border: 1px solid #ccc;
55
+ padding: 10px;
56
+ }
57
+ #participants {
58
+ margin-top: 20px;
59
+ padding: 10px;
60
+ border: 1px solid #ccc;
61
+ }
62
+ .participant {
63
+ padding: 8px;
64
+ margin: 4px 0;
65
+ display: flex;
66
+ justify-content: space-between;
67
+ align-items: center;
68
+ background: #f5f5f5;
69
+ }
70
+ .participant button {
71
+ padding: 4px 8px;
72
+ background: #ff4444;
73
+ color: white;
74
+ border: none;
75
+ border-radius: 4px;
76
+ cursor: pointer;
77
+ }
78
+ .participant-container {
79
+ position: relative;
80
+ margin: 5px;
81
+ }
82
+
83
+ .participant-name {
84
+ position: absolute;
85
+ bottom: 10px;
86
+ right: 10px;
87
+ background: rgba(0, 0, 0, 0.7);
88
+ color: white;
89
+ padding: 4px 8px;
90
+ border-radius: 4px;
91
+ font-size: 14px;
92
+ z-index: 1;
93
+ }
94
+
95
+ /* Update video styles */
96
+ video {
97
+ width: 300px;
98
+ height: 225px;
99
+ background-color: black;
100
+ border: 1px solid #ccc;
101
+ display: block; /* Ensures proper positioning of overlay */
102
+ }
103
+
104
+ /* Rooms box */
105
+ #rooms {
106
+ margin-top: 20px;
107
+ padding: 10px;
108
+ border: 1px solid #ccc;
109
+ }
110
+
111
+ .room-item {
112
+ padding: 8px;
113
+ margin: 4px 0;
114
+ display: flex;
115
+ justify-content: space-between;
116
+ align-items: center;
117
+ background: #f5f5f5;
118
+ }
119
+
120
+ .room-info {
121
+ flex-grow: 1;
122
+ margin-right: 10px;
123
+ }
124
+
125
+ .room-name {
126
+ font-weight: bold;
127
+ }
128
+
129
+ .room-metadata {
130
+ font-size: 0.9em;
131
+ color: #666;
132
+ }
133
+
134
+ .room-actions {
135
+ display: flex;
136
+ gap: 5px;
137
+ }
138
+
139
+ /* Add CSS for health indicators */
140
+ .stream-health-indicator {
141
+ position: absolute;
142
+ top: 10px;
143
+ right: 10px;
144
+ width: 12px;
145
+ height: 12px;
146
+ border-radius: 50%;
147
+ background-color: #666;
148
+ z-index: 2;
149
+ }
150
+
151
+ .stream-health-good {
152
+ background-color: #4CAF50;
153
+ }
154
+
155
+ .stream-health-fair {
156
+ background-color: #FFA726;
157
+ }
158
+
159
+ .stream-health-poor {
160
+ background-color: #F44336;
161
+ }
162
+ </style>
163
+ </head>
164
+ <body>
165
+ <h1>
166
+ Cloudflare Calls SFU Demo
167
+ (<a href="/docs/">Docs</a> | <a href="https://github.com/kidGodzilla/CloudflareCalls">GitHub</a>)
168
+ </h1>
169
+
170
+ <div id="videos">
171
+ <!-- Local preview container -->
172
+ <div class="participant-container" data-local="true">
173
+ <video id="localVideo" autoplay muted playsinline></video>
174
+ <div class="stream-health-indicator"></div>
175
+ <div class="participant-name"></div>
176
+ </div>
177
+ </div>
178
+
179
+ <div id="controls">
180
+ <button id="acquireToken">Acquire Token</button>
181
+ <button id="createRoom">Create Room</button>
182
+ <button id="joinRoom">Join Room</button>
183
+ <button id="leaveRoom">Leave Room</button>
184
+ <button id="toggleVideo">Disable Video</button>
185
+ <button id="toggleAudio">Disable Audio</button>
186
+ <button id="shareScreen">Share Screen</button>
187
+ <button id="unpublishAll">Unpublish All Tracks</button>
188
+ <button id="getSessionState">Get Session State</button>
189
+ <button id="forceUnpublish">Force Unpublish Video</button>
190
+
191
+ <select id="audioInputSelect"></select>
192
+ <select id="videoInputSelect"></select>
193
+ <select id="audioOutputSelect"></select>
194
+
195
+ <select onchange="setQuality(this.value)" style="padding: 8px; margin: 5px;">
196
+ <optgroup label="16:9 High Quality">
197
+ <option value="high_16x9_xl">16:9 High XL (1080p/30fps)</option>
198
+ <option value="high_16x9_lg">16:9 High LG (720p/30fps)</option>
199
+ <option value="high_16x9_md">16:9 High MD (480p/30fps)</option>
200
+ <option value="high_16x9_sm">16:9 High SM (360p/30fps)</option>
201
+ <option value="high_16x9_xs">16:9 High XS (270p/30fps)</option>
202
+ </optgroup>
203
+ <optgroup label="16:9 Medium Quality">
204
+ <option value="medium_16x9_xl">16:9 Medium XL (1080p/24fps)</option>
205
+ <option value="medium_16x9_lg">16:9 Medium LG (720p/24fps)</option>
206
+ <option value="medium_16x9_md">16:9 Medium MD (480p/24fps)</option>
207
+ <option value="medium_16x9_sm">16:9 Medium SM (360p/20fps)</option>
208
+ <option value="medium_16x9_xs">16:9 Medium XS (270p/20fps)</option>
209
+ </optgroup>
210
+ <optgroup label="16:9 Low Quality">
211
+ <option value="low_16x9_xl">16:9 Low XL (1080p/15fps)</option>
212
+ <option value="low_16x9_lg">16:9 Low LG (720p/15fps)</option>
213
+ <option value="low_16x9_md">16:9 Low MD (480p/15fps)</option>
214
+ <option value="low_16x9_sm">16:9 Low SM (360p/12fps)</option>
215
+ <option value="low_16x9_xs">16:9 Low XS (270p/10fps)</option>
216
+ </optgroup>
217
+
218
+ <optgroup label="9:16 High Quality (Portrait)">
219
+ <option value="high_9x16_xl">9:16 High XL (1080x1920/30fps)</option>
220
+ <option value="high_9x16_lg">9:16 High LG (720x1280/30fps)</option>
221
+ <option value="high_9x16_md">9:16 High MD (480x854/30fps)</option>
222
+ <option value="high_9x16_sm">9:16 High SM (360x640/30fps)</option>
223
+ <option value="high_9x16_xs">9:16 High XS (270x480/30fps)</option>
224
+ </optgroup>
225
+ <optgroup label="9:16 Medium Quality (Portrait)">
226
+ <option value="medium_9x16_xl">9:16 Medium XL (1080x1920/24fps)</option>
227
+ <option value="medium_9x16_lg">9:16 Medium LG (720x1280/24fps)</option>
228
+ <option value="medium_9x16_md">9:16 Medium MD (480x854/20fps)</option>
229
+ <option value="medium_9x16_sm">9:16 Medium SM (360x640/20fps)</option>
230
+ <option value="medium_9x16_xs">9:16 Medium XS (270x480/20fps)</option>
231
+ </optgroup>
232
+ <optgroup label="9:16 Low Quality (Portrait)">
233
+ <option value="low_9x16_xl">9:16 Low XL (1080x1920/15fps)</option>
234
+ <option value="low_9x16_lg">9:16 Low LG (720x1280/15fps)</option>
235
+ <option value="low_9x16_md">9:16 Low MD (480x854/12fps)</option>
236
+ <option value="low_9x16_sm">9:16 Low SM (360x640/10fps)</option>
237
+ <option value="low_9x16_xs">9:16 Low XS (270x480/10fps)</option>
238
+ </optgroup>
239
+
240
+ <optgroup label="4:3 High Quality">
241
+ <option value="high_4x3_xl">4:3 High XL (960x720/30fps)</option>
242
+ <option value="high_4x3_lg">4:3 High LG (640x480/30fps)</option>
243
+ <option value="high_4x3_md">4:3 High MD (480x360/30fps)</option>
244
+ <option value="high_4x3_sm">4:3 High SM (320x240/30fps)</option>
245
+ <option value="high_4x3_xs">4:3 High XS (240x180/30fps)</option>
246
+ </optgroup>
247
+ <optgroup label="4:3 Medium Quality">
248
+ <option value="medium_4x3_xl">4:3 Medium XL (960x720/24fps)</option>
249
+ <option value="medium_4x3_lg">4:3 Medium LG (640x480/24fps)</option>
250
+ <option value="medium_4x3_md">4:3 Medium MD (480x360/20fps)</option>
251
+ <option value="medium_4x3_sm" selected>4:3 Medium SM (320x240/20fps)</option>
252
+ <option value="medium_4x3_xs">4:3 Medium XS (240x180/20fps)</option>
253
+ </optgroup>
254
+ <optgroup label="4:3 Low Quality">
255
+ <option value="low_4x3_xl">4:3 Low XL (960x720/15fps)</option>
256
+ <option value="low_4x3_lg">4:3 Low LG (640x480/15fps)</option>
257
+ <option value="low_4x3_md">4:3 Low MD (480x360/12fps)</option>
258
+ <option value="low_4x3_sm">4:3 Low SM (320x240/10fps)</option>
259
+ <option value="low_4x3_xs">4:3 Low XS (240x180/10fps)</option>
260
+ </optgroup>
261
+
262
+ <optgroup label="1:1 High Quality (Square)">
263
+ <option value="high_1x1_xl">1:1 High XL (720x720/30fps)</option>
264
+ <option value="high_1x1_lg">1:1 High LG (480x480/30fps)</option>
265
+ <option value="high_1x1_md">1:1 High MD (360x360/30fps)</option>
266
+ <option value="high_1x1_sm">1:1 High SM (240x240/30fps)</option>
267
+ <option value="high_1x1_xs">1:1 High XS (180x180/30fps)</option>
268
+ </optgroup>
269
+ <optgroup label="1:1 Medium Quality (Square)">
270
+ <option value="medium_1x1_xl">1:1 Medium XL (720x720/24fps)</option>
271
+ <option value="medium_1x1_lg">1:1 Medium LG (480x480/24fps)</option>
272
+ <option value="medium_1x1_md">1:1 Medium MD (360x360/20fps)</option>
273
+ <option value="medium_1x1_sm">1:1 Medium SM (240x240/20fps)</option>
274
+ <option value="medium_1x1_xs">1:1 Medium XS (180x180/20fps)</option>
275
+ </optgroup>
276
+ <optgroup label="1:1 Low Quality (Square)">
277
+ <option value="low_1x1_xl">1:1 Low XL (720x720/15fps)</option>
278
+ <option value="low_1x1_lg">1:1 Low LG (480x480/15fps)</option>
279
+ <option value="low_1x1_md">1:1 Low MD (360x360/12fps)</option>
280
+ <option value="low_1x1_sm">1:1 Low SM (240x240/10fps)</option>
281
+ <option value="low_1x1_xs">1:1 Low XS (180x180/10fps)</option>
282
+ </optgroup>
283
+ </select>
284
+
285
+ <button id="previewMedia">Preview Media</button>
286
+
287
+ <button id="sendData">Send Data</button>
288
+
289
+ <div id="trackStatus"></div>
290
+ </div>
291
+
292
+ <div id="rooms">
293
+ <h3>Available Rooms</h3>
294
+ <div id="roomList"></div>
295
+ </div>
296
+
297
+ <div id="participants">
298
+ <h3>Participants</h3>
299
+ <div id="participantList"></div>
300
+ </div>
301
+
302
+ <div id="dataChannelMessages">
303
+ <h3>Data Channel Messages</h3>
304
+ </div>
305
+
306
+ <div id="connectionStatus" style="position: fixed; top: 10px; right: 10px;"></div>
307
+
308
+ <div id="connectionStats" style="position: fixed; bottom: 10px; right: 10px; background: rgba(0,0,0,0.8); color: white; padding: 15px; border-radius: 8px; font-family: monospace;">
309
+ <h4 style="margin: 0 0 0 0; cursor: pointer;" onclick="document.querySelector('.stats-grid').style.display === 'grid' ? document.querySelector('.stats-grid').style.display = 'none' : document.querySelector('.stats-grid').style.display = 'grid'">
310
+ Connection Health
311
+ </h4>
312
+ <div class="stats-grid" style="display: none; grid-template-columns: auto auto; gap: 8px; font-size: 12px; margin-top: 10px;">
313
+ <div>Upload:</div>
314
+ <div id="uploadHealth">-</div>
315
+
316
+ <div>Download:</div>
317
+ <div id="downloadHealth">-</div>
318
+
319
+ <div>Bitrate:</div>
320
+ <div id="bitrateStats">-</div>
321
+
322
+ <div>Quality:</div>
323
+ <div id="qualityLimitation">-</div>
324
+
325
+ <div>Packet Loss:</div>
326
+ <div id="packetLoss">-</div>
327
+
328
+ <div>Round Trip:</div>
329
+ <div id="roundTrip">-</div>
330
+ </div>
331
+ </div>
332
+
333
+ <!-- Adapter.js for broader WebRTC compatibility -->
334
+ <script src="https://cdnjs.cloudflare.com/ajax/libs/webrtc-adapter/8.1.2/adapter.min.js"></script>
335
+
336
+ <!-- IMPORTANT: adjust the path to your refactored CloudflareCalls library -->
337
+ <script type="module">
338
+ import CloudflareCalls from './CloudflareCalls.js';
339
+
340
+ const calls = new CloudflareCalls();
341
+ window.calls = calls; // for debugging
342
+ calls.setDebugMode(true); // Disable debug logging
343
+
344
+ const localVideo = document.getElementById('localVideo');
345
+ const videosContainer = document.getElementById('videos');
346
+ const acquireTokenBtn = document.getElementById('acquireToken');
347
+ const createRoomBtn = document.getElementById('createRoom');
348
+ const joinRoomBtn = document.getElementById('joinRoom');
349
+ const leaveRoomBtn = document.getElementById('leaveRoom');
350
+ const toggleVideoBtn = document.getElementById('toggleVideo');
351
+ const toggleAudioBtn = document.getElementById('toggleAudio');
352
+ const shareScreenBtn = document.getElementById('shareScreen');
353
+ const dataChannelMessages = document.getElementById('dataChannelMessages');
354
+ const sendDataBtn = document.getElementById('sendData');
355
+
356
+ const audioInputSelect = document.getElementById('audioInputSelect');
357
+ const videoInputSelect = document.getElementById('videoInputSelect');
358
+ const audioOutputSelect = document.getElementById('audioOutputSelect');
359
+ const previewMediaBtn = document.getElementById('previewMedia');
360
+
361
+ const getSessionStateBtn = document.getElementById('getSessionState');
362
+ const forceUnpublishBtn = document.getElementById('forceUnpublish');
363
+ const trackStatusDiv = document.getElementById('trackStatus');
364
+
365
+ const participantList = document.getElementById('participantList');
366
+
367
+ // ===== 1) Populate device lists =====
368
+ async function populateDeviceLists() {
369
+ const devices = await calls.getAvailableDevices();
370
+ // Populate audio/video input & audio output selects
371
+ devices.audioInput.forEach(device => {
372
+ const option = document.createElement('option');
373
+ option.value = device.deviceId;
374
+ option.text = device.label || `Microphone ${audioInputSelect.length + 1}`;
375
+ audioInputSelect.appendChild(option);
376
+ });
377
+ devices.videoInput.forEach(device => {
378
+ const option = document.createElement('option');
379
+ option.value = device.deviceId;
380
+ option.text = device.label || `Camera ${videoInputSelect.length + 1}`;
381
+ videoInputSelect.appendChild(option);
382
+ });
383
+ devices.audioOutput.forEach(device => {
384
+ const option = document.createElement('option');
385
+ option.value = device.deviceId;
386
+ option.text = device.label || `Speaker ${audioOutputSelect.length + 1}`;
387
+ audioOutputSelect.appendChild(option);
388
+ });
389
+ }
390
+ populateDeviceLists();
391
+
392
+ // ===== 2) Listen for device selection changes =====
393
+ audioInputSelect.addEventListener('change', async () => {
394
+ await calls.selectAudioInputDevice(audioInputSelect.value);
395
+ });
396
+ videoInputSelect.addEventListener('change', async () => {
397
+ await calls.selectVideoInputDevice(videoInputSelect.value);
398
+ });
399
+ audioOutputSelect.addEventListener('change', async () => {
400
+ await calls.selectAudioOutputDevice(audioOutputSelect.value);
401
+ });
402
+
403
+ // ===== 3) Preview Media =====
404
+ previewMediaBtn.addEventListener('click', async () => {
405
+ try {
406
+ const previewStream = await calls.previewMedia({
407
+ audioDeviceId: audioInputSelect.value,
408
+ videoDeviceId: videoInputSelect.value
409
+ }, localVideo);
410
+ console.log('Preview successful.');
411
+ } catch (err) {
412
+ alert('Error previewing media: ' + err.message);
413
+ }
414
+ });
415
+
416
+ // ===== 4) Remote Track Handling =====
417
+ calls.onRemoteTrack(async track => {
418
+ console.log('New remote track:', track);
419
+
420
+ // Skip if track is invalid or missing required properties
421
+ if (!track.id || !track.trackName || !track.kind) {
422
+ console.log('Skipping invalid track:', track);
423
+ return;
424
+ }
425
+
426
+ // Skip our own track
427
+ if (track.sessionId === calls.sessionId) {
428
+ console.log('Skipping our own track');
429
+ return;
430
+ }
431
+
432
+ // Update participant list when tracks change
433
+ updateParticipantList();
434
+
435
+ // Find participant info from the room
436
+ const participants = await calls.listParticipants();
437
+ console.log('Participants:', participants);
438
+
439
+ const participant = participants.find(p => p.sessionId === track.sessionId);
440
+ console.log('Found participant:', participant);
441
+
442
+ if (!participant) {
443
+ console.warn('Could not find participant info for session:', track.sessionId);
444
+ return;
445
+ }
446
+
447
+ // Check if this is our own session ID
448
+ const localContainer = document.querySelector('.participant-container[data-local="true"]');
449
+ if (localContainer?.getAttribute('data-participant-id') === track.sessionId) {
450
+ console.log('Skipping container creation for own session');
451
+ return;
452
+ }
453
+
454
+ // Get user info for the participant
455
+ const userInfo = await calls.getUserInfo(participant.userId);
456
+ console.log('User info:', userInfo);
457
+
458
+ // Create or find container
459
+ let participantContainer = document.querySelector(`[data-participant-id="${track.sessionId}"]`);
460
+ if (!participantContainer) {
461
+ participantContainer = document.createElement('div');
462
+ participantContainer.className = 'participant-container';
463
+ participantContainer.setAttribute('data-participant-id', track.sessionId);
464
+ videosContainer.appendChild(participantContainer);
465
+
466
+ // Add health indicator
467
+ const healthIndicator = document.createElement('div');
468
+ healthIndicator.className = 'stream-health-indicator';
469
+ participantContainer.appendChild(healthIndicator);
470
+
471
+ // Add name overlay
472
+ const nameOverlay = document.createElement('div');
473
+ nameOverlay.className = 'participant-name';
474
+ nameOverlay.textContent = `${userInfo.username} 🔈`;
475
+ participantContainer.appendChild(nameOverlay);
476
+ }
477
+
478
+ // Add the media element to the container
479
+ if (track.kind === 'video') {
480
+ const videoElement = document.createElement('video');
481
+ videoElement.autoplay = true;
482
+ videoElement.playsInline = true;
483
+
484
+ videoElement.setAttribute('data-session-id', track.sessionId);
485
+ videoElement.setAttribute('data-track-id', track.id);
486
+ videoElement.setAttribute('data-track-name', track.trackName);
487
+ videoElement.setAttribute('data-mid', track.mid);
488
+ videoElement.setAttribute('data-track-source', track.source);
489
+
490
+ videoElement.srcObject = new MediaStream([track]);
491
+ participantContainer.appendChild(videoElement);
492
+ } else if (track.kind === 'audio') {
493
+ const audioElement = document.createElement('audio');
494
+ audioElement.autoplay = true;
495
+ audioElement.controls = true;
496
+ audioElement.style.display = 'none';
497
+
498
+ audioElement.setAttribute('data-session-id', track.sessionId);
499
+ audioElement.setAttribute('data-track-id', track.id);
500
+ audioElement.setAttribute('data-track-name', track.trackName);
501
+ audioElement.setAttribute('data-mid', track.mid);
502
+
503
+ audioElement.srcObject = new MediaStream([track]);
504
+ participantContainer.appendChild(audioElement); // Add to participant container instead
505
+ }
506
+ });
507
+
508
+ // ===== 4b) Handle Track Unpublished Events =====
509
+ calls.onRemoteTrackUnpublished((sessionId, trackName) => {
510
+ console.log('Track unpublished:', { sessionId, trackName });
511
+
512
+ // Update participant list when tracks are unpublished
513
+ updateParticipantList();
514
+
515
+ // Find and remove the media element
516
+ let mediaElement = document.querySelector(`[data-session-id="${sessionId}"][data-track-name="${trackName}"]`);
517
+
518
+ if (!mediaElement) {
519
+ console.warn('Could not find media element to remove:', { sessionId, trackName });
520
+ return;
521
+ }
522
+
523
+ console.log('Found and removing track element:', mediaElement);
524
+ if (mediaElement.srcObject) {
525
+ mediaElement.srcObject.getTracks().forEach(track => track.stop());
526
+ }
527
+ mediaElement.remove();
528
+
529
+ // Check if participant container is empty (no video/audio elements)
530
+ const participantContainer = document.querySelector(`[data-participant-id="${sessionId}"]`);
531
+ if (participantContainer) {
532
+ const hasMediaElements = participantContainer.querySelector('video, audio');
533
+ if (!hasMediaElements) {
534
+ console.log('Removing empty participant container:', sessionId);
535
+ participantContainer.remove();
536
+ }
537
+ }
538
+ });
539
+
540
+ // ===== 5) Data Channel Callbacks =====
541
+ calls.onDataMessage((msg) => {
542
+ console.log('onDataMessage', msg);
543
+ const p = document.createElement('p');
544
+ p.textContent = 'Data Channel Received: ' + JSON.stringify(msg);
545
+ dataChannelMessages.appendChild(p);
546
+ });
547
+
548
+ // ===== 6) Participants =====
549
+ calls.onParticipantJoined(async (participant) => {
550
+ console.log('Participant joined:', participant);
551
+
552
+ // Add a small delay to ensure all backend state is updated
553
+ setTimeout(async () => {
554
+ await updateParticipantList();
555
+ await updateRoomList();
556
+ }, 100);
557
+ });
558
+
559
+ calls.onParticipantLeft(async (sessionId) => {
560
+ console.log('Participant left:', sessionId);
561
+
562
+ // Remove participant's container
563
+ const container = document.querySelector(`.participant-container[data-participant-id="${sessionId}"]`);
564
+ if (container) {
565
+ // Stop all tracks in the container
566
+ container.querySelectorAll('video, audio').forEach(media => {
567
+ if (media.srcObject) {
568
+ media.srcObject.getTracks().forEach(track => track.stop());
569
+ }
570
+ });
571
+ container.remove();
572
+ }
573
+
574
+ // Also check for any stray audio elements
575
+ document.querySelectorAll(`audio[data-session-id="${sessionId}"]`)
576
+ .forEach(audio => audio.remove());
577
+
578
+ await updateParticipantList();
579
+ });
580
+
581
+ // ===== 7) Button handlers =====
582
+ acquireTokenBtn.addEventListener('click', async () => {
583
+ try {
584
+ const username = window._username || prompt('Enter username:');
585
+ if (!username) return;
586
+ window._username = username;
587
+
588
+ const response = await fetch('/auth/token', {
589
+ method: 'POST',
590
+ headers: { 'Content-Type': 'application/json' },
591
+ body: JSON.stringify({ username })
592
+ });
593
+ const { token } = await response.json();
594
+
595
+ // Set the token in the library
596
+ calls.setToken(token);
597
+
598
+ // Update room list after authentication
599
+ await updateRoomList();
600
+
601
+ setupLocalPreview();
602
+
603
+ acquireTokenBtn.textContent = 'Authenticated ✓';
604
+ acquireTokenBtn.disabled = true;
605
+ } catch (err) {
606
+ console.error('Auth error:', err);
607
+ alert('Auth error: ' + err.message);
608
+ }
609
+ });
610
+
611
+ createRoomBtn.addEventListener('click', async () => {
612
+ try {
613
+ const roomName = prompt('Enter room name:') || 'Unnamed Room';
614
+ const room = await calls.createRoom({
615
+ name: roomName,
616
+ metadata: {
617
+ createdBy: window._username
618
+ }
619
+ });
620
+ console.log('Created room:', room, room.roomId);
621
+
622
+ history.replaceState(null, '', `?room_id=${encodeURIComponent(room.roomId)}`);
623
+
624
+ joinRoomBtn.click();
625
+ } catch (err) {
626
+ console.error('Error creating/joining room:', err);
627
+ alert('Error: ' + err.message);
628
+ }
629
+ });
630
+
631
+ async function joinRoomById(id) {
632
+ leaveRoomBtn.click();
633
+
634
+ previewMediaBtn.click();
635
+
636
+ try {
637
+ // Set default quality before joining
638
+ calls.setMediaQuality('medium_4x3_sm');
639
+
640
+ await calls.joinRoom(id, { username: _username });
641
+ console.log('Joined room:', id);
642
+
643
+ // Get session ID after joining
644
+ const sessionId = calls.sessionId;
645
+ console.log('Session ID:', sessionId);
646
+
647
+ // Set the session ID on our pre-existing local container
648
+ const localContainer = document.querySelector('.participant-container[data-local="true"]');
649
+ if (localContainer) {
650
+ localContainer.setAttribute('data-participant-id', sessionId);
651
+ }
652
+
653
+ // Update participant list & room list after joining
654
+ await updateParticipantList();
655
+ await updateRoomList();
656
+
657
+ history.replaceState(null, '', `?room_id=${encodeURIComponent(id)}`);
658
+
659
+ // Start stats monitoring
660
+ calls.startStatsMonitoring(1000);
661
+ } catch (err) {
662
+ console.error('Error joining room:', err);
663
+ alert('Error joining room: ' + err.message);
664
+ }
665
+ }
666
+ window.joinRoomById = joinRoomById;
667
+
668
+ joinRoomBtn.addEventListener('click', async () => {
669
+ if (!window._username) return alert('Acquire token first.');
670
+
671
+ let searchRoomId = (location.search.split('room_id=')[1]).split('&')[0];
672
+ if (!window._roomId && searchRoomId) window._roomId = searchRoomId;
673
+
674
+ if (!window._roomId) window._roomId = prompt('Enter Room ID to join:');
675
+ if (!_roomId) return;
676
+
677
+ await joinRoomById(_roomId);
678
+ });
679
+
680
+ leaveRoomBtn.addEventListener('click', async () => {
681
+ await calls.unpublishAllTracks();
682
+ await calls.leaveRoom();
683
+
684
+ // Clean up all remote participant containers
685
+ document.querySelectorAll('.participant-container:not([data-local="true"])')
686
+ .forEach(container => container.remove());
687
+
688
+ // Clean up remote audio elements that might be outside containers
689
+ document.querySelectorAll('#videos audio').forEach(audio => audio.remove());
690
+
691
+ // Clear the participant list when we leave
692
+ participantList.innerHTML = '';
693
+
694
+ // Update room list after leaving
695
+ await updateRoomList();
696
+
697
+ console.log('Left the room.');
698
+
699
+ // Clean up stats monitoring
700
+ calls.stopStatsMonitoring();
701
+ });
702
+
703
+ toggleVideoBtn.addEventListener('click', async () => {
704
+ const videoTrack = localVideo.srcObject?.getVideoTracks()[0];
705
+ if (videoTrack) {
706
+ const isEnabled = videoTrack.enabled;
707
+ videoTrack.enabled = !isEnabled;
708
+ try {
709
+ await calls.toggleMedia({ video: !isEnabled, audio: null });
710
+ toggleVideoBtn.textContent = isEnabled ? 'Enable Video' : 'Disable Video';
711
+
712
+ // Get and show updated track status
713
+ const status = await calls.getTrackStatus(videoTrack.id);
714
+ trackStatusDiv.innerHTML = `<div>Video track ${videoTrack.id}: ${status}</div>`;
715
+ } catch (err) {
716
+ console.error('Error toggling video:', err);
717
+ alert('Error: ' + err.message);
718
+ }
719
+ }
720
+ });
721
+
722
+ toggleAudioBtn.addEventListener('click', () => {
723
+ const audioTrack = localVideo.srcObject?.getAudioTracks()[0];
724
+ if (audioTrack) {
725
+ const isEnabled = audioTrack.enabled;
726
+ audioTrack.enabled = !isEnabled;
727
+ calls.toggleMedia({ audio: !isEnabled, video: null });
728
+ toggleAudioBtn.textContent = isEnabled ? 'Enable Audio' : 'Disable Audio';
729
+ }
730
+ });
731
+
732
+ shareScreenBtn.addEventListener('click', async () => {
733
+ try {
734
+ await calls.shareScreen();
735
+ alert('Screen sharing started.');
736
+ } catch (err) {
737
+ alert('Error sharing screen: ' + err.message);
738
+ }
739
+ });
740
+
741
+ // ===== 8) Sending Data =====
742
+ sendDataBtn.addEventListener('click', async () => {
743
+ const content = prompt('Enter message:');
744
+ if (!content) return;
745
+
746
+ // We'll send everything on our "chat" channel
747
+ // Make sure we've published it already (see joinRoom code above).
748
+ // Then we can do:
749
+ const message = { content, fromUser: calls.userId };
750
+
751
+ calls.sendDataToAll(message);
752
+ });
753
+
754
+ document.getElementById('unpublishAll').addEventListener('click', async () => {
755
+ await calls.unpublishAllTracks();
756
+ alert('Unpublished all tracks');
757
+ });
758
+
759
+ getSessionStateBtn.addEventListener('click', async () => {
760
+ try {
761
+ const state = await calls.getSessionState();
762
+ trackStatusDiv.innerHTML = '<h3>Track Status:</h3>' +
763
+ state.tracks.map(track =>
764
+ `<div>${track.trackName}: ${track.status}</div>`
765
+ ).join('');
766
+ } catch (err) {
767
+ console.error('Error getting session state:', err);
768
+ alert('Error getting session state: ' + err.message);
769
+ }
770
+ });
771
+
772
+ forceUnpublishBtn.addEventListener('click', async () => {
773
+ try {
774
+ const userInfo = calls.getUserInfo();
775
+ if (!userInfo?.isModerator) {
776
+ alert('You need moderator privileges to force unpublish tracks.');
777
+ return;
778
+ }
779
+
780
+ await calls.unpublishTrack('video', true);
781
+ alert('Video track force unpublished');
782
+ } catch (err) {
783
+ if (err.code === 'NOT_AUTHORIZED') {
784
+ alert('You are not authorized to force unpublish tracks.');
785
+ } else {
786
+ console.error('Error force unpublishing:', err);
787
+ alert('Error: ' + err.message);
788
+ }
789
+ }
790
+ });
791
+
792
+ window.onbeforeunload = async function(e) {
793
+ e.preventDefault();
794
+ await calls.unpublishAllTracks();
795
+ return 'Leaving room..';
796
+ }
797
+
798
+ async function updateParticipantList() {
799
+ try {
800
+ // Get current user info first
801
+ const currentUser = await calls.getUserInfo();
802
+ const isModerator = currentUser.isModerator;
803
+
804
+ // Get list of participants in room
805
+ const participants = await calls.listParticipants();
806
+
807
+ // If we're not in a room, participants will be empty
808
+ if (!participants || participants.length === 0) {
809
+ participantList.innerHTML = '';
810
+ return;
811
+ }
812
+
813
+ participantList.innerHTML = `
814
+ <div class="participant">
815
+ <strong>You (${currentUser.username})</strong>
816
+ ${isModerator ? ' 🛡️ Moderator' : ''}
817
+ </div>
818
+ ${await Promise.all(participants
819
+ .filter(p => p.userId !== currentUser.userId)
820
+ .map(async p => {
821
+ const userInfo = await calls.getUserInfo(p.userId);
822
+ return `
823
+ <div class="participant">
824
+ <strong>${userInfo.username}</strong>
825
+ ${isModerator ? `
826
+ <button onclick="forceUnpublishParticipant('${p.sessionId}')">
827
+ Force Unpublish
828
+ </button>
829
+ ` : ''}
830
+ </div>
831
+ `;
832
+ })
833
+ ).then(items => items.join(''))}
834
+ `;
835
+ } catch (err) {
836
+ console.error('Error updating participant list:', err);
837
+ // Clear the list if there's an error (likely means we're not in a room)
838
+ participantList.innerHTML = '';
839
+ }
840
+ }
841
+
842
+ async function forceUnpublishParticipant(sessionId) {
843
+ try {
844
+ const userInfo = await calls.getUserInfo();
845
+ if (!userInfo.isModerator) {
846
+ alert('Only moderators can force unpublish tracks');
847
+ return;
848
+ }
849
+
850
+ // In a real app, you might want to specify which track to unpublish
851
+ await calls.unpublishTrack('video', true, sessionId);
852
+ alert('Forced track unpublish successful');
853
+ } catch (err) {
854
+ console.error('Error force unpublishing:', err);
855
+ alert('Error: ' + err.message);
856
+ }
857
+ }
858
+ window.forceUnpublishParticipant = forceUnpublishParticipant;
859
+
860
+ const setupLocalPreview = async () => {
861
+ try {
862
+ const previewStream = await calls.previewMedia({
863
+ audioDeviceId: audioInputSelect.value,
864
+ videoDeviceId: videoInputSelect.value
865
+ }, localVideo);
866
+ console.log('Preview successful.');
867
+ } catch (err) {
868
+ alert('Error previewing media: ' + err.message);
869
+ }
870
+
871
+ try {
872
+ const userInfo = await calls.getUserInfo();
873
+ console.log('User info:', userInfo);
874
+
875
+ const localContainer = document.querySelector('.participant-container[data-local="true"]');
876
+ localContainer.setAttribute('data-participant-id', userInfo.userId);
877
+
878
+ // Add name overlay
879
+ const nameOverlay = document.querySelector('.participant-container[data-local="true"] .participant-name');
880
+ nameOverlay.textContent = `${userInfo.username} (you) 🔈`;
881
+ } catch (err) {
882
+ console.error('Error setting up local preview:', err);
883
+ }
884
+ };
885
+
886
+ function updateAudioStatus(sessionId, enabled = true) {
887
+ const nameOverlay = document.querySelector(`[data-participant-id="${sessionId}"] .participant-name`);
888
+ if (nameOverlay) {
889
+ // Clean up the name by removing status icons
890
+ const baseName = nameOverlay.textContent
891
+ .replace(/ 🔈| 🔇/g, '')
892
+ .replace(/ \(you\)/g, '');
893
+
894
+ // Add back (you) if it's the local user
895
+ const isLocal = sessionId === calls.sessionId;
896
+ nameOverlay.textContent = `${baseName}${isLocal ? ' (you)' : ''} ${enabled ? '🔈' : '🔇'}`;
897
+ console.log('Updated audio status:', { sessionId, enabled }, nameOverlay.textContent);
898
+ } else {
899
+ console.log('No name overlay found for session:', sessionId);
900
+ }
901
+ }
902
+
903
+ // Use specific handlers for most cases
904
+ calls.onTrackStatusChanged(payload => {
905
+ const { sessionId, enabled, kind } = payload;
906
+ console.log('onTrackStatusChanged kind', kind);
907
+ if (kind === 'audio') {
908
+ updateAudioStatus(sessionId, enabled);
909
+ }
910
+ });
911
+
912
+ // Use generic handler for debugging or custom handling
913
+ calls.onWebSocketMessage(msg => {
914
+ console.log('WebSocket message received (generic):', msg);
915
+ });
916
+
917
+ // Add connection status monitoring
918
+ function updateConnectionStatus() {
919
+ const status = document.getElementById('connectionStatus');
920
+ if (!window.calls?.ws) {
921
+ status.textContent = '⚠️ No WebSocket';
922
+ status.style.color = 'red';
923
+ return;
924
+ }
925
+
926
+ switch (window.calls.ws.readyState) {
927
+ case WebSocket.CONNECTING:
928
+ status.textContent = '🔄 Connecting...';
929
+ status.style.color = 'orange';
930
+ break;
931
+ case WebSocket.OPEN:
932
+ status.textContent = '🟢 Connected';
933
+ status.style.color = 'green';
934
+ break;
935
+ case WebSocket.CLOSING:
936
+ status.textContent = '🔄 Closing...';
937
+ status.style.color = 'orange';
938
+ break;
939
+ case WebSocket.CLOSED:
940
+ status.textContent = '🔴 Disconnected';
941
+ status.style.color = 'red';
942
+ break;
943
+ }
944
+ }
945
+
946
+ // Update status every second
947
+ setInterval(updateConnectionStatus, 1000);
948
+
949
+ // Add room list UI updates
950
+ async function updateRoomList() {
951
+ const roomList = document.getElementById('roomList');
952
+ const rooms = await calls.listRooms();
953
+
954
+ roomList.innerHTML = rooms.map(room => `
955
+ <div class="room-item">
956
+ <div class="room-info">
957
+ <div class="room-name">${room.name || 'Unnamed Room'}</div>
958
+ <div class="room-metadata">
959
+ Created by: ${room.metadata?.createdBy || 'Unknown'}
960
+ · ${room.participantCount} participant(s)
961
+ </div>
962
+ </div>
963
+ <div class="room-actions">
964
+ <button onclick="joinRoomById('${room.roomId}')">Join</button>
965
+ </div>
966
+ </div>
967
+ `).join('');
968
+ }
969
+
970
+ // Add metadata update handler
971
+ calls.onRoomMetadataUpdated(async (payload) => {
972
+ console.log('Room metadata updated:', payload);
973
+ await updateRoomList(); // Refresh room list
974
+ });
975
+
976
+ // Quality controls
977
+ async function setQuality(preset) {
978
+ try {
979
+ await calls.setMediaQuality(preset);
980
+ console.log(`Set quality to ${preset}`);
981
+ } catch (error) {
982
+ console.error('Error setting quality:', error);
983
+ }
984
+ }
985
+ window.setQuality = setQuality;
986
+
987
+ // Connection health monitoring
988
+ const uploadHealth = document.getElementById('uploadHealth');
989
+ const downloadHealth = document.getElementById('downloadHealth');
990
+ const bitrateStats = document.getElementById('bitrateStats');
991
+ const qualityLimitation = document.getElementById('qualityLimitation');
992
+ const packetLoss = document.getElementById('packetLoss');
993
+ const roundTrip = document.getElementById('roundTrip');
994
+
995
+ calls.onConnectionStats((stats, streamStats) => {
996
+ // Format bitrates to Mbps/Kbps
997
+ const formatBitrate = (bits) => {
998
+ if (bits > 1000000) {
999
+ return `${(bits / 1000000).toFixed(2)} Mbps`;
1000
+ }
1001
+ return `${(bits / 1000).toFixed(0)} Kbps`;
1002
+ };
1003
+
1004
+ // Update bitrates
1005
+ const outBitrate = formatBitrate(stats.outbound.bitrate);
1006
+ const inBitrate = formatBitrate(stats.inbound.bitrate);
1007
+ bitrateStats.textContent = `↑${outBitrate} ↓${inBitrate}`;
1008
+
1009
+ // Update quality limitation
1010
+ const limitation = stats.outbound.qualityLimitation;
1011
+ qualityLimitation.textContent = limitation.charAt(0).toUpperCase() + limitation.slice(1);
1012
+ qualityLimitation.style.color = limitation === 'none' ? '#4CAF50' : '#FFA726';
1013
+
1014
+ // Update packet loss
1015
+ const outLoss = stats.outbound.packetLoss.toFixed(1);
1016
+ const inLoss = stats.inbound.packetLoss.toFixed(1);
1017
+ packetLoss.textContent = `↑${outLoss}% ↓${inLoss}%`;
1018
+ packetLoss.style.color = Math.max(stats.outbound.packetLoss, stats.inbound.packetLoss) > 5
1019
+ ? '#F44336' : '#4CAF50';
1020
+
1021
+ // Update round trip time
1022
+ const rtt = (stats.connection.roundTripTime * 1000).toFixed(0);
1023
+ roundTrip.textContent = `${rtt}ms`;
1024
+ roundTrip.style.color = rtt > 200 ? '#F44336' : rtt > 100 ? '#FFA726' : '#4CAF50';
1025
+
1026
+ // Update overall health indicators
1027
+ const getHealthStatus = (stats) => {
1028
+ if (stats.packetLoss > 10 || stats.qualityLimitation !== 'none') return '🔴 Poor';
1029
+ if (stats.packetLoss > 5) return '🟡 Fair';
1030
+ return '🟢 Good';
1031
+ };
1032
+
1033
+ uploadHealth.textContent = getHealthStatus(stats.outbound);
1034
+ downloadHealth.textContent = getHealthStatus({
1035
+ packetLoss: stats.inbound.packetLoss,
1036
+ qualityLimitation: 'none'
1037
+ });
1038
+
1039
+ // Update individual stream health indicators
1040
+ if (streamStats) { // Add null check
1041
+ streamStats.forEach(streamStat => {
1042
+ const container = document.querySelector(`.participant-container[data-participant-id="${streamStat.sessionId}"]`);
1043
+ const indicator = container?.querySelector('.stream-health-indicator');
1044
+ if (!indicator) return;
1045
+
1046
+ // Remove existing health classes
1047
+ indicator.classList.remove('stream-health-good', 'stream-health-fair', 'stream-health-poor');
1048
+
1049
+ // Determine health status
1050
+ let healthClass;
1051
+ if (streamStat.packetLoss > 10 || streamStat.qualityLimitation !== 'none') {
1052
+ healthClass = 'stream-health-poor';
1053
+ } else if (streamStat.packetLoss > 5) {
1054
+ healthClass = 'stream-health-fair';
1055
+ } else {
1056
+ healthClass = 'stream-health-good';
1057
+ }
1058
+
1059
+ indicator.classList.add(healthClass);
1060
+ });
1061
+ }
1062
+ });
1063
+ </script>
1064
+ </body>
1065
+ </html>
1066
+
public/temp/test.html ADDED
@@ -0,0 +1,327 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ <!DOCTYPE html>
2
+ <html lang="en">
3
+ <head>
4
+ <meta charset="UTF-8">
5
+ <meta name="viewport" content="width=device-width, initial-scale=1.0">
6
+ <title>Simple Meeting Room</title>
7
+ <style>
8
+ #videos {
9
+ display: grid;
10
+ grid-template-columns: repeat(auto-fit, minmax(300px, 1fr));
11
+ gap: 20px;
12
+ margin: 20px;
13
+ }
14
+ .video-container {
15
+ position: relative;
16
+ background: #f0f0f0;
17
+ border-radius: 8px;
18
+ overflow: hidden;
19
+ }
20
+ video {
21
+ width: 100%;
22
+ max-width: 100%;
23
+ height: auto;
24
+ background: #000;
25
+ }
26
+ .participant-name {
27
+ position: absolute;
28
+ bottom: 10px;
29
+ left: 10px;
30
+ color: white;
31
+ background: rgba(0,0,0,0.5);
32
+ padding: 5px 10px;
33
+ border-radius: 4px;
34
+ }
35
+ #controls {
36
+ padding: 20px;
37
+ background: #f8f8f8;
38
+ margin-bottom: 20px;
39
+ }
40
+ button {
41
+ padding: 8px 16px;
42
+ margin: 0 5px;
43
+ cursor: pointer;
44
+ }
45
+ #roomInfo {
46
+ padding: 10px;
47
+ background: #e8e8e8;
48
+ margin: 10px 20px;
49
+ }
50
+ </style>
51
+ </head>
52
+ <body>
53
+ <div id="controls">
54
+ <input type="text" id="nameInput" placeholder="Enter your name">
55
+ <button id="createRoomBtn">Create Room</button>
56
+ <input type="text" id="roomIdInput" placeholder="Enter Room ID">
57
+ <button id="joinRoomBtn">Join Room</button>
58
+ <button id="leaveRoomBtn">Leave Room</button>
59
+ <button id="toggleVideoBtn">Toggle Video</button>
60
+ <button id="toggleAudioBtn">Toggle Audio</button>
61
+ </div>
62
+
63
+ <div id="roomInfo"></div>
64
+ <div id="videos">
65
+ <div class="video-container" id="localVideoContainer">
66
+ <video id="localVideo" autoplay muted playsinline></video>
67
+ <div class="participant-name">You</div>
68
+ </div>
69
+ </div>
70
+
71
+ <!-- Adapter.js for broader WebRTC compatibility -->
72
+ <script src="https://cdnjs.cloudflare.com/ajax/libs/webrtc-adapter/8.1.2/adapter.min.js"></script>
73
+ <script type="module">
74
+ import CloudflareCalls from './CloudflareCalls.js';
75
+
76
+ const calls = new CloudflareCalls({
77
+ backendUrl: '', // Default to same host
78
+ websocketUrl: `ws://${window.location.host}`
79
+ });
80
+
81
+ // Get token and initialize calls
82
+ async function ensureInitialized(username) {
83
+ if (!calls.token) {
84
+ try {
85
+ const response = await fetch('/auth/token', {
86
+ method: 'POST',
87
+ headers: {
88
+ 'Content-Type': 'application/json'
89
+ },
90
+ body: JSON.stringify({ username })
91
+ });
92
+
93
+ const { token } = await response.json();
94
+ calls.setToken(token);
95
+ return true;
96
+ } catch (err) {
97
+ console.error('Error getting token:', err);
98
+ alert('Failed to initialize. Please check if the server is running.');
99
+ return false;
100
+ }
101
+ }
102
+ return true;
103
+ }
104
+
105
+ let currentRoom = null;
106
+
107
+ // DOM Elements
108
+ const nameInput = document.getElementById('nameInput');
109
+ const createRoomBtn = document.getElementById('createRoomBtn');
110
+ const roomIdInput = document.getElementById('roomIdInput');
111
+ const joinRoomBtn = document.getElementById('joinRoomBtn');
112
+ const leaveRoomBtn = document.getElementById('leaveRoomBtn');
113
+ const toggleVideoBtn = document.getElementById('toggleVideoBtn');
114
+ const toggleAudioBtn = document.getElementById('toggleAudioBtn');
115
+ const roomInfo = document.getElementById('roomInfo');
116
+ const videos = document.getElementById('videos');
117
+ const localVideo = document.getElementById('localVideo');
118
+
119
+ // Check URL for room ID
120
+ const urlParams = new URLSearchParams(window.location.search);
121
+ if (urlParams.has('room')) {
122
+ roomIdInput.value = urlParams.get('room');
123
+ }
124
+
125
+ // Event Handlers
126
+ createRoomBtn.addEventListener('click', async () => {
127
+ if (!nameInput.value) {
128
+ alert('Please enter your name');
129
+ return;
130
+ }
131
+
132
+ try {
133
+ // Ensure we have a token first
134
+ if (!await ensureInitialized(nameInput.value)) {
135
+ return;
136
+ }
137
+
138
+ const room = await calls.createRoom({
139
+ name: `${nameInput.value}'s Room`,
140
+ metadata: { createdBy: nameInput.value }
141
+ });
142
+ currentRoom = room;
143
+ roomInfo.textContent = `Room created! Room ID: ${room.roomId}`;
144
+ await joinRoom(room.roomId);
145
+
146
+ // Update URL with room ID
147
+ history.pushState({}, '', `?room=${room.roomId}`);
148
+ } catch (err) {
149
+ console.error('Error creating room:', err);
150
+ alert('Failed to create room: ' + err.message);
151
+ }
152
+ });
153
+
154
+ joinRoomBtn.addEventListener('click', async () => {
155
+ if (!nameInput.value) {
156
+ alert('Please enter your name');
157
+ return;
158
+ }
159
+ if (!roomIdInput.value) {
160
+ alert('Please enter a room ID');
161
+ return;
162
+ }
163
+
164
+ // Ensure we have a token before joining
165
+ if (!await ensureInitialized(nameInput.value)) {
166
+ return;
167
+ }
168
+
169
+ await joinRoom(roomIdInput.value);
170
+ });
171
+
172
+ leaveRoomBtn.addEventListener('click', async () => {
173
+ if (calls) {
174
+ await calls.leaveRoom();
175
+ currentRoom = null;
176
+ roomInfo.textContent = '';
177
+ clearRemoteVideos();
178
+ history.pushState({}, '', window.location.pathname);
179
+ }
180
+ });
181
+
182
+ toggleVideoBtn.addEventListener('click', () => {
183
+ if (calls.localStream) {
184
+ const videoTracks = calls.localStream.getVideoTracks();
185
+ const currentState = videoTracks[0]?.enabled;
186
+ videoTracks.forEach(track => {
187
+ track.enabled = !currentState;
188
+ });
189
+ toggleVideoBtn.textContent = currentState ? 'Enable Video' : 'Disable Video';
190
+ }
191
+ });
192
+
193
+ toggleAudioBtn.addEventListener('click', () => {
194
+ if (calls.localStream) {
195
+ const audioTracks = calls.localStream.getAudioTracks();
196
+ const currentState = audioTracks[0]?.enabled;
197
+ audioTracks.forEach(track => {
198
+ track.enabled = !currentState;
199
+ });
200
+ toggleAudioBtn.textContent = currentState ? 'Enable Audio' : 'Disable Audio';
201
+ }
202
+ });
203
+
204
+ // Helper Functions
205
+ async function joinRoom(roomId) {
206
+ try {
207
+ await calls.joinRoom(roomId, { name: nameInput.value });
208
+ roomInfo.textContent = `Joined room: ${roomId}`;
209
+ setupCallbacks();
210
+ // Get list of current participants and their tracks
211
+ const participants = await calls.listParticipants();
212
+ console.log('Current participants:', participants);
213
+
214
+ // Set up remote streams for existing participants
215
+ for (const participant of participants) {
216
+ // Skip if it's our own session
217
+ if (participant.sessionId === calls.sessionId) continue;
218
+
219
+ console.log('Processing participant:', participant);
220
+
221
+ // Create container for this participant if not exists
222
+ const containerId = `participant-${participant.sessionId}`;
223
+ if (!document.getElementById(containerId)) {
224
+ const container = document.createElement('div');
225
+ container.id = containerId;
226
+ container.className = 'video-container';
227
+
228
+ const video = document.createElement('video');
229
+ video.autoplay = true;
230
+ video.playsinline = true;
231
+
232
+ const name = document.createElement('div');
233
+ name.className = 'participant-name';
234
+ name.textContent = 'Participant ' + participant.sessionId;
235
+
236
+ container.appendChild(video);
237
+ container.appendChild(name);
238
+ videos.appendChild(container);
239
+
240
+ // Set up MediaStream for this participant
241
+ video.srcObject = new MediaStream();
242
+ }
243
+
244
+ // Pull each track from the participant
245
+ for (const trackName of participant.publishedTracks) {
246
+ console.log(`Pulling track ${trackName} from session ${participant.sessionId}`);
247
+ await calls._pullTracks(participant.sessionId, trackName);
248
+ }
249
+
250
+ }
251
+ history.pushState({}, '', `?room=${roomId}`);
252
+ } catch (err) {
253
+ console.error('Error joining room:', err);
254
+ alert('Failed to join room: ' + err.message);
255
+ }
256
+ }
257
+
258
+ function setupCallbacks() {
259
+ calls.onRemoteTrack((track) => {
260
+ console.log('Remote track received:', track);
261
+ const containerId = `participant-${track.sessionId}`;
262
+ let container = document.getElementById(containerId);
263
+
264
+ if (!container) {
265
+ container = document.createElement('div');
266
+ container.id = containerId;
267
+ container.className = 'video-container';
268
+ const video = document.createElement('video');
269
+ video.autoplay = true;
270
+ video.playsinline = true;
271
+ const name = document.createElement('div');
272
+ name.className = 'participant-name';
273
+ name.textContent = 'Participant ' + track.sessionId;
274
+ container.appendChild(video);
275
+ container.appendChild(name);
276
+ videos.appendChild(container);
277
+ }
278
+
279
+ const video = container.querySelector('video');
280
+ if (!video.srcObject) {
281
+ video.srcObject = new MediaStream();
282
+ }
283
+ video.srcObject.addTrack(track);
284
+ });
285
+
286
+ calls.onParticipantLeft((sessionId) => {
287
+ const container = document.getElementById(`participant-${sessionId.sessionId}`);
288
+ console.log('Participant left:', sessionId);
289
+ if (container) {
290
+ container.remove();
291
+ console.log('Participant left:', sessionId);
292
+ }
293
+ });
294
+ }
295
+
296
+ function clearRemoteVideos() {
297
+ const remoteContainers = videos.querySelectorAll('.video-container:not(#localVideoContainer)');
298
+ remoteContainers.forEach(container => container.remove());
299
+ }
300
+
301
+ // Initial setup for local video
302
+ async function setupLocalVideo() {
303
+ try {
304
+ const stream = await navigator.mediaDevices.getUserMedia({ video: true, audio: true });
305
+ localVideo.srcObject = stream;
306
+ // Store stream in calls instance
307
+ calls.localStream = stream;
308
+ } catch (err) {
309
+ console.error('Error accessing media devices:', err);
310
+ alert('Failed to access camera/microphone');
311
+ }
312
+ }
313
+
314
+ // Initialize with token
315
+ setupLocalVideo();
316
+
317
+ // Auto-join room if ID is in URL
318
+ if (urlParams.has('room')) {
319
+ roomIdInput.value = urlParams.get('room');
320
+ // Only auto-join if name is provided
321
+ if (nameInput.value) {
322
+ joinRoomBtn.click();
323
+ }
324
+ }
325
+ </script>
326
+ </body>
327
+ </html>