From 20b2a9ad9c6953e2968c5aad24dbe203020a6755 Mon Sep 17 00:00:00 2001 From: ale Date: Mon, 24 Nov 2025 00:34:30 +0100 Subject: [PATCH] initial commit Signed-off-by: ale --- .dockerignore | 12 + .gitignore | 41 +++ Dockerfile | 49 +++ LICENSE | 21 ++ README.md | 599 ++++++++++++++++++++++++++++++++++ docker-compose.yml | 18 + eslint.config.mjs | 25 ++ jsconfig.json | 7 + next.config.mjs | 4 + nginx.conf | 93 ++++++ package.json | 29 ++ postcss.config.mjs | 5 + public/file.svg | 1 + public/globe.svg | 1 + public/next.svg | 1 + public/vercel.svg | 1 + public/window.svg | 1 + server.js | 498 ++++++++++++++++++++++++++++ src/app/favicon.ico | Bin 0 -> 25931 bytes src/app/globals.css | 26 ++ src/app/layout.js | 43 +++ src/app/page.js | 362 ++++++++++++++++++++ src/components/Chat.js | 354 ++++++++++++++++++++ src/components/P2PManager.js | 431 ++++++++++++++++++++++++ src/components/VideoPlayer.js | 197 +++++++++++ 25 files changed, 2819 insertions(+) create mode 100644 .dockerignore create mode 100644 .gitignore create mode 100644 Dockerfile create mode 100644 LICENSE create mode 100644 README.md create mode 100644 docker-compose.yml create mode 100644 eslint.config.mjs create mode 100644 jsconfig.json create mode 100644 next.config.mjs create mode 100644 nginx.conf create mode 100644 package.json create mode 100644 postcss.config.mjs create mode 100644 public/file.svg create mode 100644 public/globe.svg create mode 100644 public/next.svg create mode 100644 public/vercel.svg create mode 100644 public/window.svg create mode 100644 server.js create mode 100644 src/app/favicon.ico create mode 100644 src/app/globals.css create mode 100644 src/app/layout.js create mode 100644 src/app/page.js create mode 100644 src/components/Chat.js create mode 100644 src/components/P2PManager.js create mode 100644 src/components/VideoPlayer.js diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..7dfe30e --- /dev/null +++ b/.dockerignore @@ -0,0 +1,12 @@ +node_modules +.next +.git +.gitignore +README.md +*.md +.env*.local +npm-debug.log* +yarn-debug.log* +yarn-error.log* +.DS_Store +*.pem diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..5ef6a52 --- /dev/null +++ b/.gitignore @@ -0,0 +1,41 @@ +# See https://help.github.com/articles/ignoring-files/ for more about ignoring files. + +# dependencies +/node_modules +/.pnp +.pnp.* +.yarn/* +!.yarn/patches +!.yarn/plugins +!.yarn/releases +!.yarn/versions + +# testing +/coverage + +# next.js +/.next/ +/out/ + +# production +/build + +# misc +.DS_Store +*.pem + +# debug +npm-debug.log* +yarn-debug.log* +yarn-error.log* +.pnpm-debug.log* + +# env files (can opt-in for committing if needed) +.env* + +# vercel +.vercel + +# typescript +*.tsbuildinfo +next-env.d.ts diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..7b9125f --- /dev/null +++ b/Dockerfile @@ -0,0 +1,49 @@ +# Dockerfile para p2p-media-next con servidor custom +FROM node:20-alpine AS base + +# Instalar dependencias solo cuando sea necesario +FROM base AS deps +RUN apk add --no-cache libc6-compat +WORKDIR /app + +# Copiar archivos de dependencias +COPY package.json package-lock.json* ./ +RUN npm ci + +# Reconstruir el código fuente solo cuando sea necesario +FROM base AS builder +WORKDIR /app +COPY --from=deps /app/node_modules ./node_modules +COPY . . + +# Deshabilitar telemetría de Next.js durante el build +ENV NEXT_TELEMETRY_DISABLED=1 + +RUN npm run build + +# Imagen de producción +FROM base AS runner +WORKDIR /app + +ENV NODE_ENV=production +ENV NEXT_TELEMETRY_DISABLED=1 + +RUN addgroup --system --gid 1001 nodejs +RUN adduser --system --uid 1001 nextjs + +# Copiar archivos necesarios +COPY --from=builder /app/node_modules ./node_modules +COPY --from=builder /app/.next ./.next +COPY --from=builder /app/public ./public +COPY --from=builder /app/package.json ./package.json +COPY --from=builder /app/server.js ./server.js + +RUN chown -R nextjs:nodejs /app + +USER nextjs + +EXPOSE 3000 + +ENV PORT=3000 + +CMD ["node", "server.js"] diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..8b0365b --- /dev/null +++ b/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2025 ale + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/README.md b/README.md new file mode 100644 index 0000000..9b988c1 --- /dev/null +++ b/README.md @@ -0,0 +1,599 @@ +# 📺 P2P Media Streaming Platform + +![Next.js](https://img.shields.io/badge/Next.js-15.5.5-black) +![React](https://img.shields.io/badge/React-19.1.0-blue) +![Socket.IO](https://img.shields.io/badge/Socket.IO-4.8.1-green) +![WebRTC](https://img.shields.io/badge/WebRTC-SimplePeer-orange) +![License](https://img.shields.io/badge/license-MIT-blue.svg) + +> Plataforma moderna de streaming de video peer-to-peer con compartición de pantalla en tiempo real, chat multiusuario y visualización de streams remotos. + +**Desarrollada con:** Next.js 15 • React 19 • WebRTC • Socket.IO • HLS.js • Tailwind CSS + +--- + +## ✨ Características Principales + +### 🎥 Streaming en Tiempo Real +- **Compartición de Pantalla P2P**: Los usuarios pueden transmitir lo que están viendo en su reproductor +- **Miniaturas en Vivo**: Previsualizaciones de video actualizadas cada 2 segundos con hover preview +- **Visualización Remota**: Haz click en cualquier usuario para ver su reproductor en tiempo real +- **Streaming bajo Demanda**: WebRTC se activa solo cuando alguien quiere ver tu contenido + +### 💬 Chat en Tiempo Real +- Sistema de chat multiusuario con Socket.IO +- Lista de usuarios conectados con indicadores visuales +- Notificaciones de entrada/salida de usuarios +- Limitación de mensajes para prevenir spam + +### 🔒 Seguridad y Protección +- **Rate Limiting**: 30 mensajes por minuto por IP +- **Límite de Conexiones**: Máximo 5 conexiones simultáneas por IP +- **Validación de Datos**: Sanitización automática de mensajes y nombres +- **CORS Configurado**: Seguridad en comunicaciones cross-origin + +### 🌐 Proxy de Streams +- Endpoints integrados para streams RTVE (La 1, La 2, 24H) +- Proxy personalizado para URLs externas +- Manejo automático de CORS y redirecciones +- Soporte para streams HLS + +### 📊 Estadísticas en Tiempo Real +- Monitoreo de transferencias HTTP y P2P +- Velocidades de subida/descarga +- Contador de peers conectados +- Estadísticas por usuario + +--- + +## 🚀 Inicio Rápido + +### Requisitos Previos + +- **Node.js** >= 18.0.0 +- **npm** o **yarn** + +### Instalación + +```bash +# Clonar el repositorio +git clone https://github.com/tu-usuario/p2p-media-next.git +cd p2p-media-next + +# Instalar dependencias +npm install +``` + +### Desarrollo + +```bash +# Iniciar servidor de desarrollo +npm run dev +``` + +La aplicación estará disponible en **http://localhost:3000** + +### Producción + +```bash +# Construir la aplicación +npm run build + +# Iniciar en modo producción +npm start +``` + +### Docker (Opcional) + +```bash +# Construir imagen +docker build -t p2p-media . + +# Ejecutar contenedor +docker run -p 3000:3000 p2p-media +``` + +O usando Docker Compose: + +```bash +docker-compose up -d +``` + +--- + +## 🎯 Cómo Usar + +### 1. Conectarse al Chat + +1. Abre la aplicación en tu navegador +2. Ingresa un nombre de usuario (mínimo 2 caracteres) +3. Haz click en "Unirse" + +### 2. Reproducir un Video + +- Selecciona uno de los canales predefinidos (RTVE La 1, La 2, 24H) +- O ingresa una URL personalizada de stream HLS +- El video comenzará a reproducirse automáticamente + +### 3. Compartir tu Pantalla + +- Tu reproductor se comparte automáticamente +- Los demás usuarios verán una miniatura de tu video +- Un indicador 🔴 aparecerá junto a tu nombre en el chat + +### 4. Ver el Stream de Otro Usuario + +1. Pasa el mouse sobre un usuario en el chat +2. Verás una miniatura de lo que está viendo +3. Haz click en el usuario para cargar su stream en tu reproductor +4. El video se transmitirá directamente vía WebRTC (P2P) + +### 5. Volver a tu Video + +- Haz click en el botón "✕ Cerrar" en el banner morado +- Volverás a tu reproductor local + +--- + +## 🏗️ Arquitectura + +### Tecnologías Clave + +| Tecnología | Propósito | +|------------|-----------| +| **Next.js 15** | Framework React con SSR y App Router | +| **Socket.IO** | Comunicación bidireccional en tiempo real | +| **SimplePeer** | Abstracción de WebRTC para conexiones P2P | +| **HLS.js** | Reproducción de streams HLS en el navegador | +| **Tailwind CSS** | Framework de diseño utility-first | + +### Flujo de Datos + +``` +┌─────────────────┐ +│ Usuario A │ +│ (Broadcaster) │ +└────────┬────────┘ + │ + │ 1. Reproduce video + │ 2. Genera thumbnails cada 2s + │ 3. Envía thumbnails vía Socket.IO + │ + ▼ +┌─────────────────┐ +│ Servidor │ +│ Socket.IO │ +└────────┬────────┘ + │ + │ 4. Broadcast thumbnails + │ + ▼ +┌─────────────────┐ +│ Usuario B │ +│ (Viewer) │ +│ │ +│ 5. Ve thumbnail │ +│ 6. Click "ver" │◄──────────┐ +└────────┬────────┘ │ + │ │ + │ 7. request-peer │ + ▼ │ +┌─────────────────┐ │ +│ Servidor │ │ +└────────┬────────┘ │ + │ │ + │ 8. peer-requested │ + ▼ │ +┌─────────────────┐ │ +│ Usuario A │ │ +│ │ │ +│ 9. Captura │ │ +│ stream │ │ +│ 10. Inicia peer │ │ +└────────┬────────┘ │ + │ │ + │ │ + │ WebRTC P2P │ + │ (Directo) │ + └─────────────────────┘ + 11. Stream fluye + de A → B +``` + +### Componentes Principales + +#### `server.js` +Servidor Node.js personalizado con: +- Socket.IO para comunicación en tiempo real +- Proxy de streams HLS con manejo de CORS +- Rate limiting y validación de seguridad +- Gestión de usuarios y sesiones + +#### `VideoPlayer.js` +Reproductor de video inteligente: +- Reproducción de streams HLS con HLS.js +- Captura de thumbnails usando Canvas API +- Soporte para streams remotos vía WebRTC +- Detección automática de capacidades del navegador + +#### `Chat.js` +Sistema de chat multiusuario: +- Interfaz de mensajería en tiempo real +- Lista de usuarios con thumbnails en hover +- Indicadores visuales de estado +- Sistema de notificaciones + +#### `P2PManager.js` +Gestor de conexiones WebRTC: +- Inicialización de peers con SimplePeer +- Señalización a través de Socket.IO +- Gestión de streams de audio/video +- Estadísticas de transferencia P2P +- Optimización: conexiones bajo demanda + +--- + +## 🔧 Configuración + +### Variables de Entorno + +Crea un archivo `.env.local`: + +```env +# Puerto del servidor +PORT=3000 + +# Modo de ejecución +NODE_ENV=production + +# URL base (opcional) +NEXT_PUBLIC_BASE_URL=https://tu-dominio.com +``` + +### Servidor STUN/TURN + +Por defecto, el proyecto usa: +- **STUN**: `stun:manalejandro.com:3478` +- **Fallback**: Google STUN servers + +Para configurar tu propio servidor, edita `src/components/P2PManager.js`: + +```javascript +const ICE_SERVERS = { + iceServers: [ + { + urls: 'stun:tu-servidor.com:3478' + }, + { + urls: 'turn:tu-servidor.com:3478', + username: 'usuario', + credential: 'contraseña' + } + ] +}; +``` + +### Rate Limits + +Ajusta los límites en `server.js`: + +```javascript +const MAX_MESSAGES_PER_MINUTE = 30; // Mensajes por minuto +const MAX_CONNECTIONS_PER_IP = 5; // Conexiones simultáneas por IP +``` + +--- + +## 📁 Estructura del Proyecto + +``` +p2p-media-next/ +├── public/ # Archivos estáticos +├── src/ +│ ├── app/ +│ │ ├── layout.js # Layout principal de la app +│ │ ├── page.js # Página principal con lógica +│ │ └── globals.css # Estilos globales +│ └── components/ +│ ├── VideoPlayer.js # Reproductor HLS + WebRTC +│ ├── Chat.js # Chat en tiempo real +│ └── P2PManager.js # Gestor de conexiones P2P +├── server.js # Servidor Node.js personalizado +├── docker-compose.yml # Configuración Docker +├── Dockerfile # Imagen Docker +├── nginx.conf # Configuración Nginx (opcional) +├── package.json # Dependencias y scripts +├── next.config.mjs # Configuración Next.js +├── tailwind.config.mjs # Configuración Tailwind +└── README.md # Este archivo +``` + +--- + +## 🔌 API + +### Socket.IO Events + +#### Eventos del Cliente → Servidor + +| Evento | Parámetros | Descripción | +|--------|-----------|-------------| +| `register` | `{ user: string }` | Registrar usuario en el chat | +| `emit msg` | `{ user: string, chat: string }` | Enviar mensaje al chat | +| `video-thumbnail` | `{ thumbnail: string, isPlaying: boolean }` | Enviar thumbnail del video | +| `request-peer` | `{ to: string }` | Solicitar conexión P2P | +| `signal` | `{ to: string, signal: object }` | Enviar señal WebRTC | + +#### Eventos del Servidor → Cliente + +| Evento | Datos | Descripción | +|--------|-------|-------------| +| `users` | `{ users: string[] }` | Lista de usuarios conectados | +| `adduser` | `{ user: string }` | Nuevo usuario conectado | +| `join` | `{ user: string }` | Usuario se unió | +| `msg` | `{ user: string, chat: string, timestamp: number }` | Mensaje recibido | +| `user-thumbnail` | `{ user: string, thumbnail: string, isPlaying: boolean }` | Thumbnail de usuario | +| `peer-requested` | `{ from: string }` | Solicitud de conexión P2P | +| `signal` | `{ from: string, signal: object }` | Señal WebRTC recibida | +| `quit` | `{ msg: string }` | Usuario desconectado | +| `error` | `string` | Error del servidor | + +### HTTP Endpoints + +#### Streams Predefinidos + +``` +GET /api/stream/rtve-la1 +GET /api/stream/rtve-la2 +GET /api/stream/rtve-24h +``` + +Devuelve el stream HLS proxeado con headers CORS configurados. + +#### Proxy Personalizado + +``` +GET /api/proxy?url={encoded_url} +``` + +**Parámetros:** +- `url` (string, required): URL del stream codificada con `encodeURIComponent` + +**Ejemplo:** +```javascript +const streamUrl = 'https://example.com/stream.m3u8'; +const proxyUrl = `/api/proxy?url=${encodeURIComponent(streamUrl)}`; +``` + +--- + +## 🐛 Solución de Problemas + +### El video no se reproduce + +**Problema**: La pantalla se queda en negro o aparece un error. + +**Soluciones:** +1. Verifica que la URL del stream sea válida y accesible +2. Comprueba la consola del navegador (F12) para errores específicos +3. Asegúrate de que el navegador soporte HLS (Chrome, Firefox, Edge) +4. Intenta con otro stream de ejemplo + +### No se establece conexión P2P + +**Problema**: Al hacer click en un usuario, se queda en "Conectando..." + +**Soluciones:** +1. Verifica que ambos usuarios estén conectados al chat +2. Comprueba que el servidor STUN esté accesible +3. Revisa la consola para errores de WebRTC +4. Asegúrate de que no haya firewall bloqueando conexiones UDP +5. Verifica que el navegador tenga permisos de red + +### No aparecen thumbnails + +**Problema**: Los usuarios no muestran el indicador 🔴 ni thumbnails. + +**Soluciones:** +1. Verifica que el video esté reproduciéndose +2. Comprueba que el video tenga `crossOrigin="anonymous"` +3. Asegúrate de que el stream permita captura (algunos DRM protegidos no lo permiten) +4. Revisa la consola para errores de Canvas/CORS + +### Problemas de chat + +**Problema**: Los mensajes no llegan o aparece error de rate limit. + +**Soluciones:** +1. Verifica la conexión a Socket.IO (debe mostrar "Conectado" en verde) +2. No envíes más de 30 mensajes por minuto +3. Recarga la página si la conexión se perdió +4. Comprueba que el servidor esté ejecutándose + +### El componente se re-monta constantemente + +**Problema**: Logs de "Limpiando listeners" / "Registrando listeners" repetidos. + +**Solución:** +- Ya está solucionado con `useCallback` en todas las funciones callback +- Si el problema persiste, verifica que no haya cambios innecesarios en las props + +--- + +## 📊 Rendimiento + +### Métricas de Referencia + +| Métrica | Valor | +|---------|-------| +| Tiempo de carga inicial | < 2s | +| Latencia de chat | < 100ms | +| Tiempo de conexión WebRTC | 2-5s | +| Overhead de CPU (P2P activo) | 10-20% | +| Uso de memoria | ~80-150 MB | +| Ancho de banda (streaming) | Variable según calidad | + +### Optimizaciones Implementadas + +- ✅ **Lazy Loading**: Componentes cargados bajo demanda +- ✅ **useCallback**: Prevención de re-renders innecesarios +- ✅ **Conexiones bajo demanda**: WebRTC solo cuando es necesario +- ✅ **Thumbnails optimizados**: 160x90px, JPEG 50% calidad +- ✅ **Limpieza de listeners**: Prevención de memory leaks +- ✅ **Rate limiting**: Protección contra sobrecarga + +--- + +## 🚢 Despliegue + +### Vercel (Recomendado) + +1. Haz fork del repositorio en GitHub +2. Conecta tu cuenta de Vercel +3. Importa el proyecto +4. Configura las variables de entorno si es necesario +5. Despliega + +### Docker + +```bash +# Construir +docker build -t p2p-media-streaming . + +# Ejecutar +docker run -d -p 3000:3000 --name p2p-media p2p-media-streaming +``` + +### VPS / Servidor Dedicado + +```bash +# Clonar repositorio +git clone https://github.com/tu-usuario/p2p-media-next.git +cd p2p-media-next + +# Instalar dependencias +npm install + +# Construir +npm run build + +# Iniciar con PM2 (recomendado) +pm2 start npm --name "p2p-media" -- start +pm2 save +pm2 startup +``` + +--- + +## 🤝 Contribuir + +Las contribuciones son bienvenidas. Para contribuir: + +1. **Fork** el proyecto +2. Crea una **rama** para tu feature: + ```bash + git checkout -b feature/nueva-funcionalidad + ``` +3. **Commit** tus cambios: + ```bash + git commit -m 'Añadir nueva funcionalidad' + ``` +4. **Push** a la rama: + ```bash + git push origin feature/nueva-funcionalidad + ``` +5. Abre un **Pull Request** + +### Guía de Estilo + +- Usa nombres descriptivos para variables y funciones +- Comenta el código complejo +- Sigue las convenciones de React/Next.js +- Usa Tailwind CSS para estilos +- Añade logs descriptivos con emojis para debugging + +--- + +## 📋 Roadmap + +### Futuras Funcionalidades + +- [ ] Streams privados con permisos +- [ ] Control de calidad de stream (alta/media/baja) +- [ ] Salas de visualización grupales +- [ ] Chat de voz integrado +- [ ] Grabación de streams +- [ ] Modo teatro/pantalla completa compartida +- [ ] Sincronización de reproducción entre usuarios +- [ ] Sistema de moderadores +- [ ] Estadísticas detalladas por usuario +- [ ] Soporte para TURN server + +--- + +## 📄 Licencia + +Este proyecto está bajo la **Licencia MIT**. Ver el archivo [LICENSE](LICENSE) para más detalles. + +``` +MIT License + +Copyright (c) 2025 ale + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. +``` + +--- + +## 👤 Autor + +**ale** + +--- + +## 🙏 Agradecimientos + +Tecnologías y librerías utilizadas: + +- [Next.js](https://nextjs.org/) - Framework React para producción +- [React](https://react.dev/) - Librería para interfaces de usuario +- [Socket.IO](https://socket.io/) - Comunicación en tiempo real +- [SimplePeer](https://github.com/feross/simple-peer) - Abstracción de WebRTC +- [HLS.js](https://github.com/video-dev/hls.js/) - Reproductor HLS +- [Tailwind CSS](https://tailwindcss.com/) - Framework CSS utility-first + +--- + +## 📞 Soporte + +Si encuentras algún problema o tienes preguntas: + +1. 📖 Revisa la sección [Solución de Problemas](#-solución-de-problemas) +2. 🐛 Abre un [issue](https://github.com/tu-usuario/p2p-media-next/issues) en GitHub +3. 📧 Contacta al autor + +--- + +
+ +**¡Disfruta del streaming P2P!** 🎉 + +[⬆️ Volver arriba](#-p2p-media-streaming-platform) + +
diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..7adc543 --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,18 @@ +services: + p2p-media-next: + build: + context: . + dockerfile: Dockerfile + container_name: p2p-media-next + hostname: p2p-media-next + restart: always + ports: + - "3000:3000" + environment: + - NODE_ENV=production + networks: + - p2p-net + +networks: + p2p-net: + driver: bridge diff --git a/eslint.config.mjs b/eslint.config.mjs new file mode 100644 index 0000000..1e69deb --- /dev/null +++ b/eslint.config.mjs @@ -0,0 +1,25 @@ +import { dirname } from "path"; +import { fileURLToPath } from "url"; +import { FlatCompat } from "@eslint/eslintrc"; + +const __filename = fileURLToPath(import.meta.url); +const __dirname = dirname(__filename); + +const compat = new FlatCompat({ + baseDirectory: __dirname, +}); + +const eslintConfig = [ + ...compat.extends("next/core-web-vitals"), + { + ignores: [ + "node_modules/**", + ".next/**", + "out/**", + "build/**", + "next-env.d.ts", + ], + }, +]; + +export default eslintConfig; diff --git a/jsconfig.json b/jsconfig.json new file mode 100644 index 0000000..b8d6842 --- /dev/null +++ b/jsconfig.json @@ -0,0 +1,7 @@ +{ + "compilerOptions": { + "paths": { + "@/*": ["./src/*"] + } + } +} diff --git a/next.config.mjs b/next.config.mjs new file mode 100644 index 0000000..4678774 --- /dev/null +++ b/next.config.mjs @@ -0,0 +1,4 @@ +/** @type {import('next').NextConfig} */ +const nextConfig = {}; + +export default nextConfig; diff --git a/nginx.conf b/nginx.conf new file mode 100644 index 0000000..7882ceb --- /dev/null +++ b/nginx.conf @@ -0,0 +1,93 @@ +# Configuración de nginx para p2p.manalejandro.com + +upstream nextjs_backend { + server localhost:3000; + keepalive 64; +} + +server { + listen 80; + listen [::]:80; + server_name p2p.manalejandro.com; + + # Redirigir HTTP a HTTPS + return 301 https://$server_name$request_uri; +} + +server { + listen 443 ssl http2; + listen [::]:443 ssl http2; + server_name p2p.manalejandro.com; + + # Certificados SSL (ajusta las rutas según tu instalación de certbot) + ssl_certificate /etc/letsencrypt/live/manalejandro.com/fullchain.pem; + ssl_certificate_key /etc/letsencrypt/live/manalejandro.com/privkey.pem; + ssl_session_timeout 1d; + ssl_session_cache shared:SSL:50m; + ssl_session_tickets off; + + # Configuración SSL moderna + ssl_protocols TLSv1.2 TLSv1.3; + ssl_ciphers 'ECDHE-ECDSA-AES128-GCM-SHA256:ECDHE-RSA-AES128-GCM-SHA256:ECDHE-ECDSA-AES256-GCM-SHA384:ECDHE-RSA-AES256-GCM-SHA384'; + ssl_prefer_server_ciphers on; + + # HSTS + add_header Strict-Transport-Security "max-age=31536000; includeSubDomains" always; + + # Logs + access_log /var/log/nginx/p2p.manalejandro.com.access.log; + error_log /var/log/nginx/p2p.manalejandro.com.error.log; + + # Configuración de proxy + proxy_http_version 1.1; + proxy_set_header Upgrade $http_upgrade; + proxy_set_header Connection 'upgrade'; + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto $scheme; + proxy_set_header X-Forwarded-Host $host; + proxy_cache_bypass $http_upgrade; + proxy_read_timeout 86400; + + # Desactivar buffering para streaming + proxy_buffering off; + proxy_request_buffering off; + + # Aumentar límites para streams + client_max_body_size 100M; + proxy_max_temp_file_size 0; + + # Rutas de API (proxy endpoints) + location /api/ { + proxy_pass http://nextjs_backend; + + # Headers adicionales para streaming + proxy_set_header Range $http_range; + proxy_set_header If-Range $http_if_range; + + # No hacer cache de las peticiones de API + add_header Cache-Control "no-cache, no-store, must-revalidate"; + add_header Pragma "no-cache"; + add_header Expires "0"; + } + + # Socket.IO + location /socket.io/ { + proxy_pass http://nextjs_backend; + proxy_http_version 1.1; + proxy_set_header Upgrade $http_upgrade; + proxy_set_header Connection "upgrade"; + } + + # Next.js static files + location /_next/static/ { + proxy_pass http://nextjs_backend; + add_header Cache-Control "public, max-age=31536000, immutable"; + } + + # Todas las demás rutas + location / { + proxy_pass http://nextjs_backend; + } +} diff --git a/package.json b/package.json new file mode 100644 index 0000000..f54d971 --- /dev/null +++ b/package.json @@ -0,0 +1,29 @@ +{ + "name": "p2p-media-next", + "version": "1.0.0", + "description": "Plataforma de streaming P2P con WebRTC, Socket.IO y HLS.js", + "author": "ale", + "license": "MIT", + "scripts": { + "dev": "node server.js", + "build": "next build --turbopack", + "start": "NODE_ENV=production node server.js", + "lint": "eslint" + }, + "dependencies": { + "hls.js": "^1.6.13", + "next": "15.5.5", + "react": "19.1.0", + "react-dom": "19.1.0", + "simple-peer": "^9.11.1", + "socket.io": "^4.8.1", + "socket.io-client": "^4.8.1" + }, + "devDependencies": { + "@eslint/eslintrc": "^3", + "@tailwindcss/postcss": "^4", + "eslint": "^9", + "eslint-config-next": "15.5.5", + "tailwindcss": "^4" + } +} diff --git a/postcss.config.mjs b/postcss.config.mjs new file mode 100644 index 0000000..c7bcb4b --- /dev/null +++ b/postcss.config.mjs @@ -0,0 +1,5 @@ +const config = { + plugins: ["@tailwindcss/postcss"], +}; + +export default config; diff --git a/public/file.svg b/public/file.svg new file mode 100644 index 0000000..004145c --- /dev/null +++ b/public/file.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/public/globe.svg b/public/globe.svg new file mode 100644 index 0000000..567f17b --- /dev/null +++ b/public/globe.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/public/next.svg b/public/next.svg new file mode 100644 index 0000000..5174b28 --- /dev/null +++ b/public/next.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/public/vercel.svg b/public/vercel.svg new file mode 100644 index 0000000..7705396 --- /dev/null +++ b/public/vercel.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/public/window.svg b/public/window.svg new file mode 100644 index 0000000..b2b2a44 --- /dev/null +++ b/public/window.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/server.js b/server.js new file mode 100644 index 0000000..04285a0 --- /dev/null +++ b/server.js @@ -0,0 +1,498 @@ +const { createServer } = require('http'); +const { parse } = require('url'); +const next = require('next'); +const { Server } = require('socket.io'); +const https = require('https'); + +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(); + +// Configuración de streams disponibles +const STREAM_SOURCES = { + 'rtve-la1': 'https://ztnr.rtve.es/ztnr/1688877.m3u8', + 'rtve-la2': 'https://ztnr.rtve.es/ztnr/3987218.m3u8', + 'rtve-24h': 'https://rtvelivestream.rtve.es/rtvesec/24h/24h_main_dvr_720.m3u8' +}; + +// Rate limiting por IP +const rateLimits = new Map(); +const MAX_MESSAGES_PER_MINUTE = 30; +const MAX_CONNECTIONS_PER_IP = 5; + +function checkRateLimit(ip) { + const now = Date.now(); + const userLimit = rateLimits.get(ip) || { count: 0, resetTime: now + 60000 }; + + if (now > userLimit.resetTime) { + userLimit.count = 0; + userLimit.resetTime = now + 60000; + } + + userLimit.count++; + rateLimits.set(ip, userLimit); + + return userLimit.count <= MAX_MESSAGES_PER_MINUTE; +} + +// Validación de datos +function sanitizeMessage(msg) { + if (typeof msg !== 'string') return ''; + // Limitar longitud del mensaje + msg = msg.substring(0, 500); + // Eliminar caracteres peligrosos + return msg.replace(/[<>]/g, ''); +} + +function sanitizeUsername(username) { + if (typeof username !== 'string') return ''; + // Limitar longitud del nombre de usuario + username = username.substring(0, 30); + // Solo permitir caracteres alfanuméricos, espacios, guiones y guiones bajos + return username.replace(/[^a-zA-Z0-9 _-]/g, ''); +} + +// Función para hacer proxy de streams con soporte para redirecciones +function proxyStream(sourceUrl, req, res, redirectCount = 0) { + const MAX_REDIRECTS = 5; + + if (redirectCount > MAX_REDIRECTS) { + console.error('Demasiadas redirecciones para:', sourceUrl); + res.writeHead(500, { 'Content-Type': 'text/plain' }); + res.end('Error: Demasiadas redirecciones'); + return; + } + + const urlParts = new URL(sourceUrl); + const isHttps = urlParts.protocol === 'https:'; + const httpModule = isHttps ? https : require('http'); + + // Construir headers que parezcan de un navegador real + const headers = { + 'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36', + 'Accept': '*/*', + 'Accept-Language': 'es-ES,es;q=0.9', + 'Accept-Encoding': 'identity', // No solicitar compresión para evitar problemas + 'Connection': 'keep-alive', + 'Sec-Ch-Ua': '"Chromium";v="131", "Not_A Brand";v="24"', + 'Sec-Ch-Ua-Mobile': '?0', + 'Sec-Ch-Ua-Platform': '"Windows"', + 'Sec-Fetch-Dest': 'empty', + 'Sec-Fetch-Mode': 'cors', + 'Sec-Fetch-Site': 'cross-site', + 'DNT': '1' + }; + + // Para RTVE, agregar headers específicos y muy importante el Referer + if (urlParts.hostname.includes('rtve.es')) { + headers['Origin'] = 'https://www.rtve.es'; + headers['Referer'] = 'https://www.rtve.es/play/videos/directo/la-1/'; + } + + const options = { + hostname: urlParts.hostname, + port: isHttps ? 443 : 80, + path: urlParts.pathname + urlParts.search, + method: 'GET', + headers: headers + }; + + const proxyReq = httpModule.request(options, (proxyRes) => { + // Manejar redirecciones (301, 302, 303, 307, 308) + if (proxyRes.statusCode >= 300 && proxyRes.statusCode < 400 && proxyRes.headers.location) { + const redirectUrl = new URL(proxyRes.headers.location, sourceUrl).href; + + // Consumir completamente la respuesta antes de redirigir + proxyRes.resume(); + + // Llamar recursivamente para seguir la redirección + proxyStream(redirectUrl, req, res, redirectCount + 1); + return; + } + + // Si no es un 200, devolver error + if (proxyRes.statusCode !== 200) { + console.error(`Error de stream: ${proxyRes.statusCode} para ${sourceUrl}`); + res.writeHead(proxyRes.statusCode, { + 'Content-Type': 'text/plain', + 'Access-Control-Allow-Origin': '*' + }); + res.end(`Error: Stream devolvió código ${proxyRes.statusCode}`); + return; + } + + // Copiar headers de la respuesta para código 200 + const responseHeaders = { + 'Content-Type': proxyRes.headers['content-type'] || 'application/vnd.apple.mpegurl', + 'Access-Control-Allow-Origin': '*', + 'Access-Control-Allow-Methods': 'GET, OPTIONS', + 'Access-Control-Allow-Headers': 'Content-Type', + 'Cache-Control': proxyRes.headers['cache-control'] || 'no-cache', + 'Connection': 'keep-alive' + }; + + // Si es un archivo .m3u8, procesar el contenido para actualizar las URLs + if (sourceUrl.endsWith('.m3u8') || proxyRes.headers['content-type']?.includes('mpegurl')) { + let data = ''; + proxyRes.setEncoding('utf8'); + + proxyRes.on('data', (chunk) => { + data += chunk; + }); + + proxyRes.on('end', () => { + try { + const baseUrl = sourceUrl.substring(0, sourceUrl.lastIndexOf('/') + 1); + + const lines = data.split('\n').map(line => { + line = line.trim(); + // Si la línea es una URL (no es comentario ni está vacía) + if (line && !line.startsWith('#')) { + let fullUrl; + + // Si es una URL absoluta, usarla directamente + if (line.startsWith('http')) { + fullUrl = line; + } else { + // Si es relativa, construir la URL completa + fullUrl = baseUrl + line; + } + + // Solo proxear otros .m3u8, los .ts van directo (sin proxy) + if (fullUrl.endsWith('.m3u8')) { + const protocol = req.headers['x-forwarded-proto'] || 'http'; + const host = req.headers['x-forwarded-host'] || req.headers.host || 'localhost:3000'; + const proxyBaseUrl = `${protocol}://${host}/api/proxy?url=`; + return proxyBaseUrl + encodeURIComponent(fullUrl); + } else { + // Los .ts van directo al origen, sin proxy + return fullUrl; + } + } + return line; + }); + + res.writeHead(200, responseHeaders); + res.end(lines.join('\n')); + } catch (err) { + console.error('Error procesando playlist:', err); + res.writeHead(500, { 'Content-Type': 'text/plain' }); + res.end('Error al procesar el playlist'); + } + }); + } else { + // Para archivos .ts (nunca deberían llegar aquí si la config es correcta) + res.writeHead(200, responseHeaders); + proxyRes.pipe(res); + } + }); + + proxyReq.on('error', (err) => { + console.error('Error en proxy:', err); + if (!res.headersSent) { + res.writeHead(500, { + 'Content-Type': 'text/plain', + 'Access-Control-Allow-Origin': '*' + }); + res.end('Error al obtener el stream'); + } + }); + + proxyReq.setTimeout(30000, () => { + console.error('Timeout en proxy:', sourceUrl); + proxyReq.destroy(); + if (!res.headersSent) { + res.writeHead(504, { + 'Content-Type': 'text/plain', + 'Access-Control-Allow-Origin': '*' + }); + res.end('Timeout al obtener el stream'); + } + }); + + proxyReq.end(); +} + +app.prepare().then(() => { + const server = createServer((req, res) => { + const parsedUrl = parse(req.url, true); + const { pathname, query } = parsedUrl; + + // Endpoint para streams proxy + if (pathname.startsWith('/api/stream/')) { + const streamId = pathname.replace('/api/stream/', ''); + + if (STREAM_SOURCES[streamId]) { + proxyStream(STREAM_SOURCES[streamId], req, res); + return; + } else { + res.writeHead(404, { 'Content-Type': 'text/plain' }); + res.end('Stream no encontrado'); + return; + } + } + + // Endpoint para proxy de URL personalizada + if (pathname === '/api/proxy' && query.url) { + try { + const targetUrl = decodeURIComponent(query.url); + + // Validar que sea una URL HTTPS válida + if (!targetUrl.startsWith('https://') && !targetUrl.startsWith('http://')) { + res.writeHead(400, { 'Content-Type': 'text/plain' }); + res.end('URL inválida'); + return; + } + + proxyStream(targetUrl, req, res); + return; + } catch (err) { + console.error('Error al procesar URL:', err); + res.writeHead(400, { 'Content-Type': 'text/plain' }); + res.end('URL inválida'); + return; + } + } + + // Manejar solicitudes normales de Next.js + handle(req, res, parsedUrl); + }); + + const io = new Server(server, { + // Limitar tamaño de mensajes + maxHttpBufferSize: 1e6, // 1MB + // Configurar timeouts + pingTimeout: 60000, + pingInterval: 25000 + }); + + // Almacenar usuarios conectados + const connectedUsers = new Map(); + const ipConnections = new Map(); + const userThumbnails = new Map(); + + io.on('connection', (socket) => { + const clientIp = socket.handshake.address; + + // Verificar límite de conexiones por IP + const currentConnections = ipConnections.get(clientIp) || 0; + if (currentConnections >= MAX_CONNECTIONS_PER_IP) { + socket.disconnect(true); + return; + } + + ipConnections.set(clientIp, currentConnections + 1); + + // Registro de usuario + socket.on('register', (data) => { + try { + if (!data || !data.user) { + socket.emit('error', 'Datos de registro inválidos'); + return; + } + + const username = sanitizeUsername(data.user); + if (!username || username.length < 2) { + socket.emit('error', 'Nombre de usuario inválido'); + return; + } + + // Verificar si el usuario ya existe + const existingUser = Array.from(connectedUsers.values()).find(u => u.username === username); + if (existingUser) { + socket.emit('rejoin', { user: username }); + return; + } + + // Registrar usuario + connectedUsers.set(socket.id, { username, ip: clientIp }); + socket.username = username; + + // Enviar lista de usuarios al nuevo usuario + const usersList = Array.from(connectedUsers.values()).map(u => u.username); + socket.emit('users', { users: usersList }); + + // Notificar a otros usuarios + socket.broadcast.emit('adduser', { user: username }); + io.emit('join', { user: username }); + + } catch (error) { + console.error('Error en registro:', error); + socket.emit('error', 'Error al registrar usuario'); + } + }); + + // Test de conexión para debugging + socket.on('test-connection-request', (data) => { + socket.emit('test-connection'); + }); + + // Mensajes de chat + socket.on('emit msg', (data) => { + try { + if (!socket.username) { + socket.emit('error', 'Usuario no registrado'); + return; + } + + // Rate limiting + if (!checkRateLimit(clientIp)) { + socket.emit('error', 'Demasiados mensajes, espera un momento'); + return; + } + + if (!data || !data.chat) { + return; + } + + const message = sanitizeMessage(data.chat); + if (!message || message.trim().length === 0) { + return; + } + + // Broadcast del mensaje + socket.broadcast.emit('msg', { + user: socket.username, + chat: message, + timestamp: Date.now() + }); + + } catch (error) { + console.error('Error al enviar mensaje:', error); + } + }); + + // Señalización WebRTC para P2P + socket.on('signal', (data) => { + try { + if (!socket.username) return; + + if (!data || !data.to || !data.signal) { + return; + } + + // Encontrar el socket del destinatario + const targetSocket = Array.from(io.sockets.sockets.values()) + .find(s => s.username === data.to); + + if (targetSocket) { + targetSocket.emit('signal', { + from: socket.username, + signal: data.signal + }); + } + } catch (error) { + console.error('Error en señalización:', error); + } + }); + + // Solicitar peer + socket.on('request-peer', (data) => { + try { + if (!socket.username || !data || !data.to) { + return; + } + + const targetSocket = Array.from(io.sockets.sockets.values()) + .find(s => s.username === data.to); + + if (targetSocket) { + targetSocket.emit('peer-requested', { + from: socket.username + }); + } else { + const allUsers = Array.from(connectedUsers.values()).map(u => u.username); + + socket.emit('peer-not-found', { + user: data.to, + message: `El usuario ${data.to} no está conectado` + }); + } + } catch (error) { + console.error('Error al solicitar peer:', error); + } + }); + + // Solicitar ver stream de usuario + socket.on('request-watch', (data) => { + try { + if (!socket.username) return; + + if (!data || !data.target) return; + + const targetSocket = Array.from(io.sockets.sockets.values()) + .find(s => s.username === data.target); + + if (targetSocket) { + // Notificar al target que alguien quiere ver su stream + targetSocket.emit('request-watch', { + from: socket.username + }); + } + } catch (error) { + console.error('Error al solicitar watch:', error); + } + }); + + // Recibir thumbnail de video del usuario + socket.on('video-thumbnail', (data) => { + try { + if (!socket.username) return; + + // Broadcast del thumbnail a todos los demás usuarios + socket.broadcast.emit('user-thumbnail', { + user: socket.username, + thumbnail: data.thumbnail, + isPlaying: data.isPlaying, + currentTime: data.currentTime, + duration: data.duration + }); + } catch (error) { + console.error('Error al procesar thumbnail:', error); + } + }); + + // Desconexión + socket.on('disconnect', () => { + try { + if (socket.username) { + connectedUsers.delete(socket.id); + userThumbnails.delete(socket.username); + + const usersList = Array.from(connectedUsers.values()).map(u => u.username); + io.emit('users', { users: usersList }); + io.emit('quit', { msg: `QUITS ${socket.username}` }); + } + + // Decrementar contador de conexiones por IP + const currentConnections = ipConnections.get(clientIp) || 1; + if (currentConnections <= 1) { + ipConnections.delete(clientIp); + } else { + ipConnections.set(clientIp, currentConnections - 1); + } + } catch (error) { + console.error('Error en desconexión:', error); + } + }); + }); + + // Limpiar rate limits cada minuto + setInterval(() => { + const now = Date.now(); + for (const [ip, limit] of rateLimits.entries()) { + if (now > limit.resetTime + 60000) { + rateLimits.delete(ip); + } + } + }, 60000); + + server.listen(port, (err) => { + if (err) throw err; + console.log(`> Servidor listo en http://${hostname}:${port}`); + console.log(`> Socket.IO listo con protección contra ataques`); + }); +}); diff --git a/src/app/favicon.ico b/src/app/favicon.ico new file mode 100644 index 0000000000000000000000000000000000000000..718d6fea4835ec2d246af9800eddb7ffb276240c GIT binary patch literal 25931 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 LrJugUA?W`A8`#=m literal 0 HcmV?d00001 diff --git a/src/app/globals.css b/src/app/globals.css new file mode 100644 index 0000000..a2dc41e --- /dev/null +++ b/src/app/globals.css @@ -0,0 +1,26 @@ +@import "tailwindcss"; + +:root { + --background: #ffffff; + --foreground: #171717; +} + +@theme inline { + --color-background: var(--background); + --color-foreground: var(--foreground); + --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; +} diff --git a/src/app/layout.js b/src/app/layout.js new file mode 100644 index 0000000..2f02a4c --- /dev/null +++ b/src/app/layout.js @@ -0,0 +1,43 @@ +import { Geist, Geist_Mono } from "next/font/google"; +import "./globals.css"; +import { Suspense } from 'react'; + +const geistSans = Geist({ + variable: "--font-geist-sans", + subsets: ["latin"], +}); + +const geistMono = Geist_Mono({ + variable: "--font-geist-mono", + subsets: ["latin"], +}); + +export const metadata = { + title: "P2P Media Streaming - WebRTC & Socket.IO", + description: "Plataforma de streaming P2P con chat en tiempo real usando WebRTC, Socket.IO y HLS.js", +}; + +export default function RootLayout({ children }) { + return ( + + + + + + + +
+
+

Cargando...

+
+ + }> + {children} +
+ + + ); +} diff --git a/src/app/page.js b/src/app/page.js new file mode 100644 index 0000000..1a6fcd7 --- /dev/null +++ b/src/app/page.js @@ -0,0 +1,362 @@ +'use client'; + +import { useState, useEffect, useRef, useCallback } from 'react'; +import { useSearchParams } from 'next/navigation'; +import VideoPlayer from '@/components/VideoPlayer'; +import Chat from '@/components/Chat'; +import P2PManager from '@/components/P2PManager'; +import { io } from 'socket.io-client'; + +export default function Home() { + const searchParams = useSearchParams(); + const [username, setUsername] = useState(''); + const [socket, setSocket] = useState(null); + const [peers, setPeers] = useState([]); + const [videoUrl, setVideoUrl] = useState(''); + const [customUrl, setCustomUrl] = useState(''); + const [localStream, setLocalStream] = useState(null); + const [remoteStream, setRemoteStream] = useState(null); + const [watchingUser, setWatchingUser] = useState(null); + const [isCapturingStream, setIsCapturingStream] = useState(false); + const videoPlayerRef = useRef(null); + const p2pManagerRef = useRef(null); + const [stats, setStats] = useState({ + http: 0, + p2p: 0 + }); + + // URLs de ejemplo - usando proxy del servidor + const exampleVideos = [ + { + name: 'RTVE - La 1', + url: '/api/stream/rtve-la1' + }, + { + name: 'RTVE - La 2', + url: '/api/stream/rtve-la2' + }, + { + name: 'RTVE - 24H', + url: '/api/stream/rtve-24h' + } + ]; + + useEffect(() => { + // Obtener URL del parámetro de búsqueda + const urlParam = searchParams.get('url'); + if (urlParam) { + setVideoUrl(urlParam); + } else { + setVideoUrl(exampleVideos[0].url); + } + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [searchParams]); + + const handleVideoStats = useCallback((data) => { + setStats(prev => ({ + ...prev, + http: prev.http + (data.bytes || 0) + })); + }, []); + + const handlePeerStats = useCallback((data) => { + // Actualizar estadísticas P2P - usar los deltas que envía el componente + setStats(prev => ({ + ...prev, + p2p: prev.p2p + (data.downloadSpeed || 0) * 5 // velocidad * 5 segundos = bytes descargados en este intervalo + })); + }, []); + + const handleSocketReady = useCallback((socketInstance) => { + console.log('🔌 Socket listo para P2P'); + setSocket(socketInstance); + }, []); + + const handlePeersUpdate = useCallback((peersList) => { + setPeers(peersList); + }, []); + + const captureLocalStream = useCallback(() => { + if (isCapturingStream) { + console.log('⏳ Ya se está capturando stream, esperando...'); + return; + } + + if (!videoPlayerRef.current) { + console.error('❌ No hay referencia al video player'); + return; + } + + setIsCapturingStream(true); + const video = videoPlayerRef.current.querySelector('video'); + + if (!video) { + console.error('❌ No se encontró elemento video en el DOM'); + setIsCapturingStream(false); + return; + } + + console.log('🎥 Intentando capturar stream del video...'); + + try { + let stream = null; + if (video.captureStream) { + stream = video.captureStream(); + } else if (video.mozCaptureStream) { + stream = video.mozCaptureStream(); + } + + if (stream && stream.getTracks().length > 0) { + console.log('✅ Stream local capturado exitosamente'); + console.log('Tracks:', stream.getTracks().map(t => ({ kind: t.kind, enabled: t.enabled }))); + setLocalStream(stream); + } else { + console.error('❌ No se pudo capturar el stream o no tiene tracks'); + setIsCapturingStream(false); + } + } catch (err) { + console.error('❌ Error capturando stream:', err); + setIsCapturingStream(false); + } finally { + // Resetear la bandera después de un timeout + setTimeout(() => setIsCapturingStream(false), 2000); + } + }, [isCapturingStream]); + + const handleRemoteStream = useCallback((fromUser, stream) => { + console.log('📺 Stream remoto recibido de:', fromUser); + setRemoteStream(stream); + setWatchingUser(fromUser); + }, []); + + const handleWatchUser = useCallback((targetUser) => { + // Validar que no sea el mismo usuario + if (targetUser === username) { + console.error('❌ No puedes ver tu propio stream'); + alert('No puedes ver tu propio stream. Conéctate desde otro navegador o dispositivo.'); + return; + } + + console.log('👁️ Solicitando ver a:', targetUser); + console.log('Estado actual:', { + socket: !!socket, + socketConnected: socket?.connected, + p2pManager: !!p2pManagerRef.current, + hasRequestPeer: !!p2pManagerRef.current?.requestPeer, + watchingUser, + remoteStream: !!remoteStream, + targetUser, + myUsername: username + }); + + setWatchingUser(targetUser); + setRemoteStream(null); + + // Usar el P2PManager para iniciar la conexión + if (p2pManagerRef.current && p2pManagerRef.current.requestPeer) { + console.log('✅ Llamando a requestPeer para:', targetUser); + p2pManagerRef.current.requestPeer(targetUser); + } else { + console.error('❌ No hay P2PManager o requestPeer disponible'); + } + }, [socket, watchingUser, remoteStream, username]); + + const handleStopWatching = useCallback(() => { + setWatchingUser(null); + setRemoteStream(null); + }, []); + + const loadCustomUrl = () => { + if (customUrl.trim()) { + // Si la URL es externa, usar el proxy + const url = customUrl.trim(); + if (url.startsWith('http://') || url.startsWith('https://')) { + setVideoUrl(`/api/proxy?url=${encodeURIComponent(url)}`); + } else { + setVideoUrl(url); + } + } + }; + + return ( +
+ {/* Header */} +
+
+
+
+

+ P2P Media Streaming +

+

+ Streaming de video con tecnología P2P y chat en tiempo real +

+
+
+
+ WebRTC Activo +
+
+
+
+ + {/* Main Content */} +
+
+ {/* Columna Principal - Video */} +
+ {/* Banner de stream remoto */} + {watchingUser && ( +
+
+
+ 📺 +
+

+ Viendo el reproductor de {watchingUser} +

+

+ {remoteStream ? '🟢 Conectado' : '🟡 Conectando...'} +

+
+
+ +
+
+ )} + + {/* URL personalizada */} + {!watchingUser && ( +
+

URL de Video Personalizada

+

+ 💡 Las URLs externas se procesan a través de nuestro proxy para evitar problemas de CORS +

+
+ setCustomUrl(e.target.value)} + placeholder="https://ejemplo.com/stream.m3u8" + className="flex-1 px-4 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-transparent" + /> + +
+ + {/* Videos de ejemplo */} +
+

Videos de ejemplo:

+
+ {exampleVideos.map((video, index) => ( + + ))} +
+
+
+ )} + + {/* Video Player */} +
+ {watchingUser ? ( + // Modo watching: mostrar stream remoto o mensaje de carga + remoteStream ? ( + + ) : ( +
+
+

+ Conectando con {watchingUser}... +

+

+ Estableciendo conexión P2P +

+ +
+ ) + ) : ( + // Modo normal: video propio + + )} +
+ + +
+ + {/* Columna Lateral - Chat */} +
+ {/* P2P Manager (oculto) */} +
+ +
+ + {/* Chat */} +
+ +
+
+
+
+ + {/* Footer */} +
+
+
+

P2P Media Streaming Platform

+

Desarrollado con Next.js, Socket.IO, WebRTC y HLS.js

+

+ © 2025 - Streaming P2P sin WebTorrent | Servidor STUN: manalejandro.com:3478 +

+
+
+
+
+ ); +} diff --git a/src/components/Chat.js b/src/components/Chat.js new file mode 100644 index 0000000..2883860 --- /dev/null +++ b/src/components/Chat.js @@ -0,0 +1,354 @@ +'use client'; + +import { useState, useEffect, useRef } from 'react'; +import { io } from 'socket.io-client'; + +/** + * Componente de Chat en tiempo real con Socket.IO y thumbnails de video + */ +export default function Chat({ username, onUsernameChange, onSocketReady, onWatchUser }) { + const [socket, setSocket] = useState(null); + const [messages, setMessages] = useState([]); + const [users, setUsers] = useState([]); + const [userThumbnails, setUserThumbnails] = useState({}); + const [inputMessage, setInputMessage] = useState(''); + const [isConnected, setIsConnected] = useState(false); + const [tempUsername, setTempUsername] = useState(username || ''); + const [hoveredUser, setHoveredUser] = useState(null); + const messagesEndRef = useRef(null); + + // Auto-scroll al final de los mensajes + const scrollToBottom = () => { + messagesEndRef.current?.scrollIntoView({ behavior: 'smooth' }); + }; + + useEffect(() => { + scrollToBottom(); + }, [messages]); + + // Conectar a Socket.IO + useEffect(() => { + if (!username) return; + + const newSocket = io({ + transports: ['websocket', 'polling'] + }); + + newSocket.on('connect', () => { + console.log('✅ Conectado a Socket.IO'); + setIsConnected(true); + newSocket.emit('register', { user: username }); + // Notificar al padre que el socket está listo + if (onSocketReady) { + onSocketReady(newSocket); + } + }); + + newSocket.on('disconnect', () => { + console.log('Desconectado de Socket.IO'); + setIsConnected(false); + }); + + newSocket.on('users', (data) => { + setUsers(data.users || []); + }); + + newSocket.on('adduser', (data) => { + setUsers(prevUsers => [...prevUsers, data.user]); + }); + + newSocket.on('user-thumbnail', (data) => { + if (data.user && data.thumbnail) { + setUserThumbnails(prev => ({ + ...prev, + [data.user]: { + thumbnail: data.thumbnail, + isPlaying: data.isPlaying, + timestamp: Date.now() + } + })); + } + }); + + newSocket.on('join', (data) => { + setMessages(prev => [ + ...prev, + { + type: 'system', + text: `${data.user} se ha unido`, + timestamp: Date.now() + } + ]); + }); + + newSocket.on('msg', (data) => { + setMessages(prev => [ + ...prev, + { + type: 'user', + user: data.user, + text: data.chat, + timestamp: data.timestamp || Date.now() + } + ]); + }); + + newSocket.on('quit', (data) => { + setMessages(prev => [ + ...prev, + { + type: 'system', + text: data.msg, + timestamp: Date.now() + } + ]); + }); + + newSocket.on('rejoin', (data) => { + alert('Este nombre de usuario ya está en uso. Por favor elige otro.'); + if (onUsernameChange) { + onUsernameChange(''); + } + }); + + newSocket.on('error', (error) => { + console.error('Error del servidor:', error); + setMessages(prev => [ + ...prev, + { + type: 'error', + text: error, + timestamp: Date.now() + } + ]); + }); + + setSocket(newSocket); + + return () => { + newSocket.close(); + }; + }, [username, onUsernameChange]); + + const sendMessage = (e) => { + e.preventDefault(); + + if (!inputMessage.trim() || !socket || !isConnected) return; + + // Añadir mensaje propio + setMessages(prev => [ + ...prev, + { + type: 'own', + user: username, + text: inputMessage, + timestamp: Date.now() + } + ]); + + // Enviar al servidor + socket.emit('emit msg', { user: username, chat: inputMessage }); + setInputMessage(''); + }; + + const handleUsernameSubmit = (e) => { + e.preventDefault(); + if (tempUsername.trim().length >= 2) { + if (onUsernameChange) { + onUsernameChange(tempUsername.trim()); + } + } + }; + + // Formulario de username si no está conectado + if (!username) { + return ( +
+

Únete al Chat

+
+
+ + setTempUsername(e.target.value)} + placeholder="Ingresa tu nombre..." + className="w-full px-4 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-transparent" + minLength={2} + maxLength={30} + required + /> +
+ +
+
+ ); + } + + return ( +
+ {/* Header */} +
+
+

Chat en Vivo

+
+
+ {isConnected ? 'Conectado' : 'Desconectado'} +
+
+

Como: {username}

+
+ + {/* Usuarios conectados */} +
+

+ USUARIOS CONECTADOS ({users.length}) +

+ +
+ {users.map((user, index) => { + const isCurrentUser = user === username; + const hasThumbnail = userThumbnails[user]?.thumbnail; + const isHovered = hoveredUser === user; + + return ( +
setHoveredUser(user)} + onMouseLeave={() => setHoveredUser(null)} + > + + + {/* Thumbnail preview en hover - FLOTANTE */} + {isHovered && hasThumbnail && !isCurrentUser && ( +
+
+ {/* eslint-disable-next-line @next/next/no-img-element */} + {`Stream +
+ + {userThumbnails[user].isPlaying ? '▶️ Reproduciendo' : '⏸️ Pausado'} + +
+
+ + {user} + +
+
+

+ 👆 Click para ver en vivo +

+
+ )} +
+ ); + })} +
+
+ + {/* Mensajes */} +
+ {messages.length === 0 ? ( +

No hay mensajes aún...

+ ) : ( + messages.map((msg, index) => ( +
+ {msg.type === 'system' && ( + {msg.text} + )} + {msg.type === 'error' && ( + {msg.text} + )} + {(msg.type === 'user' || msg.type === 'own') && ( +
+

+ {msg.user} +

+

{msg.text}

+
+ )} +
+ )) + )} +
+
+ + {/* Input de mensaje */} +
+
+ setInputMessage(e.target.value)} + placeholder="Escribe un mensaje..." + className="flex-1 px-4 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-transparent" + maxLength={500} + disabled={!isConnected} + /> + +
+
+
+ ); +} diff --git a/src/components/P2PManager.js b/src/components/P2PManager.js new file mode 100644 index 0000000..09eb980 --- /dev/null +++ b/src/components/P2PManager.js @@ -0,0 +1,431 @@ +'use client'; + +import { useEffect, useRef, useState, forwardRef, useImperativeHandle } from 'react'; +import SimplePeer from 'simple-peer'; + +/** + * Configuración de servidores ICE + * Usando el servidor STUN personalizado + */ +const ICE_SERVERS = { + iceServers: [ + { + urls: [ + 'turn:manalejandro.com:5349', + 'stun:manalejandro.com:3478' + ], + username: 'manalejandro', + credential: 'manalejandro.com' + }, + { + urls: [ + 'stun:stun.l.google.com:19302', + 'stun:stun1.l.google.com:19302' + ] + } + ] +}; + +/** + * Gestor de conexiones P2P con WebRTC y streaming de video + */ +const P2PManager = forwardRef(({ + socket, + username, + onPeerStats, + onPeersUpdate, + localStream = null, + onRemoteStream = null, + onNeedStream = null +}, ref) => { + const [peers, setPeers] = useState([]); + const [stats, setStats] = useState({ + uploadSpeed: 0, + downloadSpeed: 0, + totalUploaded: 0, + totalDownloaded: 0, + connectedPeers: 0 + }); + + const peersRef = useRef({}); + const statsInterval = useRef(null); + const uploadBytes = useRef(0); + const downloadBytes = useRef(0); + const localStreamRef = useRef(null); + + // Buffer para señales que llegan antes de crear el peer + const pendingSignalsRef = useRef({}); + + // Almacenar streams remotos recibidos por cada peer + const remoteStreamsRef = useRef({}); + + // Refs para callbacks para evitar re-renders + const onPeerStatsRef = useRef(onPeerStats); + const onPeersUpdateRef = useRef(onPeersUpdate); + const onRemoteStreamRef = useRef(onRemoteStream); + const onNeedStreamRef = useRef(onNeedStream); + + // Actualizar refs cuando cambien las callbacks + useEffect(() => { + onPeerStatsRef.current = onPeerStats; + onPeersUpdateRef.current = onPeersUpdate; + onRemoteStreamRef.current = onRemoteStream; + onNeedStreamRef.current = onNeedStream; + }, [onPeerStats, onPeersUpdate, onRemoteStream, onNeedStream]); + + // Exponer métodos al componente padre + useImperativeHandle(ref, () => ({ + requestPeer: (targetUser) => { + return requestPeer(targetUser); + } + })); + + // Mantener referencia actualizada del stream local + useEffect(() => { + localStreamRef.current = localStream; + }, [localStream]); + + useEffect(() => { + if (!socket || !username) { + return; + } + + socket.emit('test-connection-request', { user: username }); + + // Escuchar solicitud de watch (alguien quiere ver MI stream) + socket.on('request-watch', (data) => { + if (data && data.from) { + if (!localStreamRef.current && onNeedStreamRef.current) { + onNeedStreamRef.current(); + } + } + }); + + // Escuchar solicitudes de peers (el viewer quiere vernos) + socket.on('peer-requested', (data) => { + if (!data || !data.from) return; + + // Verificar si ya existe un peer + const existingPeer = peersRef.current[data.from]; + if (existingPeer) { + if (existingPeer.connected && !existingPeer.destroyed) { + return; + } else { + existingPeer.destroy(); + delete peersRef.current[data.from]; + } + } + + // Función para iniciar el peer con stream + const startPeerWithStream = () => { + // Verificar nuevamente que no se haya creado mientras esperábamos + if (peersRef.current[data.from]) { + return; + } + + if (localStreamRef.current) { + // Responder como NO initiator pero CON stream + initiatePeer(data.from, false, null, true); + } else { + // Responder de todas formas, aunque sea sin stream + initiatePeer(data.from, false, null, false); + } + }; + + // Primero, capturar stream si no lo tenemos + if (!localStreamRef.current && onNeedStreamRef.current) { + onNeedStreamRef.current(); + setTimeout(startPeerWithStream, 1500); + } else if (localStreamRef.current) { + setTimeout(startPeerWithStream, 200); + } else { + console.error('❌ No hay stream NI callback para capturarlo'); + setTimeout(startPeerWithStream, 200); + } + }); + + // Escuchar señales de WebRTC + socket.on('signal', (data) => { + if (peersRef.current[data.from]) { + try { + peersRef.current[data.from].signal(data.signal); + } catch (error) { + console.error('❌ Error al procesar señal:', error); + delete peersRef.current[data.from]; + setPeers(prev => prev.filter(p => p !== data.from)); + } + } else { + // Si no existe el peer, almacenar la señal para procesarla después + if (!pendingSignalsRef.current[data.from]) { + pendingSignalsRef.current[data.from] = []; + } + pendingSignalsRef.current[data.from].push(data.signal); + } + }); + + // Usuario no encontrado + socket.on('peer-not-found', (data) => { + console.error(`❌ Usuario no encontrado: ${data.user} - ${data.message}`); + alert(`No se puede conectar: ${data.message}`); + }); + + // Intervalo para calcular estadísticas + statsInterval.current = setInterval(() => { + const uploadSpeed = uploadBytes.current / 5; + const downloadSpeed = downloadBytes.current / 5; + + setStats(prev => ({ + uploadSpeed, + downloadSpeed, + totalUploaded: prev.totalUploaded + uploadBytes.current, + totalDownloaded: prev.totalDownloaded + downloadBytes.current, + connectedPeers: Object.keys(peersRef.current).length + })); + + if (onPeerStatsRef.current) { + onPeerStatsRef.current({ + uploadSpeed, + downloadSpeed, + totalUploaded: uploadBytes.current, + totalDownloaded: downloadBytes.current, + peers: Object.keys(peersRef.current).length + }); + } + + uploadBytes.current = 0; + downloadBytes.current = 0; + }, 5000); + + return () => { + socket.off('request-watch'); + socket.off('peer-requested'); + socket.off('signal'); + socket.off('peer-not-found'); + + Object.values(peersRef.current).forEach(peer => { + if (peer) peer.destroy(); + }); + peersRef.current = {}; + pendingSignalsRef.current = {}; + remoteStreamsRef.current = {}; + + if (statsInterval.current) { + clearInterval(statsInterval.current); + } + }; + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [socket, username]); + + const initiatePeer = (targetUser, initiator, initialSignal = null, includeStream = false) => { + if (peersRef.current[targetUser]) { + return; + } + + const peerConfig = { + initiator, + trickle: true, + config: ICE_SERVERS + }; + + if (includeStream && localStreamRef.current) { + peerConfig.stream = localStreamRef.current; + } + + const peer = new SimplePeer(peerConfig); + + peersRef.current[targetUser] = peer; + + // Procesar señales pendientes si las hay + if (pendingSignalsRef.current[targetUser] && pendingSignalsRef.current[targetUser].length > 0) { + const signals = pendingSignalsRef.current[targetUser]; + delete pendingSignalsRef.current[targetUser]; + + signals.forEach((signal) => { + try { + peer.signal(signal); + } catch (error) { + console.error('❌ Error procesando señal pendiente:', error); + } + }); + } + + peer.on('signal', (signal) => { + socket.emit('signal', { + to: targetUser, + signal: signal + }); + }); + + peer.on('connect', () => { + setPeers(prev => { + const newPeers = [...prev, targetUser]; + if (onPeersUpdateRef.current) onPeersUpdateRef.current(newPeers); + return newPeers; + }); + }); + + peer.on('data', (data) => { + downloadBytes.current += data.length; + }); + + peer.on('stream', (remoteStream) => { + remoteStreamsRef.current[targetUser] = remoteStream; + + if (onRemoteStreamRef.current) { + onRemoteStreamRef.current(targetUser, remoteStream); + } + }); + + peer.on('close', () => { + if (peersRef.current[targetUser] === peer) { + delete peersRef.current[targetUser]; + delete remoteStreamsRef.current[targetUser]; + setPeers(prev => { + const newPeers = prev.filter(p => p !== targetUser); + if (onPeersUpdateRef.current) onPeersUpdateRef.current(newPeers); + return newPeers; + }); + } + }); + + peer.on('error', (err) => { + console.error('❌ Error en peer', targetUser, ':', err.message || err); + if (peersRef.current[targetUser] === peer) { + delete peersRef.current[targetUser]; + delete remoteStreamsRef.current[targetUser]; + setPeers(prev => { + const newPeers = prev.filter(p => p !== targetUser); + if (onPeersUpdateRef.current) onPeersUpdateRef.current(newPeers); + return newPeers; + }); + } + }); + + if (initialSignal) { + try { + peer.signal(initialSignal); + } catch (error) { + console.error('❌ Error al procesar señal inicial:', error); + } + } + }; + + const requestPeer = (targetUser) => { + const existingPeer = peersRef.current[targetUser]; + if (existingPeer) { + if (existingPeer.connected && !existingPeer.destroyed) { + const existingStream = remoteStreamsRef.current[targetUser]; + if (existingStream && onRemoteStreamRef.current) { + onRemoteStreamRef.current(targetUser, existingStream); + } + return existingPeer; + } else { + existingPeer.destroy(); + delete peersRef.current[targetUser]; + delete remoteStreamsRef.current[targetUser]; + setTimeout(() => { + socket.emit('request-peer', { to: targetUser }); + initiatePeer(targetUser, true, null, false); + }, 100); + return null; + } + } + + socket.emit('request-peer', { to: targetUser }); + initiatePeer(targetUser, true, null, false); + return peersRef.current[targetUser]; + }; + + const addStreamToPeers = (stream) => { + Object.entries(peersRef.current).forEach(([user, peer]) => { + try { + if (peer && peer.connected) { + peer.addStream(stream); + } + } catch (error) { + console.error('❌ Error añadiendo stream a peer:', error); + } + }); + }; + + const sendData = (data) => { + let sent = 0; + Object.values(peersRef.current).forEach(peer => { + try { + if (peer && peer.connected) { + peer.send(data); + sent++; + } + } catch (error) { + console.error('❌ Error al enviar datos:', error); + } + }); + + if (sent > 0) { + uploadBytes.current += data.length * sent; + } + + return sent; + }; + + return ( +
+

Conexiones P2P

+ + {/* Estadísticas */} +
+
+

Peers Conectados

+

{stats.connectedPeers}

+
+ +
+

Subida

+

+ {(stats.uploadSpeed / 1024).toFixed(1)} KB/s +

+

+ Total: {(stats.totalUploaded / 1024 / 1024).toFixed(2)} MB +

+
+ +
+

Descarga

+

+ {(stats.downloadSpeed / 1024).toFixed(1)} KB/s +

+

+ Total: {(stats.totalDownloaded / 1024 / 1024).toFixed(2)} MB +

+
+ +
+

Configuración

+

STUN Server

+

manalejandro.com:3478

+
+
+ + {/* Lista de peers */} +
+

Peers Conectados:

+ {peers.length === 0 ? ( +

Sin conexiones P2P activas

+ ) : ( +
+ {peers.map((peer, index) => ( +
+
+ {peer} +
+ ))} +
+ )} +
+
+ ); +}); + +P2PManager.displayName = 'P2PManager'; + +export default P2PManager; diff --git a/src/components/VideoPlayer.js b/src/components/VideoPlayer.js new file mode 100644 index 0000000..6f12984 --- /dev/null +++ b/src/components/VideoPlayer.js @@ -0,0 +1,197 @@ +'use client'; + +import { useEffect, useRef, useState } from 'react'; +import Hls from 'hls.js'; + +export default function VideoPlayer({ + url, + onStats, + socket, + peers = [], + username, + isRemoteStream = false, + remoteStream = null +}) { + const videoRef = useRef(null); + const hlsRef = useRef(null); + const [error, setError] = useState(null); + const isInitializedRef = useRef(false); + const segmentCache = useRef(new Map()); + const canvasRef = useRef(null); + const thumbnailIntervalRef = useRef(null); + + // Efecto para manejar stream remoto + useEffect(() => { + if (!isRemoteStream || !remoteStream || !videoRef.current) { + return; + } + + const video = videoRef.current; + video.srcObject = remoteStream; + video.play().catch(err => { + console.error('❌ Error reproduciendo stream remoto:', err); + }); + + return () => { + if (video.srcObject) { + video.srcObject = null; + } + }; + }, [isRemoteStream, remoteStream]); + + // Efecto para capturar thumbnails (solo thumbnails, NO stream automático) + useEffect(() => { + if (!videoRef.current || !socket || !username || isRemoteStream) return; + + const video = videoRef.current; + let canvas = canvasRef.current; + + if (!canvas) { + canvas = document.createElement('canvas'); + canvasRef.current = canvas; + } + + // Capturar thumbnail del video cada 3 segundos + thumbnailIntervalRef.current = setInterval(() => { + if (video.readyState >= 2 && !video.paused) { + try { + canvas.width = 160; + canvas.height = 90; + const ctx = canvas.getContext('2d'); + ctx.drawImage(video, 0, 0, canvas.width, canvas.height); + const thumbnail = canvas.toDataURL('image/jpeg', 0.5); + + // Emitir thumbnail al servidor + socket.emit('video-thumbnail', { + thumbnail, + isPlaying: !video.paused, + currentTime: video.currentTime, + duration: video.duration + }); + } catch (err) { + console.error('Error capturando thumbnail:', err); + } + } + }, 3000); + + return () => { + if (thumbnailIntervalRef.current) { + clearInterval(thumbnailIntervalRef.current); + } + }; + }, [socket, username, isRemoteStream]); + + useEffect(() => { + if (isInitializedRef.current) return; + if (!url || !videoRef.current || isRemoteStream) return; + + isInitializedRef.current = true; + + const video = videoRef.current; + + if (Hls.isSupported()) { + const hls = new Hls({ + debug: false, + enableWorker: true, + backBufferLength: 90, + maxBufferLength: 30, + }); + + hlsRef.current = hls; + + hls.on(Hls.Events.MANIFEST_PARSED, () => { + video.play().catch(() => {}); + }); + + hls.on(Hls.Events.FRAG_LOADED, (event, data) => { + const segmentUrl = data.frag.url; + const segmentData = data.frag.data; + + if (segmentData && segmentUrl) { + segmentCache.current.set(segmentUrl, segmentData); + + if (segmentCache.current.size > 50) { + const firstKey = segmentCache.current.keys().next().value; + segmentCache.current.delete(firstKey); + } + } + }); + + hls.on(Hls.Events.ERROR, (event, data) => { + if (data.fatal) { + setError('Error al cargar el video'); + } + }); + + hls.loadSource(url); + hls.attachMedia(video); + + if (socket) { + socket.on('request-segment', (data) => { + const segment = segmentCache.current.get(data.segmentUrl); + if (segment) { + socket.emit('segment-response', { + to: data.from, + segmentUrl: data.segmentUrl, + data: Array.from(new Uint8Array(segment)) + }); + } + }); + } + + return () => { + if (socket) socket.off('request-segment'); + if (hlsRef.current) { + hlsRef.current.destroy(); + hlsRef.current = null; + } + segmentCache.current.clear(); + isInitializedRef.current = false; + }; + } else if (video.canPlayType('application/vnd.apple.mpegurl')) { + video.src = url; + return () => { + isInitializedRef.current = false; + }; + } else { + setError('Tu navegador no soporta HLS'); + } + }, [url, socket]); + + return ( +
+ {error ? ( +
+

Error

+

{error}

+
+ ) : ( + <> +
+ ); +}