Spaces:
Build error
Build error
Luisnguyen1 commited on
Commit ·
5094fdb
1
Parent(s): 14c0d68
code
Browse files- .dockerignore +4 -0
- .gitignore +6 -0
- Dockerfile +23 -0
- README.md +4 -6
- apidoc.json +14 -0
- chat.html +209 -0
- cloudflare-calls-api-2024-05-21.yaml +447 -0
- header.md +1 -0
- index.js +1086 -0
- jsdoc.json +21 -0
- package.json +29 -0
- public/.gitattributes +36 -0
- public/README.md +10 -0
- public/assets/mask/basic/mask1.png +3 -0
- public/assets/mask/basic/mask2.png +3 -0
- public/assets/mask/basic/mask3.png +3 -0
- public/assets/mask/mask.png +3 -0
- public/assets/mask/medicel/mask1.png +3 -0
- public/assets/mask/medicel/mask2.png +3 -0
- public/assets/mask/medicel/mask3.png +3 -0
- public/check.html +481 -0
- public/css/style.css +490 -0
- public/img/mask1.png +3 -0
- public/index.html +295 -0
- public/join.html +222 -0
- public/js/CloudflareCalls.js +2472 -0
- public/js/CloudflareCalls.min.js +1 -0
- public/js/FaceMaskFilter.js +203 -0
- public/js/backgroundBlur.js +99 -0
- public/js/room-oldcode.js +2231 -0
- public/js/room.js +691 -0
- public/room-old.html +104 -0
- public/room.html +124 -0
- public/temp/CloudflareCalls.js +2472 -0
- public/temp/CloudflareCalls.min.js +1 -0
- public/temp/favicon.ico +0 -0
- public/temp/index.html +1066 -0
- public/temp/test.html +327 -0
.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:
|
| 3 |
-
emoji:
|
| 4 |
-
colorFrom:
|
| 5 |
-
colorTo:
|
| 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
|
public/assets/mask/basic/mask2.png
ADDED
|
Git LFS Details
|
public/assets/mask/basic/mask3.png
ADDED
|
Git LFS Details
|
public/assets/mask/mask.png
ADDED
|
Git LFS Details
|
public/assets/mask/medicel/mask1.png
ADDED
|
Git LFS Details
|
public/assets/mask/medicel/mask2.png
ADDED
|
Git LFS Details
|
public/assets/mask/medicel/mask3.png
ADDED
|
Git LFS Details
|
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
|
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, "&")
|
| 5 |
+
.replace(/</g, "<")
|
| 6 |
+
.replace(/>/g, ">")
|
| 7 |
+
.replace(/"/g, """)
|
| 8 |
+
.replace(/'/g, "'");
|
| 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">×</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">×</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>
|