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:
2025-10-24 18:17:12 +05:00
parent fb2d9258ac
commit f70e8f88b2
40 changed files with 4558 additions and 811 deletions
+3 -1
View File
@@ -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
+343 -5
View File
@@ -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",
+3
View File
@@ -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",
-190
View File
@@ -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>
);
}
+55 -35
View File
@@ -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;
+28 -30
View File
@@ -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>
);
+305 -49
View File
@@ -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()}
+220
View File
@@ -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,
};
};
+925
View File
@@ -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;
}
}
+33 -11
View File
@@ -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>
);
}
-429
View File
@@ -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;