mirror of
https://github.com/arthur-pbty/visio.git
synced 2026-06-03 23:36:40 +02:00
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
This commit is contained in:
@@ -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*
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
Generated
+775
-9
File diff suppressed because it is too large
Load Diff
+10
-3
@@ -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",
|
||||
|
||||
@@ -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}`);
|
||||
});
|
||||
});
|
||||
@@ -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 });
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
+117
-10
@@ -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%;
|
||||
}
|
||||
}
|
||||
|
||||
+14
-4
@@ -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 (
|
||||
<html lang="en">
|
||||
<html lang="fr">
|
||||
<body
|
||||
className={`${geistSans.variable} ${geistMono.variable} antialiased`}
|
||||
className={`${geistSans.variable} ${geistMono.variable} antialiased min-h-screen`}
|
||||
>
|
||||
{children}
|
||||
</body>
|
||||
|
||||
+182
-52
@@ -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 (
|
||||
<div className="flex min-h-screen items-center justify-center bg-zinc-50 font-sans dark:bg-black">
|
||||
<main className="flex min-h-screen w-full max-w-3xl flex-col items-center justify-between py-32 px-16 bg-white dark:bg-black sm:items-start">
|
||||
<Image
|
||||
className="dark:invert"
|
||||
src="/next.svg"
|
||||
alt="Next.js logo"
|
||||
width={100}
|
||||
height={20}
|
||||
priority
|
||||
/>
|
||||
<div className="flex flex-col items-center gap-6 text-center sm:items-start sm:text-left">
|
||||
<h1 className="max-w-xs text-3xl font-semibold leading-10 tracking-tight text-black dark:text-zinc-50">
|
||||
To get started, edit the page.tsx file.
|
||||
</h1>
|
||||
<p className="max-w-md text-lg leading-8 text-zinc-600 dark:text-zinc-400">
|
||||
Looking for a starting point or more instructions? Head over to{" "}
|
||||
<a
|
||||
href="https://vercel.com/templates?framework=next.js&utm_source=create-next-app&utm_medium=appdir-template-tw&utm_campaign=create-next-app"
|
||||
className="font-medium text-zinc-950 dark:text-zinc-50"
|
||||
<main className="min-h-screen bg-linear-to-br from-blue-50 to-indigo-100">
|
||||
{/* Header */}
|
||||
<header className="bg-white shadow-sm">
|
||||
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center space-x-3">
|
||||
<div className="w-10 h-10 bg-blue-500 rounded-lg flex items-center justify-center">
|
||||
<svg className="w-6 h-6 text-white" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M15 10l4.553-2.276A1 1 0 0121 8.618v6.764a1 1 0 01-1.447.894L15 14M5 18h8a2 2 0 002-2V8a2 2 0 00-2-2H5a2 2 0 00-2 2v8a2 2 0 002 2z" />
|
||||
</svg>
|
||||
</div>
|
||||
<h1 className="text-2xl font-bold text-gray-900">Visio</h1>
|
||||
</div>
|
||||
<Link
|
||||
href="/settings"
|
||||
className="p-2 text-gray-600 hover:text-gray-900 hover:bg-gray-100 rounded-lg transition-colors"
|
||||
title="Paramètres"
|
||||
>
|
||||
Templates
|
||||
</a>{" "}
|
||||
or the{" "}
|
||||
<a
|
||||
href="https://nextjs.org/learn?utm_source=create-next-app&utm_medium=appdir-template-tw&utm_campaign=create-next-app"
|
||||
className="font-medium text-zinc-950 dark:text-zinc-50"
|
||||
>
|
||||
Learning
|
||||
</a>{" "}
|
||||
center.
|
||||
<svg className="w-6 h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M10.325 4.317c.426-1.756 2.924-1.756 3.35 0a1.724 1.724 0 002.573 1.066c1.543-.94 3.31.826 2.37 2.37a1.724 1.724 0 001.065 2.572c1.756.426 1.756 2.924 0 3.35a1.724 1.724 0 00-1.066 2.573c.94 1.543-.826 3.31-2.37 2.37a1.724 1.724 0 00-2.572 1.065c-.426 1.756-2.924 1.756-3.35 0a1.724 1.724 0 00-2.573-1.066c-1.543.94-3.31-.826-2.37-2.37a1.724 1.724 0 00-1.065-2.572c-1.756-.426-1.756-2.924 0-3.35a1.724 1.724 0 001.066-2.573c-.94-1.543.826-3.31 2.37-2.37.996.608 2.296.07 2.572-1.065z" />
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M15 12a3 3 0 11-6 0 3 3 0 016 0z" />
|
||||
</svg>
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
{/* Hero Section */}
|
||||
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-16">
|
||||
<div className="text-center mb-12">
|
||||
<h2 className="text-4xl md:text-5xl font-bold text-gray-900 mb-4">
|
||||
Visioconférence simple et gratuite
|
||||
</h2>
|
||||
<p className="text-xl text-gray-600 max-w-2xl mx-auto">
|
||||
Créez une salle de visioconférence en un clic et partagez le lien avec vos participants.
|
||||
Aucune inscription requise.
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex flex-col gap-4 text-base font-medium sm:flex-row">
|
||||
<a
|
||||
className="flex h-12 w-full items-center justify-center gap-2 rounded-full bg-foreground px-5 text-background transition-colors hover:bg-[#383838] dark:hover:bg-[#ccc] md:w-[158px]"
|
||||
href="https://vercel.com/new?utm_source=create-next-app&utm_medium=appdir-template-tw&utm_campaign=create-next-app"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
|
||||
{/* Actions Cards */}
|
||||
<div className="grid md:grid-cols-2 gap-8 max-w-4xl mx-auto">
|
||||
{/* Créer une visio */}
|
||||
<div className="card hover:shadow-lg transition-shadow">
|
||||
<div className="text-center">
|
||||
<div className="w-16 h-16 bg-blue-100 rounded-full flex items-center justify-center mx-auto mb-4">
|
||||
<svg className="w-8 h-8 text-blue-500" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 6v6m0 0v6m0-6h6m-6 0H6" />
|
||||
</svg>
|
||||
</div>
|
||||
<h3 className="text-xl font-semibold text-gray-900 mb-2">Créer une visio</h3>
|
||||
<p className="text-gray-600 mb-6">
|
||||
Lancez une nouvelle visioconférence et invitez des participants
|
||||
</p>
|
||||
<button
|
||||
onClick={handleCreateRoom}
|
||||
disabled={isCreating}
|
||||
className="btn-primary w-full disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
>
|
||||
<Image
|
||||
className="dark:invert"
|
||||
src="/vercel.svg"
|
||||
alt="Vercel logomark"
|
||||
width={16}
|
||||
height={16}
|
||||
{isCreating ? (
|
||||
<span className="flex items-center justify-center">
|
||||
<svg className="animate-spin -ml-1 mr-3 h-5 w-5 text-white" fill="none" viewBox="0 0 24 24">
|
||||
<circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4"></circle>
|
||||
<path className="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"></path>
|
||||
</svg>
|
||||
Création en cours...
|
||||
</span>
|
||||
) : (
|
||||
'Nouvelle visio'
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Rejoindre une visio */}
|
||||
<div className="card hover:shadow-lg transition-shadow">
|
||||
<div className="text-center">
|
||||
<div className="w-16 h-16 bg-green-100 rounded-full flex items-center justify-center mx-auto mb-4">
|
||||
<svg className="w-8 h-8 text-green-500" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M17 20h5v-2a3 3 0 00-5.356-1.857M17 20H7m10 0v-2c0-.656-.126-1.283-.356-1.857M7 20H2v-2a3 3 0 015.356-1.857M7 20v-2c0-.656.126-1.283.356-1.857m0 0a5.002 5.002 0 019.288 0M15 7a3 3 0 11-6 0 3 3 0 016 0zm6 3a2 2 0 11-4 0 2 2 0 014 0zM7 10a2 2 0 11-4 0 2 2 0 014 0z" />
|
||||
</svg>
|
||||
</div>
|
||||
<h3 className="text-xl font-semibold text-gray-900 mb-2">Rejoindre une visio</h3>
|
||||
<p className="text-gray-600 mb-6">
|
||||
Entrez le code de la salle pour rejoindre une visioconférence
|
||||
</p>
|
||||
<form onSubmit={handleJoinRoom} className="space-y-4">
|
||||
<input
|
||||
type="text"
|
||||
value={roomCode}
|
||||
onChange={(e) => setRoomCode(e.target.value)}
|
||||
placeholder="Code de la salle"
|
||||
className="input-field text-center"
|
||||
/>
|
||||
Deploy Now
|
||||
</a>
|
||||
<a
|
||||
className="flex h-12 w-full items-center justify-center rounded-full border border-solid border-black/[.08] px-5 transition-colors hover:border-transparent hover:bg-black/[.04] dark:border-white/[.145] dark:hover:bg-[#1a1a1a] md:w-[158px]"
|
||||
href="https://nextjs.org/docs?utm_source=create-next-app&utm_medium=appdir-template-tw&utm_campaign=create-next-app"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
<button
|
||||
type="submit"
|
||||
disabled={!roomCode.trim()}
|
||||
className="btn-secondary w-full disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
>
|
||||
Documentation
|
||||
</a>
|
||||
Rejoindre
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Features */}
|
||||
<div className="mt-20">
|
||||
<h3 className="text-2xl font-bold text-center text-gray-900 mb-10">
|
||||
Pourquoi utiliser Visio ?
|
||||
</h3>
|
||||
<div className="grid md:grid-cols-3 gap-8 max-w-5xl mx-auto">
|
||||
<div className="text-center">
|
||||
<div className="w-12 h-12 bg-purple-100 rounded-lg flex items-center justify-center mx-auto mb-4">
|
||||
<svg className="w-6 h-6 text-purple-500" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M13 10V3L4 14h7v7l9-11h-7z" />
|
||||
</svg>
|
||||
</div>
|
||||
<h4 className="font-semibold text-gray-900 mb-2">Rapide</h4>
|
||||
<p className="text-gray-600 text-sm">Créez une visio en un seul clic, sans attente</p>
|
||||
</div>
|
||||
<div className="text-center">
|
||||
<div className="w-12 h-12 bg-orange-100 rounded-lg flex items-center justify-center mx-auto mb-4">
|
||||
<svg className="w-6 h-6 text-orange-500" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 15v2m-6 4h12a2 2 0 002-2v-6a2 2 0 00-2-2H6a2 2 0 00-2 2v6a2 2 0 002 2zm10-10V7a4 4 0 00-8 0v4h8z" />
|
||||
</svg>
|
||||
</div>
|
||||
<h4 className="font-semibold text-gray-900 mb-2">Sans inscription</h4>
|
||||
<p className="text-gray-600 text-sm">Aucun compte requis pour utiliser le service</p>
|
||||
</div>
|
||||
<div className="text-center">
|
||||
<div className="w-12 h-12 bg-teal-100 rounded-lg flex items-center justify-center mx-auto mb-4">
|
||||
<svg className="w-6 h-6 text-teal-500" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M8.684 13.342C8.886 12.938 9 12.482 9 12c0-.482-.114-.938-.316-1.342m0 2.684a3 3 0 110-2.684m0 2.684l6.632 3.316m-6.632-6l6.632-3.316m0 0a3 3 0 105.367-2.684 3 3 0 00-5.367 2.684zm0 9.316a3 3 0 105.368 2.684 3 3 0 00-5.368-2.684z" />
|
||||
</svg>
|
||||
</div>
|
||||
<h4 className="font-semibold text-gray-900 mb-2">Facile à partager</h4>
|
||||
<p className="text-gray-600 text-sm">Partagez simplement le lien avec vos participants</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Footer */}
|
||||
<footer className="bg-white border-t border-gray-200 mt-20">
|
||||
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-6">
|
||||
<p className="text-center text-gray-500 text-sm">
|
||||
© 2026 Visio - visio.arthurp.fr - Visioconférence simple et gratuite
|
||||
</p>
|
||||
</div>
|
||||
</footer>
|
||||
</main>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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<string>('visio_username', '');
|
||||
const [userName, setUserName] = useState('');
|
||||
const [tempName, setTempName] = useState('');
|
||||
const [isJoined, setIsJoined] = useState(false);
|
||||
const [roomExists, setRoomExists] = useState<boolean | null>(null);
|
||||
const [roomClosed, setRoomClosed] = useState(false);
|
||||
const [participants, setParticipants] = useState<Participant[]>([]);
|
||||
const [isMuted, setIsMuted] = useState(false);
|
||||
const [isVideoOff, setIsVideoOff] = useState(false);
|
||||
const [showSettings, setShowSettings] = useState(false);
|
||||
const [copied, setCopied] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [speakingUsers, setSpeakingUsers] = useState<Set<string>>(new Set());
|
||||
|
||||
const localVideoRef = useRef<HTMLVideoElement>(null);
|
||||
const audioContextRef = useRef<AudioContext | null>(null);
|
||||
const analyzersRef = useRef<Map<string, { analyser: AnalyserNode; source: MediaStreamAudioSourceNode }>>(new Map());
|
||||
const speakingTimeoutsRef = useRef<Map<string, NodeJS.Timeout>>(new Map());
|
||||
const localStreamRef = useRef<MediaStream | null>(null);
|
||||
const socketRef = useRef<Socket | null>(null);
|
||||
const peersRef = useRef<Map<string, RTCPeerConnection>>(new Map());
|
||||
const pendingCandidatesRef = useRef<Map<string, RTCIceCandidate[]>>(new Map());
|
||||
const activityIntervalRef = useRef<NodeJS.Timeout | null>(null);
|
||||
const pingIntervalRef = useRef<NodeJS.Timeout | null>(null);
|
||||
const pongTimeoutRef = useRef<NodeJS.Timeout | null>(null);
|
||||
const lastPongRef = useRef<number>(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 (
|
||||
<div className="min-h-screen bg-linear-to-br from-blue-50 to-indigo-100 flex items-center justify-center">
|
||||
<div className="text-center">
|
||||
<div className="animate-spin rounded-full h-12 w-12 border-b-2 border-blue-500 mx-auto mb-4"></div>
|
||||
<p className="text-gray-600">Chargement...</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Salle fermée
|
||||
if (roomClosed) {
|
||||
return (
|
||||
<div className="min-h-screen bg-linear-to-br from-blue-50 to-indigo-100 flex items-center justify-center p-4">
|
||||
<div className="card max-w-md w-full text-center">
|
||||
<div className="w-16 h-16 bg-orange-100 rounded-full flex items-center justify-center mx-auto mb-4">
|
||||
<svg className="w-8 h-8 text-orange-500" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-3L13.732 4c-.77-1.333-2.694-1.333-3.464 0L3.34 16c-.77 1.333.192 3 1.732 3z" />
|
||||
</svg>
|
||||
</div>
|
||||
<h2 className="text-2xl font-bold text-gray-900 mb-2">Salle fermée</h2>
|
||||
<p className="text-gray-600 mb-6">
|
||||
Cette visioconférence a été fermée car elle est restée inactive pendant plus de 5 minutes.
|
||||
</p>
|
||||
<Link href="/" className="btn-primary inline-block">
|
||||
Retour à l'accueil
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Salle non trouvée
|
||||
if (!roomExists) {
|
||||
return (
|
||||
<div className="min-h-screen bg-linear-to-br from-blue-50 to-indigo-100 flex items-center justify-center p-4">
|
||||
<div className="card max-w-md w-full text-center">
|
||||
<div className="w-16 h-16 bg-red-100 rounded-full flex items-center justify-center mx-auto mb-4">
|
||||
<svg className="w-8 h-8 text-red-500" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" />
|
||||
</svg>
|
||||
</div>
|
||||
<h2 className="text-2xl font-bold text-gray-900 mb-2">Salle introuvable</h2>
|
||||
<p className="text-gray-600 mb-6">
|
||||
Cette salle de visioconférence n'existe pas ou le code est incorrect.
|
||||
</p>
|
||||
<Link href="/" className="btn-primary inline-block">
|
||||
Retour à l'accueil
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Page pour rejoindre (demande de nom)
|
||||
if (!isJoined) {
|
||||
return (
|
||||
<div className="min-h-screen bg-linear-to-br from-blue-50 to-indigo-100 flex items-center justify-center p-4">
|
||||
<div className="card max-w-md w-full">
|
||||
<div className="text-center mb-6">
|
||||
<div className="w-16 h-16 bg-blue-100 rounded-full flex items-center justify-center mx-auto mb-4">
|
||||
<svg className="w-8 h-8 text-blue-500" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M15 10l4.553-2.276A1 1 0 0121 8.618v6.764a1 1 0 01-1.447.894L15 14M5 18h8a2 2 0 002-2V8a2 2 0 00-2-2H5a2 2 0 00-2 2v8a2 2 0 002 2z" />
|
||||
</svg>
|
||||
</div>
|
||||
<h2 className="text-2xl font-bold text-gray-900 mb-2">Rejoindre la visio</h2>
|
||||
<p className="text-gray-600">
|
||||
Code de la salle : <span className="font-mono font-semibold">{roomId}</span>
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{error && (
|
||||
<div className="bg-red-50 border border-red-200 text-red-700 px-4 py-3 rounded-lg mb-4">
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<form onSubmit={handleJoin} className="space-y-4">
|
||||
<div>
|
||||
<label htmlFor="name" className="block text-sm font-medium text-gray-700 mb-2">
|
||||
Votre nom
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
id="name"
|
||||
value={tempName}
|
||||
onChange={(e) => setTempName(e.target.value)}
|
||||
placeholder="Entrez votre nom"
|
||||
className="input-field"
|
||||
autoFocus
|
||||
/>
|
||||
</div>
|
||||
<button type="submit" className="btn-primary w-full">
|
||||
Rejoindre
|
||||
</button>
|
||||
</form>
|
||||
|
||||
<div className="mt-6 text-center">
|
||||
<Link href="/" className="text-blue-500 hover:text-blue-600 text-sm">
|
||||
← Retour à l'accueil
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Page de visioconférence
|
||||
const gridClass = `video-grid video-grid-${Math.min(participants.length + 1, 4)}`;
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-gray-900 flex flex-col">
|
||||
{/* Header */}
|
||||
<header className="bg-gray-800 px-4 py-3 flex items-center justify-between">
|
||||
<div className="flex items-center space-x-4">
|
||||
<Link href="/" className="flex items-center space-x-2">
|
||||
<div className="w-8 h-8 bg-blue-500 rounded-lg flex items-center justify-center">
|
||||
<svg className="w-5 h-5 text-white" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M15 10l4.553-2.276A1 1 0 0121 8.618v6.764a1 1 0 01-1.447.894L15 14M5 18h8a2 2 0 002-2V8a2 2 0 00-2-2H5a2 2 0 00-2 2v8a2 2 0 002 2z" />
|
||||
</svg>
|
||||
</div>
|
||||
<span className="text-white font-semibold hidden sm:inline">Visio</span>
|
||||
</Link>
|
||||
<span className="text-gray-400 text-sm">
|
||||
Salle : <span className="font-mono">{roomId}</span>
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center space-x-2">
|
||||
<button
|
||||
onClick={copyLink}
|
||||
className="flex items-center space-x-2 bg-gray-700 hover:bg-gray-600 text-white px-3 py-2 rounded-lg text-sm transition-colors"
|
||||
>
|
||||
{copied ? (
|
||||
<>
|
||||
<svg className="w-4 h-4 text-green-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M5 13l4 4L19 7" />
|
||||
</svg>
|
||||
<span>Copié !</span>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M8.684 13.342C8.886 12.938 9 12.482 9 12c0-.482-.114-.938-.316-1.342m0 2.684a3 3 0 110-2.684m0 2.684l6.632 3.316m-6.632-6l6.632-3.316m0 0a3 3 0 105.367-2.684 3 3 0 00-5.367 2.684zm0 9.316a3 3 0 105.368 2.684 3 3 0 00-5.368-2.684z" />
|
||||
</svg>
|
||||
<span className="hidden sm:inline">Partager</span>
|
||||
</>
|
||||
)}
|
||||
</button>
|
||||
|
||||
<button
|
||||
onClick={() => {
|
||||
setTempName(userName);
|
||||
setShowSettings(true);
|
||||
}}
|
||||
className="p-2 text-gray-400 hover:text-white hover:bg-gray-700 rounded-lg transition-colors"
|
||||
title="Paramètres"
|
||||
>
|
||||
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M10.325 4.317c.426-1.756 2.924-1.756 3.35 0a1.724 1.724 0 002.573 1.066c1.543-.94 3.31.826 2.37 2.37a1.724 1.724 0 001.065 2.572c1.756.426 1.756 2.924 0 3.35a1.724 1.724 0 00-1.066 2.573c.94 1.543-.826 3.31-2.37 2.37a1.724 1.724 0 00-2.572 1.065c-.426 1.756-2.924 1.756-3.35 0a1.724 1.724 0 00-2.573-1.066c-1.543.94-3.31-.826-2.37-2.37a1.724 1.724 0 00-1.065-2.572c-1.756-.426-1.756-2.924 0-3.35a1.724 1.724 0 001.066-2.573c-.94-1.543.826-3.31 2.37-2.37.996.608 2.296.07 2.572-1.065z" />
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M15 12a3 3 0 11-6 0 3 3 0 016 0z" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
{/* Zone vidéo */}
|
||||
<main className="flex-1 p-4 overflow-auto">
|
||||
<div className={gridClass}>
|
||||
{/* Vidéo locale */}
|
||||
<div className={`video-container aspect-video ${speakingUsers.has('local') ? 'speaking' : ''}`}>
|
||||
<video
|
||||
ref={localVideoRef}
|
||||
autoPlay
|
||||
muted
|
||||
playsInline
|
||||
className={isVideoOff ? 'hidden' : ''}
|
||||
/>
|
||||
{isVideoOff && (
|
||||
<div className="video-placeholder">
|
||||
<div className="w-20 h-20 bg-gray-700 rounded-full flex items-center justify-center">
|
||||
<span className="text-3xl text-white font-semibold">
|
||||
{userName.charAt(0).toUpperCase()}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
<div className="video-label">
|
||||
{userName} (Vous)
|
||||
{isMuted && (
|
||||
<svg className="w-4 h-4 inline ml-2" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M5.586 15H4a1 1 0 01-1-1v-4a1 1 0 011-1h1.586l4.707-4.707C10.923 3.663 12 4.109 12 5v14c0 .891-1.077 1.337-1.707.707L5.586 15z" clipRule="evenodd" />
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M17 14l2-2m0 0l2-2m-2 2l-2-2m2 2l2 2" />
|
||||
</svg>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Vidéos des participants */}
|
||||
{participants.map((participant) => (
|
||||
<div key={participant.id} className={`video-container aspect-video ${speakingUsers.has(participant.id) ? 'speaking' : ''}`}>
|
||||
{participant.stream && !participant.videoOff ? (
|
||||
<video
|
||||
autoPlay
|
||||
playsInline
|
||||
ref={(el) => {
|
||||
if (el && participant.stream) {
|
||||
el.srcObject = participant.stream;
|
||||
// Configurer la détection vocale pour ce participant
|
||||
setupVoiceDetection(participant.id, participant.stream);
|
||||
}
|
||||
}}
|
||||
/>
|
||||
) : (
|
||||
<div className="video-placeholder">
|
||||
<div className="w-20 h-20 bg-gray-700 rounded-full flex items-center justify-center">
|
||||
<span className="text-3xl text-white font-semibold">
|
||||
{participant.name.charAt(0).toUpperCase()}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
<div className="video-label">
|
||||
{participant.name}
|
||||
{participant.muted && (
|
||||
<svg className="w-4 h-4 inline ml-2" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M5.586 15H4a1 1 0 01-1-1v-4a1 1 0 011-1h1.586l4.707-4.707C10.923 3.663 12 4.109 12 5v14c0 .891-1.077 1.337-1.707.707L5.586 15z" clipRule="evenodd" />
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M17 14l2-2m0 0l2-2m-2 2l-2-2m2 2l2 2" />
|
||||
</svg>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{participants.length === 0 && (
|
||||
<div className="mt-8 text-center text-gray-400">
|
||||
<p>En attente d'autres participants...</p>
|
||||
<p className="text-sm mt-2">
|
||||
Partagez le lien pour inviter des personnes
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</main>
|
||||
|
||||
{/* Barre de contrôles */}
|
||||
<footer className="bg-gray-800 px-4 py-4">
|
||||
<div className="flex items-center justify-center space-x-4">
|
||||
<button
|
||||
onClick={toggleMute}
|
||||
className={`btn-icon ${isMuted ? 'bg-red-500 hover:bg-red-600' : 'bg-gray-700 hover:bg-gray-600'}`}
|
||||
title={isMuted ? 'Activer le micro' : 'Couper le micro'}
|
||||
>
|
||||
{isMuted ? (
|
||||
<svg className="w-6 h-6 text-white" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M5.586 15H4a1 1 0 01-1-1v-4a1 1 0 011-1h1.586l4.707-4.707C10.923 3.663 12 4.109 12 5v14c0 .891-1.077 1.337-1.707.707L5.586 15z" clipRule="evenodd" />
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M17 14l2-2m0 0l2-2m-2 2l-2-2m2 2l2 2" />
|
||||
</svg>
|
||||
) : (
|
||||
<svg className="w-6 h-6 text-white" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M19 11a7 7 0 01-7 7m0 0a7 7 0 01-7-7m7 7v4m0 0H8m4 0h4m-4-8a3 3 0 01-3-3V5a3 3 0 116 0v6a3 3 0 01-3 3z" />
|
||||
</svg>
|
||||
)}
|
||||
</button>
|
||||
|
||||
<button
|
||||
onClick={toggleVideo}
|
||||
className={`btn-icon ${isVideoOff ? 'bg-red-500 hover:bg-red-600' : 'bg-gray-700 hover:bg-gray-600'}`}
|
||||
title={isVideoOff ? 'Activer la caméra' : 'Couper la caméra'}
|
||||
>
|
||||
{isVideoOff ? (
|
||||
<svg className="w-6 h-6 text-white" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M15 10l4.553-2.276A1 1 0 0121 8.618v6.764a1 1 0 01-1.447.894L15 14M5 18h8a2 2 0 002-2V8a2 2 0 00-2-2H5a2 2 0 00-2 2v8a2 2 0 002 2z" />
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M17 14l2-2m0 0l2-2m-2 2l-2-2m2 2l2 2" />
|
||||
</svg>
|
||||
) : (
|
||||
<svg className="w-6 h-6 text-white" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M15 10l4.553-2.276A1 1 0 0121 8.618v6.764a1 1 0 01-1.447.894L15 14M5 18h8a2 2 0 002-2V8a2 2 0 00-2-2H5a2 2 0 00-2 2v8a2 2 0 002 2z" />
|
||||
</svg>
|
||||
)}
|
||||
</button>
|
||||
|
||||
<button
|
||||
onClick={leaveRoom}
|
||||
className="btn-icon bg-red-500 hover:bg-red-600"
|
||||
title="Quitter"
|
||||
>
|
||||
<svg className="w-6 h-6 text-white" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M16 8l2-2m0 0l2-2m-2 2l-2-2m2 2l2 2M5 3a2 2 0 00-2 2v1c0 8.284 6.716 15 15 15h1a2 2 0 002-2v-3.28a1 1 0 00-.684-.948l-4.493-1.498a1 1 0 00-1.21.502l-1.13 2.257a11.042 11.042 0 01-5.516-5.517l2.257-1.128a1 1 0 00.502-1.21L9.228 3.683A1 1 0 008.279 3H5z" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="text-center mt-2 text-gray-500 text-sm">
|
||||
{participants.length + 1} participant{participants.length > 0 ? 's' : ''}
|
||||
</div>
|
||||
</footer>
|
||||
|
||||
{/* Modal paramètres */}
|
||||
{showSettings && (
|
||||
<div className="fixed inset-0 bg-black/50 flex items-center justify-center z-50 p-4">
|
||||
<div className="bg-white rounded-xl p-6 max-w-md w-full">
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<h3 className="text-xl font-bold text-gray-900">Paramètres</h3>
|
||||
<button
|
||||
onClick={() => setShowSettings(false)}
|
||||
className="p-2 text-gray-500 hover:text-gray-700 hover:bg-gray-100 rounded-lg"
|
||||
>
|
||||
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<form onSubmit={handleNameChange} className="space-y-4">
|
||||
<div>
|
||||
<label htmlFor="settings-name" className="block text-sm font-medium text-gray-700 mb-2">
|
||||
Votre nom
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
id="settings-name"
|
||||
value={tempName}
|
||||
onChange={(e) => setTempName(e.target.value)}
|
||||
className="input-field"
|
||||
/>
|
||||
</div>
|
||||
<div className="flex space-x-3">
|
||||
<button type="submit" className="btn-primary flex-1">
|
||||
Enregistrer
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setShowSettings(false)}
|
||||
className="btn-secondary flex-1"
|
||||
>
|
||||
Annuler
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -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<string>('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 (
|
||||
<main className="min-h-screen bg-linear-to-br from-blue-50 to-indigo-100">
|
||||
{/* Header */}
|
||||
<header className="bg-white shadow-sm">
|
||||
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<Link href="/" className="flex items-center space-x-3">
|
||||
<div className="w-10 h-10 bg-blue-500 rounded-lg flex items-center justify-center">
|
||||
<svg className="w-6 h-6 text-white" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M15 10l4.553-2.276A1 1 0 0121 8.618v6.764a1 1 0 01-1.447.894L15 14M5 18h8a2 2 0 002-2V8a2 2 0 00-2-2H5a2 2 0 00-2 2v8a2 2 0 002 2z" />
|
||||
</svg>
|
||||
</div>
|
||||
<h1 className="text-2xl font-bold text-gray-900">Visio</h1>
|
||||
</Link>
|
||||
<button
|
||||
onClick={() => router.back()}
|
||||
className="p-2 text-gray-600 hover:text-gray-900 hover:bg-gray-100 rounded-lg transition-colors"
|
||||
>
|
||||
<svg className="w-6 h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<div className="max-w-2xl mx-auto px-4 sm:px-6 lg:px-8 py-12">
|
||||
<div className="card">
|
||||
<h2 className="text-2xl font-bold text-gray-900 mb-6">Paramètres</h2>
|
||||
|
||||
<form onSubmit={handleSave} className="space-y-6">
|
||||
<div>
|
||||
<label htmlFor="username" className="block text-sm font-medium text-gray-700 mb-2">
|
||||
Votre nom d'affichage
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
id="username"
|
||||
value={inputName}
|
||||
onChange={(e) => setInputName(e.target.value)}
|
||||
placeholder="Entrez votre nom"
|
||||
className="input-field"
|
||||
/>
|
||||
<p className="mt-2 text-sm text-gray-500">
|
||||
Ce nom sera affiché aux autres participants lors de vos visioconférences.
|
||||
Il est sauvegardé dans votre navigateur.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center space-x-4">
|
||||
<button type="submit" className="btn-primary">
|
||||
Enregistrer
|
||||
</button>
|
||||
{saved && (
|
||||
<span className="text-green-600 flex items-center">
|
||||
<svg className="w-5 h-5 mr-1" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M5 13l4 4L19 7" />
|
||||
</svg>
|
||||
Enregistré !
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</form>
|
||||
|
||||
<hr className="my-8 border-gray-200" />
|
||||
|
||||
<div>
|
||||
<h3 className="text-lg font-semibold text-gray-900 mb-4">À propos</h3>
|
||||
<p className="text-gray-600 text-sm">
|
||||
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é.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mt-6 text-center">
|
||||
<Link href="/" className="text-blue-500 hover:text-blue-600 font-medium">
|
||||
← Retour à l'accueil
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
@@ -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<SocketContextType>({
|
||||
socket: null,
|
||||
isConnected: false
|
||||
});
|
||||
|
||||
export function SocketProvider({ children }: { children: ReactNode }) {
|
||||
const [socket, setSocket] = useState<Socket | null>(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 (
|
||||
<SocketContext.Provider value={{ socket, isConnected }}>
|
||||
{children}
|
||||
</SocketContext.Provider>
|
||||
);
|
||||
}
|
||||
|
||||
export function useSocket() {
|
||||
return useContext(SocketContext);
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
'use client';
|
||||
|
||||
import { useState, useEffect } from 'react';
|
||||
|
||||
export function useLocalStorage<T>(key: string, initialValue: T): [T, (value: T) => void] {
|
||||
const [storedValue, setStoredValue] = useState<T>(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];
|
||||
}
|
||||
@@ -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;
|
||||
@@ -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'
|
||||
});
|
||||
}
|
||||
Reference in New Issue
Block a user