From 739fa547197af22fdecca57f9ebc9f2b558665af Mon Sep 17 00:00:00 2001 From: Puechberty Arthur Date: Mon, 30 Mar 2026 23:13:20 +0200 Subject: [PATCH] feat: add settings page for user display name and implement local storage hook feat: create SocketContext for managing socket connections feat: implement useLocalStorage hook for persistent state management feat: set up SQLite database with room management functions feat: add utility functions for generating room IDs and formatting dates --- .gitignore | 8 + README.md | 75 ++- docker-compose.yml | 20 + package-lock.json | 784 +++++++++++++++++++++++++- package.json | 13 +- server.js | 227 ++++++++ src/app/api/rooms/[id]/route.ts | 67 +++ src/app/api/rooms/route.ts | 48 ++ src/app/globals.css | 127 ++++- src/app/layout.tsx | 18 +- src/app/page.tsx | 242 ++++++-- src/app/room/[id]/page.tsx | 951 ++++++++++++++++++++++++++++++++ src/app/settings/page.tsx | 109 ++++ src/contexts/SocketContext.tsx | 50 ++ src/hooks/useLocalStorage.ts | 33 ++ src/lib/database.ts | 67 +++ src/lib/utils.ts | 16 + 17 files changed, 2752 insertions(+), 103 deletions(-) create mode 100644 docker-compose.yml create mode 100644 server.js create mode 100644 src/app/api/rooms/[id]/route.ts create mode 100644 src/app/api/rooms/route.ts create mode 100644 src/app/room/[id]/page.tsx create mode 100644 src/app/settings/page.tsx create mode 100644 src/contexts/SocketContext.tsx create mode 100644 src/hooks/useLocalStorage.ts create mode 100644 src/lib/database.ts create mode 100644 src/lib/utils.ts diff --git a/.gitignore b/.gitignore index 5ef6a52..365a670 100644 --- a/.gitignore +++ b/.gitignore @@ -30,6 +30,14 @@ yarn-debug.log* yarn-error.log* .pnpm-debug.log* +# editor / local tooling +.vscode/ + +# local database artifacts +*.db +*.db-shm +*.db-wal + # env files (can opt-in for committing if needed) .env* diff --git a/README.md b/README.md index e215bc4..1664c2c 100644 --- a/README.md +++ b/README.md @@ -1,36 +1,69 @@ -This is a [Next.js](https://nextjs.org) project bootstrapped with [`create-next-app`](https://nextjs.org/docs/app/api-reference/cli/create-next-app). +# Visio - Visioconference WebRTC -## Getting Started +Visio est une application de visioconference simple, rapide et sans inscription. +Le projet est construit avec Next.js, React et WebRTC pour offrir des appels peer-to-peer. -First, run the development server: +## Site en production + +Application en ligne: https://visio.arthurp.fr + +Si vous mentionnez ce projet, vous pouvez faire un lien direct vers: +https://visio.arthurp.fr + +## Fonctionnalites + +- Creation de salle en un clic +- Partage par lien unique +- Aucun compte requis +- Nom utilisateur memorise localement +- Controle micro/camera +- Fermeture automatique des salles inactives +- Interface en francais + +## Stack technique + +- Frontend: Next.js 16 + React 19 +- Backend: serveur Node.js personnalise +- Temps reel: Socket.io +- Video/audio: WebRTC (simple-peer) +- Persistance: SQLite (better-sqlite3) + +## Installation locale + +Prerequis: + +- Node.js 18+ +- npm + +Commandes: ```bash +npm install npm run dev -# or -yarn dev -# or -pnpm dev -# or -bun dev ``` -Open [http://localhost:3000](http://localhost:3000) with your browser to see the result. +Application disponible ensuite sur: +http://localhost:3000 -You can start editing the page by modifying `app/page.tsx`. The page auto-updates as you edit the file. +## Scripts -This project uses [`next/font`](https://nextjs.org/docs/app/building-your-application/optimizing/fonts) to automatically optimize and load [Geist](https://vercel.com/font), a new font family for Vercel. +- `npm run dev` : demarrage en developpement +- `npm run build` : build de production +- `npm start` : lancement serveur de production +- `npm run lint` : verification ESLint -## Learn More +## Variables d'environnement -To learn more about Next.js, take a look at the following resources: +- `PORT` : port HTTP du serveur (defaut `3000`) +- `NODE_ENV` : `development` ou `production` -- [Next.js Documentation](https://nextjs.org/docs) - learn about Next.js features and API. -- [Learn Next.js](https://nextjs.org/learn) - an interactive Next.js tutorial. +## Deploiement -You can check out [the Next.js GitHub repository](https://github.com/vercel/next.js) - your feedback and contributions are welcome! +```bash +npm run build +npm start +``` -## Deploy on Vercel +## Licence -The easiest way to deploy your Next.js app is to use the [Vercel Platform](https://vercel.com/new?utm_medium=default-template&filter=next.js&utm_source=create-next-app&utm_campaign=create-next-app-readme) from the creators of Next.js. - -Check out our [Next.js deployment documentation](https://nextjs.org/docs/app/building-your-application/deploying) for more details. +MIT diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..5a67c7f --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,20 @@ +services: + visio: + image: node:20-alpine + container_name: visio-app + working_dir: /app + volumes: + - ./:/app + - /app/node_modules + ports: + - "3010:3000" + environment: + - PORT=3000 + command: sh -c "npm install && npm run build && npm start" + restart: unless-stopped + healthcheck: + test: ["CMD", "wget", "-q", "--spider", "http://localhost:3000"] + interval: 30s + timeout: 10s + retries: 3 + start_period: 40s diff --git a/package-lock.json b/package-lock.json index 000f4b5..901c342 100644 --- a/package-lock.json +++ b/package-lock.json @@ -8,15 +8,22 @@ "name": "visio", "version": "0.1.0", "dependencies": { + "better-sqlite3": "^12.6.2", "next": "16.1.6", "react": "19.2.3", - "react-dom": "19.2.3" + "react-dom": "19.2.3", + "simple-peer": "^9.11.1", + "socket.io": "^4.8.3", + "socket.io-client": "^4.8.3", + "uuid": "^13.0.0" }, "devDependencies": { "@tailwindcss/postcss": "^4", + "@types/better-sqlite3": "^7.6.13", "@types/node": "^20", "@types/react": "^19", "@types/react-dom": "^19", + "@types/uuid": "^10.0.0", "eslint": "^9", "eslint-config-next": "16.1.6", "tailwindcss": "^4", @@ -1234,6 +1241,12 @@ "dev": true, "license": "MIT" }, + "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==", + "license": "MIT" + }, "node_modules/@swc/helpers": { "version": "0.5.15", "resolved": "https://registry.npmjs.org/@swc/helpers/-/helpers-0.5.15.tgz", @@ -1525,6 +1538,25 @@ "tslib": "^2.4.0" } }, + "node_modules/@types/better-sqlite3": { + "version": "7.6.13", + "resolved": "https://registry.npmjs.org/@types/better-sqlite3/-/better-sqlite3-7.6.13.tgz", + "integrity": "sha512-NMv9ASNARoKksWtsq/SHakpYAYnhBrQgGD8zkLYk/jaK8jUGn08CfEdTRgYhMypUQAfzSP8W6gNLe0q19/t4VA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/node": "*" + } + }, + "node_modules/@types/cors": { + "version": "2.8.19", + "resolved": "https://registry.npmjs.org/@types/cors/-/cors-2.8.19.tgz", + "integrity": "sha512-mFNylyeyqN93lfe/9CSxOGREz8cpzAhH+E93xJ4xWQf62V8sQ/24reV2nyzUWM6H6Xji+GGHpkbLe7pVoUEskg==", + "license": "MIT", + "dependencies": { + "@types/node": "*" + } + }, "node_modules/@types/estree": { "version": "1.0.8", "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.8.tgz", @@ -1550,7 +1582,6 @@ "version": "20.19.31", "resolved": "https://registry.npmjs.org/@types/node/-/node-20.19.31.tgz", "integrity": "sha512-5jsi0wpncvTD33Sh1UCgacK37FFwDn+EG7wCmEvs62fCvBL+n8/76cAYDok21NF6+jaVWIqKwCZyX7Vbu8eB3A==", - "dev": true, "license": "MIT", "dependencies": { "undici-types": "~6.21.0" @@ -1577,6 +1608,13 @@ "@types/react": "^19.2.0" } }, + "node_modules/@types/uuid": { + "version": "10.0.0", + "resolved": "https://registry.npmjs.org/@types/uuid/-/uuid-10.0.0.tgz", + "integrity": "sha512-7gqG38EyHgyP1S+7+xomFtL+ZNHcKv6DwNaCZmJmo1vgMugyF3TCnXVg4t1uk89mLNwnLtnY3TpOpCOyp1/xHQ==", + "dev": true, + "license": "MIT" + }, "node_modules/@typescript-eslint/eslint-plugin": { "version": "8.54.0", "resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.54.0.tgz", @@ -2116,6 +2154,19 @@ "win32" ] }, + "node_modules/accepts": { + "version": "1.3.8", + "resolved": "https://registry.npmjs.org/accepts/-/accepts-1.3.8.tgz", + "integrity": "sha512-PYAthTa2m2VKxuvSD3DPC/Gy+U+sOA1LAuT8mkmRuvw+NACSaeXEQ+NHcVF7rONl6qcaxV3Uuemwawk+7+SJLw==", + "license": "MIT", + "dependencies": { + "mime-types": "~2.1.34", + "negotiator": "0.6.3" + }, + "engines": { + "node": ">= 0.6" + } + }, "node_modules/acorn": { "version": "8.15.0", "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.15.0.tgz", @@ -2410,6 +2461,35 @@ "dev": true, "license": "MIT" }, + "node_modules/base64-js": { + "version": "1.5.1", + "resolved": "https://registry.npmjs.org/base64-js/-/base64-js-1.5.1.tgz", + "integrity": "sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT" + }, + "node_modules/base64id": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/base64id/-/base64id-2.0.0.tgz", + "integrity": "sha512-lGe34o6EHj9y3Kts9R4ZYs/Gr+6N7MCaMlIFA3F1R2O5/m7K06AxfSeO5530PEERE6/WyEg3lsuyw4GHlPZHog==", + "license": "MIT", + "engines": { + "node": "^4.5.0 || >= 5.9" + } + }, "node_modules/baseline-browser-mapping": { "version": "2.9.19", "resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.9.19.tgz", @@ -2419,6 +2499,64 @@ "baseline-browser-mapping": "dist/cli.js" } }, + "node_modules/better-sqlite3": { + "version": "12.6.2", + "resolved": "https://registry.npmjs.org/better-sqlite3/-/better-sqlite3-12.6.2.tgz", + "integrity": "sha512-8VYKM3MjCa9WcaSAI3hzwhmyHVlH8tiGFwf0RlTsZPWJ1I5MkzjiudCo4KC4DxOaL/53A5B1sI/IbldNFDbsKA==", + "hasInstallScript": true, + "license": "MIT", + "dependencies": { + "bindings": "^1.5.0", + "prebuild-install": "^7.1.1" + }, + "engines": { + "node": "20.x || 22.x || 23.x || 24.x || 25.x" + } + }, + "node_modules/bindings": { + "version": "1.5.0", + "resolved": "https://registry.npmjs.org/bindings/-/bindings-1.5.0.tgz", + "integrity": "sha512-p2q/t/mhvuOj/UeLlV6566GD/guowlr0hHxClI0W9m7MWYkL1F0hLo+0Aexs9HSPCtR1SXQ0TD3MMKrXZajbiQ==", + "license": "MIT", + "dependencies": { + "file-uri-to-path": "1.0.0" + } + }, + "node_modules/bl": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/bl/-/bl-4.1.0.tgz", + "integrity": "sha512-1W07cM9gS6DcLperZfFSj+bWLtaPGSOHWhPiGzXmvVJbRLdG82sH/Kn8EtW1VqWVA54AKf2h5k5BbnIbwF3h6w==", + "license": "MIT", + "dependencies": { + "buffer": "^5.5.0", + "inherits": "^2.0.4", + "readable-stream": "^3.4.0" + } + }, + "node_modules/bl/node_modules/buffer": { + "version": "5.7.1", + "resolved": "https://registry.npmjs.org/buffer/-/buffer-5.7.1.tgz", + "integrity": "sha512-EHcyIPBQ4BSGlvjB16k5KgAJ27CIsHY/2JBmCRReo48y9rQ3MaUzWX3KVlBa4U7MyX02HdVj0K7C3WaB3ju7FQ==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT", + "dependencies": { + "base64-js": "^1.3.1", + "ieee754": "^1.1.13" + } + }, "node_modules/brace-expansion": { "version": "1.1.12", "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.12.tgz", @@ -2478,6 +2616,30 @@ "node": "^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7" } }, + "node_modules/buffer": { + "version": "6.0.3", + "resolved": "https://registry.npmjs.org/buffer/-/buffer-6.0.3.tgz", + "integrity": "sha512-FTiCpNxtwiZZHEZbcbTIcZjERVICn9yq/pDFkTl95/AxzD1naBctN7YO68riM/gLSDY7sdrMby8hofADYuuqOA==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT", + "dependencies": { + "base64-js": "^1.3.1", + "ieee754": "^1.2.1" + } + }, "node_modules/call-bind": { "version": "1.0.8", "resolved": "https://registry.npmjs.org/call-bind/-/call-bind-1.0.8.tgz", @@ -2575,6 +2737,12 @@ "url": "https://github.com/chalk/chalk?sponsor=1" } }, + "node_modules/chownr": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/chownr/-/chownr-1.1.4.tgz", + "integrity": "sha512-jJ0bqzaylmJtVnNgzTeSOs8DPavpbYgEr/b0YL8/2GO3xJEhInFmhKMUnEJQjZumK7KXGFhUy89PrsJWlakBVg==", + "license": "ISC" + }, "node_modules/client-only": { "version": "0.0.1", "resolved": "https://registry.npmjs.org/client-only/-/client-only-0.0.1.tgz", @@ -2615,6 +2783,32 @@ "dev": true, "license": "MIT" }, + "node_modules/cookie": { + "version": "0.7.2", + "resolved": "https://registry.npmjs.org/cookie/-/cookie-0.7.2.tgz", + "integrity": "sha512-yki5XnKuf750l50uGTllt6kKILY4nQ1eNIQatoXEByZ5dWgnKqbnqmTrBE5B4N7lrMJKQ2ytWMiTO2o0v6Ew/w==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/cors": { + "version": "2.8.6", + "resolved": "https://registry.npmjs.org/cors/-/cors-2.8.6.tgz", + "integrity": "sha512-tJtZBBHA6vjIAaF6EnIaq6laBBP9aq/Y3ouVJjEfoHbRBcHBAHYcMh/w8LDrk2PvIMMq8gmopa5D4V8RmbrxGw==", + "license": "MIT", + "dependencies": { + "object-assign": "^4", + "vary": "^1" + }, + "engines": { + "node": ">= 0.10" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, "node_modules/cross-spawn": { "version": "7.0.6", "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz", @@ -2702,7 +2896,6 @@ "version": "4.4.3", "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", - "dev": true, "license": "MIT", "dependencies": { "ms": "^2.1.3" @@ -2716,6 +2909,30 @@ } } }, + "node_modules/decompress-response": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/decompress-response/-/decompress-response-6.0.0.tgz", + "integrity": "sha512-aW35yZM6Bb/4oJlZncMH2LCoZtJXTRxES17vE3hoRiowU2kWHaJKFkSBDnDR+cm9J+9QhXmREyIfv0pji9ejCQ==", + "license": "MIT", + "dependencies": { + "mimic-response": "^3.1.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/deep-extend": { + "version": "0.6.0", + "resolved": "https://registry.npmjs.org/deep-extend/-/deep-extend-0.6.0.tgz", + "integrity": "sha512-LOHxIOaPYdHlJRtCQfDIVZtfw/ufM8+rVj649RIHzcm/vGwQRXFt6OPqIFWsm2XEMrNIEtWR64sY1LEKD2vAOA==", + "license": "MIT", + "engines": { + "node": ">=4.0.0" + } + }, "node_modules/deep-is": { "version": "0.1.4", "resolved": "https://registry.npmjs.org/deep-is/-/deep-is-0.1.4.tgz", @@ -2763,7 +2980,6 @@ "version": "2.1.2", "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz", "integrity": "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==", - "devOptional": true, "license": "Apache-2.0", "engines": { "node": ">=8" @@ -2811,6 +3027,57 @@ "dev": true, "license": "MIT" }, + "node_modules/end-of-stream": { + "version": "1.4.5", + "resolved": "https://registry.npmjs.org/end-of-stream/-/end-of-stream-1.4.5.tgz", + "integrity": "sha512-ooEGc6HP26xXq/N+GCGOT0JKCLDGrq2bQUZrQ7gyrJiZANJ/8YDTxTpQBXGMn+WbIQXNVpyWymm7KYVICQnyOg==", + "license": "MIT", + "dependencies": { + "once": "^1.4.0" + } + }, + "node_modules/engine.io": { + "version": "6.6.5", + "resolved": "https://registry.npmjs.org/engine.io/-/engine.io-6.6.5.tgz", + "integrity": "sha512-2RZdgEbXmp5+dVbRm0P7HQUImZpICccJy7rN7Tv+SFa55pH+lxnuw6/K1ZxxBfHoYpSkHLAO92oa8O4SwFXA2A==", + "license": "MIT", + "dependencies": { + "@types/cors": "^2.8.12", + "@types/node": ">=10.0.0", + "accepts": "~1.3.4", + "base64id": "2.0.0", + "cookie": "~0.7.2", + "cors": "~2.8.5", + "debug": "~4.4.1", + "engine.io-parser": "~5.2.1", + "ws": "~8.18.3" + }, + "engines": { + "node": ">=10.2.0" + } + }, + "node_modules/engine.io-client": { + "version": "6.6.4", + "resolved": "https://registry.npmjs.org/engine.io-client/-/engine.io-client-6.6.4.tgz", + "integrity": "sha512-+kjUJnZGwzewFDw951CDWcwj35vMNf2fcj7xQWOctq1F2i1jkDdVvdFG9kM/BEChymCH36KgjnW0NsL58JYRxw==", + "license": "MIT", + "dependencies": { + "@socket.io/component-emitter": "~3.1.0", + "debug": "~4.4.1", + "engine.io-parser": "~5.2.1", + "ws": "~8.18.3", + "xmlhttprequest-ssl": "~2.1.1" + } + }, + "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==", + "license": "MIT", + "engines": { + "node": ">=10.0.0" + } + }, "node_modules/enhanced-resolve": { "version": "5.19.0", "resolved": "https://registry.npmjs.org/enhanced-resolve/-/enhanced-resolve-5.19.0.tgz", @@ -2825,6 +3092,12 @@ "node": ">=10.13.0" } }, + "node_modules/err-code": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/err-code/-/err-code-3.0.1.tgz", + "integrity": "sha512-GiaH0KJUewYok+eeY05IIgjtAe4Yltygk9Wqp1V5yVWLdhf0hYZchRjNIT9bb0mSwRcIusT3cx7PJUf3zEIfUA==", + "license": "MIT" + }, "node_modules/es-abstract": { "version": "1.24.1", "resolved": "https://registry.npmjs.org/es-abstract/-/es-abstract-1.24.1.tgz", @@ -3217,6 +3490,7 @@ "integrity": "sha512-whOE1HFo/qJDyX4SnXzP4N6zOWn79WhnCUY/iDR0mPfQZO8wcYE4JClzI2oZrhBnnMUCBCHZhO6VQyoBU95mZA==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@rtsao/scc": "^1.1.0", "array-includes": "^3.1.9", @@ -3450,6 +3724,15 @@ "node": ">=0.10.0" } }, + "node_modules/expand-template": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/expand-template/-/expand-template-2.0.3.tgz", + "integrity": "sha512-XYfuKMvj4O35f/pOXLObndIRvyQ+/+6AhODh+OKWj9S9498pHHn/IMszH+gt0fBCRWMNfk1ZSp5x3AifmnI2vg==", + "license": "(MIT OR WTFPL)", + "engines": { + "node": ">=6" + } + }, "node_modules/fast-deep-equal": { "version": "3.1.3", "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz", @@ -3524,6 +3807,12 @@ "node": ">=16.0.0" } }, + "node_modules/file-uri-to-path": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/file-uri-to-path/-/file-uri-to-path-1.0.0.tgz", + "integrity": "sha512-0Zt+s3L7Vf1biwWZ29aARiVYLx7iMGnEUl9x33fbB/j3jR81u/O2LbqK+Bm1CDSNDKVtJ/YjwY7TUd5SkeLQLw==", + "license": "MIT" + }, "node_modules/fill-range": { "version": "7.1.1", "resolved": "https://registry.npmjs.org/fill-range/-/fill-range-7.1.1.tgz", @@ -3591,6 +3880,12 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/fs-constants": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/fs-constants/-/fs-constants-1.0.0.tgz", + "integrity": "sha512-y6OAwoSIf7FyjMIv94u+b5rdheZEjzR63GTyZJm5qh4Bi+2YgwLCcI/fPFZkL5PSixOt6ZNKm+w+Hfp/Bciwow==", + "license": "MIT" + }, "node_modules/function-bind": { "version": "1.1.2", "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz", @@ -3652,6 +3947,12 @@ "node": ">=6.9.0" } }, + "node_modules/get-browser-rtc": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/get-browser-rtc/-/get-browser-rtc-1.1.0.tgz", + "integrity": "sha512-MghbMJ61EJrRsDe7w1Bvqt3ZsBuqhce5nrn/XAwgwOXhcsz53/ltdxOse1h/8eKXj5slzxdsz56g5rzOFSGwfQ==", + "license": "MIT" + }, "node_modules/get-intrinsic": { "version": "1.3.0", "resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.3.0.tgz", @@ -3722,6 +4023,12 @@ "url": "https://github.com/privatenumber/get-tsconfig?sponsor=1" } }, + "node_modules/github-from-package": { + "version": "0.0.0", + "resolved": "https://registry.npmjs.org/github-from-package/-/github-from-package-0.0.0.tgz", + "integrity": "sha512-SyHy3T1v2NUXn29OsWdxmK6RwHD+vkj3v8en8AOBZ1wBQ/hCAQ5bAQTD02kW4W9tUp/3Qh6J8r9EvntiyCmOOw==", + "license": "MIT" + }, "node_modules/glob-parent": { "version": "6.0.2", "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-6.0.2.tgz", @@ -3896,6 +4203,26 @@ "hermes-estree": "0.25.1" } }, + "node_modules/ieee754": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/ieee754/-/ieee754-1.2.1.tgz", + "integrity": "sha512-dcyqhDvX1C46lXZcVqCpK+FtMRQVdIMN6/Df5js2zouUsqG7I6sFxitIC+7KYK29KdXOLHdu9zL4sFnoVQnqaA==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "BSD-3-Clause" + }, "node_modules/ignore": { "version": "5.3.2", "resolved": "https://registry.npmjs.org/ignore/-/ignore-5.3.2.tgz", @@ -3933,6 +4260,18 @@ "node": ">=0.8.19" } }, + "node_modules/inherits": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz", + "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==", + "license": "ISC" + }, + "node_modules/ini": { + "version": "1.3.8", + "resolved": "https://registry.npmjs.org/ini/-/ini-1.3.8.tgz", + "integrity": "sha512-JV/yugV2uzW5iMRSiZAyDtQd+nxtUnjeLt0acNdw98kKLrvuRVyB80tsREOE7yvGVgalhZ6RNXCmEHkUKBKxew==", + "license": "ISC" + }, "node_modules/internal-slot": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/internal-slot/-/internal-slot-1.1.0.tgz", @@ -4883,6 +5222,39 @@ "node": ">=8.6" } }, + "node_modules/mime-db": { + "version": "1.52.0", + "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.52.0.tgz", + "integrity": "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/mime-types": { + "version": "2.1.35", + "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-2.1.35.tgz", + "integrity": "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==", + "license": "MIT", + "dependencies": { + "mime-db": "1.52.0" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/mimic-response": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/mimic-response/-/mimic-response-3.1.0.tgz", + "integrity": "sha512-z0yWI+4FDrrweS8Zmt4Ej5HdJmky15+L2e6Wgn3+iK5fWzb6T3fhNFq2+MeTRb064c6Wr4N/wv0DzQTjNzHNGQ==", + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/minimatch": { "version": "3.1.2", "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.2.tgz", @@ -4900,17 +5272,21 @@ "version": "1.2.8", "resolved": "https://registry.npmjs.org/minimist/-/minimist-1.2.8.tgz", "integrity": "sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA==", - "dev": true, "license": "MIT", "funding": { "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/mkdirp-classic": { + "version": "0.5.3", + "resolved": "https://registry.npmjs.org/mkdirp-classic/-/mkdirp-classic-0.5.3.tgz", + "integrity": "sha512-gKLcREMhtuZRwRAfqP3RFW+TK4JqApVBtOIftVgjuABpAtpxhPGaDcfvbhNvD0B8iD1oUr/txX35NjcaY6Ns/A==", + "license": "MIT" + }, "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/nanoid": { @@ -4931,6 +5307,12 @@ "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" } }, + "node_modules/napi-build-utils": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/napi-build-utils/-/napi-build-utils-2.0.0.tgz", + "integrity": "sha512-GEbrYkbfF7MoNaoh2iGG84Mnf/WZfB0GdGEsM8wz7Expx/LlWf5U8t9nvJKXSp3qr5IsEbK04cBGhol/KwOsWA==", + "license": "MIT" + }, "node_modules/napi-postinstall": { "version": "0.3.4", "resolved": "https://registry.npmjs.org/napi-postinstall/-/napi-postinstall-0.3.4.tgz", @@ -4954,6 +5336,15 @@ "dev": true, "license": "MIT" }, + "node_modules/negotiator": { + "version": "0.6.3", + "resolved": "https://registry.npmjs.org/negotiator/-/negotiator-0.6.3.tgz", + "integrity": "sha512-+EUsqGPLsM+j/zdChZjsnX51g4XrHFOIXwfnCVPGlQk/k5giakcKsuxCObBRu6DSm9opw/O6slWbJdghQM4bBg==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, "node_modules/next": { "version": "16.1.6", "resolved": "https://registry.npmjs.org/next/-/next-16.1.6.tgz", @@ -5035,6 +5426,30 @@ "node": "^10 || ^12 || >=14" } }, + "node_modules/node-abi": { + "version": "3.87.0", + "resolved": "https://registry.npmjs.org/node-abi/-/node-abi-3.87.0.tgz", + "integrity": "sha512-+CGM1L1CgmtheLcBuleyYOn7NWPVu0s0EJH2C4puxgEZb9h8QpR9G2dBfZJOAUhi7VQxuBPMd0hiISWcTyiYyQ==", + "license": "MIT", + "dependencies": { + "semver": "^7.3.5" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/node-abi/node_modules/semver": { + "version": "7.7.3", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.3.tgz", + "integrity": "sha512-SdsKMrI9TdgjdweUSR9MweHA4EJ8YxHn8DFaDisvhVlUOe4BF1tLD7GAj0lIqWVl+dPb/rExr0Btby5loQm20Q==", + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" + } + }, "node_modules/node-releases": { "version": "2.0.27", "resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.27.tgz", @@ -5046,7 +5461,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" @@ -5165,6 +5579,15 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/once": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/once/-/once-1.4.0.tgz", + "integrity": "sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w==", + "license": "ISC", + "dependencies": { + "wrappy": "1" + } + }, "node_modules/optionator": { "version": "0.9.4", "resolved": "https://registry.npmjs.org/optionator/-/optionator-0.9.4.tgz", @@ -5331,6 +5754,32 @@ "node": "^10 || ^12 || >=14" } }, + "node_modules/prebuild-install": { + "version": "7.1.3", + "resolved": "https://registry.npmjs.org/prebuild-install/-/prebuild-install-7.1.3.tgz", + "integrity": "sha512-8Mf2cbV7x1cXPUILADGI3wuhfqWvtiLA1iclTDbFRZkgRQS0NqsPZphna9V+HyTEadheuPmjaJMsbzKQFOzLug==", + "license": "MIT", + "dependencies": { + "detect-libc": "^2.0.0", + "expand-template": "^2.0.3", + "github-from-package": "0.0.0", + "minimist": "^1.2.3", + "mkdirp-classic": "^0.5.3", + "napi-build-utils": "^2.0.0", + "node-abi": "^3.3.0", + "pump": "^3.0.0", + "rc": "^1.2.7", + "simple-get": "^4.0.0", + "tar-fs": "^2.0.0", + "tunnel-agent": "^0.6.0" + }, + "bin": { + "prebuild-install": "bin.js" + }, + "engines": { + "node": ">=10" + } + }, "node_modules/prelude-ls": { "version": "1.2.1", "resolved": "https://registry.npmjs.org/prelude-ls/-/prelude-ls-1.2.1.tgz", @@ -5353,6 +5802,16 @@ "react-is": "^16.13.1" } }, + "node_modules/pump": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/pump/-/pump-3.0.3.tgz", + "integrity": "sha512-todwxLMY7/heScKmntwQG8CXVkWUOdYxIvY2s0VWAAMh/nd8SoYiRaKjlr7+iCs984f2P8zvrfWcDDYVb73NfA==", + "license": "MIT", + "dependencies": { + "end-of-stream": "^1.1.0", + "once": "^1.3.1" + } + }, "node_modules/punycode": { "version": "2.3.1", "resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz", @@ -5367,7 +5826,6 @@ "version": "1.2.3", "resolved": "https://registry.npmjs.org/queue-microtask/-/queue-microtask-1.2.3.tgz", "integrity": "sha512-NuaNSa6flKT5JaSYQzJok04JzTL1CA6aGhv5rfLW3PgqA+M2ChpZQnAC8h8i4ZFkBS8X5RqkDBHA7r4hej3K9A==", - "dev": true, "funding": [ { "type": "github", @@ -5384,6 +5842,39 @@ ], "license": "MIT" }, + "node_modules/randombytes": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/randombytes/-/randombytes-2.1.0.tgz", + "integrity": "sha512-vYl3iOX+4CKUWuxGi9Ukhie6fsqXqS9FE2Zaic4tNFD2N2QQaXOMFbuKK4QmDHC0JO6B1Zp41J0LpT0oR68amQ==", + "license": "MIT", + "dependencies": { + "safe-buffer": "^5.1.0" + } + }, + "node_modules/rc": { + "version": "1.2.8", + "resolved": "https://registry.npmjs.org/rc/-/rc-1.2.8.tgz", + "integrity": "sha512-y3bGgqKj3QBdxLbLkomlohkvsA8gdAiUQlSBJnBhfn+BPxg4bc62d8TcBW15wavDfgexCgccckhcZvywyQYPOw==", + "license": "(BSD-2-Clause OR MIT OR Apache-2.0)", + "dependencies": { + "deep-extend": "^0.6.0", + "ini": "~1.3.0", + "minimist": "^1.2.0", + "strip-json-comments": "~2.0.1" + }, + "bin": { + "rc": "cli.js" + } + }, + "node_modules/rc/node_modules/strip-json-comments": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/strip-json-comments/-/strip-json-comments-2.0.1.tgz", + "integrity": "sha512-4gB8na07fecVVkOI6Rs4e7T6NOTki5EmL7TUduTs6bu3EdnSycntVJ4re8kgZA+wx9IueI2Y11bfbgwtzuE0KQ==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/react": { "version": "19.2.3", "resolved": "https://registry.npmjs.org/react/-/react-19.2.3.tgz", @@ -5414,6 +5905,20 @@ "dev": true, "license": "MIT" }, + "node_modules/readable-stream": { + "version": "3.6.2", + "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-3.6.2.tgz", + "integrity": "sha512-9u/sniCrY3D5WdsERHzHE4G2YCXqoG5FTHUiCC4SIbr6XcLZBY05ya9EKjYek9O5xOAwjGq+1JdGBAS7Q9ScoA==", + "license": "MIT", + "dependencies": { + "inherits": "^2.0.3", + "string_decoder": "^1.1.1", + "util-deprecate": "^1.0.1" + }, + "engines": { + "node": ">= 6" + } + }, "node_modules/reflect.getprototypeof": { "version": "1.0.10", "resolved": "https://registry.npmjs.org/reflect.getprototypeof/-/reflect.getprototypeof-1.0.10.tgz", @@ -5554,6 +6059,26 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/safe-buffer": { + "version": "5.2.1", + "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz", + "integrity": "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT" + }, "node_modules/safe-push-apply": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/safe-push-apply/-/safe-push-apply-1.0.0.tgz", @@ -5811,6 +6336,136 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/simple-concat": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/simple-concat/-/simple-concat-1.0.1.tgz", + "integrity": "sha512-cSFtAPtRhljv69IK0hTVZQ+OfE9nePi/rtJmw5UjHeVyVroEqJXP1sFztKUy1qU+xvz3u/sfYJLa947b7nAN2Q==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT" + }, + "node_modules/simple-get": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/simple-get/-/simple-get-4.0.1.tgz", + "integrity": "sha512-brv7p5WgH0jmQJr1ZDDfKDOSeWWg+OVypG99A/5vYGPqJ6pxiaHLy8nxtFjBA7oMa01ebA9gfh1uMCFqOuXxvA==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT", + "dependencies": { + "decompress-response": "^6.0.0", + "once": "^1.3.1", + "simple-concat": "^1.0.0" + } + }, + "node_modules/simple-peer": { + "version": "9.11.1", + "resolved": "https://registry.npmjs.org/simple-peer/-/simple-peer-9.11.1.tgz", + "integrity": "sha512-D1SaWpOW8afq1CZGWB8xTfrT3FekjQmPValrqncJMX7QFl8YwhrPTZvMCANLtgBwwdS+7zURyqxDDEmY558tTw==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT", + "dependencies": { + "buffer": "^6.0.3", + "debug": "^4.3.2", + "err-code": "^3.0.1", + "get-browser-rtc": "^1.1.0", + "queue-microtask": "^1.2.3", + "randombytes": "^2.1.0", + "readable-stream": "^3.6.0" + } + }, + "node_modules/socket.io": { + "version": "4.8.3", + "resolved": "https://registry.npmjs.org/socket.io/-/socket.io-4.8.3.tgz", + "integrity": "sha512-2Dd78bqzzjE6KPkD5fHZmDAKRNe3J15q+YHDrIsy9WEkqttc7GY+kT9OBLSMaPbQaEd0x1BjcmtMtXkfpc+T5A==", + "license": "MIT", + "dependencies": { + "accepts": "~1.3.4", + "base64id": "~2.0.0", + "cors": "~2.8.5", + "debug": "~4.4.1", + "engine.io": "~6.6.0", + "socket.io-adapter": "~2.5.2", + "socket.io-parser": "~4.2.4" + }, + "engines": { + "node": ">=10.2.0" + } + }, + "node_modules/socket.io-adapter": { + "version": "2.5.6", + "resolved": "https://registry.npmjs.org/socket.io-adapter/-/socket.io-adapter-2.5.6.tgz", + "integrity": "sha512-DkkO/dz7MGln0dHn5bmN3pPy+JmywNICWrJqVWiVOyvXjWQFIv9c2h24JrQLLFJ2aQVQf/Cvl1vblnd4r2apLQ==", + "license": "MIT", + "dependencies": { + "debug": "~4.4.1", + "ws": "~8.18.3" + } + }, + "node_modules/socket.io-client": { + "version": "4.8.3", + "resolved": "https://registry.npmjs.org/socket.io-client/-/socket.io-client-4.8.3.tgz", + "integrity": "sha512-uP0bpjWrjQmUt5DTHq9RuoCBdFJF10cdX9X+a368j/Ft0wmaVgxlrjvK3kjvgCODOMMOz9lcaRzxmso0bTWZ/g==", + "license": "MIT", + "dependencies": { + "@socket.io/component-emitter": "~3.1.0", + "debug": "~4.4.1", + "engine.io-client": "~6.6.1", + "socket.io-parser": "~4.2.4" + }, + "engines": { + "node": ">=10.0.0" + } + }, + "node_modules/socket.io-parser": { + "version": "4.2.5", + "resolved": "https://registry.npmjs.org/socket.io-parser/-/socket.io-parser-4.2.5.tgz", + "integrity": "sha512-bPMmpy/5WWKHea5Y/jYAP6k74A+hvmRCQaJuJB6I/ML5JZq/KfNieUVo/3Mh7SAqn7TyFdIo6wqYHInG1MU1bQ==", + "license": "MIT", + "dependencies": { + "@socket.io/component-emitter": "~3.1.0", + "debug": "~4.4.1" + }, + "engines": { + "node": ">=10.0.0" + } + }, "node_modules/source-map-js": { "version": "1.2.1", "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", @@ -5841,6 +6496,15 @@ "node": ">= 0.4" } }, + "node_modules/string_decoder": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.3.0.tgz", + "integrity": "sha512-hkRX8U1WjJFd8LsDJ2yQ/wWWxaopEsABU1XfkM8A+j0+85JAGppt16cr1Whg6KIbb4okU6Mql6BOj+uup/wKeA==", + "license": "MIT", + "dependencies": { + "safe-buffer": "~5.2.0" + } + }, "node_modules/string.prototype.includes": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/string.prototype.includes/-/string.prototype.includes-2.0.1.tgz", @@ -6047,6 +6711,34 @@ "url": "https://opencollective.com/webpack" } }, + "node_modules/tar-fs": { + "version": "2.1.4", + "resolved": "https://registry.npmjs.org/tar-fs/-/tar-fs-2.1.4.tgz", + "integrity": "sha512-mDAjwmZdh7LTT6pNleZ05Yt65HC3E+NiQzl672vQG38jIrehtJk/J3mNwIg+vShQPcLF/LV7CMnDW6vjj6sfYQ==", + "license": "MIT", + "dependencies": { + "chownr": "^1.1.1", + "mkdirp-classic": "^0.5.2", + "pump": "^3.0.0", + "tar-stream": "^2.1.4" + } + }, + "node_modules/tar-stream": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/tar-stream/-/tar-stream-2.2.0.tgz", + "integrity": "sha512-ujeqbceABgwMZxEJnk2HDY2DlnUZ+9oEcb1KzTVfYHio0UE6dG71n60d8D2I4qNvleWrrXpmjpt7vZeF1LnMZQ==", + "license": "MIT", + "dependencies": { + "bl": "^4.0.3", + "end-of-stream": "^1.4.1", + "fs-constants": "^1.0.0", + "inherits": "^2.0.3", + "readable-stream": "^3.1.1" + }, + "engines": { + "node": ">=6" + } + }, "node_modules/tinyglobby": { "version": "0.2.15", "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.15.tgz", @@ -6154,6 +6846,18 @@ "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", "license": "0BSD" }, + "node_modules/tunnel-agent": { + "version": "0.6.0", + "resolved": "https://registry.npmjs.org/tunnel-agent/-/tunnel-agent-0.6.0.tgz", + "integrity": "sha512-McnNiV1l8RYeY8tBgEpuodCC1mLUdbSN+CYBL7kJsJNInOP8UjDDEwdk6Mw60vdLLrr5NHKZhMAOSrR2NZuQ+w==", + "license": "Apache-2.0", + "dependencies": { + "safe-buffer": "^5.0.1" + }, + "engines": { + "node": "*" + } + }, "node_modules/type-check": { "version": "0.4.0", "resolved": "https://registry.npmjs.org/type-check/-/type-check-0.4.0.tgz", @@ -6307,7 +7011,6 @@ "version": "6.21.0", "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.21.0.tgz", "integrity": "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==", - "dev": true, "license": "MIT" }, "node_modules/unrs-resolver": { @@ -6386,6 +7089,34 @@ "punycode": "^2.1.0" } }, + "node_modules/util-deprecate": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz", + "integrity": "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==", + "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" + ], + "license": "MIT", + "bin": { + "uuid": "dist-node/bin/uuid" + } + }, + "node_modules/vary": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/vary/-/vary-1.1.2.tgz", + "integrity": "sha512-BNGbWLfd0eUPabhkXUVm0j8uuvREyTh5ovRa/dyow/BqAbZJyC+5fU+IzQOzmAKzYqYRAISoRhdQr3eIZ/PXqg==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, "node_modules/which": { "version": "2.0.2", "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz", @@ -6501,6 +7232,41 @@ "node": ">=0.10.0" } }, + "node_modules/wrappy": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz", + "integrity": "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==", + "license": "ISC" + }, + "node_modules/ws": { + "version": "8.18.3", + "resolved": "https://registry.npmjs.org/ws/-/ws-8.18.3.tgz", + "integrity": "sha512-PEIGCY5tSlUt50cqyMXfCzX+oOPqN0vuGqWzbcJ2xvnkzkq46oOpz7dQaTDBdfICb4N14+GARUDw2XV2N4tvzg==", + "license": "MIT", + "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/yallist": { "version": "3.1.1", "resolved": "https://registry.npmjs.org/yallist/-/yallist-3.1.1.tgz", diff --git a/package.json b/package.json index ad163d4..3612463 100644 --- a/package.json +++ b/package.json @@ -3,21 +3,28 @@ "version": "0.1.0", "private": true, "scripts": { - "dev": "next dev", + "dev": "node server.js", "build": "next build", - "start": "next start", + "start": "NODE_ENV=production node server.js", "lint": "eslint" }, "dependencies": { + "better-sqlite3": "^12.6.2", "next": "16.1.6", "react": "19.2.3", - "react-dom": "19.2.3" + "react-dom": "19.2.3", + "simple-peer": "^9.11.1", + "socket.io": "^4.8.3", + "socket.io-client": "^4.8.3", + "uuid": "^13.0.0" }, "devDependencies": { "@tailwindcss/postcss": "^4", + "@types/better-sqlite3": "^7.6.13", "@types/node": "^20", "@types/react": "^19", "@types/react-dom": "^19", + "@types/uuid": "^10.0.0", "eslint": "^9", "eslint-config-next": "16.1.6", "tailwindcss": "^4", diff --git a/server.js b/server.js new file mode 100644 index 0000000..fb7df81 --- /dev/null +++ b/server.js @@ -0,0 +1,227 @@ +const { createServer } = require('http'); +const { parse } = require('url'); +const next = require('next'); +const { Server } = require('socket.io'); +const Database = require('better-sqlite3'); +const path = require('path'); + +const dev = process.env.NODE_ENV !== 'production'; +const hostname = 'localhost'; +const port = parseInt(process.env.PORT || '3000', 10); + +const app = next({ dev, hostname, port }); +const handle = app.getRequestHandler(); + +// Connexion à la base de données SQLite +const dbPath = path.join(process.cwd(), 'visio.db'); +const db = new Database(dbPath); + +// Fonction pour nettoyer les salles inactives dans la DB +function cleanupInactiveRoomsInDB() { + const stmt = db.prepare(` + UPDATE rooms SET is_active = 0 + WHERE is_active = 1 + AND datetime(last_activity, '+5 minutes') < datetime('now') + `); + const result = stmt.run(); + if (result.changes > 0) { + console.log(`Nettoyage: ${result.changes} salle(s) inactive(s) fermée(s)`); + } + return result.changes; +} + +// Stockage des salles et participants en mémoire +const rooms = new Map(); +const roomTimers = new Map(); + +// Durée d'inactivité avant fermeture (5 minutes) +const INACTIVITY_TIMEOUT = 5 * 60 * 1000; + +app.prepare().then(() => { + // Nettoyer les salles inactives au démarrage + console.log('Nettoyage des salles inactives au démarrage...'); + cleanupInactiveRoomsInDB(); + + // Lancer un nettoyage périodique toutes les minutes + setInterval(() => { + cleanupInactiveRoomsInDB(); + }, 60 * 1000); + + const httpServer = createServer((req, res) => { + const parsedUrl = parse(req.url, true); + handle(req, res, parsedUrl); + }); + + const io = new Server(httpServer, { + cors: { + origin: '*', + methods: ['GET', 'POST'] + } + }); + + // Fonction pour réinitialiser le timer d'inactivité + function resetRoomTimer(roomId) { + if (roomTimers.has(roomId)) { + clearTimeout(roomTimers.get(roomId)); + } + + const timer = setTimeout(() => { + const room = rooms.get(roomId); + if (room && room.participants.size === 0) { + console.log(`Fermeture de la salle ${roomId} pour inactivité`); + io.to(roomId).emit('room-closed'); + rooms.delete(roomId); + roomTimers.delete(roomId); + + // Marquer la salle comme fermée dans la DB + fetch(`http://localhost:${port}/api/rooms/${roomId}`, { + method: 'DELETE' + }).catch(console.error); + } + }, INACTIVITY_TIMEOUT); + + roomTimers.set(roomId, timer); + } + + io.on('connection', (socket) => { + console.log('Nouvelle connexion:', socket.id); + + let currentRoom = null; + let currentUser = null; + + socket.on('join-room', ({ roomId, userName }) => { + currentRoom = roomId; + currentUser = { id: socket.id, name: userName, videoOff: false, muted: false }; + + // Rejoindre la room Socket.io + socket.join(roomId); + + // Initialiser ou récupérer la salle + if (!rooms.has(roomId)) { + rooms.set(roomId, { + participants: new Map() + }); + } + + const room = rooms.get(roomId); + + // Envoyer la liste des participants existants au nouveau (avec leur état audio/vidéo) + const existingUsers = Array.from(room.participants.values()); + socket.emit('existing-users', { users: existingUsers }); + + // Ajouter le nouveau participant + room.participants.set(socket.id, currentUser); + + // Notifier les autres de la nouvelle connexion + socket.to(roomId).emit('user-joined', { + userId: socket.id, + userName: userName + }); + + // Réinitialiser le timer d'inactivité + resetRoomTimer(roomId); + + console.log(`${userName} a rejoint la salle ${roomId}`); + }); + + socket.on('offer', ({ offer, to }) => { + const room = rooms.get(currentRoom); + const fromUser = room?.participants.get(socket.id); + + socket.to(to).emit('offer', { + offer, + from: socket.id, + fromName: fromUser?.name || 'Inconnu' + }); + }); + + socket.on('answer', ({ answer, to }) => { + socket.to(to).emit('answer', { + answer, + from: socket.id + }); + }); + + socket.on('ice-candidate', ({ candidate, to }) => { + socket.to(to).emit('ice-candidate', { + candidate, + from: socket.id + }); + }); + + socket.on('name-change', ({ name }) => { + if (currentRoom && currentUser) { + currentUser.name = name; + const room = rooms.get(currentRoom); + if (room) { + room.participants.set(socket.id, currentUser); + } + + socket.to(currentRoom).emit('user-name-changed', { + userId: socket.id, + newName: name + }); + } + }); + + socket.on('video-toggle', ({ videoOff }) => { + if (currentRoom && currentUser) { + currentUser.videoOff = videoOff; + const room = rooms.get(currentRoom); + if (room) { + room.participants.set(socket.id, currentUser); + } + + socket.to(currentRoom).emit('user-video-toggle', { + userId: socket.id, + videoOff + }); + } + }); + + socket.on('audio-toggle', ({ muted }) => { + if (currentRoom && currentUser) { + currentUser.muted = muted; + const room = rooms.get(currentRoom); + if (room) { + room.participants.set(socket.id, currentUser); + } + + socket.to(currentRoom).emit('user-audio-toggle', { + userId: socket.id, + muted + }); + } + }); + + // Répondre aux pings des clients pour la détection de déconnexion + socket.on('ping-server', () => { + socket.emit('pong-server'); + }); + + socket.on('disconnect', () => { + if (currentRoom) { + const room = rooms.get(currentRoom); + if (room) { + room.participants.delete(socket.id); + + // Notifier les autres + socket.to(currentRoom).emit('user-left', { + userId: socket.id + }); + + console.log(`Utilisateur ${socket.id} a quitté la salle ${currentRoom}`); + + // Si la salle est vide, démarrer le timer de fermeture + if (room.participants.size === 0) { + resetRoomTimer(currentRoom); + } + } + } + }); + }); + + httpServer.listen(port, () => { + console.log(`> Serveur prêt sur http://${hostname}:${port}`); + }); +}); diff --git a/src/app/api/rooms/[id]/route.ts b/src/app/api/rooms/[id]/route.ts new file mode 100644 index 0000000..029735e --- /dev/null +++ b/src/app/api/rooms/[id]/route.ts @@ -0,0 +1,67 @@ +import { NextResponse } from 'next/server'; +import { getRoom, updateRoomActivity, closeRoom } from '@/lib/database'; + +export async function GET( + request: Request, + { params }: { params: Promise<{ id: string }> } +) { + const { id } = await params; + + const room = getRoom(id); + + if (!room) { + return NextResponse.json( + { error: 'Salle non trouvée' }, + { status: 404 } + ); + } + + if (!room.is_active) { + return NextResponse.json( + { error: 'Cette salle a été fermée' }, + { status: 410 } + ); + } + + return NextResponse.json(room); +} + +export async function PATCH( + request: Request, + { params }: { params: Promise<{ id: string }> } +) { + const { id } = await params; + + const room = getRoom(id); + + if (!room) { + return NextResponse.json( + { error: 'Salle non trouvée' }, + { status: 404 } + ); + } + + updateRoomActivity(id); + + return NextResponse.json({ success: true }); +} + +export async function DELETE( + request: Request, + { params }: { params: Promise<{ id: string }> } +) { + const { id } = await params; + + const room = getRoom(id); + + if (!room) { + return NextResponse.json( + { error: 'Salle non trouvée' }, + { status: 404 } + ); + } + + closeRoom(id); + + return NextResponse.json({ success: true }); +} diff --git a/src/app/api/rooms/route.ts b/src/app/api/rooms/route.ts new file mode 100644 index 0000000..2cc1016 --- /dev/null +++ b/src/app/api/rooms/route.ts @@ -0,0 +1,48 @@ +import { NextResponse } from 'next/server'; +import { createRoom, getRoom } from '@/lib/database'; +import { generateRoomId } from '@/lib/utils'; + +export async function POST(request: Request) { + try { + const body = await request.json(); + const { name } = body; + + const roomId = generateRoomId(); + const room = createRoom(roomId, name || 'Visioconférence'); + + return NextResponse.json({ + id: room.id, + name: room.name, + created_at: room.created_at, + }); + } catch (error) { + console.error('Erreur lors de la création de la salle:', error); + return NextResponse.json( + { error: 'Erreur lors de la création de la salle' }, + { status: 500 } + ); + } +} + +export async function GET(request: Request) { + const { searchParams } = new URL(request.url); + const id = searchParams.get('id'); + + if (!id) { + return NextResponse.json( + { error: 'ID de salle requis' }, + { status: 400 } + ); + } + + const room = getRoom(id); + + if (!room) { + return NextResponse.json( + { error: 'Salle non trouvée' }, + { status: 404 } + ); + } + + return NextResponse.json(room); +} diff --git a/src/app/globals.css b/src/app/globals.css index a2dc41e..51d914d 100644 --- a/src/app/globals.css +++ b/src/app/globals.css @@ -1,26 +1,133 @@ @import "tailwindcss"; :root { - --background: #ffffff; - --foreground: #171717; + --background: #f8fafc; + --foreground: #1e293b; + --primary: #3b82f6; + --primary-hover: #2563eb; + --secondary: #64748b; + --accent: #10b981; + --danger: #ef4444; + --card-bg: #ffffff; + --border: #e2e8f0; } @theme inline { --color-background: var(--background); --color-foreground: var(--foreground); + --color-primary: var(--primary); + --color-primary-hover: var(--primary-hover); + --color-secondary: var(--secondary); + --color-accent: var(--accent); + --color-danger: var(--danger); + --color-card-bg: var(--card-bg); + --color-border: var(--border); --font-sans: var(--font-geist-sans); --font-mono: var(--font-geist-mono); } -@media (prefers-color-scheme: dark) { - :root { - --background: #0a0a0a; - --foreground: #ededed; - } -} - body { background: var(--background); color: var(--foreground); - font-family: Arial, Helvetica, sans-serif; + font-family: 'Inter', Arial, Helvetica, sans-serif; +} + +/* Styles personnalisés */ +.btn-primary { + @apply bg-blue-500 hover:bg-blue-600 text-white font-medium py-3 px-6 rounded-lg transition-all duration-200 shadow-md hover:shadow-lg; +} + +.btn-secondary { + @apply bg-gray-200 hover:bg-gray-300 text-gray-800 font-medium py-3 px-6 rounded-lg transition-all duration-200; +} + +.btn-danger { + @apply bg-red-500 hover:bg-red-600 text-white font-medium py-2 px-4 rounded-lg transition-all duration-200; +} + +.btn-icon { + @apply p-3 rounded-full transition-all duration-200 flex items-center justify-center; +} + +.card { + @apply bg-white rounded-xl shadow-md p-6 border border-gray-100; +} + +.input-field { + @apply w-full px-4 py-3 border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-transparent transition-all duration-200; +} + +/* Animation pour les vidéos */ +.video-container { + @apply relative rounded-xl overflow-hidden bg-gray-900 shadow-lg; + width: 100%; + max-width: 600px; + min-width: 280px; + min-height: 200px; + flex: 1 1 300px; +} + +.video-container.aspect-video { + aspect-ratio: 16 / 9; +} + +/* Bordure verte quand quelqu'un parle */ +.video-container.speaking { + box-shadow: 0 0 0 3px #22c55e, 0 0 20px rgba(34, 197, 94, 0.4); + transition: box-shadow 0.15s ease-in-out; +} + +.video-container video { + position: absolute; + top: 0; + left: 0; + width: 100%; + height: 100%; + object-fit: cover; +} + +.video-container .video-placeholder { + position: absolute; + top: 0; + left: 0; + width: 100%; + height: 100%; + display: flex; + align-items: center; + justify-content: center; + background-color: #1f2937; +} + +.video-label { + @apply absolute bottom-3 left-3 bg-black/60 text-white px-3 py-1 rounded-full text-sm font-medium; +} + +/* Flex responsive pour les vidéos */ +.video-grid { + display: flex; + flex-wrap: wrap; + gap: 1rem; + justify-content: center; + align-items: flex-start; + max-width: 1800px; + margin: 0 auto; + padding: 1rem; +} + +/* Ajustements selon le nombre de participants */ +.video-grid-1 .video-container { + max-width: 900px; + flex: 0 1 900px; +} + +.video-grid-2 .video-container { + max-width: 700px; + flex: 0 1 700px; +} + +@media (max-width: 768px) { + .video-container { + flex: 1 1 100%; + max-width: 100%; + } } diff --git a/src/app/layout.tsx b/src/app/layout.tsx index f7fa87e..56c53e1 100644 --- a/src/app/layout.tsx +++ b/src/app/layout.tsx @@ -13,8 +13,18 @@ const geistMono = Geist_Mono({ }); export const metadata: Metadata = { - title: "Create Next App", - description: "Generated by create next app", + title: "Visio - Vidéoconférence Simple", + description: "Créez et rejoignez des visioconférences gratuitement, sans inscription", + keywords: ["visioconférence", "vidéo", "appel", "gratuit", "sans inscription"], + authors: [{ name: "Arthur P" }], + openGraph: { + title: "Visio - Vidéoconférence Simple", + description: "Créez et rejoignez des visioconférences gratuitement, sans inscription", + url: "https://visio.arthurp.fr", + siteName: "Visio", + locale: "fr_FR", + type: "website", + }, }; export default function RootLayout({ @@ -23,9 +33,9 @@ export default function RootLayout({ children: React.ReactNode; }>) { return ( - + {children} diff --git a/src/app/page.tsx b/src/app/page.tsx index 295f8fd..7ce1b6c 100644 --- a/src/app/page.tsx +++ b/src/app/page.tsx @@ -1,65 +1,195 @@ -import Image from "next/image"; +'use client'; + +import Link from 'next/link'; +import { useState } from 'react'; +import { useRouter } from 'next/navigation'; export default function Home() { + const [roomCode, setRoomCode] = useState(''); + const [isCreating, setIsCreating] = useState(false); + const router = useRouter(); + + const handleCreateRoom = async () => { + setIsCreating(true); + try { + const response = await fetch('/api/rooms', { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ name: 'Nouvelle visioconférence' }), + }); + + const data = await response.json(); + if (data.id) { + router.push(`/room/${data.id}`); + } + } catch (error) { + console.error('Erreur lors de la création de la salle:', error); + alert('Erreur lors de la création de la salle'); + } finally { + setIsCreating(false); + } + }; + + const handleJoinRoom = (e: React.FormEvent) => { + e.preventDefault(); + if (roomCode.trim()) { + router.push(`/room/${roomCode.trim()}`); + } + }; + return ( -
-
- Next.js logo -
-

- To get started, edit the page.tsx file. -

-

- Looking for a starting point or more instructions? Head over to{" "} - + {/* Header */} +

+
+
+
+
+ + + +
+

Visio

+
+ - Templates -
{" "} - or the{" "} - - Learning - {" "} - center. + + + + + +
+
+
+ + {/* Hero Section */} +
+
+

+ Visioconférence simple et gratuite +

+

+ Créez une salle de visioconférence en un clic et partagez le lien avec vos participants. + Aucune inscription requise.

-
- - Vercel logomark - Deploy Now - - - Documentation - + + {/* Actions Cards */} +
+ {/* Créer une visio */} +
+
+
+ + + +
+

Créer une visio

+

+ Lancez une nouvelle visioconférence et invitez des participants +

+ +
+
+ + {/* Rejoindre une visio */} +
+
+
+ + + +
+

Rejoindre une visio

+

+ Entrez le code de la salle pour rejoindre une visioconférence +

+
+ setRoomCode(e.target.value)} + placeholder="Code de la salle" + className="input-field text-center" + /> + +
+
+
-
-
+ + {/* Features */} +
+

+ Pourquoi utiliser Visio ? +

+
+
+
+ + + +
+

Rapide

+

Créez une visio en un seul clic, sans attente

+
+
+
+ + + +
+

Sans inscription

+

Aucun compte requis pour utiliser le service

+
+
+
+ + + +
+

Facile à partager

+

Partagez simplement le lien avec vos participants

+
+
+
+ + + {/* Footer */} + + ); } diff --git a/src/app/room/[id]/page.tsx b/src/app/room/[id]/page.tsx new file mode 100644 index 0000000..5befb13 --- /dev/null +++ b/src/app/room/[id]/page.tsx @@ -0,0 +1,951 @@ +'use client'; + +import { useState, useEffect, useRef, useCallback } from 'react'; +import { useParams, useRouter } from 'next/navigation'; +import Link from 'next/link'; +import { io, Socket } from 'socket.io-client'; +import { useLocalStorage } from '@/hooks/useLocalStorage'; + +interface Participant { + id: string; + name: string; + stream?: MediaStream; + videoOff?: boolean; + muted?: boolean; +} + +export default function RoomPage() { + const params = useParams(); + const router = useRouter(); + const roomId = params.id as string; + + const [storedUserName, setStoredUserName] = useLocalStorage('visio_username', ''); + const [userName, setUserName] = useState(''); + const [tempName, setTempName] = useState(''); + const [isJoined, setIsJoined] = useState(false); + const [roomExists, setRoomExists] = useState(null); + const [roomClosed, setRoomClosed] = useState(false); + const [participants, setParticipants] = useState([]); + const [isMuted, setIsMuted] = useState(false); + const [isVideoOff, setIsVideoOff] = useState(false); + const [showSettings, setShowSettings] = useState(false); + const [copied, setCopied] = useState(false); + const [error, setError] = useState(null); + const [speakingUsers, setSpeakingUsers] = useState>(new Set()); + + const localVideoRef = useRef(null); + const audioContextRef = useRef(null); + const analyzersRef = useRef>(new Map()); + const speakingTimeoutsRef = useRef>(new Map()); + const localStreamRef = useRef(null); + const socketRef = useRef(null); + const peersRef = useRef>(new Map()); + const pendingCandidatesRef = useRef>(new Map()); + const activityIntervalRef = useRef(null); + const pingIntervalRef = useRef(null); + const pongTimeoutRef = useRef(null); + const lastPongRef = useRef(Date.now()); + + // Vérifier si la salle existe + useEffect(() => { + const checkRoom = async () => { + try { + const response = await fetch(`/api/rooms/${roomId}`); + if (response.ok) { + setRoomExists(true); + } else if (response.status === 410) { + setRoomClosed(true); + setRoomExists(false); + } else { + setRoomExists(false); + } + } catch { + setRoomExists(false); + } + }; + + checkRoom(); + }, [roomId]); + + // Charger le nom depuis localStorage + useEffect(() => { + if (storedUserName) { + setTempName(storedUserName); + } + }, [storedUserName]); + + // Mettre à jour l'activité de la salle + const updateActivity = useCallback(async () => { + try { + await fetch(`/api/rooms/${roomId}`, { method: 'PATCH' }); + } catch (error) { + console.error('Erreur mise à jour activité:', error); + } + }, [roomId]); + + // Initialiser les médias locaux + const initLocalMedia = async () => { + try { + const stream = await navigator.mediaDevices.getUserMedia({ + video: true, + audio: true + }); + + localStreamRef.current = stream; + + if (localVideoRef.current) { + localVideoRef.current.srcObject = stream; + } + + // Configurer la détection vocale pour soi-même + setupVoiceDetection('local', stream); + + return stream; + } catch (err) { + console.error('Erreur accès média:', err); + setError('Impossible d\'accéder à la caméra ou au microphone. Veuillez vérifier les permissions.'); + throw err; + } + }; + + // Configuration WebRTC + const createPeerConnection = useCallback((peerId: string, peerName: string) => { + const config: RTCConfiguration = { + iceServers: [ + { urls: 'stun:stun.l.google.com:19302' }, + { urls: 'stun:stun1.l.google.com:19302' }, + { urls: 'stun:stun2.l.google.com:19302' }, + { urls: 'stun:stun3.l.google.com:19302' }, + { urls: 'stun:stun4.l.google.com:19302' }, + // Serveurs TURN publics pour les connexions difficiles (mobile, NAT restrictif) + { + 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' + } + ], + iceCandidatePoolSize: 10 + }; + + console.log(`Création connexion peer avec ${peerName} (${peerId})`); + const pc = new RTCPeerConnection(config); + + // Ajouter les tracks locaux + if (localStreamRef.current) { + localStreamRef.current.getTracks().forEach(track => { + pc.addTrack(track, localStreamRef.current!); + }); + } + + // Gérer les ICE candidates + pc.onicecandidate = (event) => { + if (event.candidate && socketRef.current) { + socketRef.current.emit('ice-candidate', { + candidate: event.candidate, + to: peerId + }); + } + }; + + pc.onicecandidateerror = (event) => { + console.warn(`Erreur ICE candidate pour ${peerId}:`, event); + }; + + pc.oniceconnectionstatechange = () => { + console.log(`ICE connection state avec ${peerName}: ${pc.iceConnectionState}`); + if (pc.iceConnectionState === 'failed') { + console.log(`Tentative de redémarrage ICE avec ${peerName}`); + pc.restartIce(); + } + }; + + // Gérer les tracks distants + pc.ontrack = (event) => { + console.log(`Track reçu de ${peerName}`); + const [remoteStream] = event.streams; + setParticipants(prev => { + const existing = prev.find(p => p.id === peerId); + if (existing) { + return prev.map(p => p.id === peerId ? { ...p, stream: remoteStream } : p); + } + return [...prev, { id: peerId, name: peerName, stream: remoteStream }]; + }); + }; + + pc.onconnectionstatechange = () => { + console.log(`Connection state avec ${peerName}: ${pc.connectionState}`); + if (pc.connectionState === 'disconnected' || pc.connectionState === 'failed') { + handlePeerDisconnect(peerId); + } + }; + + peersRef.current.set(peerId, pc); + + // Appliquer les candidats en attente + const pendingCandidates = pendingCandidatesRef.current.get(peerId) || []; + if (pendingCandidates.length > 0) { + console.log(`Application de ${pendingCandidates.length} candidats en attente pour ${peerName}`); + pendingCandidates.forEach(candidate => { + pc.addIceCandidate(candidate).catch(err => + console.warn('Erreur ajout candidat en attente:', err) + ); + }); + pendingCandidatesRef.current.delete(peerId); + } + + return pc; + }, []); + + const handlePeerDisconnect = (peerId: string) => { + const pc = peersRef.current.get(peerId); + if (pc) { + pc.close(); + peersRef.current.delete(peerId); + } + pendingCandidatesRef.current.delete(peerId); + // Nettoyer l'analyseur audio + const analyzerData = analyzersRef.current.get(peerId); + if (analyzerData) { + analyzerData.source.disconnect(); + analyzersRef.current.delete(peerId); + } + // Nettoyer le timeout de speaking + const speakingTimeout = speakingTimeoutsRef.current.get(peerId); + if (speakingTimeout) { + clearTimeout(speakingTimeout); + speakingTimeoutsRef.current.delete(peerId); + } + setSpeakingUsers(prev => { + const next = new Set(prev); + next.delete(peerId); + return next; + }); + setParticipants(prev => prev.filter(p => p.id !== peerId)); + }; + + // Détection vocale avec Web Audio API + const setupVoiceDetection = useCallback((userId: string, stream: MediaStream) => { + if (!audioContextRef.current) { + audioContextRef.current = new AudioContext(); + } + + // Éviter les doublons + if (analyzersRef.current.has(userId)) { + return; + } + + const audioContext = audioContextRef.current; + const analyser = audioContext.createAnalyser(); + analyser.fftSize = 512; + analyser.smoothingTimeConstant = 0.4; + + const source = audioContext.createMediaStreamSource(stream); + source.connect(analyser); + + analyzersRef.current.set(userId, { analyser, source }); + + const dataArray = new Uint8Array(analyser.frequencyBinCount); + + const checkAudio = () => { + if (!analyzersRef.current.has(userId)) return; + + analyser.getByteFrequencyData(dataArray); + const average = dataArray.reduce((a, b) => a + b, 0) / dataArray.length; + + const isSpeaking = average > 10; // Seuil de détection + + if (isSpeaking) { + // Annuler le timeout existant si on parle + const existingTimeout = speakingTimeoutsRef.current.get(userId); + if (existingTimeout) { + clearTimeout(existingTimeout); + speakingTimeoutsRef.current.delete(userId); + } + + setSpeakingUsers(prev => { + if (!prev.has(userId)) { + const next = new Set(prev); + next.add(userId); + return next; + } + return prev; + }); + } else { + // Délai de 500ms avant de retirer l'état speaking + setSpeakingUsers(prev => { + if (prev.has(userId) && !speakingTimeoutsRef.current.has(userId)) { + const timeout = setTimeout(() => { + setSpeakingUsers(p => { + const next = new Set(p); + next.delete(userId); + return next; + }); + speakingTimeoutsRef.current.delete(userId); + }, 500); + speakingTimeoutsRef.current.set(userId, timeout); + } + return prev; + }); + } + + requestAnimationFrame(checkAudio); + }; + + checkAudio(); + }, []); + + // Rejoindre la salle + const handleJoin = async (e: React.FormEvent) => { + e.preventDefault(); + + if (!tempName.trim()) { + setError('Veuillez entrer votre nom'); + return; + } + + const finalName = tempName.trim(); + setUserName(finalName); + setStoredUserName(finalName); + + try { + await initLocalMedia(); + + // Connexion Socket.io + const socket = io({ + path: '/socket.io' + }); + + socketRef.current = socket; + + socket.on('connect', () => { + console.log('Socket.io connecté'); + socket.emit('join-room', { roomId, userName: finalName }); + setIsJoined(true); + + // Démarrer le heartbeat d'activité + activityIntervalRef.current = setInterval(updateActivity, 30000); + updateActivity(); + + // Démarrer le système de ping pour détecter les déconnexions + lastPongRef.current = Date.now(); + pingIntervalRef.current = setInterval(() => { + if (socketRef.current?.connected) { + socketRef.current.emit('ping-server'); + + // Vérifier si on a reçu un pong récemment (10 secondes max) + const timeSinceLastPong = Date.now() - lastPongRef.current; + if (timeSinceLastPong > 10000) { + console.error('Serveur injoignable - pas de réponse depuis', timeSinceLastPong, 'ms'); + setError('Le serveur ne répond plus. Vérifiez votre connexion.'); + cleanup(); + } + } + }, 3000); // Ping toutes les 3 secondes + }); + + socket.on('pong-server', () => { + lastPongRef.current = Date.now(); + }); + + socket.on('existing-users', async ({ users }) => { + // Liste des utilisateurs déjà présents (avec leur état audio/vidéo) + for (const user of users) { + if (!participants.find(p => p.id === user.id)) { + setParticipants(prev => [...prev, { + id: user.id, + name: user.name, + videoOff: user.videoOff || false, + muted: user.muted || false + }]); + } + } + }); + + socket.on('user-joined', async ({ userId, userName: peerName }) => { + // Un nouvel utilisateur a rejoint, créer une offre + const pc = createPeerConnection(userId, peerName); + const offer = await pc.createOffer(); + await pc.setLocalDescription(offer); + + socket.emit('offer', { + offer: offer, + to: userId + }); + + setParticipants(prev => { + if (!prev.find(p => p.id === userId)) { + return [...prev, { id: userId, name: peerName }]; + } + return prev; + }); + }); + + socket.on('offer', async ({ offer, from, fromName }) => { + // Recevoir une offre, créer une réponse + const pc = createPeerConnection(from, fromName); + await pc.setRemoteDescription(new RTCSessionDescription(offer)); + + // Appliquer les candidats ICE en attente + const pendingCandidates = pendingCandidatesRef.current.get(from) || []; + if (pendingCandidates.length > 0) { + console.log(`Application de ${pendingCandidates.length} candidats en attente après offer`); + for (const candidate of pendingCandidates) { + try { + await pc.addIceCandidate(candidate); + } catch (err) { + console.warn('Erreur ajout candidat en attente:', err); + } + } + pendingCandidatesRef.current.delete(from); + } + + const answer = await pc.createAnswer(); + await pc.setLocalDescription(answer); + + socket.emit('answer', { + answer: answer, + to: from + }); + }); + + socket.on('answer', async ({ answer, from }) => { + // Recevoir une réponse + const pc = peersRef.current.get(from); + if (pc) { + await pc.setRemoteDescription(new RTCSessionDescription(answer)); + + // Appliquer les candidats ICE en attente + const pendingCandidates = pendingCandidatesRef.current.get(from) || []; + if (pendingCandidates.length > 0) { + console.log(`Application de ${pendingCandidates.length} candidats en attente après answer`); + for (const candidate of pendingCandidates) { + try { + await pc.addIceCandidate(candidate); + } catch (err) { + console.warn('Erreur ajout candidat en attente:', err); + } + } + pendingCandidatesRef.current.delete(from); + } + } + }); + + socket.on('ice-candidate', async ({ candidate, from }) => { + // Recevoir un ICE candidate + if (!candidate) return; + + const pc = peersRef.current.get(from); + if (pc && pc.remoteDescription) { + // La connexion est prête, ajouter le candidat directement + try { + await pc.addIceCandidate(new RTCIceCandidate(candidate)); + } catch (err) { + console.warn('Erreur ajout ICE candidate:', err); + } + } else { + // Mettre en file d'attente si la connexion n'est pas encore prête + console.log(`Mise en file d'attente du candidat ICE pour ${from}`); + const pending = pendingCandidatesRef.current.get(from) || []; + pending.push(new RTCIceCandidate(candidate)); + pendingCandidatesRef.current.set(from, pending); + } + }); + + socket.on('user-left', ({ userId }) => { + handlePeerDisconnect(userId); + }); + + socket.on('user-name-changed', ({ userId, newName }) => { + setParticipants(prev => + prev.map(p => p.id === userId ? { ...p, name: newName } : p) + ); + }); + + socket.on('user-video-toggle', ({ userId, videoOff }) => { + setParticipants(prev => + prev.map(p => p.id === userId ? { ...p, videoOff } : p) + ); + }); + + socket.on('user-audio-toggle', ({ userId, muted }) => { + setParticipants(prev => + prev.map(p => p.id === userId ? { ...p, muted } : p) + ); + }); + + socket.on('room-closed', () => { + setRoomClosed(true); + cleanup(); + }); + + socket.on('disconnect', () => { + console.log('Socket.io déconnecté'); + if (isJoined && !roomClosed) { + setError('Connexion perdue. Veuillez rafraîchir la page.'); + } + }); + + socket.on('connect_error', (error) => { + console.error('Socket.io erreur:', error); + setError('Erreur de connexion au serveur'); + }); + + } catch (err) { + console.error('Erreur lors de la connexion:', err); + } + }; + + // Cleanup + const cleanup = useCallback(() => { + if (activityIntervalRef.current) { + clearInterval(activityIntervalRef.current); + } + + if (pingIntervalRef.current) { + clearInterval(pingIntervalRef.current); + } + + if (pongTimeoutRef.current) { + clearTimeout(pongTimeoutRef.current); + } + + if (localStreamRef.current) { + localStreamRef.current.getTracks().forEach(track => track.stop()); + } + + peersRef.current.forEach(pc => pc.close()); + peersRef.current.clear(); + + if (socketRef.current) { + socketRef.current.disconnect(); + } + }, []); + + useEffect(() => { + return cleanup; + }, [cleanup]); + + // Réassigner le stream vidéo local quand le composant est rendu + useEffect(() => { + if (isJoined && localStreamRef.current && localVideoRef.current) { + localVideoRef.current.srcObject = localStreamRef.current; + } + }, [isJoined]); + + // Contrôles média + const toggleMute = () => { + if (localStreamRef.current) { + const newMuted = !isMuted; + localStreamRef.current.getAudioTracks().forEach(track => { + track.enabled = !newMuted; + }); + setIsMuted(newMuted); + + // Notifier les autres participants + if (socketRef.current) { + socketRef.current.emit('audio-toggle', { muted: newMuted }); + } + } + }; + + const toggleVideo = () => { + if (localStreamRef.current) { + const newVideoOff = !isVideoOff; + localStreamRef.current.getVideoTracks().forEach(track => { + track.enabled = !newVideoOff; + }); + setIsVideoOff(newVideoOff); + + // Notifier les autres participants + if (socketRef.current) { + socketRef.current.emit('video-toggle', { videoOff: newVideoOff }); + } + } + }; + + const leaveRoom = () => { + cleanup(); + router.push('/'); + }; + + const copyLink = () => { + const url = `${window.location.origin}/room/${roomId}`; + navigator.clipboard.writeText(url); + setCopied(true); + setTimeout(() => setCopied(false), 2000); + }; + + const handleNameChange = (e: React.FormEvent) => { + e.preventDefault(); + if (tempName.trim()) { + setUserName(tempName.trim()); + setStoredUserName(tempName.trim()); + setShowSettings(false); + + // Notifier les autres participants du changement de nom + if (socketRef.current) { + socketRef.current.emit('name-change', { + name: tempName.trim() + }); + } + } + }; + + // Page de chargement + if (roomExists === null) { + return ( +
+
+
+

Chargement...

+
+
+ ); + } + + // Salle fermée + if (roomClosed) { + return ( +
+
+
+ + + +
+

Salle fermée

+

+ Cette visioconférence a été fermée car elle est restée inactive pendant plus de 5 minutes. +

+ + Retour à l'accueil + +
+
+ ); + } + + // Salle non trouvée + if (!roomExists) { + return ( +
+
+
+ + + +
+

Salle introuvable

+

+ Cette salle de visioconférence n'existe pas ou le code est incorrect. +

+ + Retour à l'accueil + +
+
+ ); + } + + // Page pour rejoindre (demande de nom) + if (!isJoined) { + return ( +
+
+
+
+ + + +
+

Rejoindre la visio

+

+ Code de la salle : {roomId} +

+
+ + {error && ( +
+ {error} +
+ )} + +
+
+ + setTempName(e.target.value)} + placeholder="Entrez votre nom" + className="input-field" + autoFocus + /> +
+ +
+ +
+ + ← Retour à l'accueil + +
+
+
+ ); + } + + // Page de visioconférence + const gridClass = `video-grid video-grid-${Math.min(participants.length + 1, 4)}`; + + return ( +
+ {/* Header */} +
+
+ +
+ + + +
+ Visio + + + Salle : {roomId} + +
+ +
+ + + +
+
+ + {/* Zone vidéo */} +
+
+ {/* Vidéo locale */} +
+
+ + {/* Vidéos des participants */} + {participants.map((participant) => ( +
+ {participant.stream && !participant.videoOff ? ( +
+ ))} +
+ + {participants.length === 0 && ( +
+

En attente d'autres participants...

+

+ Partagez le lien pour inviter des personnes +

+
+ )} +
+ + {/* Barre de contrôles */} +
+
+ + + + + +
+ +
+ {participants.length + 1} participant{participants.length > 0 ? 's' : ''} +
+
+ + {/* Modal paramètres */} + {showSettings && ( +
+
+
+

Paramètres

+ +
+ +
+
+ + setTempName(e.target.value)} + className="input-field" + /> +
+
+ + +
+
+
+
+ )} +
+ ); +} diff --git a/src/app/settings/page.tsx b/src/app/settings/page.tsx new file mode 100644 index 0000000..0400b06 --- /dev/null +++ b/src/app/settings/page.tsx @@ -0,0 +1,109 @@ +'use client'; + +import { useState, useEffect } from 'react'; +import Link from 'next/link'; +import { useRouter } from 'next/navigation'; +import { useLocalStorage } from '@/hooks/useLocalStorage'; + +export default function SettingsPage() { + const router = useRouter(); + const [userName, setUserName] = useLocalStorage('visio_username', ''); + const [inputName, setInputName] = useState(''); + const [saved, setSaved] = useState(false); + + useEffect(() => { + setInputName(userName); + }, [userName]); + + const handleSave = (e: React.FormEvent) => { + e.preventDefault(); + setUserName(inputName.trim()); + setSaved(true); + setTimeout(() => setSaved(false), 2000); + }; + + return ( +
+ {/* Header */} +
+
+
+ +
+ + + +
+

Visio

+ + +
+
+
+ +
+
+

Paramètres

+ +
+
+ + setInputName(e.target.value)} + placeholder="Entrez votre nom" + className="input-field" + /> +

+ Ce nom sera affiché aux autres participants lors de vos visioconférences. + Il est sauvegardé dans votre navigateur. +

+
+ +
+ + {saved && ( + + + + + Enregistré ! + + )} +
+
+ +
+ +
+

À propos

+

+ Visio est un service de visioconférence gratuit et sans inscription. + Vos données ne sont pas collectées et les salles se ferment automatiquement + après 5 minutes d'inactivité. +

+
+
+ +
+ + ← Retour à l'accueil + +
+
+
+ ); +} diff --git a/src/contexts/SocketContext.tsx b/src/contexts/SocketContext.tsx new file mode 100644 index 0000000..4cbf88c --- /dev/null +++ b/src/contexts/SocketContext.tsx @@ -0,0 +1,50 @@ +'use client'; + +import { createContext, useContext, useEffect, useState, ReactNode } from 'react'; +import { io, Socket } from 'socket.io-client'; + +interface SocketContextType { + socket: Socket | null; + isConnected: boolean; +} + +const SocketContext = createContext({ + socket: null, + isConnected: false +}); + +export function SocketProvider({ children }: { children: ReactNode }) { + const [socket, setSocket] = useState(null); + const [isConnected, setIsConnected] = useState(false); + + useEffect(() => { + const socketInstance = io({ + path: '/socket.io', + autoConnect: false + }); + + socketInstance.on('connect', () => { + setIsConnected(true); + }); + + socketInstance.on('disconnect', () => { + setIsConnected(false); + }); + + setSocket(socketInstance); + + return () => { + socketInstance.disconnect(); + }; + }, []); + + return ( + + {children} + + ); +} + +export function useSocket() { + return useContext(SocketContext); +} diff --git a/src/hooks/useLocalStorage.ts b/src/hooks/useLocalStorage.ts new file mode 100644 index 0000000..95728f1 --- /dev/null +++ b/src/hooks/useLocalStorage.ts @@ -0,0 +1,33 @@ +'use client'; + +import { useState, useEffect } from 'react'; + +export function useLocalStorage(key: string, initialValue: T): [T, (value: T) => void] { + const [storedValue, setStoredValue] = useState(initialValue); + const [isClient, setIsClient] = useState(false); + + useEffect(() => { + setIsClient(true); + try { + const item = window.localStorage.getItem(key); + if (item) { + setStoredValue(JSON.parse(item)); + } + } catch (error) { + console.error('Erreur lors de la lecture du localStorage:', error); + } + }, [key]); + + const setValue = (value: T) => { + try { + setStoredValue(value); + if (typeof window !== 'undefined') { + window.localStorage.setItem(key, JSON.stringify(value)); + } + } catch (error) { + console.error('Erreur lors de l\'écriture dans le localStorage:', error); + } + }; + + return [storedValue, setValue]; +} diff --git a/src/lib/database.ts b/src/lib/database.ts new file mode 100644 index 0000000..c9da840 --- /dev/null +++ b/src/lib/database.ts @@ -0,0 +1,67 @@ +import Database from 'better-sqlite3'; +import path from 'path'; + +const dbPath = path.join(process.cwd(), 'visio.db'); +const db = new Database(dbPath); + +// Initialiser la base de données +db.exec(` + CREATE TABLE IF NOT EXISTS rooms ( + id TEXT PRIMARY KEY, + name TEXT NOT NULL, + created_at DATETIME DEFAULT CURRENT_TIMESTAMP, + last_activity DATETIME DEFAULT CURRENT_TIMESTAMP, + is_active INTEGER DEFAULT 1 + ) +`); + +export interface Room { + id: string; + name: string; + created_at: string; + last_activity: string; + is_active: number; +} + +export function createRoom(id: string, name: string): Room { + const stmt = db.prepare('INSERT INTO rooms (id, name) VALUES (?, ?)'); + stmt.run(id, name); + return getRoom(id)!; +} + +export function getRoom(id: string): Room | undefined { + const stmt = db.prepare('SELECT * FROM rooms WHERE id = ?'); + return stmt.get(id) as Room | undefined; +} + +export function updateRoomActivity(id: string): void { + const stmt = db.prepare('UPDATE rooms SET last_activity = CURRENT_TIMESTAMP WHERE id = ?'); + stmt.run(id); +} + +export function closeRoom(id: string): void { + const stmt = db.prepare('UPDATE rooms SET is_active = 0 WHERE id = ?'); + stmt.run(id); +} + +export function getInactiveRooms(minutesInactive: number = 5): Room[] { + const stmt = db.prepare(` + SELECT * FROM rooms + WHERE is_active = 1 + AND datetime(last_activity, '+' || ? || ' minutes') < datetime('now') + `); + return stmt.all(minutesInactive) as Room[]; +} + +export function cleanupInactiveRooms(): number { + const inactiveRooms = getInactiveRooms(5); + const stmt = db.prepare('UPDATE rooms SET is_active = 0 WHERE id = ?'); + + for (const room of inactiveRooms) { + stmt.run(room.id); + } + + return inactiveRooms.length; +} + +export default db; diff --git a/src/lib/utils.ts b/src/lib/utils.ts new file mode 100644 index 0000000..96cc3e0 --- /dev/null +++ b/src/lib/utils.ts @@ -0,0 +1,16 @@ +import { v4 as uuidv4 } from 'uuid'; + +export function generateRoomId(): string { + return uuidv4().slice(0, 8); +} + +export function formatDate(dateString: string): string { + const date = new Date(dateString); + return date.toLocaleDateString('fr-FR', { + day: '2-digit', + month: '2-digit', + year: 'numeric', + hour: '2-digit', + minute: '2-digit' + }); +}