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:
Puechberty Arthur
2026-03-30 23:13:20 +02:00
parent a0df2e5437
commit 739fa54719
17 changed files with 2752 additions and 103 deletions
+8
View File
@@ -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*
+54 -21
View File
@@ -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
+20
View File
@@ -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
+775 -9
View File
File diff suppressed because it is too large Load Diff
+10 -3
View File
@@ -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",
+227
View File
@@ -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}`);
});
});
+67
View File
@@ -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 });
}
+48
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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>
);
}
+951
View File
@@ -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 é 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>
);
}
+109
View File
@@ -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>
);
}
+50
View File
@@ -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);
}
+33
View File
@@ -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];
}
+67
View File
@@ -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;
+16
View File
@@ -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'
});
}