Update environment configuration for local development, add new dependencies for WebRTC functionality, and refactor user session management components. Remove deprecated SessionUsersPanel and enhance SessionUsersPanel2 with improved user handling and controls. Integrate socket.io for real-time communication in the server.
This commit is contained in:
+3
-1
@@ -1,2 +1,4 @@
|
||||
# VITE_API_URL=http://192.168.1.23:3000
|
||||
VITE_API_URL=http://192.168.1.224:3000
|
||||
# VITE_API_URL=http://192.168.1.224:3000
|
||||
VITE_API_URL=http://localhost:3000
|
||||
VITE_WEBRTC_URL=http://localhost:3001
|
||||
Generated
+343
-5
@@ -8,19 +8,26 @@
|
||||
"name": "client",
|
||||
"version": "0.0.0",
|
||||
"dependencies": {
|
||||
"@epicgames-ps/lib-pixelstreamingfrontend-ue5.7": "^0.1.1",
|
||||
"@tanstack/react-query": "^5.90.2",
|
||||
"@uidotdev/usehooks": "^2.4.1",
|
||||
"clsx": "^2.1.1",
|
||||
"ky": "^1.11.0",
|
||||
"motion": "^12.23.24",
|
||||
"react": "^19.1.1",
|
||||
"react-dom": "^19.1.1",
|
||||
"react-qr-code": "^2.0.18",
|
||||
"react-router": "^7.9.3",
|
||||
"socket.io-client": "^4.8.1",
|
||||
"uuid": "^13.0.0",
|
||||
"zustand": "^5.0.8"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@eslint/js": "^9.36.0",
|
||||
"@types/node": "^24.6.0",
|
||||
"@types/react": "^19.2.2",
|
||||
"@types/react": "^19.1.16",
|
||||
"@types/react-dom": "^19.1.9",
|
||||
"@types/uuid": "^11.0.0",
|
||||
"@vitejs/plugin-react-swc": "^4.1.0",
|
||||
"autoprefixer": "^10.4.21",
|
||||
"eslint": "^9.36.0",
|
||||
@@ -47,6 +54,25 @@
|
||||
"url": "https://github.com/sponsors/sindresorhus"
|
||||
}
|
||||
},
|
||||
"node_modules/@epicgames-ps/lib-pixelstreamingcommon-ue5.7": {
|
||||
"version": "0.1.1",
|
||||
"resolved": "https://registry.npmjs.org/@epicgames-ps/lib-pixelstreamingcommon-ue5.7/-/lib-pixelstreamingcommon-ue5.7-0.1.1.tgz",
|
||||
"integrity": "sha512-5jOeiLJLFn+gjn0AWZetPM6z+665AIMAxr575hwCTxS9+1n5FxSxcjTVlyP5vEIM9rqYjTYeayMYDYfAIOketA==",
|
||||
"dependencies": {
|
||||
"@protobuf-ts/runtime": "^2.9.4",
|
||||
"@types/ws": "^8.5.14",
|
||||
"ws": "^8.18.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@epicgames-ps/lib-pixelstreamingfrontend-ue5.7": {
|
||||
"version": "0.1.2",
|
||||
"resolved": "https://registry.npmjs.org/@epicgames-ps/lib-pixelstreamingfrontend-ue5.7/-/lib-pixelstreamingfrontend-ue5.7-0.1.2.tgz",
|
||||
"integrity": "sha512-jahQ7k6uzrYD89pRqEJIBWpGuCrGExs3mUBsbstV4Yx9JymMgaVbMY3xbq7kUh9bN6g0007el/2WFoFyMvm58A==",
|
||||
"dependencies": {
|
||||
"@epicgames-ps/lib-pixelstreamingcommon-ue5.7": "^0.1.0",
|
||||
"sdp": "^3.2.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/aix-ppc64": {
|
||||
"version": "0.25.10",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.25.10.tgz",
|
||||
@@ -804,6 +830,11 @@
|
||||
"node": ">=14"
|
||||
}
|
||||
},
|
||||
"node_modules/@protobuf-ts/runtime": {
|
||||
"version": "2.11.1",
|
||||
"resolved": "https://registry.npmjs.org/@protobuf-ts/runtime/-/runtime-2.11.1.tgz",
|
||||
"integrity": "sha512-KuDaT1IfHkugM2pyz+FwiY80ejWrkH1pAtOBOZFuR6SXEFTsnb/jiQWQ1rCIrcKx2BtyxnxW6BWwsVSA/Ie+WQ=="
|
||||
},
|
||||
"node_modules/@rolldown/pluginutils": {
|
||||
"version": "1.0.0-beta.35",
|
||||
"resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.0-beta.35.tgz",
|
||||
@@ -1119,6 +1150,11 @@
|
||||
"win32"
|
||||
]
|
||||
},
|
||||
"node_modules/@socket.io/component-emitter": {
|
||||
"version": "3.1.2",
|
||||
"resolved": "https://registry.npmjs.org/@socket.io/component-emitter/-/component-emitter-3.1.2.tgz",
|
||||
"integrity": "sha512-9BCxFwvbGg/RsZK9tjXd8s4UcwR0MWeFQ1XEKIQVVvAGJyINdrqKMcTRyLoK8Rse1GjzLV9cwjWV1olXRWEXVA=="
|
||||
},
|
||||
"node_modules/@swc/core": {
|
||||
"version": "1.13.5",
|
||||
"resolved": "https://registry.npmjs.org/@swc/core/-/core-1.13.5.tgz",
|
||||
@@ -1389,7 +1425,6 @@
|
||||
"version": "24.7.0",
|
||||
"resolved": "https://registry.npmjs.org/@types/node/-/node-24.7.0.tgz",
|
||||
"integrity": "sha512-IbKooQVqUBrlzWTi79E8Fw78l8k1RNtlDDNWsFZs7XonuQSJ8oNYfEeclhprUldXISRMLzBpILuKgPlIxm+/Yw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"undici-types": "~7.14.0"
|
||||
@@ -1415,6 +1450,24 @@
|
||||
"@types/react": "^19.2.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@types/uuid": {
|
||||
"version": "11.0.0",
|
||||
"resolved": "https://registry.npmjs.org/@types/uuid/-/uuid-11.0.0.tgz",
|
||||
"integrity": "sha512-HVyk8nj2m+jcFRNazzqyVKiZezyhDKrGUA3jlEcg/nZ6Ms+qHwocba1Y/AaVaznJTAM9xpdFSh+ptbNrhOGvZA==",
|
||||
"deprecated": "This is a stub types definition. uuid provides its own type definitions, so you do not need this installed.",
|
||||
"dev": true,
|
||||
"dependencies": {
|
||||
"uuid": "*"
|
||||
}
|
||||
},
|
||||
"node_modules/@types/ws": {
|
||||
"version": "8.18.1",
|
||||
"resolved": "https://registry.npmjs.org/@types/ws/-/ws-8.18.1.tgz",
|
||||
"integrity": "sha512-ThVF6DCVhA8kUGy+aazFQ4kXQ7E1Ty7A3ypFOe0IcJV8O/M511G99AW24irKrW56Wt44yG9+ij8FaqoBGkuBXg==",
|
||||
"dependencies": {
|
||||
"@types/node": "*"
|
||||
}
|
||||
},
|
||||
"node_modules/@typescript-eslint/eslint-plugin": {
|
||||
"version": "8.46.0",
|
||||
"resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.46.0.tgz",
|
||||
@@ -1673,6 +1726,18 @@
|
||||
"url": "https://opencollective.com/typescript-eslint"
|
||||
}
|
||||
},
|
||||
"node_modules/@uidotdev/usehooks": {
|
||||
"version": "2.4.1",
|
||||
"resolved": "https://registry.npmjs.org/@uidotdev/usehooks/-/usehooks-2.4.1.tgz",
|
||||
"integrity": "sha512-1I+RwWyS+kdv3Mv0Vmc+p0dPYH0DTRAo04HLyXReYBL9AeseDWUJyi4THuksBJcu9F0Pih69Ak150VDnqbVnXg==",
|
||||
"engines": {
|
||||
"node": ">=16"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"react": ">=18.0.0",
|
||||
"react-dom": ">=18.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@vitejs/plugin-react-swc": {
|
||||
"version": "4.1.0",
|
||||
"resolved": "https://registry.npmjs.org/@vitejs/plugin-react-swc/-/plugin-react-swc-4.1.0.tgz",
|
||||
@@ -2166,6 +2231,62 @@
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/engine.io-client": {
|
||||
"version": "6.6.3",
|
||||
"resolved": "https://registry.npmjs.org/engine.io-client/-/engine.io-client-6.6.3.tgz",
|
||||
"integrity": "sha512-T0iLjnyNWahNyv/lcjS2y4oE358tVS/SYQNxYXGAJ9/GLgH4VCvOQ/mhTjqU88mLZCQgiG8RIegFHYCdVC+j5w==",
|
||||
"dependencies": {
|
||||
"@socket.io/component-emitter": "~3.1.0",
|
||||
"debug": "~4.3.1",
|
||||
"engine.io-parser": "~5.2.1",
|
||||
"ws": "~8.17.1",
|
||||
"xmlhttprequest-ssl": "~2.1.1"
|
||||
}
|
||||
},
|
||||
"node_modules/engine.io-client/node_modules/debug": {
|
||||
"version": "4.3.7",
|
||||
"resolved": "https://registry.npmjs.org/debug/-/debug-4.3.7.tgz",
|
||||
"integrity": "sha512-Er2nc/H7RrMXZBFCEim6TCmMk02Z8vLC2Rbi1KEBggpo0fS6l0S1nnapwmIi3yW/+GOJap1Krg4w0Hg80oCqgQ==",
|
||||
"dependencies": {
|
||||
"ms": "^2.1.3"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=6.0"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"supports-color": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/engine.io-client/node_modules/ws": {
|
||||
"version": "8.17.1",
|
||||
"resolved": "https://registry.npmjs.org/ws/-/ws-8.17.1.tgz",
|
||||
"integrity": "sha512-6XQFvXTkbfUOZOKKILFG1PDK2NDQs4azKQl26T0YS5CxqWLgXajbPZ+h4gZekJyRqFU8pvnbAbbs/3TgRPy+GQ==",
|
||||
"engines": {
|
||||
"node": ">=10.0.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"bufferutil": "^4.0.1",
|
||||
"utf-8-validate": ">=5.0.2"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"bufferutil": {
|
||||
"optional": true
|
||||
},
|
||||
"utf-8-validate": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/engine.io-parser": {
|
||||
"version": "5.2.3",
|
||||
"resolved": "https://registry.npmjs.org/engine.io-parser/-/engine.io-parser-5.2.3.tgz",
|
||||
"integrity": "sha512-HqD3yTBfnBxIrbnM1DoD6Pcq8NECnh8d4As1Qgh0z5Gg3jRRIqijury0CL3ghu/edArpUYiYqQiDUQBIs4np3Q==",
|
||||
"engines": {
|
||||
"node": ">=10.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/esbuild": {
|
||||
"version": "0.25.10",
|
||||
"resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.25.10.tgz",
|
||||
@@ -2565,6 +2686,32 @@
|
||||
"url": "https://github.com/sponsors/rawify"
|
||||
}
|
||||
},
|
||||
"node_modules/framer-motion": {
|
||||
"version": "12.23.24",
|
||||
"resolved": "https://registry.npmjs.org/framer-motion/-/framer-motion-12.23.24.tgz",
|
||||
"integrity": "sha512-HMi5HRoRCTou+3fb3h9oTLyJGBxHfW+HnNE25tAXOvVx/IvwMHK0cx7IR4a2ZU6sh3IX1Z+4ts32PcYBOqka8w==",
|
||||
"dependencies": {
|
||||
"motion-dom": "^12.23.23",
|
||||
"motion-utils": "^12.23.6",
|
||||
"tslib": "^2.4.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@emotion/is-prop-valid": "*",
|
||||
"react": "^18.0.0 || ^19.0.0",
|
||||
"react-dom": "^18.0.0 || ^19.0.0"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"@emotion/is-prop-valid": {
|
||||
"optional": true
|
||||
},
|
||||
"react": {
|
||||
"optional": true
|
||||
},
|
||||
"react-dom": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/fsevents": {
|
||||
"version": "2.3.3",
|
||||
"resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz",
|
||||
@@ -2835,6 +2982,11 @@
|
||||
"jiti": "bin/jiti.js"
|
||||
}
|
||||
},
|
||||
"node_modules/js-tokens": {
|
||||
"version": "4.0.0",
|
||||
"resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz",
|
||||
"integrity": "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ=="
|
||||
},
|
||||
"node_modules/js-yaml": {
|
||||
"version": "4.1.0",
|
||||
"resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.1.0.tgz",
|
||||
@@ -2948,6 +3100,17 @@
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/loose-envify": {
|
||||
"version": "1.4.0",
|
||||
"resolved": "https://registry.npmjs.org/loose-envify/-/loose-envify-1.4.0.tgz",
|
||||
"integrity": "sha512-lyuxPGr/Wfhrlem2CL/UcnUc1zcqKAImBDzukY7Y5F/yQiNdko6+fRLevlw1HgMySw7f611UIY408EtxRSoK3Q==",
|
||||
"dependencies": {
|
||||
"js-tokens": "^3.0.0 || ^4.0.0"
|
||||
},
|
||||
"bin": {
|
||||
"loose-envify": "cli.js"
|
||||
}
|
||||
},
|
||||
"node_modules/lru-cache": {
|
||||
"version": "10.4.3",
|
||||
"resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-10.4.3.tgz",
|
||||
@@ -3002,11 +3165,48 @@
|
||||
"node": ">=16 || 14 >=14.17"
|
||||
}
|
||||
},
|
||||
"node_modules/motion": {
|
||||
"version": "12.23.24",
|
||||
"resolved": "https://registry.npmjs.org/motion/-/motion-12.23.24.tgz",
|
||||
"integrity": "sha512-Rc5E7oe2YZ72N//S3QXGzbnXgqNrTESv8KKxABR20q2FLch9gHLo0JLyYo2hZ238bZ9Gx6cWhj9VO0IgwbMjCw==",
|
||||
"dependencies": {
|
||||
"framer-motion": "^12.23.24",
|
||||
"tslib": "^2.4.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@emotion/is-prop-valid": "*",
|
||||
"react": "^18.0.0 || ^19.0.0",
|
||||
"react-dom": "^18.0.0 || ^19.0.0"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"@emotion/is-prop-valid": {
|
||||
"optional": true
|
||||
},
|
||||
"react": {
|
||||
"optional": true
|
||||
},
|
||||
"react-dom": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/motion-dom": {
|
||||
"version": "12.23.23",
|
||||
"resolved": "https://registry.npmjs.org/motion-dom/-/motion-dom-12.23.23.tgz",
|
||||
"integrity": "sha512-n5yolOs0TQQBRUFImrRfs/+6X4p3Q4n1dUEqt/H58Vx7OW6RF+foWEgmTVDhIWJIMXOuNNL0apKH2S16en9eiA==",
|
||||
"dependencies": {
|
||||
"motion-utils": "^12.23.6"
|
||||
}
|
||||
},
|
||||
"node_modules/motion-utils": {
|
||||
"version": "12.23.6",
|
||||
"resolved": "https://registry.npmjs.org/motion-utils/-/motion-utils-12.23.6.tgz",
|
||||
"integrity": "sha512-eAWoPgr4eFEOFfg2WjIsMoqJTW6Z8MTUCgn/GZ3VRpClWBdnbjryiA3ZSNLyxCTmCQx4RmYX6jX1iWHbenUPNQ=="
|
||||
},
|
||||
"node_modules/ms": {
|
||||
"version": "2.1.3",
|
||||
"resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz",
|
||||
"integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/mz": {
|
||||
@@ -3078,7 +3278,6 @@
|
||||
"version": "4.1.1",
|
||||
"resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz",
|
||||
"integrity": "sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=0.10.0"
|
||||
@@ -3421,6 +3620,16 @@
|
||||
"node": ">= 0.8.0"
|
||||
}
|
||||
},
|
||||
"node_modules/prop-types": {
|
||||
"version": "15.8.1",
|
||||
"resolved": "https://registry.npmjs.org/prop-types/-/prop-types-15.8.1.tgz",
|
||||
"integrity": "sha512-oj87CgZICdulUohogVAR7AjlC0327U4el4L6eAvOqCeudMDVU0NThNaV+b9Df4dXgSP1gXMTnPdhfe/2qDH5cg==",
|
||||
"dependencies": {
|
||||
"loose-envify": "^1.4.0",
|
||||
"object-assign": "^4.1.1",
|
||||
"react-is": "^16.13.1"
|
||||
}
|
||||
},
|
||||
"node_modules/punycode": {
|
||||
"version": "2.3.1",
|
||||
"resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz",
|
||||
@@ -3431,6 +3640,11 @@
|
||||
"node": ">=6"
|
||||
}
|
||||
},
|
||||
"node_modules/qr.js": {
|
||||
"version": "0.0.0",
|
||||
"resolved": "https://registry.npmjs.org/qr.js/-/qr.js-0.0.0.tgz",
|
||||
"integrity": "sha512-c4iYnWb+k2E+vYpRimHqSu575b1/wKl4XFeJGpFmrJQz5I88v9aY2czh7s0w36srfCM1sXgC/xpoJz5dJfq+OQ=="
|
||||
},
|
||||
"node_modules/queue-microtask": {
|
||||
"version": "1.2.3",
|
||||
"resolved": "https://registry.npmjs.org/queue-microtask/-/queue-microtask-1.2.3.tgz",
|
||||
@@ -3473,6 +3687,23 @@
|
||||
"react": "^19.2.0"
|
||||
}
|
||||
},
|
||||
"node_modules/react-is": {
|
||||
"version": "16.13.1",
|
||||
"resolved": "https://registry.npmjs.org/react-is/-/react-is-16.13.1.tgz",
|
||||
"integrity": "sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ=="
|
||||
},
|
||||
"node_modules/react-qr-code": {
|
||||
"version": "2.0.18",
|
||||
"resolved": "https://registry.npmjs.org/react-qr-code/-/react-qr-code-2.0.18.tgz",
|
||||
"integrity": "sha512-v1Jqz7urLMhkO6jkgJuBYhnqvXagzceg3qJUWayuCK/c6LTIonpWbwxR1f1APGd4xrW/QcQEovNrAojbUz65Tg==",
|
||||
"dependencies": {
|
||||
"prop-types": "^15.8.1",
|
||||
"qr.js": "0.0.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"react": "*"
|
||||
}
|
||||
},
|
||||
"node_modules/react-router": {
|
||||
"version": "7.9.3",
|
||||
"resolved": "https://registry.npmjs.org/react-router/-/react-router-7.9.3.tgz",
|
||||
@@ -3632,6 +3863,11 @@
|
||||
"integrity": "sha512-eNv+WrVbKu1f3vbYJT/xtiF5syA5HPIMtf9IgY/nKg0sWqzAUEvqY/xm7OcZc/qafLx/iO9FgOmeSAp4v5ti/Q==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/sdp": {
|
||||
"version": "3.2.1",
|
||||
"resolved": "https://registry.npmjs.org/sdp/-/sdp-3.2.1.tgz",
|
||||
"integrity": "sha512-lwsAIzOPlH8/7IIjjz3K0zYBk7aBVVcvjMwt3M4fLxpjMYyy7i3I97SLHebgn4YBjirkzfp3RvRDWSKsh/+WFw=="
|
||||
},
|
||||
"node_modules/semver": {
|
||||
"version": "7.7.3",
|
||||
"resolved": "https://registry.npmjs.org/semver/-/semver-7.7.3.tgz",
|
||||
@@ -3687,6 +3923,64 @@
|
||||
"url": "https://github.com/sponsors/isaacs"
|
||||
}
|
||||
},
|
||||
"node_modules/socket.io-client": {
|
||||
"version": "4.8.1",
|
||||
"resolved": "https://registry.npmjs.org/socket.io-client/-/socket.io-client-4.8.1.tgz",
|
||||
"integrity": "sha512-hJVXfu3E28NmzGk8o1sHhN3om52tRvwYeidbj7xKy2eIIse5IoKX3USlS6Tqt3BHAtflLIkCQBkzVrEEfWUyYQ==",
|
||||
"dependencies": {
|
||||
"@socket.io/component-emitter": "~3.1.0",
|
||||
"debug": "~4.3.2",
|
||||
"engine.io-client": "~6.6.1",
|
||||
"socket.io-parser": "~4.2.4"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=10.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/socket.io-client/node_modules/debug": {
|
||||
"version": "4.3.7",
|
||||
"resolved": "https://registry.npmjs.org/debug/-/debug-4.3.7.tgz",
|
||||
"integrity": "sha512-Er2nc/H7RrMXZBFCEim6TCmMk02Z8vLC2Rbi1KEBggpo0fS6l0S1nnapwmIi3yW/+GOJap1Krg4w0Hg80oCqgQ==",
|
||||
"dependencies": {
|
||||
"ms": "^2.1.3"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=6.0"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"supports-color": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/socket.io-parser": {
|
||||
"version": "4.2.4",
|
||||
"resolved": "https://registry.npmjs.org/socket.io-parser/-/socket.io-parser-4.2.4.tgz",
|
||||
"integrity": "sha512-/GbIKmo8ioc+NIWIhwdecY0ge+qVBSMdgxGygevmdHj24bsfgtCmcUUcQ5ZzcylGFHsN3k4HB4Cgkl96KVnuew==",
|
||||
"dependencies": {
|
||||
"@socket.io/component-emitter": "~3.1.0",
|
||||
"debug": "~4.3.1"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=10.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/socket.io-parser/node_modules/debug": {
|
||||
"version": "4.3.7",
|
||||
"resolved": "https://registry.npmjs.org/debug/-/debug-4.3.7.tgz",
|
||||
"integrity": "sha512-Er2nc/H7RrMXZBFCEim6TCmMk02Z8vLC2Rbi1KEBggpo0fS6l0S1nnapwmIi3yW/+GOJap1Krg4w0Hg80oCqgQ==",
|
||||
"dependencies": {
|
||||
"ms": "^2.1.3"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=6.0"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"supports-color": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/source-map-js": {
|
||||
"version": "1.2.1",
|
||||
"resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz",
|
||||
@@ -4005,6 +4299,11 @@
|
||||
"dev": true,
|
||||
"license": "Apache-2.0"
|
||||
},
|
||||
"node_modules/tslib": {
|
||||
"version": "2.8.1",
|
||||
"resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz",
|
||||
"integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w=="
|
||||
},
|
||||
"node_modules/type-check": {
|
||||
"version": "0.4.0",
|
||||
"resolved": "https://registry.npmjs.org/type-check/-/type-check-0.4.0.tgz",
|
||||
@@ -4060,7 +4359,6 @@
|
||||
"version": "7.14.0",
|
||||
"resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.14.0.tgz",
|
||||
"integrity": "sha512-QQiYxHuyZ9gQUIrmPo3IA+hUl4KYk8uSA7cHrcKd/l3p1OTpZcM0Tbp9x7FAtXdAYhlasd60ncPpgu6ihG6TOA==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/update-browserslist-db": {
|
||||
@@ -4111,6 +4409,18 @@
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/uuid": {
|
||||
"version": "13.0.0",
|
||||
"resolved": "https://registry.npmjs.org/uuid/-/uuid-13.0.0.tgz",
|
||||
"integrity": "sha512-XQegIaBTVUjSHliKqcnFqYypAd4S+WCYt5NIeRs6w/UAry7z8Y9j5ZwRRL4kzq9U3sD6v+85er9FvkEaBpji2w==",
|
||||
"funding": [
|
||||
"https://github.com/sponsors/broofa",
|
||||
"https://github.com/sponsors/ctavan"
|
||||
],
|
||||
"bin": {
|
||||
"uuid": "dist-node/bin/uuid"
|
||||
}
|
||||
},
|
||||
"node_modules/vite": {
|
||||
"version": "7.1.9",
|
||||
"resolved": "https://registry.npmjs.org/vite/-/vite-7.1.9.tgz",
|
||||
@@ -4338,6 +4648,34 @@
|
||||
"url": "https://github.com/chalk/ansi-styles?sponsor=1"
|
||||
}
|
||||
},
|
||||
"node_modules/ws": {
|
||||
"version": "8.18.3",
|
||||
"resolved": "https://registry.npmjs.org/ws/-/ws-8.18.3.tgz",
|
||||
"integrity": "sha512-PEIGCY5tSlUt50cqyMXfCzX+oOPqN0vuGqWzbcJ2xvnkzkq46oOpz7dQaTDBdfICb4N14+GARUDw2XV2N4tvzg==",
|
||||
"engines": {
|
||||
"node": ">=10.0.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"bufferutil": "^4.0.1",
|
||||
"utf-8-validate": ">=5.0.2"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"bufferutil": {
|
||||
"optional": true
|
||||
},
|
||||
"utf-8-validate": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/xmlhttprequest-ssl": {
|
||||
"version": "2.1.2",
|
||||
"resolved": "https://registry.npmjs.org/xmlhttprequest-ssl/-/xmlhttprequest-ssl-2.1.2.tgz",
|
||||
"integrity": "sha512-TEU+nJVUUnA4CYJFLvK5X9AOeH4KvDvhIfm0vV1GaQRtchnG0hgK5p8hw/xjv8cunWYCsiPCSDzObPyhEwq3KQ==",
|
||||
"engines": {
|
||||
"node": ">=0.4.0"
|
||||
}
|
||||
},
|
||||
"node_modules/yocto-queue": {
|
||||
"version": "0.1.0",
|
||||
"resolved": "https://registry.npmjs.org/yocto-queue/-/yocto-queue-0.1.0.tgz",
|
||||
|
||||
@@ -20,6 +20,8 @@
|
||||
"react-dom": "^19.1.1",
|
||||
"react-qr-code": "^2.0.18",
|
||||
"react-router": "^7.9.3",
|
||||
"socket.io-client": "^4.8.1",
|
||||
"uuid": "^13.0.0",
|
||||
"zustand": "^5.0.8"
|
||||
},
|
||||
"devDependencies": {
|
||||
@@ -27,6 +29,7 @@
|
||||
"@types/node": "^24.6.0",
|
||||
"@types/react": "^19.1.16",
|
||||
"@types/react-dom": "^19.1.9",
|
||||
"@types/uuid": "^11.0.0",
|
||||
"@vitejs/plugin-react-swc": "^4.1.0",
|
||||
"autoprefixer": "^10.4.21",
|
||||
"eslint": "^9.36.0",
|
||||
|
||||
@@ -1,190 +0,0 @@
|
||||
import { useState, useRef, useEffect, useCallback } from "react";
|
||||
import UserCamera from "./ui/UserCamera";
|
||||
import UserDevicesControls from "./ui/UserDevicesControls";
|
||||
import clsx from "clsx";
|
||||
|
||||
const DRAG_THRESHOLD = 15;
|
||||
const OFFSET = 0.01111; // 1.111vw
|
||||
const TRANSITION = "all 0.5s cubic-bezier(.63,.08,.37,.89)";
|
||||
|
||||
export default function SessionUsersPanel() {
|
||||
const users = [
|
||||
{
|
||||
id: 1,
|
||||
name: "John Doe",
|
||||
isSpeaking: true,
|
||||
isMuted: false,
|
||||
isVideoOff: false,
|
||||
isControlDisabled: false,
|
||||
isAdmin: true,
|
||||
},
|
||||
{
|
||||
id: 2,
|
||||
name: "Jane Doe",
|
||||
isSpeaking: false,
|
||||
isMuted: true,
|
||||
isVideoOff: true,
|
||||
isControlDisabled: true,
|
||||
},
|
||||
{
|
||||
id: 3,
|
||||
name: "Jim Doe",
|
||||
isSpeaking: false,
|
||||
isMuted: false,
|
||||
isVideoOff: false,
|
||||
isControlDisabled: false,
|
||||
},
|
||||
];
|
||||
|
||||
const [corner, setCorner] = useState({ top: false, left: false });
|
||||
const [dragState, setDragState] = useState<"idle" | "dragging" | "snapping" | "released">("idle");
|
||||
const [position, setPosition] = useState({ x: 0, y: 0 });
|
||||
|
||||
const containerRef = useRef<HTMLDivElement>(null);
|
||||
const dragDataRef = useRef({ offsetX: 0, offsetY: 0, startX: 0, startY: 0, hasStarted: false });
|
||||
|
||||
const getPointerPos = (e: MouseEvent | TouchEvent | React.MouseEvent | React.TouchEvent) => ({
|
||||
x: "touches" in e ? e.touches[0].clientX : e.clientX,
|
||||
y: "touches" in e ? e.touches[0].clientY : e.clientY,
|
||||
});
|
||||
|
||||
const handleMove = useCallback((e: MouseEvent | TouchEvent) => {
|
||||
if (!containerRef.current) return;
|
||||
|
||||
const pos = getPointerPos(e);
|
||||
const { startX, startY, offsetX, offsetY, hasStarted } = dragDataRef.current;
|
||||
|
||||
if (!hasStarted) {
|
||||
const distance = Math.hypot(pos.x - startX, pos.y - startY);
|
||||
if (distance < DRAG_THRESHOLD) return;
|
||||
|
||||
dragDataRef.current.hasStarted = true;
|
||||
setDragState("dragging");
|
||||
}
|
||||
|
||||
setPosition({ x: pos.x - offsetX, y: pos.y - offsetY });
|
||||
}, []);
|
||||
|
||||
const handleEnd = useCallback(() => {
|
||||
if (!containerRef.current) return;
|
||||
|
||||
const rect = containerRef.current.getBoundingClientRect();
|
||||
const centerX = rect.left + rect.width / 2;
|
||||
const centerY = rect.top + rect.height / 2;
|
||||
const shouldBeTop = centerY < window.innerHeight / 2;
|
||||
const shouldBeLeft = centerX < window.innerWidth / 2;
|
||||
|
||||
if (dragDataRef.current.hasStarted) {
|
||||
// Фиксируем текущую позицию без transition
|
||||
setPosition({ x: rect.left, y: rect.top });
|
||||
setDragState("released");
|
||||
|
||||
// Запускаем анимацию к углу
|
||||
requestAnimationFrame(() => {
|
||||
requestAnimationFrame(() => {
|
||||
setDragState("snapping");
|
||||
setCorner({ top: shouldBeTop, left: shouldBeLeft });
|
||||
setTimeout(() => setDragState("idle"), 500);
|
||||
});
|
||||
});
|
||||
} else {
|
||||
setDragState("idle");
|
||||
}
|
||||
|
||||
dragDataRef.current.hasStarted = false;
|
||||
window.removeEventListener("mousemove", handleMove);
|
||||
window.removeEventListener("touchmove", handleMove);
|
||||
window.removeEventListener("mouseup", handleEnd);
|
||||
window.removeEventListener("touchend", handleEnd);
|
||||
}, [handleMove]);
|
||||
|
||||
const handleStart = (e: React.MouseEvent | React.TouchEvent) => {
|
||||
if (!containerRef.current) return;
|
||||
|
||||
const rect = containerRef.current.getBoundingClientRect();
|
||||
const pos = getPointerPos(e);
|
||||
|
||||
dragDataRef.current = {
|
||||
startX: pos.x,
|
||||
startY: pos.y,
|
||||
offsetX: pos.x - rect.left,
|
||||
offsetY: pos.y - rect.top,
|
||||
hasStarted: false,
|
||||
};
|
||||
|
||||
setPosition({ x: rect.left, y: rect.top });
|
||||
|
||||
window.addEventListener("mousemove", handleMove);
|
||||
window.addEventListener("touchmove", handleMove);
|
||||
window.addEventListener("mouseup", handleEnd);
|
||||
window.addEventListener("touchend", handleEnd);
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
window.removeEventListener("mousemove", handleMove);
|
||||
window.removeEventListener("touchmove", handleMove);
|
||||
window.removeEventListener("mouseup", handleEnd);
|
||||
window.removeEventListener("touchend", handleEnd);
|
||||
};
|
||||
}, [handleMove, handleEnd]);
|
||||
|
||||
const offset = window.innerWidth * OFFSET;
|
||||
|
||||
// Вычисляем финальные координаты угла
|
||||
const getCornerPosition = () => {
|
||||
if (!containerRef.current) return { x: offset, y: offset };
|
||||
const rect = containerRef.current.getBoundingClientRect();
|
||||
return {
|
||||
x: corner.left ? offset : window.innerWidth - offset - rect.width,
|
||||
y: corner.top ? offset : window.innerHeight - offset - rect.height,
|
||||
};
|
||||
};
|
||||
|
||||
let style: React.CSSProperties;
|
||||
if (dragState === "dragging" || dragState === "released") {
|
||||
// Во время перетаскивания или сразу после отпускания
|
||||
style = {
|
||||
left: position.x,
|
||||
top: position.y,
|
||||
transition: "none"
|
||||
};
|
||||
} else {
|
||||
// Анимация к углу или покой в углу
|
||||
const cornerPos = getCornerPosition();
|
||||
style = {
|
||||
left: cornerPos.x,
|
||||
top: cornerPos.y,
|
||||
transition: dragState === "snapping" ? TRANSITION : "none",
|
||||
};
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={containerRef}
|
||||
onMouseDown={handleStart}
|
||||
onTouchStart={handleStart}
|
||||
className="flex absolute gap-4 active:cursor-grabbing cursor-grab"
|
||||
style={style}
|
||||
>
|
||||
<div
|
||||
className={clsx(
|
||||
"flex gap-4 w-max",
|
||||
corner.left ? "flex-row-reverse" : "flex-row",
|
||||
corner.top ? "items-start" : "items-end"
|
||||
)}
|
||||
>
|
||||
{users.map((user) => (
|
||||
<UserCamera
|
||||
key={user.id}
|
||||
onMute={() => console.log(`Mute user ${user.id}`)}
|
||||
onVideoOff={() => console.log(`Video off user ${user.id}`)}
|
||||
onCanControl={() => console.log(`Can control user ${user.id}`)}
|
||||
{...user}
|
||||
/>
|
||||
))}
|
||||
<UserDevicesControls />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,36 +1,28 @@
|
||||
import UserCamera from "./ui/UserCamera";
|
||||
import UserDevicesControls from "./ui/UserDevicesControls";
|
||||
import DraggableContainer from "./DraggableContainer";
|
||||
import { useWebRTC } from "../hooks/useWebRTC";
|
||||
|
||||
const users = [
|
||||
{
|
||||
id: 1,
|
||||
name: "John Doe",
|
||||
isSpeaking: true,
|
||||
isMuted: false,
|
||||
isVideoOff: false,
|
||||
isControlDisabled: false,
|
||||
isAdmin: true,
|
||||
},
|
||||
{
|
||||
id: 2,
|
||||
name: "Jane Doe",
|
||||
isSpeaking: false,
|
||||
isMuted: true,
|
||||
isVideoOff: true,
|
||||
isControlDisabled: true,
|
||||
},
|
||||
{
|
||||
id: 3,
|
||||
name: "Jim Doe",
|
||||
isSpeaking: false,
|
||||
isMuted: false,
|
||||
isVideoOff: false,
|
||||
isControlDisabled: false,
|
||||
},
|
||||
];
|
||||
interface SessionUsersPanel2Props {
|
||||
roomId: string;
|
||||
autoJoin?: boolean;
|
||||
}
|
||||
|
||||
function SessionUsersPanel2({
|
||||
roomId,
|
||||
autoJoin = false,
|
||||
}: SessionUsersPanel2Props) {
|
||||
const {
|
||||
localStream,
|
||||
participants,
|
||||
isAudioMuted: isLocalAudioMuted,
|
||||
isVideoMuted: isLocalVideoMuted,
|
||||
toggleAudio,
|
||||
toggleVideo,
|
||||
} = useWebRTC(roomId, autoJoin);
|
||||
|
||||
const hasLocalStream = localStream !== null;
|
||||
|
||||
function SessionUsersPanel2() {
|
||||
return (
|
||||
<DraggableContainer
|
||||
enableSnapping={true}
|
||||
@@ -39,17 +31,45 @@ function SessionUsersPanel2() {
|
||||
padding="1.111vw"
|
||||
className="flex gap-4 z-[999]"
|
||||
>
|
||||
{users.map((user) => (
|
||||
{/* Локальная камера пользователя */}
|
||||
<UserCamera
|
||||
name="Вы"
|
||||
isSpeaking={false}
|
||||
isMuted={isLocalAudioMuted}
|
||||
isVideoOff={isLocalVideoMuted}
|
||||
isControlDisabled={false}
|
||||
isAdmin={true}
|
||||
isLocal={true}
|
||||
mediaStream={localStream}
|
||||
onMute={toggleAudio}
|
||||
onVideoOff={toggleVideo}
|
||||
onCanControl={() => console.log("Toggle control")}
|
||||
/>
|
||||
|
||||
{/* Камеры удаленных участников */}
|
||||
{participants.map((participant) => (
|
||||
<UserCamera
|
||||
key={user.id}
|
||||
onMute={() => console.log(`Mute user ${user.id}`)}
|
||||
onVideoOff={() => console.log(`Video off user ${user.id}`)}
|
||||
onCanControl={() => console.log(`Can control user ${user.id}`)}
|
||||
{...user}
|
||||
key={participant.id}
|
||||
name={participant.id}
|
||||
isSpeaking={false}
|
||||
isMuted={false}
|
||||
isVideoOff={false}
|
||||
isControlDisabled={true}
|
||||
isAdmin={true} // Локальный пользователь - админ своей сессии
|
||||
mediaStream={participant.stream}
|
||||
onMute={() => console.log(`Mute user ${participant.id}`)}
|
||||
onVideoOff={() => console.log(`Video off user ${participant.id}`)}
|
||||
onCanControl={() => console.log(`Can control user ${participant.id}`)}
|
||||
/>
|
||||
))}
|
||||
|
||||
<UserDevicesControls />
|
||||
<UserDevicesControls
|
||||
toggleAudio={toggleAudio}
|
||||
toggleVideo={toggleVideo}
|
||||
isAudioMuted={isLocalAudioMuted}
|
||||
isVideoMuted={isLocalVideoMuted}
|
||||
hasLocalStream={hasLocalStream}
|
||||
/>
|
||||
</DraggableContainer>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,21 @@
|
||||
function VolumeIcon() {
|
||||
return (
|
||||
<svg viewBox="0 0 20 20" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path
|
||||
d="M9.583 3.75a.833.833 0 0 0-.916.183l-3.5 3.5a.833.833 0 0 0-.584.244H3.333a.833.833 0 0 0-.833.833v3.333a.833.833 0 0 0 .833.834h1.25a.833.833 0 0 0 .584.243l3.5 3.5a.833.833 0 0 0 1.416-.583V4.167a.833.833 0 0 0-.5-.417"
|
||||
fill="currentColor"
|
||||
/>
|
||||
<path
|
||||
d="M13.096 6.905a.625.625 0 1 0-.884.884A3.333 3.333 0 0 1 13.333 10a3.333 3.333 0 0 1-1.121 2.212.625.625 0 1 0 .884.883A4.583 4.583 0 0 0 14.583 10a4.583 4.583 0 0 0-1.487-3.095"
|
||||
fill="currentColor"
|
||||
/>
|
||||
<path
|
||||
d="M14.762 5.238a.625.625 0 0 0-.884.884A5.833 5.833 0 0 1 15.833 10a5.833 5.833 0 0 1-1.955 4.378.625.625 0 0 0 .884.884A7.083 7.083 0 0 0 17.083 10a7.083 7.083 0 0 0-2.321-5.262"
|
||||
fill="currentColor"
|
||||
/>
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
export default VolumeIcon;
|
||||
|
||||
@@ -0,0 +1,17 @@
|
||||
function VolumeOffIcon() {
|
||||
return (
|
||||
<svg viewBox="0 0 20 20" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path
|
||||
d="M9.583 3.75a.833.833 0 0 0-.916.183l-3.5 3.5a.833.833 0 0 0-.584.244H3.333a.833.833 0 0 0-.833.833v3.333a.833.833 0 0 0 .833.834h1.25a.833.833 0 0 0 .584.243l3.5 3.5a.833.833 0 0 0 1.416-.583V4.167a.833.833 0 0 0-.5-.417"
|
||||
fill="currentColor"
|
||||
/>
|
||||
<path
|
||||
d="M13.232 7.768a.625.625 0 1 0-.884.884L14.116 10l-1.768 1.768a.625.625 0 1 0 .884.884L15 11.116l1.768 1.768a.625.625 0 1 0 .884-.884L15.884 10l1.768-1.768a.625.625 0 1 0-.884-.884L15 9.116z"
|
||||
fill="currentColor"
|
||||
/>
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
export default VolumeOffIcon;
|
||||
|
||||
@@ -1,36 +1,18 @@
|
||||
import { useRef, useState, useEffect } from "react";
|
||||
import { useRef, useEffect, useState } from "react";
|
||||
import SendIcon from "../icons/SendIcon";
|
||||
import Button from "../ui/Button";
|
||||
import { useMe } from "../../hooks/useAuth";
|
||||
import clsx from "clsx";
|
||||
import PopupWrapper from "../PopupWrapper";
|
||||
import DraggableContainer from "../DraggableContainer";
|
||||
import { useWebRTC } from "../../hooks/useWebRTC";
|
||||
|
||||
export default function ChatPopup() {
|
||||
const headerRef = useRef<HTMLDivElement>(null);
|
||||
const [messages, setMessages] = useState<MessageItemProps[]>([
|
||||
{
|
||||
senderId: "1",
|
||||
timestamp: "12:22",
|
||||
content:
|
||||
"У меня все сломалось, ничего не работает, картинки нет, все пошло по пизде, помогите мне кто-нибудь, пожалуйста",
|
||||
},
|
||||
{
|
||||
senderId: "2",
|
||||
timestamp: "12:22",
|
||||
content: "🤡🤡🤡",
|
||||
},
|
||||
]);
|
||||
const { chatMessages, sendMessage, currentUserId } = useWebRTC();
|
||||
|
||||
function onMessageSend(message: string) {
|
||||
setMessages([
|
||||
...messages,
|
||||
{
|
||||
senderId: "2",
|
||||
timestamp: "12:22",
|
||||
content: message,
|
||||
},
|
||||
]);
|
||||
sendMessage(message);
|
||||
}
|
||||
|
||||
return (
|
||||
@@ -43,7 +25,7 @@ export default function ChatPopup() {
|
||||
>
|
||||
<PopupWrapper title="Чат" className="sm:overflow-hidden" headerRef={headerRef}>
|
||||
<div className="flex flex-col 2xl:h-[19.444vw] max-sm:h-[87.5dvh] 2xl:-m-[1.389vw] -m-5">
|
||||
<MessageFeed messages={messages} />
|
||||
<MessageFeed messages={chatMessages} currentUserId={currentUserId} />
|
||||
<MessageInput onMessageSend={onMessageSend} />
|
||||
</div>
|
||||
</PopupWrapper>
|
||||
@@ -51,7 +33,17 @@ export default function ChatPopup() {
|
||||
);
|
||||
}
|
||||
|
||||
function MessageFeed({ messages }: { messages: MessageItemProps[] }) {
|
||||
interface MessageFeedProps {
|
||||
messages: Array<{
|
||||
id: string;
|
||||
senderId: string;
|
||||
content: string;
|
||||
timestamp: Date;
|
||||
}>;
|
||||
currentUserId: string;
|
||||
}
|
||||
|
||||
function MessageFeed({ messages, currentUserId }: MessageFeedProps) {
|
||||
const messagesEndRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
// Скролл к концу при получении нового сообщения
|
||||
@@ -76,8 +68,16 @@ function MessageFeed({ messages }: { messages: MessageItemProps[] }) {
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex flex-col 2xl:gap-[1.111vw] gap-4 items-end mt-auto">
|
||||
{messages.map((message, index) => (
|
||||
<MessageItem key={index} {...message} />
|
||||
{messages.map((message) => (
|
||||
<MessageItem
|
||||
key={message.id}
|
||||
content={message.content}
|
||||
timestamp={new Date(message.timestamp).toLocaleTimeString('ru-RU', {
|
||||
hour: '2-digit',
|
||||
minute: '2-digit'
|
||||
})}
|
||||
isFromMe={message.senderId === currentUserId}
|
||||
/>
|
||||
))}
|
||||
<div ref={messagesEndRef} />
|
||||
</div>
|
||||
@@ -87,16 +87,14 @@ function MessageFeed({ messages }: { messages: MessageItemProps[] }) {
|
||||
}
|
||||
|
||||
interface MessageItemProps {
|
||||
senderId: string;
|
||||
timestamp: string;
|
||||
content: string;
|
||||
isFromMe: boolean;
|
||||
}
|
||||
|
||||
function MessageItem({ senderId, timestamp, content }: MessageItemProps) {
|
||||
function MessageItem({ timestamp, content, isFromMe }: MessageItemProps) {
|
||||
const { data: user } = useMe();
|
||||
|
||||
const isFromMe = senderId === "1";
|
||||
|
||||
return (
|
||||
<div
|
||||
className={clsx(
|
||||
|
||||
@@ -7,14 +7,25 @@ import XMarkFilledIcon from "../icons/XMarkFilledIcon";
|
||||
import Avatar from "../ui/Avatar";
|
||||
import Button from "../ui/Button";
|
||||
import ShareFilledIcon from "../icons/ShareFilledIcon";
|
||||
import HandRaisedOffFilledIcon from "../icons/HandRaisedOffFilledIcon";
|
||||
import MicrophoneOffFilledIcon from "../icons/MicrophoneOffFilledIcon";
|
||||
import { Fragment, useRef } from "react";
|
||||
import DraggableContainer from "../DraggableContainer";
|
||||
import { useWebRTC } from "../../hooks/useWebRTC";
|
||||
import type { Participant } from "../../lib/webrtc";
|
||||
|
||||
export default function ParticipantsPopup() {
|
||||
const participants = [1, 2, 3, 4, 5, 6, 7, 8, 9, 10];
|
||||
const { participants, currentUserId, localStream } = useWebRTC();
|
||||
const headerRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
// Добавляем локального пользователя в начало списка
|
||||
const allParticipants: (Participant & { isLocal?: boolean })[] = [
|
||||
{
|
||||
id: currentUserId,
|
||||
stream: localStream || undefined,
|
||||
isLocal: true,
|
||||
},
|
||||
...participants,
|
||||
];
|
||||
|
||||
return (
|
||||
<DraggableContainer
|
||||
@@ -27,12 +38,21 @@ export default function ParticipantsPopup() {
|
||||
<PopupWrapper title="Участники" headerRef={headerRef}>
|
||||
<div className="flex flex-col 2xl:gap-[1.667vw] gap-6 2xl:-mt-[1.389vw] -mt-5">
|
||||
<div className="flex flex-col gap-4 2xl:gap-[1.111vw] 2xl:max-h-[calc(11.944vw+1.389vw)] max-h-[73.75dvh] overflow-y-auto 2xl:pt-[1.389vw] pt-5">
|
||||
{participants.map((participant, index) => (
|
||||
<Fragment key={index}>
|
||||
<ParticipantItem id={participant.toString()} />
|
||||
<hr className="w-full 2xl:h-[0.69vw] h-px border-[#F6F6F6] last:hidden" />
|
||||
</Fragment>
|
||||
))}
|
||||
{allParticipants.length === 0 ? (
|
||||
<div className="text-center text-gray-500 py-8">
|
||||
Нет участников
|
||||
</div>
|
||||
) : (
|
||||
allParticipants.map((participant) => (
|
||||
<Fragment key={participant.id}>
|
||||
<ParticipantItem
|
||||
participant={participant}
|
||||
isLocal={participant.isLocal || false}
|
||||
/>
|
||||
<hr className="w-full 2xl:h-[0.69vw] h-px border-[#F6F6F6] last:hidden" />
|
||||
</Fragment>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
<Button
|
||||
variant="primary"
|
||||
@@ -59,59 +79,85 @@ export default function ParticipantsPopup() {
|
||||
);
|
||||
}
|
||||
|
||||
function ParticipantItem({ id }: { id: string }) {
|
||||
const isMuted = true;
|
||||
const isNotControlling = true;
|
||||
|
||||
function ParticipantItem({
|
||||
participant,
|
||||
isLocal
|
||||
}: {
|
||||
participant: Participant & { isLocal?: boolean };
|
||||
isLocal: boolean;
|
||||
}) {
|
||||
const parentRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
// Проверяем наличие аудио/видео треков
|
||||
const hasAudio = participant.stream?.getAudioTracks().some(track => track.enabled) ?? false;
|
||||
const hasVideo = participant.stream?.getVideoTracks().some(track => track.enabled) ?? false;
|
||||
const isMuted = !hasAudio;
|
||||
const isVideoOff = !hasVideo;
|
||||
|
||||
// Определяем статус участника
|
||||
const status: "admin" | "caution" | undefined = participant.stream ? "admin" : "caution";
|
||||
|
||||
return (
|
||||
<div ref={parentRef} className="flex items-center justify-between w-full">
|
||||
<div ref={parentRef} className="flex justify-between items-center w-full">
|
||||
<div className="flex items-center 2xl:gap-[0.833vw] gap-3">
|
||||
<Avatar size="medium" status="caution" />
|
||||
<Avatar size="medium" status={status} />
|
||||
<div className="flex flex-col 2xl:gap-[0.278vw] gap-1">
|
||||
<span className="button-m">Иван Иванович {id}</span>
|
||||
<span className="caption-s text-[#CCCCCC]">Роль</span>
|
||||
<span className="button-m">
|
||||
{isLocal ? "Вы" : participant.name || `Участник ${participant.id.slice(0, 8)}`}
|
||||
</span>
|
||||
<span className="caption-s text-[#CCCCCC]">
|
||||
{isLocal ? "Организатор" : "Участник"}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex 2xl:gap-[0.556vw] gap-2 items-center">
|
||||
{isNotControlling && (
|
||||
<div className="2xl:size-[1.111vw] size-4 text-[#FF4517]">
|
||||
<HandRaisedOffFilledIcon />
|
||||
</div>
|
||||
)}
|
||||
{isMuted && (
|
||||
<div className="2xl:size-[1.111vw] size-4 text-[#FF4517]">
|
||||
<MicrophoneOffFilledIcon />
|
||||
</div>
|
||||
)}
|
||||
{isVideoOff && (
|
||||
<div className="2xl:size-[1.111vw] size-4 text-[#FF4517]">
|
||||
<VideoOffFilledIcon />
|
||||
</div>
|
||||
)}
|
||||
|
||||
<ActionsPopover
|
||||
options={[
|
||||
{
|
||||
icon: <MicrophoneFilledIcon />,
|
||||
label: "Выключить микрофон",
|
||||
onClick: () => {},
|
||||
},
|
||||
{
|
||||
icon: <VideoOffFilledIcon />,
|
||||
label: "Выключить камеру",
|
||||
onClick: () => {},
|
||||
disabled: true,
|
||||
},
|
||||
{
|
||||
icon: <HandRaisedFilledIcon />,
|
||||
label: "Передать управление",
|
||||
onClick: () => {},
|
||||
},
|
||||
{
|
||||
icon: <XMarkFilledIcon />,
|
||||
label: "Удалить со встречи",
|
||||
onClick: () => {},
|
||||
},
|
||||
]}
|
||||
/>
|
||||
{/* Действия только для удаленных участников и только для администратора */}
|
||||
{!isLocal && (
|
||||
<ActionsPopover
|
||||
options={[
|
||||
{
|
||||
icon: <MicrophoneFilledIcon />,
|
||||
label: "Выключить микрофон",
|
||||
onClick: () => {
|
||||
console.log("Mute participant:", participant.id);
|
||||
},
|
||||
},
|
||||
{
|
||||
icon: <VideoOffFilledIcon />,
|
||||
label: "Выключить камеру",
|
||||
onClick: () => {
|
||||
console.log("Turn off video:", participant.id);
|
||||
},
|
||||
},
|
||||
{
|
||||
icon: <HandRaisedFilledIcon />,
|
||||
label: "Передать управление",
|
||||
onClick: () => {
|
||||
console.log("Grant control:", participant.id);
|
||||
},
|
||||
},
|
||||
{
|
||||
icon: <XMarkFilledIcon />,
|
||||
label: "Удалить со встречи",
|
||||
onClick: () => {
|
||||
console.log("Remove participant:", participant.id);
|
||||
},
|
||||
},
|
||||
]}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useEffect, useRef } from "react";
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import HandRaisedOffFilledIcon from "../icons/HandRaisedOffFilledIcon";
|
||||
import HandRaisedFilledIcon from "../icons/HandRaisedFilledIcon";
|
||||
import MicrophoneFilledIcon from "../icons/MicrophoneFilledIcon";
|
||||
@@ -8,21 +8,31 @@ import VideoFilledIcon from "../icons/VideoFilledIcon";
|
||||
import ControlButton from "./ControlButton";
|
||||
import Admin from "../indicators/Admin";
|
||||
import clsx from "clsx";
|
||||
import VolumeIcon from "../icons/VolumeIcon";
|
||||
import VolumeOffIcon from "../icons/VolumeOffIcon";
|
||||
|
||||
interface UserCameraControlsProps {
|
||||
isMuted: boolean;
|
||||
isVideoOff: boolean;
|
||||
isControlDisabled: boolean;
|
||||
isAdmin: boolean;
|
||||
onMute: () => void;
|
||||
onVideoOff: () => void;
|
||||
onCanControl: () => void;
|
||||
}
|
||||
|
||||
interface UserCameraProps {
|
||||
isMuted: boolean;
|
||||
isVideoOff: boolean;
|
||||
isControlDisabled: boolean;
|
||||
onMute: () => void;
|
||||
onVideoOff: () => void;
|
||||
onCanControl: () => void;
|
||||
}
|
||||
|
||||
interface UserCameraProps extends UserCameraControlsProps {
|
||||
isAdmin?: boolean;
|
||||
name?: string;
|
||||
mediaStream?: MediaStream | null;
|
||||
isSpeaking?: boolean;
|
||||
isLocal?: boolean;
|
||||
}
|
||||
|
||||
export default function UserCamera({
|
||||
@@ -36,27 +46,185 @@ export default function UserCamera({
|
||||
isAdmin = false,
|
||||
name = "Гость",
|
||||
mediaStream = null,
|
||||
isLocal = false,
|
||||
}: UserCameraProps) {
|
||||
const ref = useRef<HTMLVideoElement>(null);
|
||||
const [isAudioMuted, setIsAudioMuted] = useState(true); // Для удаленных участников - начинаем с muted
|
||||
|
||||
useEffect(() => {
|
||||
if (ref.current) {
|
||||
if (ref.current && mediaStream) {
|
||||
console.log(
|
||||
`[UserCamera] Setting srcObject for ${name}, isLocal: ${isLocal}, stream:`,
|
||||
mediaStream
|
||||
);
|
||||
ref.current.srcObject = mediaStream;
|
||||
|
||||
// Убеждаемся что видео muted для autoplay
|
||||
if (!isLocal) {
|
||||
ref.current.muted = true;
|
||||
console.log(`[UserCamera] Set muted=true for remote video ${name}`);
|
||||
}
|
||||
|
||||
// Принудительно запускаем воспроизведение
|
||||
ref.current.play().catch((error) => {
|
||||
console.error(`[UserCamera] Failed to play video for ${name}:`, error);
|
||||
});
|
||||
|
||||
// Дополнительная попытка воспроизведения с задержкой для Firefox
|
||||
if (!isLocal) {
|
||||
// Попытка через 500ms
|
||||
setTimeout(() => {
|
||||
if (ref.current) {
|
||||
console.log(`[UserCamera] First retry for ${name}, paused: ${ref.current.paused}, readyState: ${ref.current.readyState}`);
|
||||
if (ref.current.paused) {
|
||||
ref.current.play().catch((error) => {
|
||||
console.error(`[UserCamera] First retry play failed for ${name}:`, error);
|
||||
});
|
||||
}
|
||||
}
|
||||
}, 500);
|
||||
|
||||
// Попытка через 1 секунду
|
||||
setTimeout(() => {
|
||||
if (ref.current && ref.current.paused) {
|
||||
console.log(`[UserCamera] Second retry for ${name} after timeout`);
|
||||
ref.current.play().catch((error) => {
|
||||
console.error(`[UserCamera] Second retry play failed for ${name}:`, error);
|
||||
});
|
||||
}
|
||||
}, 1000);
|
||||
|
||||
// Еще одна попытка через 3 секунды
|
||||
setTimeout(() => {
|
||||
if (ref.current) {
|
||||
console.log(`[UserCamera] Final retry for ${name}, paused: ${ref.current.paused}, readyState: ${ref.current.readyState}`);
|
||||
if (ref.current.paused) {
|
||||
ref.current.play().catch((error) => {
|
||||
console.error(`[UserCamera] Final retry play failed for ${name}:`, error);
|
||||
});
|
||||
}
|
||||
}
|
||||
}, 3000);
|
||||
}
|
||||
} else if (ref.current && !mediaStream) {
|
||||
console.log(`[UserCamera] Clearing srcObject for ${name}`);
|
||||
ref.current.srcObject = null;
|
||||
}
|
||||
}, [mediaStream]);
|
||||
}, [mediaStream, name, isLocal]);
|
||||
|
||||
// Добавляем обработчики событий для отладки
|
||||
useEffect(() => {
|
||||
const videoElement = ref.current;
|
||||
if (!videoElement) return;
|
||||
|
||||
const handleLoadStart = () => {
|
||||
console.log(`[UserCamera] ${name} video loadstart`);
|
||||
};
|
||||
|
||||
const handleLoadedData = () => {
|
||||
console.log(`[UserCamera] ${name} video loadeddata`);
|
||||
};
|
||||
|
||||
const handleCanPlay = () => {
|
||||
console.log(`[UserCamera] ${name} video canplay`);
|
||||
};
|
||||
|
||||
const handleLoadedMetadata = () => {
|
||||
console.log(`[UserCamera] ${name} video loadedmetadata`);
|
||||
};
|
||||
|
||||
const handleCanPlayThrough = () => {
|
||||
console.log(`[UserCamera] ${name} video canplaythrough`);
|
||||
};
|
||||
|
||||
const handlePlay = () => {
|
||||
console.log(`[UserCamera] ${name} video play event`);
|
||||
};
|
||||
|
||||
const handlePlaying = () => {
|
||||
console.log(`[UserCamera] ${name} video playing event`);
|
||||
};
|
||||
|
||||
const handleWaiting = () => {
|
||||
console.log(`[UserCamera] ${name} video waiting event, paused: ${videoElement.paused}, readyState: ${videoElement.readyState}`);
|
||||
};
|
||||
|
||||
const handleStalled = () => {
|
||||
console.log(`[UserCamera] ${name} video stalled event, paused: ${videoElement.paused}, readyState: ${videoElement.readyState}`);
|
||||
};
|
||||
|
||||
const handlePause = () => {
|
||||
console.log(`[UserCamera] ${name} video pause event`);
|
||||
};
|
||||
|
||||
const handleError = (e: Event) => {
|
||||
console.error(`[UserCamera] ${name} video error:`, e);
|
||||
};
|
||||
|
||||
videoElement.addEventListener('loadstart', handleLoadStart);
|
||||
videoElement.addEventListener('loadeddata', handleLoadedData);
|
||||
videoElement.addEventListener('loadedmetadata', handleLoadedMetadata);
|
||||
videoElement.addEventListener('canplay', handleCanPlay);
|
||||
videoElement.addEventListener('canplaythrough', handleCanPlayThrough);
|
||||
videoElement.addEventListener('play', handlePlay);
|
||||
videoElement.addEventListener('playing', handlePlaying);
|
||||
videoElement.addEventListener('waiting', handleWaiting);
|
||||
videoElement.addEventListener('stalled', handleStalled);
|
||||
videoElement.addEventListener('pause', handlePause);
|
||||
videoElement.addEventListener('error', handleError);
|
||||
|
||||
return () => {
|
||||
videoElement.removeEventListener('loadstart', handleLoadStart);
|
||||
videoElement.removeEventListener('loadeddata', handleLoadedData);
|
||||
videoElement.removeEventListener('loadedmetadata', handleLoadedMetadata);
|
||||
videoElement.removeEventListener('canplay', handleCanPlay);
|
||||
videoElement.removeEventListener('canplaythrough', handleCanPlayThrough);
|
||||
videoElement.removeEventListener('play', handlePlay);
|
||||
videoElement.removeEventListener('playing', handlePlaying);
|
||||
videoElement.removeEventListener('waiting', handleWaiting);
|
||||
videoElement.removeEventListener('stalled', handleStalled);
|
||||
videoElement.removeEventListener('pause', handlePause);
|
||||
videoElement.removeEventListener('error', handleError);
|
||||
};
|
||||
}, [name]);
|
||||
|
||||
const toggleRemoteAudio = () => {
|
||||
if (!isLocal && ref.current) {
|
||||
const newMutedState = !isAudioMuted;
|
||||
ref.current.muted = newMutedState;
|
||||
setIsAudioMuted(newMutedState);
|
||||
console.log(
|
||||
`[UserCamera] ${name} audio ${newMutedState ? "muted" : "unmuted"}, video element muted: ${ref.current.muted}`
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
const handleVideoClick = () => {
|
||||
if (!isLocal && ref.current) {
|
||||
console.log(`[UserCamera] User clicked on ${name} video, paused: ${ref.current.paused}, readyState: ${ref.current.readyState}, muted: ${ref.current.muted}`);
|
||||
if (ref.current.paused) {
|
||||
ref.current.play().catch((error) => {
|
||||
console.error(`[UserCamera] Click play failed for ${name}:`, error);
|
||||
});
|
||||
} else {
|
||||
console.log(`[UserCamera] Video ${name} is already playing`);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div
|
||||
className={clsx(
|
||||
"aspect-square h-fit group 2xl:rounded-[1.667vw] rounded-2xl relative flex-shrink-0 transition-[width,box-shadow,background-color] duration-300 pointer-events-auto hover:w-[10.833vw] w-[6.944vw] shadow-[0_4px_40px_0_rgba(15,16,17,0.1),0_2px_2px_0_rgba(0,0,0,0.06)]",
|
||||
isAdmin && "order-last",
|
||||
"aspect-square h-fit group 2xl:rounded-[1.667vw] rounded-2xl relative flex-shrink-0 transition-[width,box-shadow,background-color] duration-300 pointer-events-auto hover:w-[10.833vw] w-[6.944vw] shadow-[0_4px_40px_0_rgba(15,16,17,0.1),0_2px_2px_0_rgba(0,0,0,0.06)] overflow-hidden",
|
||||
isLocal && "order-last",
|
||||
isSpeaking
|
||||
? "ring-[0.139vw] ring-[#7B60F3]"
|
||||
: "ring-[0.069vw] ring-[#FFFFFF4D]",
|
||||
isVideoOff ? "bg-green-500" : "bg-yellow-500"
|
||||
isVideoOff ? "bg-green-500" : "bg-yellow-500/10"
|
||||
)}
|
||||
onClick={handleVideoClick}
|
||||
>
|
||||
{isAdmin && <Admin className="absolute top-0 right-0" />}
|
||||
{isLocal && <Admin className="absolute top-0 right-0" />}
|
||||
|
||||
<div
|
||||
key="name"
|
||||
@@ -65,22 +233,104 @@ export default function UserCamera({
|
||||
{name}
|
||||
</div>
|
||||
|
||||
{/* Заглушка когда нет видео */}
|
||||
{!mediaStream && (
|
||||
<div className="flex absolute inset-0 justify-center items-center bg-gradient-to-br from-gray-700 to-gray-900">
|
||||
<div className="flex flex-col gap-2 items-center text-white/60">
|
||||
<div className="2xl:size-[2.778vw] size-10">
|
||||
<VideoOffFilledIcon />
|
||||
</div>
|
||||
<span className="text-xs">Нет видео</span>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Подсказка для запуска видео */}
|
||||
{!isLocal && mediaStream && (
|
||||
<div className="flex absolute inset-0 justify-center items-center opacity-0 transition-opacity duration-300 pointer-events-none bg-black/50 group-hover:opacity-100">
|
||||
<div className="px-2 py-1 text-xs text-white rounded bg-black/70">
|
||||
Кликните для запуска видео
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<video
|
||||
ref={ref}
|
||||
className="object-cover size-full"
|
||||
className={clsx(
|
||||
"object-cover size-full",
|
||||
isLocal && "scale-x-[-1]",
|
||||
!mediaStream && "hidden"
|
||||
)}
|
||||
autoPlay
|
||||
muted={isMuted}
|
||||
muted={isLocal ? isMuted : isAudioMuted}
|
||||
playsInline
|
||||
webkit-playsinline="true"
|
||||
controls={false}
|
||||
preload="metadata"
|
||||
loop={false}
|
||||
onLoadedData={() => {
|
||||
if (!isLocal && ref.current) {
|
||||
console.log(`[UserCamera] onLoadedData for ${name}, attempting play, readyState: ${ref.current.readyState}`);
|
||||
ref.current.play().catch((error) => {
|
||||
console.error(`[UserCamera] onLoadedData play failed for ${name}:`, error);
|
||||
});
|
||||
}
|
||||
}}
|
||||
onLoadedMetadata={() => {
|
||||
if (!isLocal && ref.current) {
|
||||
console.log(`[UserCamera] onLoadedMetadata for ${name}, attempting play, readyState: ${ref.current.readyState}`);
|
||||
ref.current.play().catch((error) => {
|
||||
console.error(`[UserCamera] onLoadedMetadata play failed for ${name}:`, error);
|
||||
});
|
||||
}
|
||||
}}
|
||||
onCanPlay={() => {
|
||||
if (!isLocal && ref.current) {
|
||||
console.log(`[UserCamera] onCanPlay for ${name}, attempting play`);
|
||||
ref.current.play().catch((error) => {
|
||||
console.error(`[UserCamera] onCanPlay play failed for ${name}:`, error);
|
||||
});
|
||||
}
|
||||
}}
|
||||
onPlaying={() => {
|
||||
console.log(`[UserCamera] onPlaying for ${name} - video is actually playing!`);
|
||||
}}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
handleVideoClick();
|
||||
}}
|
||||
/>
|
||||
|
||||
<UserCameraControls
|
||||
isMuted={isMuted}
|
||||
isVideoOff={isVideoOff}
|
||||
isControlDisabled={isControlDisabled}
|
||||
onMute={onMute}
|
||||
onVideoOff={onVideoOff}
|
||||
onCanControl={onCanControl}
|
||||
/>
|
||||
{/* Кнопка управления звуком для удаленных участников */}
|
||||
{!isLocal && mediaStream && (
|
||||
<div
|
||||
className="absolute top-[0.556vw] right-[0.556vw] opacity-0 group-hover:opacity-100 transition-opacity duration-300"
|
||||
onMouseDown={(e) => e.stopPropagation()}
|
||||
>
|
||||
<button
|
||||
onClick={toggleRemoteAudio}
|
||||
className="2xl:size-[1.667vw] size-6 bg-[#14141426] backdrop-blur-[4px] hover:bg-[#14141440] rounded-full flex items-center justify-center transition-colors"
|
||||
title={isAudioMuted ? "Включить звук" : "Выключить звук"}
|
||||
>
|
||||
<div className="2xl:size-[0.972vw] size-3.5 text-white">
|
||||
{isAudioMuted ? <VolumeOffIcon /> : <VolumeIcon />}
|
||||
</div>
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Элементы управления только для удаленных участников */}
|
||||
{!isLocal && (
|
||||
<UserCameraControls
|
||||
isMuted={isMuted}
|
||||
isVideoOff={isVideoOff}
|
||||
isControlDisabled={isControlDisabled}
|
||||
isAdmin={isAdmin || false}
|
||||
onMute={onMute}
|
||||
onVideoOff={onVideoOff}
|
||||
onCanControl={onCanControl}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -89,12 +339,14 @@ function UserCameraControls({
|
||||
isMuted,
|
||||
isVideoOff,
|
||||
isControlDisabled,
|
||||
isAdmin,
|
||||
onMute,
|
||||
onVideoOff,
|
||||
onCanControl,
|
||||
}: UserCameraControlsProps) {
|
||||
return (
|
||||
<div className="absolute transition-[bottom] duration-300 2xl:bottom-[0.278vw] 2xl:group-hover:bottom-[0.556vw] group-hover:bottom-2 bottom-1 left-1/2 -translate-x-1/2">
|
||||
{/* Индикатор muted - показывается всегда */}
|
||||
<div
|
||||
className={clsx(
|
||||
"2xl:size-[1.667vw] size-6 bg-[#14141426] backdrop-blur-[4px] transition-opacity duration-300 rounded-full flex items-center justify-center z-10a absolute left-1/2 -translate-x-1/2 2xl:bottom-0 [0.278vw] group-hover:opacity-0",
|
||||
@@ -105,35 +357,39 @@ function UserCameraControls({
|
||||
<MicrophoneOffIcon />
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
className="flex gap-[0.278vw] mb-[0.278vw] group-hover:opacity-100 opacity-0 transition-opacity duration-300"
|
||||
onMouseDown={(e) => e.stopPropagation()}
|
||||
>
|
||||
<ControlButton
|
||||
icon={isMuted ? <MicrophoneOffIcon /> : <MicrophoneFilledIcon />}
|
||||
size={"small"}
|
||||
disabled={isMuted}
|
||||
onClick={onMute}
|
||||
/>
|
||||
<ControlButton
|
||||
icon={isVideoOff ? <VideoOffFilledIcon /> : <VideoFilledIcon />}
|
||||
size={"small"}
|
||||
disabled={isVideoOff}
|
||||
onClick={onVideoOff}
|
||||
/>
|
||||
<ControlButton
|
||||
icon={
|
||||
isControlDisabled ? (
|
||||
<HandRaisedOffFilledIcon />
|
||||
) : (
|
||||
<HandRaisedFilledIcon />
|
||||
)
|
||||
}
|
||||
size={"small"}
|
||||
disabled={isControlDisabled}
|
||||
onClick={onCanControl}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Кнопки управления - только для администраторов */}
|
||||
{isAdmin && (
|
||||
<div
|
||||
className="flex gap-[0.278vw] mb-[0.278vw] group-hover:opacity-100 opacity-0 transition-opacity duration-300"
|
||||
onMouseDown={(e) => e.stopPropagation()}
|
||||
>
|
||||
<ControlButton
|
||||
icon={isMuted ? <MicrophoneOffIcon /> : <MicrophoneFilledIcon />}
|
||||
size={"small"}
|
||||
disabled={false}
|
||||
onClick={onMute}
|
||||
/>
|
||||
<ControlButton
|
||||
icon={isVideoOff ? <VideoOffFilledIcon /> : <VideoFilledIcon />}
|
||||
size={"small"}
|
||||
disabled={false}
|
||||
onClick={onVideoOff}
|
||||
/>
|
||||
<ControlButton
|
||||
icon={
|
||||
isControlDisabled ? (
|
||||
<HandRaisedOffFilledIcon />
|
||||
) : (
|
||||
<HandRaisedFilledIcon />
|
||||
)
|
||||
}
|
||||
size={"small"}
|
||||
disabled={isControlDisabled}
|
||||
onClick={onCanControl}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,23 +1,30 @@
|
||||
import MicrophoneFilledIcon from "../icons/MicrophoneFilledIcon";
|
||||
import MicrophoneOffIcon from "../icons/MicrophoneOffIcon";
|
||||
import ControlButton from "./ControlButton";
|
||||
import VideoFilledIcon from "../icons/VideoFilledIcon";
|
||||
import VideoOffFilledIcon from "../icons/VideoOffFilledIcon";
|
||||
import HandRaisedFilledIcon from "../icons/HandRaisedFilledIcon";
|
||||
import CogFilledIcon from "../icons/CogFilledIcon";
|
||||
import useModalStore from "../../store/modalStore";
|
||||
import SettingsModal from "../modals/SettingsModal";
|
||||
|
||||
export default function UserDevicesControls() {
|
||||
export interface UserDevicesControlsProps {
|
||||
toggleAudio: () => void;
|
||||
toggleVideo: () => void;
|
||||
isAudioMuted: boolean;
|
||||
isVideoMuted: boolean;
|
||||
hasLocalStream?: boolean;
|
||||
}
|
||||
|
||||
export default function UserDevicesControls({
|
||||
toggleAudio,
|
||||
toggleVideo,
|
||||
isAudioMuted,
|
||||
isVideoMuted,
|
||||
hasLocalStream = true,
|
||||
}: UserDevicesControlsProps) {
|
||||
const { setModal } = useModalStore();
|
||||
|
||||
function ToggleAudioDevice() {
|
||||
console.log("Mute device");
|
||||
}
|
||||
function ToggleVideoDevice() {
|
||||
console.log("Video device");
|
||||
}
|
||||
function ToggleCanControl() {
|
||||
console.log("Can control device");
|
||||
}
|
||||
function ToggleSettings() {
|
||||
setModal(<SettingsModal />);
|
||||
}
|
||||
@@ -27,20 +34,22 @@ export default function UserDevicesControls() {
|
||||
<ControlButton
|
||||
onMouseDown={(e) => e.stopPropagation()}
|
||||
size="large"
|
||||
icon={<MicrophoneFilledIcon />}
|
||||
onClick={ToggleAudioDevice}
|
||||
icon={isAudioMuted ? <MicrophoneOffIcon /> : <MicrophoneFilledIcon />}
|
||||
disabled={!hasLocalStream}
|
||||
onClick={toggleAudio}
|
||||
/>
|
||||
<ControlButton
|
||||
onMouseDown={(e) => e.stopPropagation()}
|
||||
size="large"
|
||||
icon={<VideoFilledIcon />}
|
||||
onClick={ToggleVideoDevice}
|
||||
icon={isVideoMuted ? <VideoOffFilledIcon /> : <VideoFilledIcon />}
|
||||
disabled={!hasLocalStream}
|
||||
onClick={toggleVideo}
|
||||
/>
|
||||
<ControlButton
|
||||
onMouseDown={(e) => e.stopPropagation()}
|
||||
size="large"
|
||||
icon={<HandRaisedFilledIcon />}
|
||||
onClick={ToggleCanControl}
|
||||
onClick={() => console.log("Toggle can control")}
|
||||
/>
|
||||
<ControlButton
|
||||
onMouseDown={(e) => e.stopPropagation()}
|
||||
|
||||
@@ -0,0 +1,220 @@
|
||||
import { useEffect, useState, useRef } from "react";
|
||||
import {
|
||||
createWebRTCService,
|
||||
type Participant,
|
||||
type ChatMessage,
|
||||
} from "../lib/webrtc";
|
||||
|
||||
let webrtcServiceInstance: ReturnType<typeof createWebRTCService> | null = null;
|
||||
let isInitializing = false;
|
||||
|
||||
export const useWebRTC = (roomId?: string, autoJoin = false) => {
|
||||
const callbacksRegisteredRef = useRef(false);
|
||||
const hasJoinedRoomRef = useRef(false);
|
||||
const [localStream, setLocalStream] = useState<MediaStream | null>(null);
|
||||
const [participants, setParticipants] = useState<Participant[]>([]);
|
||||
const [isAudioMuted, setIsAudioMuted] = useState(false);
|
||||
const [isVideoMuted, setIsVideoMuted] = useState(false);
|
||||
const [chatMessages, setChatMessages] = useState<ChatMessage[]>([]);
|
||||
const [isConnected, setIsConnected] = useState(false);
|
||||
const [isInitialized, setIsInitialized] = useState(false);
|
||||
|
||||
// Мониторинг изменений участников
|
||||
useEffect(() => {
|
||||
console.log("[useWebRTC] Participants state updated:", participants.map(p => ({
|
||||
id: p.id,
|
||||
hasStream: !!p.stream,
|
||||
})));
|
||||
}, [participants]);
|
||||
|
||||
useEffect(() => {
|
||||
// Создаем сервис только один раз (синглтон)
|
||||
if (!webrtcServiceInstance) {
|
||||
webrtcServiceInstance = createWebRTCService({});
|
||||
}
|
||||
|
||||
// Инициализируем состояние из существующего сервиса
|
||||
const existingStream = webrtcServiceInstance.getLocalStream();
|
||||
if (existingStream) {
|
||||
console.log("[useWebRTC] Initializing with existing local stream");
|
||||
setLocalStream(existingStream);
|
||||
setIsInitialized(true);
|
||||
}
|
||||
|
||||
const existingParticipants = webrtcServiceInstance.getParticipants();
|
||||
console.log("[useWebRTC] Component mounted, existing participants:", existingParticipants.length);
|
||||
if (existingParticipants.length > 0) {
|
||||
console.log("[useWebRTC] Initializing with participants:", existingParticipants.map(p => p.id));
|
||||
setParticipants(existingParticipants);
|
||||
}
|
||||
|
||||
const existingMessages = webrtcServiceInstance.getChatMessages();
|
||||
if (existingMessages.length > 0) {
|
||||
console.log("[useWebRTC] Initializing with existing messages:", existingMessages.length);
|
||||
setChatMessages(existingMessages);
|
||||
}
|
||||
|
||||
// Добавляем коллбэки только один раз для этого компонента
|
||||
if (callbacksRegisteredRef.current) {
|
||||
return;
|
||||
}
|
||||
|
||||
callbacksRegisteredRef.current = true;
|
||||
const removeCallbacks = webrtcServiceInstance.addCallbacks({
|
||||
onLocalStreamReady: (stream) => {
|
||||
console.log("[useWebRTC] Local stream ready");
|
||||
setLocalStream(stream);
|
||||
setIsInitialized(true);
|
||||
},
|
||||
onRemoteStreamReady: (participantId, stream) => {
|
||||
console.log("[useWebRTC] onRemoteStreamReady called for:", participantId);
|
||||
setParticipants((prev) => {
|
||||
const existing = prev.find((p) => p.id === participantId);
|
||||
if (existing) {
|
||||
console.log("[useWebRTC] Updating stream for existing participant:", participantId);
|
||||
return prev.map((p) =>
|
||||
p.id === participantId ? { ...p, stream } : p
|
||||
);
|
||||
} else {
|
||||
console.log("[useWebRTC] Adding new participant with stream:", participantId);
|
||||
return [...prev, { id: participantId, stream }];
|
||||
}
|
||||
});
|
||||
},
|
||||
onRoomParticipants: () => {
|
||||
setIsConnected(true);
|
||||
},
|
||||
onParticipantJoined: (participant) => {
|
||||
console.log("[useWebRTC] onParticipantJoined called for:", participant.id);
|
||||
setParticipants((prev) => {
|
||||
if (prev.find((p) => p.id === participant.id)) {
|
||||
console.log("[useWebRTC] Participant already in list, skipping");
|
||||
return prev;
|
||||
}
|
||||
console.log("[useWebRTC] Adding participant to state");
|
||||
return [...prev, participant];
|
||||
});
|
||||
},
|
||||
onParticipantLeft: (participantId) => {
|
||||
setParticipants((prev) => prev.filter((p) => p.id !== participantId));
|
||||
},
|
||||
onChatMessage: (message) => {
|
||||
setChatMessages((prev) => [...prev, message]);
|
||||
},
|
||||
onDataChannelOpen: () => {
|
||||
// DataChannel opened
|
||||
},
|
||||
onDataChannelClose: () => {
|
||||
// DataChannel closed
|
||||
},
|
||||
onError: (error) => {
|
||||
console.error("[useWebRTC] Error:", error);
|
||||
},
|
||||
});
|
||||
|
||||
const initWebRTC = async () => {
|
||||
if (!webrtcServiceInstance || isInitializing) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Проверяем, есть ли уже localStream
|
||||
if (webrtcServiceInstance.getLocalStream()) {
|
||||
setIsInitialized(true);
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
isInitializing = true;
|
||||
const stream = await webrtcServiceInstance.initializeLocalStream();
|
||||
|
||||
// Даже если stream === null (пользователь отказался от разрешений),
|
||||
// считаем инициализацию завершенной
|
||||
if (stream === null) {
|
||||
console.log("[useWebRTC] Initialized without local stream (user denied permissions)");
|
||||
setIsInitialized(true);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("[useWebRTC] Initialization error:", error);
|
||||
// Даже при ошибке разрешаем продолжить
|
||||
setIsInitialized(true);
|
||||
} finally {
|
||||
isInitializing = false;
|
||||
}
|
||||
};
|
||||
|
||||
initWebRTC();
|
||||
|
||||
// Cleanup при размонтировании компонента
|
||||
return () => {
|
||||
callbacksRegisteredRef.current = false;
|
||||
removeCallbacks();
|
||||
};
|
||||
}, []); // Пустой массив зависимостей - эффект срабатывает только при монтировании
|
||||
|
||||
// Отдельный эффект для присоединения к комнате
|
||||
// ВАЖНО: Присоединяемся только ПОСЛЕ инициализации localStream!
|
||||
useEffect(() => {
|
||||
if (
|
||||
!webrtcServiceInstance ||
|
||||
!autoJoin ||
|
||||
!roomId ||
|
||||
hasJoinedRoomRef.current ||
|
||||
!isInitialized
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
const joinRoomAsync = async () => {
|
||||
await webrtcServiceInstance!.joinRoom(roomId);
|
||||
hasJoinedRoomRef.current = true;
|
||||
};
|
||||
|
||||
joinRoomAsync();
|
||||
}, [roomId, autoJoin, isInitialized]);
|
||||
|
||||
const toggleAudio = () => {
|
||||
if (!webrtcServiceInstance) return;
|
||||
const newState = webrtcServiceInstance.toggleAudio();
|
||||
setIsAudioMuted(!newState);
|
||||
};
|
||||
|
||||
const toggleVideo = () => {
|
||||
if (!webrtcServiceInstance) return;
|
||||
const newState = webrtcServiceInstance.toggleVideo();
|
||||
setIsVideoMuted(!newState);
|
||||
};
|
||||
|
||||
const sendMessage = (content: string) => {
|
||||
if (!webrtcServiceInstance) return;
|
||||
webrtcServiceInstance.sendChatMessage(content);
|
||||
};
|
||||
|
||||
const joinRoom = async (roomId: string) => {
|
||||
if (!webrtcServiceInstance) return;
|
||||
await webrtcServiceInstance.joinRoom(roomId);
|
||||
setIsConnected(true);
|
||||
};
|
||||
|
||||
const leaveRoom = () => {
|
||||
if (!webrtcServiceInstance) return;
|
||||
webrtcServiceInstance.leaveRoom();
|
||||
setIsConnected(false);
|
||||
setParticipants([]);
|
||||
};
|
||||
|
||||
return {
|
||||
localStream,
|
||||
participants,
|
||||
isAudioMuted,
|
||||
isVideoMuted,
|
||||
chatMessages,
|
||||
isConnected,
|
||||
isInitialized,
|
||||
currentUserId: webrtcServiceInstance?.getCurrentUserId() || "",
|
||||
toggleAudio,
|
||||
toggleVideo,
|
||||
sendMessage,
|
||||
joinRoom,
|
||||
leaveRoom,
|
||||
};
|
||||
};
|
||||
@@ -0,0 +1,925 @@
|
||||
import { io, Socket } from "socket.io-client";
|
||||
import { v4 as uuidv4 } from "uuid";
|
||||
|
||||
export interface ChatMessage {
|
||||
id: string;
|
||||
senderId: string;
|
||||
senderName?: string;
|
||||
content: string;
|
||||
timestamp: Date;
|
||||
type: "text" | "system";
|
||||
}
|
||||
|
||||
export interface Participant {
|
||||
id: string;
|
||||
name?: string;
|
||||
stream?: MediaStream;
|
||||
peerConnection?: RTCPeerConnection;
|
||||
dataChannel?: RTCDataChannel;
|
||||
isMuted?: boolean;
|
||||
isVideoOff?: boolean;
|
||||
isSpeaking?: boolean;
|
||||
iceCandidateQueue?: RTCIceCandidate[];
|
||||
}
|
||||
|
||||
export interface WebRTCCallbacks {
|
||||
onParticipantJoined?: (participant: Participant) => void;
|
||||
onParticipantLeft?: (participantId: string) => void;
|
||||
onLocalStreamReady?: (stream: MediaStream) => void;
|
||||
onRemoteStreamReady?: (participantId: string, stream: MediaStream) => void;
|
||||
onRoomParticipants?: (participantIds: string[]) => void;
|
||||
onChatMessage?: (message: ChatMessage) => void;
|
||||
onDataChannelOpen?: (participantId: string) => void;
|
||||
onDataChannelClose?: (participantId: string) => void;
|
||||
onParticipantAudioToggle?: (
|
||||
participantId: string,
|
||||
isEnabled: boolean
|
||||
) => void;
|
||||
onParticipantVideoToggle?: (
|
||||
participantId: string,
|
||||
isEnabled: boolean
|
||||
) => void;
|
||||
onError?: (error: Error) => void;
|
||||
}
|
||||
|
||||
interface WebRTCState {
|
||||
socket: Socket;
|
||||
localStream: MediaStream | null;
|
||||
participants: Map<string, Participant>;
|
||||
roomId: string | null;
|
||||
userId: string;
|
||||
isAudioEnabled: boolean;
|
||||
isVideoEnabled: boolean;
|
||||
callbacks: WebRTCCallbacks[]; // Изменено на массив
|
||||
chatMessages: ChatMessage[];
|
||||
}
|
||||
|
||||
let state: WebRTCState | null = null;
|
||||
|
||||
const ICE_SERVERS = [
|
||||
{
|
||||
urls: "turn:185.173.176.83:3478",
|
||||
username: "username1",
|
||||
credential: "password1",
|
||||
},
|
||||
// {
|
||||
// urls: "turn:openrelay.metered.ca:80",
|
||||
// username: "openrelayproject",
|
||||
// credential: "openrelayproject",
|
||||
// },
|
||||
// {
|
||||
// urls: "turn:openrelay.metered.ca:443",
|
||||
// username: "openrelayproject",
|
||||
// credential: "openrelayproject",
|
||||
// },
|
||||
// {
|
||||
// urls: "turn:openrelay.metered.ca:443?transport=tcp",
|
||||
// username: "openrelayproject",
|
||||
// credential: "openrelayproject",
|
||||
// },
|
||||
];
|
||||
|
||||
// Для localhost можно использовать упрощенную конфигурацию
|
||||
const isLocalhost =
|
||||
window.location.hostname === "localhost" ||
|
||||
window.location.hostname === "127.0.0.1" ||
|
||||
window.location.hostname === "[::1]";
|
||||
|
||||
if (isLocalhost) {
|
||||
console.log(
|
||||
"[WebRTC] Running on localhost - using TURN server for reliable connection"
|
||||
);
|
||||
}
|
||||
|
||||
// Вспомогательная функция для вызова всех коллбэков
|
||||
function callAllCallbacks<K extends keyof WebRTCCallbacks>(
|
||||
eventName: K,
|
||||
...args: Parameters<NonNullable<WebRTCCallbacks[K]>>
|
||||
) {
|
||||
if (!state) return;
|
||||
state.callbacks.forEach((callbacks) => {
|
||||
const callback = callbacks[eventName];
|
||||
if (callback) {
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
(callback as any)(...args);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
export function createWebRTCService(callbacks: WebRTCCallbacks = {}) {
|
||||
if (state) {
|
||||
cleanup();
|
||||
}
|
||||
|
||||
console.log("Creating WebRTC service...");
|
||||
|
||||
// Подключаемся к WebRTC серверу на порту 3001
|
||||
const serverUrl = import.meta.env.VITE_WEBRTC_URL || "http://localhost:3001";
|
||||
console.log("Connecting to WebRTC server:", serverUrl);
|
||||
|
||||
const socket = io(serverUrl, {
|
||||
transports: ["websocket", "polling"],
|
||||
});
|
||||
const userId = uuidv4();
|
||||
console.log("Generated user ID:", userId);
|
||||
|
||||
state = {
|
||||
socket,
|
||||
localStream: null,
|
||||
participants: new Map(),
|
||||
roomId: null,
|
||||
userId,
|
||||
isAudioEnabled: true,
|
||||
isVideoEnabled: true,
|
||||
callbacks: [callbacks], // Массив коллбэков
|
||||
chatMessages: [],
|
||||
};
|
||||
|
||||
setupSocketListeners();
|
||||
|
||||
return {
|
||||
initializeLocalStream,
|
||||
joinRoom,
|
||||
toggleAudio,
|
||||
toggleVideo,
|
||||
leaveRoom,
|
||||
sendChatMessage,
|
||||
getChatMessages: () => state?.chatMessages || [],
|
||||
getCurrentUserId: () => state?.userId || "",
|
||||
getParticipants: () => Array.from(state?.participants.values() || []),
|
||||
getLocalStream: () => state?.localStream || null,
|
||||
isAudioMuted: () => (state ? !state.isAudioEnabled : true),
|
||||
isVideoMuted: () => (state ? !state.isVideoEnabled : true),
|
||||
hasLocalStream: () => state?.localStream !== null,
|
||||
addCallbacks: (newCallbacks: WebRTCCallbacks) => {
|
||||
if (state) {
|
||||
state.callbacks.push(newCallbacks);
|
||||
console.log("Added callbacks, total count:", state.callbacks.length);
|
||||
}
|
||||
return () => {
|
||||
if (state) {
|
||||
const index = state.callbacks.indexOf(newCallbacks);
|
||||
if (index > -1) {
|
||||
state.callbacks.splice(index, 1);
|
||||
console.log(
|
||||
"Removed callbacks, remaining count:",
|
||||
state.callbacks.length
|
||||
);
|
||||
}
|
||||
}
|
||||
};
|
||||
},
|
||||
cleanup,
|
||||
};
|
||||
}
|
||||
|
||||
function setupSocketListeners() {
|
||||
if (!state) return;
|
||||
|
||||
const { socket } = state;
|
||||
console.log("Setting up socket listeners...");
|
||||
|
||||
socket.on("connect", () => {
|
||||
console.log("Socket connected with ID:", socket.id);
|
||||
});
|
||||
|
||||
socket.on("disconnect", (reason) => {
|
||||
console.log("Socket disconnected, reason:", reason);
|
||||
});
|
||||
|
||||
socket.on("connect_error", (error) => {
|
||||
console.error("Socket connection error:", error);
|
||||
});
|
||||
|
||||
socket.on("reconnect", (attemptNumber) => {
|
||||
console.log("Socket reconnected after", attemptNumber, "attempts");
|
||||
});
|
||||
|
||||
socket.on("reconnect_error", (error) => {
|
||||
console.error("Socket reconnection error:", error);
|
||||
});
|
||||
|
||||
socket.on("room-participants", (participants: string[]) => {
|
||||
console.log("Room participants received:", participants);
|
||||
console.log("Current user ID:", state?.userId);
|
||||
console.log(
|
||||
"[WebRTC] I am the new user, connecting to existing participants"
|
||||
);
|
||||
callAllCallbacks("onRoomParticipants", participants);
|
||||
|
||||
// Новый пользователь (мы) инициируем соединения со всеми существующими участниками
|
||||
participants.forEach((participantId) => {
|
||||
addParticipant(participantId);
|
||||
createPeerConnection(participantId, true); // МЫ - инициаторы
|
||||
});
|
||||
});
|
||||
|
||||
socket.on("user-joined", (userId: string) => {
|
||||
console.log("[WebRTC] User joined event received:", userId);
|
||||
console.log(
|
||||
"[WebRTC] New user will initiate connection to us, waiting for offer"
|
||||
);
|
||||
if (!state) return;
|
||||
|
||||
// Только добавляем участника в UI, НЕ создаем peer connection
|
||||
// Новый пользователь сам инициирует соединение с нами
|
||||
addParticipant(userId);
|
||||
// НЕ вызываем createPeerConnection - ждем входящий offer
|
||||
});
|
||||
|
||||
socket.on("user-left", (userId: string) => {
|
||||
console.log("User left event received:", userId);
|
||||
if (!state) return;
|
||||
|
||||
const participant = state.participants.get(userId);
|
||||
if (participant) {
|
||||
participant.peerConnection?.close();
|
||||
state.participants.delete(userId);
|
||||
callAllCallbacks("onParticipantLeft", userId);
|
||||
}
|
||||
});
|
||||
|
||||
socket.on(
|
||||
"offer",
|
||||
async ({
|
||||
offer,
|
||||
sender,
|
||||
}: {
|
||||
offer: RTCSessionDescriptionInit;
|
||||
sender: string;
|
||||
}) => {
|
||||
console.log("Received offer from:", sender);
|
||||
await handleOffer(sender, offer);
|
||||
}
|
||||
);
|
||||
|
||||
socket.on(
|
||||
"answer",
|
||||
async ({
|
||||
answer,
|
||||
sender,
|
||||
}: {
|
||||
answer: RTCSessionDescriptionInit;
|
||||
sender: string;
|
||||
}) => {
|
||||
console.log("Received answer from:", sender);
|
||||
await handleAnswer(sender, answer);
|
||||
}
|
||||
);
|
||||
|
||||
socket.on(
|
||||
"ice-candidate",
|
||||
async ({
|
||||
candidate,
|
||||
sender,
|
||||
}: {
|
||||
candidate: RTCIceCandidate;
|
||||
sender: string;
|
||||
}) => {
|
||||
console.log("Received ICE candidate from:", sender);
|
||||
await handleIceCandidate(sender, candidate);
|
||||
}
|
||||
);
|
||||
|
||||
console.log("Socket listeners set up complete");
|
||||
}
|
||||
|
||||
async function initializeLocalStream(): Promise<MediaStream | null> {
|
||||
if (!state) throw new Error("WebRTC service not initialized");
|
||||
|
||||
try {
|
||||
// Проверяем доступность WebRTC API
|
||||
if (!navigator.mediaDevices) {
|
||||
throw new Error("WebRTC не поддерживается в этом браузере.");
|
||||
}
|
||||
|
||||
if (!navigator.mediaDevices.getUserMedia) {
|
||||
throw new Error("getUserMedia не поддерживается в этом браузере.");
|
||||
}
|
||||
|
||||
console.log("Requesting media access...");
|
||||
const stream = await navigator.mediaDevices.getUserMedia({
|
||||
video: true,
|
||||
audio: true,
|
||||
});
|
||||
|
||||
console.log("Local stream:", stream);
|
||||
|
||||
stream.getTracks().forEach((track) => {
|
||||
console.log("Track:", track.kind, track.label);
|
||||
});
|
||||
|
||||
state.localStream = stream;
|
||||
callAllCallbacks("onLocalStreamReady", stream);
|
||||
return stream;
|
||||
} catch (error) {
|
||||
console.error("Error accessing media devices:", error);
|
||||
|
||||
let errorMessage = "Не удалось получить доступ к камере или микрофону";
|
||||
|
||||
if (error instanceof Error) {
|
||||
if (error.name === "NotAllowedError") {
|
||||
errorMessage =
|
||||
"Доступ к камере и микрофону запрещен. Вы можете продолжить без видео и аудио.";
|
||||
} else if (error.name === "NotFoundError") {
|
||||
errorMessage =
|
||||
"Камера или микрофон не найдены. Вы можете продолжить без видео и аудио.";
|
||||
} else if (error.name === "NotReadableError") {
|
||||
errorMessage =
|
||||
"Камера или микрофон заняты другим приложением. Вы можете продолжить без видео и аудио.";
|
||||
}
|
||||
}
|
||||
|
||||
console.warn("Продолжаем без локального медиа-потока:", errorMessage);
|
||||
callAllCallbacks("onError", new Error(errorMessage));
|
||||
|
||||
// Возвращаем null вместо выброса ошибки, чтобы пользователь мог продолжить
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
async function joinRoom(roomId: string): Promise<void> {
|
||||
if (!state) throw new Error("WebRTC service not initialized");
|
||||
|
||||
console.log("Joining room:", roomId, "with user ID:", state.userId);
|
||||
state.roomId = roomId;
|
||||
state.socket.emit("join-room", { roomId, userId: state.userId });
|
||||
}
|
||||
|
||||
// Вспомогательная функция для добавления участника
|
||||
function addParticipant(participantId: string): Participant {
|
||||
if (!state) throw new Error("WebRTC service not initialized");
|
||||
|
||||
let participant = state.participants.get(participantId);
|
||||
if (!participant) {
|
||||
participant = { id: participantId };
|
||||
state.participants.set(participantId, participant);
|
||||
console.log("[WebRTC] Adding new participant:", participantId);
|
||||
callAllCallbacks("onParticipantJoined", participant);
|
||||
}
|
||||
return participant;
|
||||
}
|
||||
|
||||
async function createPeerConnection(
|
||||
participantId: string,
|
||||
isInitiator: boolean
|
||||
): Promise<void> {
|
||||
if (!state) return;
|
||||
|
||||
const participant = state.participants.get(participantId);
|
||||
if (!participant) {
|
||||
console.error("[WebRTC] Participant not found:", participantId);
|
||||
return;
|
||||
}
|
||||
|
||||
// Если peer connection уже существует, не создаем новый
|
||||
if (participant.peerConnection) {
|
||||
console.log("[WebRTC] Peer connection already exists for:", participantId);
|
||||
return;
|
||||
}
|
||||
|
||||
console.log(
|
||||
"[WebRTC] Creating new peer connection for:",
|
||||
participantId,
|
||||
"isInitiator:",
|
||||
isInitiator
|
||||
);
|
||||
const peerConnection = new RTCPeerConnection({
|
||||
iceServers: ICE_SERVERS, // Используем TURN сервер для всех соединений
|
||||
iceTransportPolicy: "all", // Использовать все доступные транспорты
|
||||
bundlePolicy: "max-bundle", // Объединить все медиа в один транспорт
|
||||
rtcpMuxPolicy: "require", // Мультиплексировать RTP и RTCP
|
||||
iceCandidatePoolSize: 10, // Предварительно собрать candidates
|
||||
});
|
||||
participant.peerConnection = peerConnection;
|
||||
|
||||
// Create DataChannel for chat (only initiator creates the channel)
|
||||
if (isInitiator) {
|
||||
const dataChannel = peerConnection.createDataChannel("chat", {
|
||||
ordered: true,
|
||||
});
|
||||
participant.dataChannel = dataChannel;
|
||||
setupDataChannelListeners(dataChannel, participantId);
|
||||
}
|
||||
|
||||
// Handle incoming DataChannel
|
||||
peerConnection.ondatachannel = (event) => {
|
||||
const dataChannel = event.channel;
|
||||
participant!.dataChannel = dataChannel;
|
||||
setupDataChannelListeners(dataChannel, participantId);
|
||||
};
|
||||
|
||||
// Add local stream tracks or create silent/black tracks
|
||||
if (state.localStream) {
|
||||
state.localStream.getTracks().forEach((track) => {
|
||||
peerConnection.addTrack(track, state!.localStream!);
|
||||
});
|
||||
console.log("[WebRTC] Added local stream tracks for:", participantId);
|
||||
} else {
|
||||
// Если нет локального потока, создаем пустые треки для совместимости
|
||||
console.log(
|
||||
"[WebRTC] No local stream, creating silent/black tracks for:",
|
||||
participantId
|
||||
);
|
||||
|
||||
// Создаем пустой audio track
|
||||
const audioContext = new AudioContext();
|
||||
const oscillator = audioContext.createOscillator();
|
||||
const dst = audioContext.createMediaStreamDestination();
|
||||
oscillator.connect(dst);
|
||||
oscillator.start();
|
||||
const audioTrack = dst.stream.getAudioTracks()[0];
|
||||
audioTrack.enabled = false; // Отключаем сразу
|
||||
|
||||
// Создаем черный video track
|
||||
const canvas = document.createElement("canvas");
|
||||
canvas.width = 640;
|
||||
canvas.height = 480;
|
||||
const ctx = canvas.getContext("2d");
|
||||
if (ctx) {
|
||||
ctx.fillStyle = "black";
|
||||
ctx.fillRect(0, 0, canvas.width, canvas.height);
|
||||
}
|
||||
const canvasStream = canvas.captureStream(1);
|
||||
const videoTrack = canvasStream.getVideoTracks()[0];
|
||||
videoTrack.enabled = false; // Отключаем сразу
|
||||
|
||||
// Добавляем треки с MediaStream для лучшей совместимости
|
||||
const dummyStream = new MediaStream([audioTrack, videoTrack]);
|
||||
dummyStream.getTracks().forEach((track) => {
|
||||
peerConnection.addTrack(track, dummyStream);
|
||||
});
|
||||
}
|
||||
|
||||
// Handle remote stream
|
||||
peerConnection.ontrack = (event) => {
|
||||
console.log(
|
||||
"🎥 Received remote track from:",
|
||||
participantId,
|
||||
`(${event.track.kind})`
|
||||
);
|
||||
const [remoteStream] = event.streams;
|
||||
if (remoteStream) {
|
||||
participant!.stream = remoteStream;
|
||||
callAllCallbacks("onRemoteStreamReady", participantId, remoteStream);
|
||||
} else {
|
||||
console.error("No remote stream in track event!");
|
||||
}
|
||||
};
|
||||
|
||||
// Handle ICE candidates
|
||||
peerConnection.onicecandidate = (event) => {
|
||||
if (event.candidate && state) {
|
||||
const candidateStr = event.candidate.candidate || "";
|
||||
const type = candidateStr.includes("typ host")
|
||||
? "host"
|
||||
: candidateStr.includes("typ srflx")
|
||||
? "srflx"
|
||||
: candidateStr.includes("typ relay")
|
||||
? "relay"
|
||||
: "unknown";
|
||||
console.log(
|
||||
`[WebRTC] Sending ICE candidate to ${participantId}: type=${type}, candidate=${candidateStr.substring(
|
||||
0,
|
||||
50
|
||||
)}...`
|
||||
);
|
||||
state.socket.emit("ice-candidate", {
|
||||
target: participantId,
|
||||
candidate: event.candidate,
|
||||
});
|
||||
} else if (!event.candidate) {
|
||||
console.log(`[WebRTC] ICE gathering complete for ${participantId}`);
|
||||
}
|
||||
};
|
||||
|
||||
peerConnection.onicegatheringstatechange = () => {
|
||||
console.log(
|
||||
`[WebRTC] ICE gathering state for ${participantId}:`,
|
||||
peerConnection.iceGatheringState
|
||||
);
|
||||
};
|
||||
|
||||
// Monitor connection state
|
||||
peerConnection.onconnectionstatechange = () => {
|
||||
console.log(
|
||||
`[WebRTC] Connection state for ${participantId}:`,
|
||||
peerConnection.connectionState
|
||||
);
|
||||
};
|
||||
|
||||
peerConnection.oniceconnectionstatechange = () => {
|
||||
console.log(
|
||||
`[WebRTC] ICE connection state for ${participantId}:`,
|
||||
peerConnection.iceConnectionState
|
||||
);
|
||||
|
||||
// Автоматический restart при failed
|
||||
if (peerConnection.iceConnectionState === "failed") {
|
||||
console.warn(
|
||||
`[WebRTC] ICE failed for ${participantId}, attempting restart...`
|
||||
);
|
||||
peerConnection.restartIce();
|
||||
}
|
||||
|
||||
// Уведомляем о disconnected
|
||||
if (peerConnection.iceConnectionState === "disconnected") {
|
||||
console.warn(
|
||||
`[WebRTC] ICE disconnected for ${participantId}, may reconnect automatically`
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
// Create offer if initiator
|
||||
if (isInitiator) {
|
||||
try {
|
||||
console.log("[WebRTC] Creating offer for:", participantId);
|
||||
console.log(
|
||||
"[WebRTC] Current ICE gathering state:",
|
||||
peerConnection.iceGatheringState
|
||||
);
|
||||
console.log(
|
||||
"[WebRTC] Current signaling state:",
|
||||
peerConnection.signalingState
|
||||
);
|
||||
|
||||
// Создаем offer с настройками на прием аудио и видео
|
||||
// Используем старый формат для совместимости с Firefox
|
||||
const offerOptions: RTCOfferOptions = {
|
||||
offerToReceiveAudio: true,
|
||||
offerToReceiveVideo: true,
|
||||
};
|
||||
|
||||
const offer = await peerConnection.createOffer(offerOptions);
|
||||
console.log("[WebRTC] Offer created, SDP type:", offer.type);
|
||||
|
||||
await peerConnection.setLocalDescription(offer);
|
||||
console.log(
|
||||
"[WebRTC] Local description set, ICE gathering state:",
|
||||
peerConnection.iceGatheringState
|
||||
);
|
||||
console.log("[WebRTC] hasLocalStream:", !!state.localStream);
|
||||
|
||||
// Используем Trickle ICE - отправляем offer сразу, candidates отправятся отдельно
|
||||
console.log("[WebRTC] Sending offer to:", participantId);
|
||||
state.socket.emit("offer", {
|
||||
target: participantId,
|
||||
offer: offer,
|
||||
});
|
||||
console.log(
|
||||
"[WebRTC] Offer sent, waiting for ICE candidates to be gathered and sent..."
|
||||
);
|
||||
} catch (error) {
|
||||
console.error("Error creating offer:", error);
|
||||
callAllCallbacks("onError", error as Error);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function handleOffer(
|
||||
senderId: string,
|
||||
offer: RTCSessionDescriptionInit
|
||||
): Promise<void> {
|
||||
if (!state) return;
|
||||
|
||||
console.log("[WebRTC] Received offer from:", senderId);
|
||||
|
||||
// Добавляем участника, если его еще нет
|
||||
addParticipant(senderId);
|
||||
|
||||
const participant = state.participants.get(senderId);
|
||||
if (!participant) {
|
||||
console.error("[WebRTC] Participant not found:", senderId);
|
||||
return;
|
||||
}
|
||||
|
||||
// Создаем peer connection (мы НЕ инициаторы, отвечаем на offer)
|
||||
await createPeerConnection(senderId, false);
|
||||
|
||||
if (!participant.peerConnection) {
|
||||
console.error("[WebRTC] Failed to create peer connection for:", senderId);
|
||||
return;
|
||||
}
|
||||
|
||||
const peerConnection = participant.peerConnection;
|
||||
|
||||
try {
|
||||
console.log("[WebRTC] Setting remote description (offer) from:", senderId);
|
||||
await peerConnection.setRemoteDescription(offer);
|
||||
console.log(
|
||||
"[WebRTC] Remote description set, ICE gathering state:",
|
||||
peerConnection.iceGatheringState
|
||||
);
|
||||
|
||||
// Обрабатываем очередь ICE candidates
|
||||
if (
|
||||
participant.iceCandidateQueue &&
|
||||
participant.iceCandidateQueue.length > 0
|
||||
) {
|
||||
console.log(
|
||||
"[WebRTC] Processing queued ICE candidates:",
|
||||
participant.iceCandidateQueue.length
|
||||
);
|
||||
for (const queuedCandidate of participant.iceCandidateQueue) {
|
||||
try {
|
||||
await peerConnection.addIceCandidate(queuedCandidate);
|
||||
} catch (error) {
|
||||
console.error("Error adding queued ICE candidate:", error);
|
||||
}
|
||||
}
|
||||
participant.iceCandidateQueue = [];
|
||||
}
|
||||
|
||||
console.log("[WebRTC] Creating answer for:", senderId);
|
||||
const answer = await peerConnection.createAnswer();
|
||||
console.log("[WebRTC] Answer created");
|
||||
|
||||
await peerConnection.setLocalDescription(answer);
|
||||
console.log(
|
||||
"[WebRTC] Local description (answer) set, ICE gathering state:",
|
||||
peerConnection.iceGatheringState
|
||||
);
|
||||
|
||||
state.socket.emit("answer", {
|
||||
target: senderId,
|
||||
answer: answer,
|
||||
});
|
||||
console.log("[WebRTC] Answer sent to:", senderId);
|
||||
} catch (error) {
|
||||
console.error("Error handling offer:", error);
|
||||
callAllCallbacks("onError", error as Error);
|
||||
}
|
||||
}
|
||||
|
||||
async function handleAnswer(
|
||||
senderId: string,
|
||||
answer: RTCSessionDescriptionInit
|
||||
): Promise<void> {
|
||||
if (!state) return;
|
||||
|
||||
console.log("[WebRTC] Received answer from:", senderId);
|
||||
|
||||
const participant = state.participants.get(senderId);
|
||||
if (!participant?.peerConnection) {
|
||||
console.warn(
|
||||
"[WebRTC] No peer connection found for answer from:",
|
||||
senderId
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
const signalingState = participant.peerConnection.signalingState;
|
||||
console.log("[WebRTC] Current signaling state:", signalingState);
|
||||
|
||||
// Проверяем, что мы ожидаем answer
|
||||
if (signalingState !== "have-local-offer") {
|
||||
console.warn(
|
||||
"[WebRTC] Ignoring answer - not in have-local-offer state:",
|
||||
signalingState
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
await participant.peerConnection.setRemoteDescription(answer);
|
||||
console.log("[WebRTC] Successfully set remote answer from:", senderId);
|
||||
|
||||
// Обрабатываем очередь ICE candidates
|
||||
if (
|
||||
participant.iceCandidateQueue &&
|
||||
participant.iceCandidateQueue.length > 0
|
||||
) {
|
||||
console.log(
|
||||
"[WebRTC] Processing queued ICE candidates:",
|
||||
participant.iceCandidateQueue.length
|
||||
);
|
||||
for (const queuedCandidate of participant.iceCandidateQueue) {
|
||||
try {
|
||||
await participant.peerConnection.addIceCandidate(queuedCandidate);
|
||||
} catch (error) {
|
||||
console.error("Error adding queued ICE candidate:", error);
|
||||
}
|
||||
}
|
||||
participant.iceCandidateQueue = [];
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Error handling answer:", error);
|
||||
callAllCallbacks("onError", error as Error);
|
||||
}
|
||||
}
|
||||
|
||||
async function handleIceCandidate(
|
||||
senderId: string,
|
||||
candidate: RTCIceCandidate
|
||||
): Promise<void> {
|
||||
if (!state) return;
|
||||
|
||||
const participant = state.participants.get(senderId);
|
||||
if (!participant?.peerConnection) {
|
||||
console.warn(
|
||||
"[WebRTC] No peer connection found for ICE candidate from:",
|
||||
senderId
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
// Проверяем, что у нас установлено remote description
|
||||
if (!participant.peerConnection.remoteDescription) {
|
||||
console.log("[WebRTC] Queuing ICE candidate - no remote description yet");
|
||||
// Добавляем в очередь для последующей обработки
|
||||
if (!participant.iceCandidateQueue) {
|
||||
participant.iceCandidateQueue = [];
|
||||
}
|
||||
participant.iceCandidateQueue.push(candidate);
|
||||
console.log(
|
||||
`[WebRTC] Queue size for ${senderId}:`,
|
||||
participant.iceCandidateQueue.length
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
await participant.peerConnection.addIceCandidate(candidate);
|
||||
const candidateStr = candidate.candidate || "";
|
||||
const type = candidateStr.includes("typ host")
|
||||
? "host"
|
||||
: candidateStr.includes("typ srflx")
|
||||
? "srflx"
|
||||
: candidateStr.includes("typ relay")
|
||||
? "relay"
|
||||
: "unknown";
|
||||
console.log(
|
||||
`[WebRTC] Added ICE candidate from ${senderId}, type: ${type}, candidate=${candidateStr.substring(
|
||||
0,
|
||||
50
|
||||
)}...`
|
||||
);
|
||||
} catch (error) {
|
||||
console.error("Error handling ICE candidate:", error);
|
||||
// Не выбрасываем ошибку в callback - это нормально при переподключениях
|
||||
}
|
||||
}
|
||||
|
||||
function toggleAudio(): boolean {
|
||||
if (!state?.localStream) {
|
||||
console.warn("Cannot toggle audio: no local stream available");
|
||||
return false;
|
||||
}
|
||||
|
||||
const audioTracks = state.localStream.getAudioTracks();
|
||||
if (audioTracks.length === 0) {
|
||||
console.warn("Cannot toggle audio: no audio tracks available");
|
||||
return false;
|
||||
}
|
||||
|
||||
audioTracks.forEach((track) => {
|
||||
track.enabled = !track.enabled;
|
||||
});
|
||||
state.isAudioEnabled = !state.isAudioEnabled;
|
||||
return state.isAudioEnabled;
|
||||
}
|
||||
|
||||
function toggleVideo(): boolean {
|
||||
if (!state?.localStream) {
|
||||
console.warn("Cannot toggle video: no local stream available");
|
||||
return false;
|
||||
}
|
||||
|
||||
const videoTracks = state.localStream.getVideoTracks();
|
||||
if (videoTracks.length === 0) {
|
||||
console.warn("Cannot toggle video: no video tracks available");
|
||||
return false;
|
||||
}
|
||||
|
||||
videoTracks.forEach((track) => {
|
||||
track.enabled = !track.enabled;
|
||||
});
|
||||
state.isVideoEnabled = !state.isVideoEnabled;
|
||||
return state.isVideoEnabled;
|
||||
}
|
||||
|
||||
function setupDataChannelListeners(
|
||||
dataChannel: RTCDataChannel,
|
||||
participantId: string
|
||||
): void {
|
||||
if (!state) return;
|
||||
|
||||
dataChannel.onopen = () => {
|
||||
console.log("DataChannel opened with participant:", participantId);
|
||||
callAllCallbacks("onDataChannelOpen", participantId);
|
||||
};
|
||||
|
||||
dataChannel.onclose = () => {
|
||||
console.log("DataChannel closed with participant:", participantId);
|
||||
callAllCallbacks("onDataChannelClose", participantId);
|
||||
};
|
||||
|
||||
dataChannel.onmessage = (event) => {
|
||||
try {
|
||||
const message: ChatMessage = JSON.parse(event.data);
|
||||
console.log("📨 Received chat message from DataChannel:", message);
|
||||
|
||||
// Only add messages from other participants (not our own)
|
||||
if (message.senderId !== state!.userId) {
|
||||
// Add to local messages
|
||||
state!.chatMessages.push(message);
|
||||
console.log(
|
||||
"📨 Added message to local state, total messages:",
|
||||
state!.chatMessages.length
|
||||
);
|
||||
|
||||
// Notify callback
|
||||
callAllCallbacks("onChatMessage", message);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Error parsing chat message:", error);
|
||||
}
|
||||
};
|
||||
|
||||
dataChannel.onerror = (error) => {
|
||||
console.error("DataChannel error with participant:", participantId, error);
|
||||
callAllCallbacks("onError", new Error(`DataChannel error: ${error}`));
|
||||
};
|
||||
}
|
||||
|
||||
function sendChatMessage(content: string): void {
|
||||
if (!state || !content.trim()) return;
|
||||
|
||||
const message: ChatMessage = {
|
||||
id: uuidv4(),
|
||||
senderId: state.userId,
|
||||
content: content.trim(),
|
||||
timestamp: new Date(),
|
||||
type: "text",
|
||||
};
|
||||
|
||||
// Add to local messages
|
||||
state.chatMessages.push(message);
|
||||
console.log(
|
||||
"Added own message to local state, total messages:",
|
||||
state.chatMessages.length
|
||||
);
|
||||
|
||||
// Send to all participants via DataChannel
|
||||
console.log(
|
||||
"📤 Sending message to participants, total participants:",
|
||||
state.participants.size
|
||||
);
|
||||
state.participants.forEach((participant) => {
|
||||
if (
|
||||
participant.dataChannel &&
|
||||
participant.dataChannel.readyState === "open"
|
||||
) {
|
||||
try {
|
||||
participant.dataChannel.send(JSON.stringify(message));
|
||||
console.log(
|
||||
"📤 Successfully sent message to participant:",
|
||||
participant.id
|
||||
);
|
||||
} catch (error) {
|
||||
console.error(
|
||||
"📤 Error sending chat message to participant:",
|
||||
participant.id,
|
||||
error
|
||||
);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
// Notify local callback (for own messages to update UI)
|
||||
callAllCallbacks("onChatMessage", message);
|
||||
}
|
||||
|
||||
function leaveRoom(): void {
|
||||
if (!state) return;
|
||||
|
||||
// Close all peer connections
|
||||
state.participants.forEach((participant) => {
|
||||
participant.peerConnection?.close();
|
||||
});
|
||||
state.participants.clear();
|
||||
|
||||
// Stop local stream
|
||||
if (state.localStream) {
|
||||
state.localStream.getTracks().forEach((track) => track.stop());
|
||||
state.localStream = null;
|
||||
}
|
||||
|
||||
// Leave room via socket
|
||||
if (state.socket.connected && state.roomId) {
|
||||
state.socket.emit("leave-room", {
|
||||
roomId: state.roomId,
|
||||
userId: state.userId,
|
||||
});
|
||||
}
|
||||
|
||||
state.roomId = null;
|
||||
}
|
||||
|
||||
function cleanup(): void {
|
||||
if (state) {
|
||||
leaveRoom();
|
||||
state.socket.disconnect();
|
||||
state = null;
|
||||
}
|
||||
}
|
||||
@@ -22,7 +22,7 @@ import { PixelStreamingWrapper } from "../components/PixelStreamingWrapper";
|
||||
import WarningIcon from "../components/icons/WarningIcon";
|
||||
import Button from "../components/ui/Button";
|
||||
import LoaderIcon from "../components/icons/LoaderIcon";
|
||||
import SessionUsersPanel from "../components/SessionUsersPanel2";
|
||||
import SessionUsersPanel2 from "../components/SessionUsersPanel2";
|
||||
|
||||
function NewSessionPage() {
|
||||
const { setPopup } = usePopupStore();
|
||||
@@ -88,15 +88,15 @@ function NewSessionPage() {
|
||||
setPopup(<SharePopup link={`${window.location.origin}/sessions/${id}`} />);
|
||||
}
|
||||
|
||||
// Перенаправление на тестовую страницу при завершении сессии
|
||||
useEffect(() => {
|
||||
if (session?.status === "ended") {
|
||||
const timer = setTimeout(() => {
|
||||
navigate("/test");
|
||||
}, 5000);
|
||||
return () => clearTimeout(timer);
|
||||
}
|
||||
}, [session?.status, navigate]);
|
||||
// Не перенаправляем автоматически - пользователи могут продолжать общаться
|
||||
// useEffect(() => {
|
||||
// if (session?.status === "ended") {
|
||||
// const timer = setTimeout(() => {
|
||||
// navigate("/test");
|
||||
// }, 5000);
|
||||
// return () => clearTimeout(timer);
|
||||
// }
|
||||
// }, [session?.status, navigate]);
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
@@ -140,6 +140,7 @@ function NewSessionPage() {
|
||||
|
||||
return (
|
||||
<div className="flex overflow-hidden relative order-3 w-screen h-screen bg-black justify-center_items-center">
|
||||
{/* Pixel Streaming - показывается только когда сессия активна */}
|
||||
{session.status === "started" &&
|
||||
session.mode === "stream" &&
|
||||
session.server?.localIp &&
|
||||
@@ -153,6 +154,7 @@ function NewSessionPage() {
|
||||
StartVideoMuted: true,
|
||||
HoveringMouse: true,
|
||||
WaitForStreamer: true,
|
||||
StreamerId: "DefaultStreamer",
|
||||
}}
|
||||
onVideoInitialized={() => {
|
||||
console.log("Video initialized");
|
||||
@@ -160,6 +162,24 @@ function NewSessionPage() {
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Сообщение когда Pixel Streaming завершён, но WebRTC чат работает */}
|
||||
{session.status === "ended" && (
|
||||
<div className="flex flex-col gap-6 justify-center items-center w-full h-full">
|
||||
<div className="flex flex-col gap-2 items-center">
|
||||
<div className="text-2xl font-semibold text-white">
|
||||
Сессия завершена
|
||||
</div>
|
||||
<div className="text-base text-gray-400">
|
||||
Вы можете продолжать общаться через видеочат
|
||||
</div>
|
||||
</div>
|
||||
<Button variant="primary" onClick={() => navigate("/test")}>
|
||||
Покинуть сессию
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<ActionsSidebarWrapper className="z-[99]">
|
||||
<FloatingActionButton
|
||||
className="max-2xl:hidden"
|
||||
@@ -210,7 +230,9 @@ function NewSessionPage() {
|
||||
</FloatingActionButton>
|
||||
<ControlsPopover />
|
||||
</ActionsSidebarWrapper>
|
||||
<SessionUsersPanel />
|
||||
|
||||
{/* WebRTC видеочат - работает всегда, пока пользователь на странице */}
|
||||
<SessionUsersPanel2 roomId={session.id} autoJoin={true} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,429 +0,0 @@
|
||||
import { useParams, useNavigate } from "react-router";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { api } from "../lib/api";
|
||||
import LoaderIcon from "../components/icons/LoaderIcon";
|
||||
import CheckIcon from "../components/icons/CheckIcon";
|
||||
import ClockIcon from "../components/icons/ClockIcon";
|
||||
import WarningIcon from "../components/icons/WarningIcon";
|
||||
import StartIcon from "../components/icons/StartIcon";
|
||||
import Button from "../components/ui/Button";
|
||||
import clsx from "clsx";
|
||||
import { useEffect } from "react";
|
||||
import { PixelStreamingWrapper } from "../components/PixelStreamingWrapper";
|
||||
|
||||
interface Session {
|
||||
id: string;
|
||||
appId: string;
|
||||
userId: string | null;
|
||||
mode: "stream" | "local";
|
||||
status: "starting" | "started" | "ending" | "ended";
|
||||
tier: "demo" | "prod" | null;
|
||||
serverId: string | null;
|
||||
appPid: number | null;
|
||||
cirrusPid: number | null;
|
||||
streamerPort: number | null;
|
||||
playerPort: number | null;
|
||||
sfuPort: number | null;
|
||||
startAt: string;
|
||||
endAt: string | null;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
app?: {
|
||||
id: string;
|
||||
name: string;
|
||||
title: string;
|
||||
gpuLimitMb: number | null;
|
||||
psVersion: number | null;
|
||||
};
|
||||
server?: {
|
||||
id: string;
|
||||
localIp: string;
|
||||
hostname: string;
|
||||
type: "stream" | "local";
|
||||
tier: "demo" | "prod" | null;
|
||||
location: "ru1" | "uae1" | null;
|
||||
} | null;
|
||||
user?: {
|
||||
id: string;
|
||||
email: string;
|
||||
role: string;
|
||||
displayName: string;
|
||||
} | null;
|
||||
}
|
||||
|
||||
function SessionPage() {
|
||||
const { id } = useParams();
|
||||
const navigate = useNavigate();
|
||||
|
||||
const {
|
||||
data: sessionData,
|
||||
isLoading,
|
||||
error,
|
||||
refetch,
|
||||
} = useQuery({
|
||||
queryKey: ["session", id],
|
||||
queryFn: async () => {
|
||||
const response = await api.get(`sessions/${id}`).json<{
|
||||
session: Session;
|
||||
}>();
|
||||
return response;
|
||||
},
|
||||
refetchInterval: (query) => {
|
||||
// Автоматически обновляем каждые 2 секунды, если сессия в процессе запуска
|
||||
const data = query.state.data;
|
||||
if (
|
||||
data?.session.status === "starting" ||
|
||||
data?.session.status === "ending"
|
||||
) {
|
||||
return 2000;
|
||||
}
|
||||
return false;
|
||||
},
|
||||
});
|
||||
|
||||
const session = sessionData?.session;
|
||||
|
||||
// Перенаправление на тестовую страницу при завершении сессии
|
||||
useEffect(() => {
|
||||
if (session?.status === "ended") {
|
||||
const timer = setTimeout(() => {
|
||||
navigate("/test");
|
||||
}, 5000);
|
||||
return () => clearTimeout(timer);
|
||||
}
|
||||
}, [session?.status, navigate]);
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="flex justify-center items-center min-h-screen bg-gray-50">
|
||||
<div className="flex flex-col gap-4 items-center">
|
||||
<div className="size-12 text-[#7B60F3] animate-spin">
|
||||
<LoaderIcon />
|
||||
</div>
|
||||
<p className="text-gray-600 text-m">
|
||||
Загрузка информации о сессии...
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (error || !session) {
|
||||
return (
|
||||
<div className="flex justify-center items-center min-h-screen bg-gray-50">
|
||||
<div className="p-8 w-full max-w-2xl bg-white rounded-lg shadow-md">
|
||||
<div className="flex gap-4 items-start">
|
||||
<div className="text-red-500 size-6">
|
||||
<WarningIcon />
|
||||
</div>
|
||||
<div className="flex-1">
|
||||
<h1 className="mb-2 text-red-900 title-l">Сессия не найдена</h1>
|
||||
<p className="mb-6 text-gray-600 text-m">
|
||||
{error instanceof Error
|
||||
? error.message
|
||||
: "Не удалось загрузить информацию о сессии"}
|
||||
</p>
|
||||
<Button variant="primary" onClick={() => navigate("/test")}>
|
||||
Вернуться назад
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="px-4 py-8 min-h-screen bg-gray-50">
|
||||
<div className="mx-auto max-w-4xl">
|
||||
{/* Header с названием приложения */}
|
||||
<div className="flex gap-4 justify-between items-center mb-6">
|
||||
<div>
|
||||
<h1 className="font-bold text-gray-900 title-l">
|
||||
{session.app?.title || "Приложение"}
|
||||
</h1>
|
||||
<p className="text-gray-500 text-s">
|
||||
ID сессии: {session.id.slice(0, 8)}...
|
||||
</p>
|
||||
</div>
|
||||
<StatusBadge status={session.status} />
|
||||
</div>
|
||||
|
||||
{/* Pixel Streaming Player - показывается когда сессия запущена */}
|
||||
{session.status === "started" &&
|
||||
session.mode === "stream" &&
|
||||
session.server?.localIp &&
|
||||
session.playerPort && (
|
||||
<div className="mb-6 aspect-video">
|
||||
<PixelStreamingWrapper
|
||||
initialSettings={{
|
||||
ss: `ws://${session.server.localIp}:${session.playerPort}`,
|
||||
AutoPlayVideo: true,
|
||||
AutoConnect: true,
|
||||
StartVideoMuted: true,
|
||||
HoveringMouse: true,
|
||||
WaitForStreamer: true,
|
||||
}}
|
||||
onVideoInitialized={() => {
|
||||
console.log("Video initialized");
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Основная карточка с информацией */}
|
||||
<div className="grid gap-6 mb-6 md:grid-cols-2">
|
||||
{/* Информация о сессии */}
|
||||
<div className="p-6 bg-white rounded-2xl shadow-sm">
|
||||
<h2 className="flex gap-2 items-center mb-4 font-semibold text-gray-900 title-m">
|
||||
<div className="size-5 text-[#7B60F3]">
|
||||
<StartIcon />
|
||||
</div>
|
||||
Информация о сессии
|
||||
</h2>
|
||||
<div className="space-y-3">
|
||||
<InfoRow label="Режим" value={getModeLabel(session.mode)} />
|
||||
{session.tier && (
|
||||
<InfoRow label="Уровень" value={getTierLabel(session.tier)} />
|
||||
)}
|
||||
<InfoRow label="Начало" value={formatDateTime(session.startAt)} />
|
||||
{session.endAt && (
|
||||
<InfoRow
|
||||
label="Завершение"
|
||||
value={formatDateTime(session.endAt)}
|
||||
/>
|
||||
)}
|
||||
{session.appPid && (
|
||||
<InfoRow label="PID приложения" value={session.appPid} />
|
||||
)}
|
||||
{session.cirrusPid && (
|
||||
<InfoRow label="PID Cirrus" value={session.cirrusPid} />
|
||||
)}
|
||||
{session.streamerPort && (
|
||||
<InfoRow label="Streamer Port" value={session.streamerPort} />
|
||||
)}
|
||||
{session.playerPort && (
|
||||
<InfoRow label="Player Port" value={session.playerPort} />
|
||||
)}
|
||||
{session.sfuPort && (
|
||||
<InfoRow label="SFU Port" value={session.sfuPort} />
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Информация о сервере */}
|
||||
{session.server && (
|
||||
<div className="p-6 bg-white rounded-2xl shadow-sm">
|
||||
<h2 className="flex gap-2 items-center mb-4 font-semibold text-gray-900 title-m">
|
||||
<div className="size-5 text-[#7B60F3]">
|
||||
<CheckIcon />
|
||||
</div>
|
||||
Сервер
|
||||
</h2>
|
||||
<div className="space-y-3">
|
||||
<InfoRow label="Hostname" value={session.server.hostname} />
|
||||
<InfoRow label="IP адрес" value={session.server.localIp} />
|
||||
<InfoRow
|
||||
label="Тип"
|
||||
value={
|
||||
session.server.type === "stream" ? "Стрим" : "Локальный"
|
||||
}
|
||||
/>
|
||||
{session.server.location && (
|
||||
<InfoRow
|
||||
label="Локация"
|
||||
value={getLocationLabel(session.server.location)}
|
||||
/>
|
||||
)}
|
||||
{session.server.tier && (
|
||||
<InfoRow
|
||||
label="Уровень"
|
||||
value={getTierLabel(session.server.tier)}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Если сервер еще не назначен */}
|
||||
{!session.server && session.status === "starting" && (
|
||||
<div className="p-6 bg-white rounded-2xl shadow-sm">
|
||||
<h2 className="flex gap-2 items-center mb-4 font-semibold text-gray-900 title-m">
|
||||
<div className="text-yellow-500 animate-spin size-5">
|
||||
<LoaderIcon />
|
||||
</div>
|
||||
Сервер
|
||||
</h2>
|
||||
<p className="text-gray-600 text-s">
|
||||
Подбирается подходящий сервер...
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Информация о приложении */}
|
||||
{session.app && (
|
||||
<div className="p-6 bg-white rounded-2xl shadow-sm md:col-span-2">
|
||||
<h2 className="flex gap-2 items-center mb-4 font-semibold text-gray-900 title-m">
|
||||
<div className="size-5 text-[#7B60F3]">
|
||||
<ClockIcon />
|
||||
</div>
|
||||
Приложение
|
||||
</h2>
|
||||
<div className="grid gap-3 md:grid-cols-2">
|
||||
<InfoRow label="Название" value={session.app.title} />
|
||||
<InfoRow label="Имя системное" value={session.app.name} />
|
||||
{session.app.gpuLimitMb && (
|
||||
<InfoRow
|
||||
label="Лимит GPU"
|
||||
value={`${session.app.gpuLimitMb} МБ`}
|
||||
/>
|
||||
)}
|
||||
{session.app.psVersion && (
|
||||
<InfoRow label="Версия PS" value={session.app.psVersion} />
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Статусное сообщение */}
|
||||
<StatusMessage status={session.status} />
|
||||
|
||||
{/* Кнопки управления */}
|
||||
<div className="flex gap-4 justify-center">
|
||||
<Button variant="secondary" onClick={() => navigate("/test")}>
|
||||
Вернуться назад
|
||||
</Button>
|
||||
{(session.status === "starting" || session.status === "ending") && (
|
||||
<Button variant="primary" onClick={() => refetch()}>
|
||||
Обновить
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Компоненты помощники
|
||||
function StatusBadge({ status }: { status: Session["status"] }) {
|
||||
const config = {
|
||||
starting: {
|
||||
label: "Запускается",
|
||||
color: "bg-yellow-100 text-yellow-800",
|
||||
icon: <LoaderIcon />,
|
||||
animate: true,
|
||||
},
|
||||
started: {
|
||||
label: "Запущена",
|
||||
color: "bg-green-100 text-green-800",
|
||||
icon: <CheckIcon />,
|
||||
animate: false,
|
||||
},
|
||||
ending: {
|
||||
label: "Завершается",
|
||||
color: "bg-orange-100 text-orange-800",
|
||||
icon: <LoaderIcon />,
|
||||
animate: true,
|
||||
},
|
||||
ended: {
|
||||
label: "Завершена",
|
||||
color: "bg-gray-100 text-gray-800",
|
||||
icon: <ClockIcon />,
|
||||
animate: false,
|
||||
},
|
||||
};
|
||||
|
||||
const statusConfig = config[status];
|
||||
|
||||
return (
|
||||
<div
|
||||
className={clsx(
|
||||
"flex gap-2 items-center px-4 py-2 rounded-xl button-m font-medium",
|
||||
statusConfig.color
|
||||
)}
|
||||
>
|
||||
<div className={clsx("size-4", statusConfig.animate && "animate-spin")}>
|
||||
{statusConfig.icon}
|
||||
</div>
|
||||
{statusConfig.label}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function InfoRow({ label, value }: { label: string; value: string | number }) {
|
||||
return (
|
||||
<div className="flex justify-between items-start">
|
||||
<span className="text-gray-500 text-s">{label}:</span>
|
||||
<span className="font-medium text-right text-gray-900 text-s">
|
||||
{value}
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function StatusMessage({ status }: { status: Session["status"] }) {
|
||||
const messages = {
|
||||
starting: {
|
||||
text: "Сессия запускается. Пожалуйста, подождите...",
|
||||
color: "bg-yellow-50 border-yellow-200 text-yellow-800",
|
||||
icon: <LoaderIcon />,
|
||||
},
|
||||
started: {
|
||||
text: "Сессия успешно запущена и готова к работе!",
|
||||
color: "bg-green-50 border-green-200 text-green-800",
|
||||
icon: <CheckIcon />,
|
||||
},
|
||||
ending: {
|
||||
text: "Сессия завершается...",
|
||||
color: "bg-orange-50 border-orange-200 text-orange-800",
|
||||
icon: <LoaderIcon />,
|
||||
},
|
||||
ended: {
|
||||
text: "Сессия завершена. Через 5 секунд вы будете перенаправлены на главную страницу.",
|
||||
color: "bg-gray-50 border-gray-200 text-gray-800",
|
||||
icon: <ClockIcon />,
|
||||
},
|
||||
};
|
||||
|
||||
const message = messages[status];
|
||||
|
||||
return (
|
||||
<div
|
||||
className={clsx(
|
||||
"flex gap-4 items-start p-4 mb-6 rounded-xl border",
|
||||
message.color
|
||||
)}
|
||||
>
|
||||
<div className="size-5 flex-shrink-0 mt-0.5">{message.icon}</div>
|
||||
<p className="text-m">{message.text}</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Утилиты форматирования
|
||||
function formatDateTime(dateString: string): string {
|
||||
const date = new Date(dateString);
|
||||
return new Intl.DateTimeFormat("ru-RU", {
|
||||
dateStyle: "short",
|
||||
timeStyle: "medium",
|
||||
}).format(date);
|
||||
}
|
||||
|
||||
function getModeLabel(mode: "stream" | "local"): string {
|
||||
return mode === "stream" ? "Стриминг" : "Локальный";
|
||||
}
|
||||
|
||||
function getTierLabel(tier: "demo" | "prod"): string {
|
||||
return tier === "demo" ? "Демо" : "Продакшн";
|
||||
}
|
||||
|
||||
function getLocationLabel(location: "ru1" | "uae1"): string {
|
||||
const labels = {
|
||||
ru1: "Россия (ru1)",
|
||||
uae1: "ОАЭ (uae1)",
|
||||
};
|
||||
return labels[location] || location;
|
||||
}
|
||||
|
||||
export default SessionPage;
|
||||
Reference in New Issue
Block a user