Tutorial Completo: Chat de Video en Vivo con PHP + WebRTC

 

 Tutorial Completo: Chat de Video en Vivo con PHP + WebRTC

🎯 Lo que Construirás

Hola, soy Aizaz Dinho y en este tutorial te mostraré cómo crear un chat de video en vivo con PHP y WebRTC.

Al finalizar, tendrás una aplicación completa con:

  • ✅ Sistema de login de usuarios

  • ✅ Lista de usuarios en línea

  • ✅ Llamadas de video P2P (Peer-to-Peer)

  • ✅ Notificaciones en tiempo real

  • ✅ Estado "ocupado" durante llamadas

  • ✅ URLs amigables para perfiles

  • ✅ WebSockets para comunicación instantánea


📋 Requisitos Previos

  • PHP 7.4+ con extensiones: pdo_mysql, json, mbstring

  • MySQL 5.7+ o MariaDB

  • Node.js (para el servidor WebSocket)

  • Navegador moderno (Chrome, Firefox, Edge)

  • Conocimientos básicos de PHP, JavaScript y HTML


🏗️ Estructura del Proyecto

text
video-chat/
├── assets/
│   ├── css/
│   │   └── style.css
│   ├── js/
│   │   ├── app.js
│   │   └── webrtc.js
│   └── images/
│       └── default-avatar.png
├── includes/
│   ├── config.php
│   ├── database.php
│   └── functions.php
├── websocket/
│   ├── server.js
│   └── package.json
├── index.php
├── login.php
├── register.php
├── profile.php
├── logout.php
├── api/
│   ├── get_users.php
│   └── get_user.php
└── database.sql

🗄️ Paso 1: Configurar la Base de Datos

Crear la Base de Datos

sql
-- database.sql
CREATE DATABASE IF NOT EXISTS video_chat;
USE video_chat;

-- Tabla de usuarios
CREATE TABLE users (
    id INT AUTO_INCREMENT PRIMARY KEY,
    username VARCHAR(50) UNIQUE NOT NULL,
    email VARCHAR(100) UNIQUE NOT NULL,
    password VARCHAR(255) NOT NULL,
    full_name VARCHAR(100) NOT NULL,
    profile_image VARCHAR(255) DEFAULT 'default-avatar.png',
    status ENUM('online', 'offline', 'busy') DEFAULT 'offline',
    last_activity TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
    created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
);

-- Tabla para registrar llamadas (opcional)
CREATE TABLE call_logs (
    id INT AUTO_INCREMENT PRIMARY KEY,
    caller_id INT NOT NULL,
    receiver_id INT NOT NULL,
    status ENUM('iniciada', 'respondida', 'rechazada', 'finalizada') DEFAULT 'iniciada',
    started_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
    ended_at TIMESTAMP NULL,
    duration INT DEFAULT 0,
    FOREIGN KEY (caller_id) REFERENCES users(id),
    FOREIGN KEY (receiver_id) REFERENCES users(id)
);

-- Insertar usuarios de prueba
INSERT INTO users (username, email, password, full_name) VALUES
('juan', 'juan@email.com', '$2y$10$92IXUNpkjO0rOQ5byMi.Ye4oKoEa3Ro9llC/.og/at2.uheWG/igi', 'Juan Pérez'),
('maria', 'maria@email.com', '$2y$10$92IXUNpkjO0rOQ5byMi.Ye4oKoEa3Ro9llC/.og/at2.uheWG/igi', 'María López'),
('carlos', 'carlos@email.com', '$2y$10$92IXUNpkjO0rOQ5byMi.Ye4oKoEa3Ro9llC/.og/at2.uheWG/igi', 'Carlos Gómez');

-- Nota: La contraseña para todos es 'password'

⚙️ Paso 2: Configuración PHP

2.1 Archivo de Configuración

php
<!-- includes/config.php -->
<?php
// Configuración de la base de datos
define('DB_HOST', 'localhost');
define('DB_NAME', 'video_chat');
define('DB_USER', 'root');
define('DB_PASS', '');

// Configuración de la aplicación
define('SITE_URL', 'http://localhost/video-chat/');
define('SITE_NAME', 'VideoChat Pro');

// Configuración de sesión
ini_set('session.cookie_httponly', 1);
ini_set('session.use_only_cookies', 1);
ini_set('session.cookie_secure', 0); // Cambiar a 1 en producción con HTTPS

// Iniciar sesión si no está iniciada
if (session_status() === PHP_SESSION_NONE) {
    session_start();
}

// Zona horaria
date_default_timezone_set('America/Mexico_City');

// Reportar todos los errores (solo en desarrollo)
error_reporting(E_ALL);
ini_set('display_errors', 1);
?>

2.2 Conexión a la Base de Datos

php
<!-- includes/database.php -->
<?php
require_once 'config.php';

class Database {
    private static $instance = null;
    private $connection;
    
    private function __construct() {
        try {
            $this->connection = new PDO(
                "mysql:host=" . DB_HOST . ";dbname=" . DB_NAME . ";charset=utf8mb4",
                DB_USER,
                DB_PASS,
                [
                    PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION,
                    PDO::ATTR_DEFAULT_FETCH_MODE => PDO::FETCH_ASSOC,
                    PDO::ATTR_EMULATE_PREPARES => false
                ]
            );
        } catch(PDOException $e) {
            die("Error de conexión: " . $e->getMessage());
        }
    }
    
    public static function getInstance() {
        if (self::$instance === null) {
            self::$instance = new self();
        }
        return self::$instance;
    }
    
    public function getConnection() {
        return $this->connection;
    }
    
    // Método para ejecutar consultas
    public function query($sql, $params = []) {
        $stmt = $this->connection->prepare($sql);
        $stmt->execute($params);
        return $stmt;
    }
    
    // Método para obtener un registro
    public function fetchOne($sql, $params = []) {
        $stmt = $this->query($sql, $params);
        return $stmt->fetch();
    }
    
    // Método para obtener todos los registros
    public function fetchAll($sql, $params = []) {
        $stmt = $this->query($sql, $params);
        return $stmt->fetchAll();
    }
}
?>

2.3 Funciones Auxiliares

php
<!-- includes/functions.php -->
<?php
require_once 'database.php';

// Función para verificar si el usuario está logueado
function isLoggedIn() {
    return isset($_SESSION['user_id']);
}

// Función para obtener el usuario actual
function getCurrentUser() {
    if (!isLoggedIn()) return null;
    
    $db = Database::getInstance();
    return $db->fetchOne(
        "SELECT id, username, email, full_name, profile_image, status 
         FROM users WHERE id = ?",
        [$_SESSION['user_id']]
    );
}

// Función para obtener todos los usuarios (excepto el actual)
function getUsers($excludeId = null) {
    $db = Database::getInstance();
    $sql = "SELECT id, username, full_name, profile_image, status 
            FROM users WHERE id != ? ORDER BY username";
    return $db->fetchAll($sql, [$excludeId ?? $_SESSION['user_id']]);
}

// Función para obtener un usuario por ID
function getUserById($id) {
    $db = Database::getInstance();
    return $db->fetchOne(
        "SELECT id, username, full_name, profile_image, status 
         FROM users WHERE id = ?",
        [$id]
    );
}

// Función para actualizar el estado del usuario
function updateUserStatus($userId, $status) {
    $db = Database::getInstance();
    $db->query(
        "UPDATE users SET status = ? WHERE id = ?",
        [$status, $userId]
    );
}

// Función para verificar si un usuario existe
function userExists($id) {
    $db = Database::getInstance();
    $result = $db->fetchOne(
        "SELECT id FROM users WHERE id = ?",
        [$id]
    );
    return $result !== false;
}

// Función para registrar una llamada
function logCall($callerId, $receiverId, $status = 'iniciada') {
    $db = Database::getInstance();
    $db->query(
        "INSERT INTO call_logs (caller_id, receiver_id, status) VALUES (?, ?, ?)",
        [$callerId, $receiverId, $status]
    );
    return $db->getConnection()->lastInsertId();
}

// Función para actualizar el estado de una llamada
function updateCallStatus($callId, $status, $endedAt = null) {
    $db = Database::getInstance();
    $sql = "UPDATE call_logs SET status = ?";
    $params = [$status];
    
    if ($endedAt) {
        $sql .= ", ended_at = ?";
        $params[] = $endedAt;
    }
    
    $sql .= " WHERE id = ?";
    $params[] = $callId;
    
    $db->query($sql, $params);
}

// Función de redirección segura
function redirect($url) {
    header("Location: " . SITE_URL . $url);
    exit();
}

// Función para sanitizar entrada
function sanitize($input) {
    return htmlspecialchars(strip_tags(trim($input)), ENT_QUOTES, 'UTF-8');
}
?>

🔐 Paso 3: Sistema de Autenticación

3.1 Página de Login

php
<!-- login.php -->
<?php
require_once 'includes/config.php';
require_once 'includes/functions.php';

// Si ya está logueado, redirigir al home
if (isLoggedIn()) {
    redirect('index.php');
}

$error = '';

// Procesar el formulario de login
if ($_SERVER['REQUEST_METHOD'] === 'POST') {
    $username = sanitize($_POST['username'] ?? '');
    $password = $_POST['password'] ?? '';
    
    if (empty($username) || empty($password)) {
        $error = 'Por favor, complete todos los campos';
    } else {
        $db = Database::getInstance();
        $user = $db->fetchOne(
            "SELECT id, username, password, full_name, profile_image FROM users WHERE username = ? OR email = ?",
            [$username, $username]
        );
        
        if ($user && password_verify($password, $user['password'])) {
            $_SESSION['user_id'] = $user['id'];
            $_SESSION['username'] = $user['username'];
            $_SESSION['full_name'] = $user['full_name'];
            
            // Actualizar estado a online
            updateUserStatus($user['id'], 'online');
            
            redirect('index.php');
        } else {
            $error = 'Usuario o contraseña incorrectos';
        }
    }
}
?>
<!DOCTYPE html>
<html lang="es">
<head>
    <meta charset="UTF-8">
    <meta name="viewport" content="width=device-width, initial-scale=1.0">
    <title>Login - <?= SITE_NAME ?></title>
    <link rel="stylesheet" href="assets/css/style.css">
</head>
<body>
    <div class="container">
        <div class="login-box">
            <h1>🎥 <?= SITE_NAME ?></h1>
            <h2>Iniciar Sesión</h2>
            
            <?php if ($error): ?>
                <div class="alert alert-error"><?= $error ?></div>
            <?php endif; ?>
            
            <form method="POST" action="">
                <div class="form-group">
                    <label for="username">Usuario o Email</label>
                    <input type="text" id="username" name="username" required 
                           placeholder="Ingresa tu usuario o email">
                </div>
                
                <div class="form-group">
                    <label for="password">Contraseña</label>
                    <input type="password" id="password" name="password" required 
                           placeholder="Ingresa tu contraseña">
                </div>
                
                <button type="submit" class="btn btn-primary">Iniciar Sesión</button>
            </form>
            
            <p class="text-center">
                ¿No tienes cuenta? <a href="register.php">Regístrate aquí</a>
            </p>
            
            <div class="demo-accounts">
                <p><small>Cuentas de demo:</small></p>
                <p><small>Usuario: juan | Contraseña: password</small></p>
                <p><small>Usuario: maria | Contraseña: password</small></p>
            </div>
        </div>
    </div>
</body>
</html>

3.2 Página de Registro

php
<!-- register.php -->
<?php
require_once 'includes/config.php';
require_once 'includes/functions.php';

if (isLoggedIn()) {
    redirect('index.php');
}

$error = '';
$success = '';

if ($_SERVER['REQUEST_METHOD'] === 'POST') {
    $username = sanitize($_POST['username'] ?? '');
    $email = filter_var($_POST['email'] ?? '', FILTER_SANITIZE_EMAIL);
    $full_name = sanitize($_POST['full_name'] ?? '');
    $password = $_POST['password'] ?? '';
    $confirm_password = $_POST['confirm_password'] ?? '';
    
    // Validaciones
    if (empty($username) || empty($email) || empty($full_name) || empty($password)) {
        $error = 'Todos los campos son obligatorios';
    } elseif (!filter_var($email, FILTER_VALIDATE_EMAIL)) {
        $error = 'Email inválido';
    } elseif (strlen($password) < 6) {
        $error = 'La contraseña debe tener al menos 6 caracteres';
    } elseif ($password !== $confirm_password) {
        $error = 'Las contraseñas no coinciden';
    } else {
        $db = Database::getInstance();
        
        // Verificar si el usuario o email ya existen
        $existing = $db->fetchOne(
            "SELECT id FROM users WHERE username = ? OR email = ?",
            [$username, $email]
        );
        
        if ($existing) {
            $error = 'El usuario o email ya está registrado';
        } else {
            // Hash de la contraseña
            $hashedPassword = password_hash($password, PASSWORD_DEFAULT);
            
            // Insertar nuevo usuario
            $db->query(
                "INSERT INTO users (username, email, password, full_name) VALUES (?, ?, ?, ?)",
                [$username, $email, $hashedPassword, $full_name]
            );
            
            $success = '¡Registro exitoso! Ahora puedes iniciar sesión.';
            
            // Limpiar campos
            $_POST = [];
        }
    }
}
?>
<!DOCTYPE html>
<html lang="es">
<head>
    <meta charset="UTF-8">
    <meta name="viewport" content="width=device-width, initial-scale=1.0">
    <title>Registro - <?= SITE_NAME ?></title>
    <link rel="stylesheet" href="assets/css/style.css">
</head>
<body>
    <div class="container">
        <div class="register-box">
            <h1>📝 Crear Cuenta</h1>
            
            <?php if ($error): ?>
                <div class="alert alert-error"><?= $error ?></div>
            <?php endif; ?>
            
            <?php if ($success): ?>
                <div class="alert alert-success"><?= $success ?></div>
            <?php endif; ?>
            
            <form method="POST" action="">
                <div class="form-group">
                    <label for="full_name">Nombre Completo</label>
                    <input type="text" id="full_name" name="full_name" required 
                           value="<?= $_POST['full_name'] ?? '' ?>"
                           placeholder="Tu nombre completo">
                </div>
                
                <div class="form-group">
                    <label for="username">Nombre de Usuario</label>
                    <input type="text" id="username" name="username" required 
                           value="<?= $_POST['username'] ?? '' ?>"
                           placeholder="Elige un nombre de usuario">
                </div>
                
                <div class="form-group">
                    <label for="email">Email</label>
                    <input type="email" id="email" name="email" required 
                           value="<?= $_POST['email'] ?? '' ?>"
                           placeholder="tu@email.com">
                </div>
                
                <div class="form-group">
                    <label for="password">Contraseña</label>
                    <input type="password" id="password" name="password" required 
                           placeholder="Mínimo 6 caracteres">
                </div>
                
                <div class="form-group">
                    <label for="confirm_password">Confirmar Contraseña</label>
                    <input type="password" id="confirm_password" name="confirm_password" required 
                           placeholder="Repite tu contraseña">
                </div>
                
                <button type="submit" class="btn btn-primary">Registrarse</button>
            </form>
            
            <p class="text-center">
                ¿Ya tienes cuenta? <a href="login.php">Inicia sesión</a>
            </p>
        </div>
    </div>
</body>
</html>

3.3 Cerrar Sesión

php
<!-- logout.php -->
<?php
require_once 'includes/config.php';
require_once 'includes/functions.php';

// Actualizar estado a offline
if (isLoggedIn()) {
    updateUserStatus($_SESSION['user_id'], 'offline');
}

// Destruir la sesión
$_SESSION = [];
session_destroy();
session_write_close();

// Redirigir al login
redirect('login.php');
?>

📡 Paso 4: Servidor WebSocket con Node.js

4.1 Instalar Dependencias

bash
cd websocket
npm init -y
npm install ws

4.2 Servidor WebSocket

javascript
// websocket/server.js
const WebSocket = require('ws');
const http = require('http');

// Configuración
const WS_PORT = 8080;
const clients = new Map(); // userId -> { ws, username }

// Crear servidor WebSocket
const wss = new WebSocket.Server({ port: WS_PORT });

console.log(`🟢 Servidor WebSocket corriendo en puerto ${WS_PORT}`);

// Manejar conexiones
wss.on('connection', (ws, req) => {
    console.log('🔗 Nueva conexión WebSocket');
    
    let userId = null;
    let username = null;
    
    // Manejar mensajes
    ws.on('message', (message) => {
        try {
            const data = JSON.parse(message);
            console.log(`📨 Mensaje recibido de ${username || 'desconocido'}:`, data.type);
            
            switch (data.type) {
                case 'auth':
                    // Autenticar usuario
                    userId = data.userId;
                    username = data.username;
                    clients.set(userId, { ws, username });
                    
                    // Notificar a todos los usuarios sobre el nuevo usuario
                    broadcastOnlineUsers();
                    
                    // Responder confirmación
                    ws.send(JSON.stringify({
                        type: 'auth_success',
                        userId: userId
                    }));
                    break;
                    
                case 'call_request':
                    // Usuario A quiere llamar a Usuario B
                    const receiverId = data.receiverId;
                    const callerId = data.callerId;
                    const callerName = data.callerName;
                    
                    // Verificar si el receptor está en línea
                    if (clients.has(receiverId)) {
                        const receiver = clients.get(receiverId);
                        
                        // Verificar si el receptor está ocupado
                        if (receiver.ws._isBusy) {
                            // Notificar al emisor que el receptor está ocupado
                            ws.send(JSON.stringify({
                                type: 'call_busy',
                                receiverId: receiverId
                            }));
                        } else {
                            // Enviar notificación de llamada al receptor
                            receiver.ws.send(JSON.stringify({
                                type: 'incoming_call',
                                callerId: callerId,
                                callerName: callerName,
                                offer: data.offer,
                                roomId: data.roomId
                            }));
                            
                            // Marcar al emisor como ocupado
                            ws._isBusy = true;
                        }
                    } else {
                        // Receptor no está en línea
                        ws.send(JSON.stringify({
                            type: 'user_offline',
                            receiverId: receiverId
                        }));
                    }
                    break;
                    
                case 'call_accepted':
                    // Receptor aceptó la llamada
                    const callerWs = clients.get(data.callerId);
                    if (callerWs) {
                        callerWs.ws.send(JSON.stringify({
                            type: 'call_connected',
                            receiverId: data.receiverId,
                            answer: data.answer,
                            roomId: data.roomId
                        }));
                        // Marcar al receptor como ocupado
                        ws._isBusy = true;
                    }
                    break;
                    
                case 'call_rejected':
                    // Receptor rechazó la llamada
                    const callerWs2 = clients.get(data.callerId);
                    if (callerWs2) {
                        callerWs2.ws.send(JSON.stringify({
                            type: 'call_rejected',
                            receiverId: data.receiverId
                        }));
                        // Marcar al emisor como no ocupado
                        callerWs2.ws._isBusy = false;
                    }
                    break;
                    
                case 'call_ended':
                    // Finalizar llamada
                    const userId1 = data.userId;
                    const userId2 = data.otherUserId;
                    
                    // Liberar a ambos usuarios
                    if (clients.has(userId1)) {
                        clients.get(userId1).ws._isBusy = false;
                    }
                    if (clients.has(userId2)) {
                        clients.get(userId2).ws._isBusy = false;
                    }
                    
                    // Notificar al otro usuario
                    if (clients.has(userId2)) {
                        clients.get(userId2).ws.send(JSON.stringify({
                            type: 'call_ended_by_other',
                            userId: userId1
                        }));
                    }
                    break;
                    
                case 'webrtc_signal':
                    // Reenviar señales WebRTC (ICE candidates)
                    const targetId = data.targetId;
                    if (clients.has(targetId)) {
                        clients.get(targetId).ws.send(JSON.stringify({
                            type: 'webrtc_signal',
                            fromId: data.fromId,
                            candidate: data.candidate
                        }));
                    }
                    break;
                    
                case 'logout':
                    // Usuario cerró sesión
                    if (userId && clients.has(userId)) {
                        clients.delete(userId);
                        broadcastOnlineUsers();
                    }
                    break;
            }
        } catch (error) {
            console.error('❌ Error procesando mensaje:', error);
        }
    });
    
    // Manejar desconexión
    ws.on('close', () => {
        console.log(`🔌 Usuario desconectado: ${username || 'desconocido'}`);
        
        if (userId && clients.has(userId)) {
            clients.delete(userId);
            broadcastOnlineUsers();
        }
    });
    
    // Manejar errores
    ws.on('error', (error) => {
        console.error('❌ Error en WebSocket:', error);
    });
});

// Función para transmitir usuarios en línea
function broadcastOnlineUsers() {
    const onlineUsers = Array.from(clients.entries()).map(([id, data]) => ({
        id: parseInt(id),
        username: data.username
    }));
    
    const message = JSON.stringify({
        type: 'online_users',
        users: onlineUsers
    });
    
    clients.forEach((client) => {
        if (client.ws.readyState === WebSocket.OPEN) {
            client.ws.send(message);
        }
    });
}

4.3 Iniciar el Servidor WebSocket

bash
# En la carpeta websocket
node server.js

🎨 Paso 5: Frontend y JavaScript

5.1 Estilos CSS

css
/* assets/css/style.css */
* {
    margin: 0;
    padding: 0;
    box-sizing: border-box;
}

body {
    font-family: 'Segoe UI', Tahoma, Geneva, Verdana, sans-serif;
    background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
    min-height: 100vh;
}

.container {
    max-width: 1200px;
    margin: 0 auto;
    padding: 20px;
}

/* ===== Login / Register ===== */
.login-box, .register-box {
    max-width: 400px;
    margin: 50px auto;
    background: white;
    padding: 40px;
    border-radius: 10px;
    box-shadow: 0 10px 40px rgba(0,0,0,0.2);
}

.login-box h1, .register-box h1 {
    text-align: center;
    color: #667eea;
    margin-bottom: 10px;
}

.login-box h2 {
    text-align: center;
    color: #333;
    margin-bottom: 30px;
    font-weight: 300;
}

.form-group {
    margin-bottom: 20px;
}

.form-group label {
    display: block;
    margin-bottom: 5px;
    color: #555;
    font-weight: 500;
}

.form-group input {
    width: 100%;
    padding: 12px;
    border: 1px solid #ddd;
    border-radius: 5px;
    font-size: 16px;
    transition: border-color 0.3s;
}

.form-group input:focus {
    outline: none;
    border-color: #667eea;
}

.btn {
    width: 100%;
    padding: 12px;
    border: none;
    border-radius: 5px;
    font-size: 16px;
    font-weight: 600;
    cursor: pointer;
    transition: all 0.3s;
}

.btn-primary {
    background: #667eea;
    color: white;
}

.btn-primary:hover {
    background: #5a67d8;
    transform: translateY(-2px);
    box-shadow: 0 5px 15px rgba(102, 126, 234, 0.4);
}

.alert {
    padding: 12px;
    border-radius: 5px;
    margin-bottom: 20px;
}

.alert-error {
    background: #fee;
    color: #c33;
    border: 1px solid #fcc;
}

.alert-success {
    background: #efe;
    color: #3c3;
    border: 1px solid #cfc;
}

.text-center {
    text-align: center;
    margin-top: 20px;
}

.text-center a {
    color: #667eea;
    text-decoration: none;
    font-weight: 500;
}

.demo-accounts {
    margin-top: 30px;
    padding: 15px;
    background: #f8f9fa;
    border-radius: 5px;
}

/* ===== Home / User List ===== */
.header {
    background: white;
    padding: 15px 30px;
    border-radius: 10px;
    display: flex;
    justify-content: space-between;
    align-items: center;
    margin-bottom: 30px;
    box-shadow: 0 2px 10px rgba(0,0,0,0.1);
}

.header h1 {
    color: #667eea;
}

.user-info {
    display: flex;
    align-items: center;
    gap: 15px;
}

.user-avatar {
    width: 40px;
    height: 40px;
    border-radius: 50%;
    object-fit: cover;
}

.btn-logout {
    background: #e53e3e;
    color: white;
    padding: 8px 20px;
    border: none;
    border-radius: 5px;
    cursor: pointer;
    font-weight: 600;
    transition: background 0.3s;
}

.btn-logout:hover {
    background: #c53030;
}

.user-grid {
    display: grid;
    grid-template-columns: repeat(auto-fill, minmax(250px, 1fr));
    gap: 20px;
}

.user-card {
    background: white;
    padding: 20px;
    border-radius: 10px;
    text-align: center;
    box-shadow: 0 2px 10px rgba(0,0,0,0.1);
    transition: transform 0.3s, box-shadow 0.3s;
    cursor: pointer;
}

.user-card:hover {
    transform: translateY(-5px);
    box-shadow: 0 5px 20px rgba(0,0,0,0.2);
}

.user-card .avatar {
    width: 80px;
    height: 80px;
    border-radius: 50%;
    object-fit: cover;
    margin-bottom: 10px;
}

.user-card .status {
    display: inline-block;
    width: 12px;
    height: 12px;
    border-radius: 50%;
    margin-right: 5px;
}

.status-online { background: #48bb78; }
.status-offline { background: #a0aec0; }
.status-busy { background: #ed8936; }

/* ===== Profile Page ===== */
.profile-container {
    background: white;
    border-radius: 10px;
    padding: 30px;
    max-width: 600px;
    margin: 0 auto;
    box-shadow: 0 2px 10px rgba(0,0,0,0.1);
}

.profile-header {
    text-align: center;
    margin-bottom: 30px;
}

.profile-header .avatar {
    width: 120px;
    height: 120px;
    border-radius: 50%;
    object-fit: cover;
    border: 4px solid #667eea;
}

.profile-header h2 {
    margin-top: 15px;
    color: #333;
}

.profile-header .status-text {
    color: #666;
}

.btn-call {
    background: #48bb78;
    color: white;
    padding: 12px 40px;
    border: none;
    border-radius: 50px;
    font-size: 18px;
    font-weight: 600;
    cursor: pointer;
    transition: all 0.3s;
}

.btn-call:hover {
    background: #38a169;
    transform: scale(1.05);
}

/* ===== Call Popup ===== */
.call-popup {
    position: fixed;
    bottom: 30px;
    right: 30px;
    background: white;
    border-radius: 15px;
    padding: 20px;
    box-shadow: 0 10px 40px rgba(0,0,0,0.3);
    max-width: 350px;
    z-index: 1000;
    display: none;
    animation: slideUp 0.3s ease;
}

.call-popup.active {
    display: block;
}

@keyframes slideUp {
    from { opacity: 0; transform: translateY(20px); }
    to { opacity: 1; transform: translateY(0); }
}

.call-popup .caller-info {
    display: flex;
    align-items: center;
    gap: 15px;
    margin-bottom: 15px;
}

.call-popup .caller-info .avatar {
    width: 50px;
    height: 50px;
    border-radius: 50%;
    object-fit: cover;
}

.call-popup .caller-info .caller-name {
    font-weight: 600;
    color: #333;
}

.call-popup .caller-info .caller-status {
    color: #666;
    font-size: 14px;
}

.call-actions {
    display: flex;
    gap: 10px;
}

.call-actions button {
    flex: 1;
    padding: 10px;
    border: none;
    border-radius: 8px;
    font-weight: 600;
    cursor: pointer;
    transition: all 0.3s;
}

.btn-answer {
    background: #48bb78;
    color: white;
}

.btn-answer:hover {
    background: #38a169;
}

.btn-reject {
    background: #fc8181;
    color: white;
}

.btn-reject:hover {
    background: #e53e3e;
}

/* ===== Video Call ===== */
.video-container {
    position: fixed;
    top: 0;
    left: 0;
    width: 100%;
    height: 100%;
    background: rgba(0,0,0,0.9);
    z-index: 2000;
    display: none;
    justify-content: center;
    align-items: center;
}

.video-container.active {
    display: flex;
}

.video-wrapper {
    position: relative;
    width: 100%;
    max-width: 1200px;
    height: 90vh;
    display: flex;
    gap: 20px;
    padding: 20px;
}

.video-wrapper video {
    flex: 1;
    background: #1a202c;
    border-radius: 10px;
    object-fit: cover;
}

#remoteVideo {
    flex: 2;
}

.video-controls {
    position: absolute;
    bottom: 40px;
    left: 50%;
    transform: translateX(-50%);
    display: flex;
    gap: 20px;
}

.video-controls button {
    width: 60px;
    height: 60px;
    border-radius: 50%;
    border: none;
    font-size: 24px;
    cursor: pointer;
    transition: all 0.3s;
    background: white;
}

.video-controls button:hover {
    transform: scale(1.1);
}

.btn-hangup {
    background: #e53e3e !important;
    color: white !important;
}

.btn-hangup:hover {
    background: #c53030 !important;
}

/* ===== Responsive ===== */
@media (max-width: 768px) {
    .video-wrapper {
        flex-direction: column;
        height: 100vh;
        padding: 10px;
    }
    
    .video-wrapper video {
        max-height: 50vh;
    }
    
    #remoteVideo {
        flex: 1;
    }
    
    .user-grid {
        grid-template-columns: 1fr;
    }
    
    .header {
        flex-direction: column;
        gap: 10px;
    }
}

5.2 JavaScript Principal

javascript
// assets/js/app.js
class VideoChatApp {
    constructor() {
        this.ws = null;
        this.currentUser = null;
        this.isLoggedIn = false;
        this.isInCall = false;
        this.peerConnection = null;
        this.localStream = null;
        this.remoteStream = null;
        this.roomId = null;
        this.targetUserId = null;
        this.callTimeout = null;
        
        this.init();
    }
    
    init() {
        // Obtener datos del usuario desde PHP
        this.currentUser = window.currentUser || null;
        
        if (this.currentUser) {
            this.isLoggedIn = true;
            this.connectWebSocket();
            this.loadUsers();
            this.setupEventListeners();
        }
    }
    
    // ===== WebSocket =====
    connectWebSocket() {
        const wsUrl = `ws://${window.location.hostname}:8080`;
        this.ws = new WebSocket(wsUrl);
        
        this.ws.onopen = () => {
            console.log('🔗 Conectado al servidor WebSocket');
            // Autenticar
            this.ws.send(JSON.stringify({
                type: 'auth',
                userId: this.currentUser.id,
                username: this.currentUser.username

Comentarios