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,mbstringMySQL 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
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
-- 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
<!-- 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
<!-- 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
<!-- 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
<!-- 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
<!-- 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
<!-- 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
cd websocket npm init -y npm install ws
4.2 Servidor WebSocket
// 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
# En la carpeta websocket node server.js
🎨 Paso 5: Frontend y JavaScript
5.1 Estilos 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
// 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
Publicar un comentario