initial commit

Signed-off-by: ale <ale@manalejandro.com>
Este commit está contenido en:
ale
2025-11-24 00:34:30 +01:00
commit 20b2a9ad9c
Se han modificado 25 ficheros con 2819 adiciones y 0 borrados

12
.dockerignore Archivo normal
Ver fichero

@@ -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

41
.gitignore vendido Archivo normal
Ver fichero

@@ -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

49
Dockerfile Archivo normal
Ver fichero

@@ -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"]

21
LICENSE Archivo normal
Ver fichero

@@ -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.

599
README.md Archivo normal
Ver fichero

@@ -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
---
<div align="center">
**¡Disfruta del streaming P2P!** 🎉
[⬆️ Volver arriba](#-p2p-media-streaming-platform)
</div>

18
docker-compose.yml Archivo normal
Ver fichero

@@ -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

25
eslint.config.mjs Archivo normal
Ver fichero

@@ -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;

7
jsconfig.json Archivo normal
Ver fichero

@@ -0,0 +1,7 @@
{
"compilerOptions": {
"paths": {
"@/*": ["./src/*"]
}
}
}

4
next.config.mjs Archivo normal
Ver fichero

@@ -0,0 +1,4 @@
/** @type {import('next').NextConfig} */
const nextConfig = {};
export default nextConfig;

93
nginx.conf Archivo normal
Ver fichero

@@ -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;
}
}

29
package.json Archivo normal
Ver fichero

@@ -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"
}
}

5
postcss.config.mjs Archivo normal
Ver fichero

@@ -0,0 +1,5 @@
const config = {
plugins: ["@tailwindcss/postcss"],
};
export default config;

1
public/file.svg Archivo normal
Ver fichero

@@ -0,0 +1 @@
<svg fill="none" viewBox="0 0 16 16" xmlns="http://www.w3.org/2000/svg"><path d="M14.5 13.5V5.41a1 1 0 0 0-.3-.7L9.8.29A1 1 0 0 0 9.08 0H1.5v13.5A2.5 2.5 0 0 0 4 16h8a2.5 2.5 0 0 0 2.5-2.5m-1.5 0v-7H8v-5H3v12a1 1 0 0 0 1 1h8a1 1 0 0 0 1-1M9.5 5V2.12L12.38 5zM5.13 5h-.62v1.25h2.12V5zm-.62 3h7.12v1.25H4.5zm.62 3h-.62v1.25h7.12V11z" clip-rule="evenodd" fill="#666" fill-rule="evenodd"/></svg>

Después

Anchura:  |  Altura:  |  Tamaño: 391 B

1
public/globe.svg Archivo normal
Ver fichero

@@ -0,0 +1 @@
<svg fill="none" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16"><g clip-path="url(#a)"><path fill-rule="evenodd" clip-rule="evenodd" d="M10.27 14.1a6.5 6.5 0 0 0 3.67-3.45q-1.24.21-2.7.34-.31 1.83-.97 3.1M8 16A8 8 0 1 0 8 0a8 8 0 0 0 0 16m.48-1.52a7 7 0 0 1-.96 0H7.5a4 4 0 0 1-.84-1.32q-.38-.89-.63-2.08a40 40 0 0 0 3.92 0q-.25 1.2-.63 2.08a4 4 0 0 1-.84 1.31zm2.94-4.76q1.66-.15 2.95-.43a7 7 0 0 0 0-2.58q-1.3-.27-2.95-.43a18 18 0 0 1 0 3.44m-1.27-3.54a17 17 0 0 1 0 3.64 39 39 0 0 1-4.3 0 17 17 0 0 1 0-3.64 39 39 0 0 1 4.3 0m1.1-1.17q1.45.13 2.69.34a6.5 6.5 0 0 0-3.67-3.44q.65 1.26.98 3.1M8.48 1.5l.01.02q.41.37.84 1.31.38.89.63 2.08a40 40 0 0 0-3.92 0q.25-1.2.63-2.08a4 4 0 0 1 .85-1.32 7 7 0 0 1 .96 0m-2.75.4a6.5 6.5 0 0 0-3.67 3.44 29 29 0 0 1 2.7-.34q.31-1.83.97-3.1M4.58 6.28q-1.66.16-2.95.43a7 7 0 0 0 0 2.58q1.3.27 2.95.43a18 18 0 0 1 0-3.44m.17 4.71q-1.45-.12-2.69-.34a6.5 6.5 0 0 0 3.67 3.44q-.65-1.27-.98-3.1" fill="#666"/></g><defs><clipPath id="a"><path fill="#fff" d="M0 0h16v16H0z"/></clipPath></defs></svg>

Después

Anchura:  |  Altura:  |  Tamaño: 1.0 KiB

1
public/next.svg Archivo normal
Ver fichero

@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 394 80"><path fill="#000" d="M262 0h68.5v12.7h-27.2v66.6h-13.6V12.7H262V0ZM149 0v12.7H94v20.4h44.3v12.6H94v21h55v12.6H80.5V0h68.7zm34.3 0h-17.8l63.8 79.4h17.9l-32-39.7 32-39.6h-17.9l-23 28.6-23-28.6zm18.3 56.7-9-11-27.1 33.7h17.8l18.3-22.7z"/><path fill="#000" d="M81 79.3 17 0H0v79.3h13.6V17l50.2 62.3H81Zm252.6-.4c-1 0-1.8-.4-2.5-1s-1.1-1.6-1.1-2.6.3-1.8 1-2.5 1.6-1 2.6-1 1.8.3 2.5 1a3.4 3.4 0 0 1 .6 4.3 3.7 3.7 0 0 1-3 1.8zm23.2-33.5h6v23.3c0 2.1-.4 4-1.3 5.5a9.1 9.1 0 0 1-3.8 3.5c-1.6.8-3.5 1.3-5.7 1.3-2 0-3.7-.4-5.3-1s-2.8-1.8-3.7-3.2c-.9-1.3-1.4-3-1.4-5h6c.1.8.3 1.6.7 2.2s1 1.2 1.6 1.5c.7.4 1.5.5 2.4.5 1 0 1.8-.2 2.4-.6a4 4 0 0 0 1.6-1.8c.3-.8.5-1.8.5-3V45.5zm30.9 9.1a4.4 4.4 0 0 0-2-3.3 7.5 7.5 0 0 0-4.3-1.1c-1.3 0-2.4.2-3.3.5-.9.4-1.6 1-2 1.6a3.5 3.5 0 0 0-.3 4c.3.5.7.9 1.3 1.2l1.8 1 2 .5 3.2.8c1.3.3 2.5.7 3.7 1.2a13 13 0 0 1 3.2 1.8 8.1 8.1 0 0 1 3 6.5c0 2-.5 3.7-1.5 5.1a10 10 0 0 1-4.4 3.5c-1.8.8-4.1 1.2-6.8 1.2-2.6 0-4.9-.4-6.8-1.2-2-.8-3.4-2-4.5-3.5a10 10 0 0 1-1.7-5.6h6a5 5 0 0 0 3.5 4.6c1 .4 2.2.6 3.4.6 1.3 0 2.5-.2 3.5-.6 1-.4 1.8-1 2.4-1.7a4 4 0 0 0 .8-2.4c0-.9-.2-1.6-.7-2.2a11 11 0 0 0-2.1-1.4l-3.2-1-3.8-1c-2.8-.7-5-1.7-6.6-3.2a7.2 7.2 0 0 1-2.4-5.7 8 8 0 0 1 1.7-5 10 10 0 0 1 4.3-3.5c2-.8 4-1.2 6.4-1.2 2.3 0 4.4.4 6.2 1.2 1.8.8 3.2 2 4.3 3.4 1 1.4 1.5 3 1.5 5h-5.8z"/></svg>

Después

Anchura:  |  Altura:  |  Tamaño: 1.3 KiB

1
public/vercel.svg Archivo normal
Ver fichero

@@ -0,0 +1 @@
<svg fill="none" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 1155 1000"><path d="m577.3 0 577.4 1000H0z" fill="#fff"/></svg>

Después

Anchura:  |  Altura:  |  Tamaño: 128 B

1
public/window.svg Archivo normal
Ver fichero

@@ -0,0 +1 @@
<svg fill="none" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16"><path fill-rule="evenodd" clip-rule="evenodd" d="M1.5 2.5h13v10a1 1 0 0 1-1 1h-11a1 1 0 0 1-1-1zM0 1h16v11.5a2.5 2.5 0 0 1-2.5 2.5h-11A2.5 2.5 0 0 1 0 12.5zm3.75 4.5a.75.75 0 1 0 0-1.5.75.75 0 0 0 0 1.5M7 4.75a.75.75 0 1 1-1.5 0 .75.75 0 0 1 1.5 0m1.75.75a.75.75 0 1 0 0-1.5.75.75 0 0 0 0 1.5" fill="#666"/></svg>

Después

Anchura:  |  Altura:  |  Tamaño: 385 B

498
server.js Archivo normal
Ver fichero

@@ -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`);
});
});

BIN
src/app/favicon.ico Archivo normal

Archivo binario no mostrado.

Después

Anchura:  |  Altura:  |  Tamaño: 25 KiB

26
src/app/globals.css Archivo normal
Ver fichero

@@ -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;
}

43
src/app/layout.js Archivo normal
Ver fichero

@@ -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 (
<html lang="es">
<head>
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<meta name="theme-color" content="#3b82f6" />
</head>
<body
className={`${geistSans.variable} ${geistMono.variable} antialiased`}
>
<Suspense fallback={
<div className="min-h-screen flex items-center justify-center bg-gradient-to-br from-blue-50 to-purple-50">
<div className="text-center">
<div className="inline-block animate-spin rounded-full h-12 w-12 border-b-2 border-blue-600"></div>
<p className="mt-4 text-gray-600">Cargando...</p>
</div>
</div>
}>
{children}
</Suspense>
</body>
</html>
);
}

362
src/app/page.js Archivo normal
Ver fichero

@@ -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 (
<div className="min-h-screen bg-gradient-to-br from-blue-50 via-purple-50 to-pink-50">
{/* Header */}
<header className="bg-white shadow-md">
<div className="container mx-auto px-4 py-6">
<div className="flex items-center justify-between">
<div>
<h1 className="text-3xl font-bold bg-gradient-to-r from-blue-600 to-purple-600 bg-clip-text text-transparent">
P2P Media Streaming
</h1>
<p className="text-gray-600 text-sm mt-1">
Streaming de video con tecnología P2P y chat en tiempo real
</p>
</div>
<div className="flex items-center space-x-2">
<div className="w-3 h-3 bg-green-400 rounded-full animate-pulse"></div>
<span className="text-sm text-gray-600">WebRTC Activo</span>
</div>
</div>
</div>
</header>
{/* Main Content */}
<main className="container mx-auto px-4 py-8">
<div className="grid grid-cols-1 lg:grid-cols-3 gap-6">
{/* Columna Principal - Video */}
<div className="lg:col-span-2 space-y-6">
{/* Banner de stream remoto */}
{watchingUser && (
<div className="bg-purple-100 border-2 border-purple-500 rounded-lg p-4">
<div className="flex items-center justify-between">
<div className="flex items-center space-x-3">
<span className="text-2xl">📺</span>
<div>
<h3 className="text-lg font-bold text-purple-900">
Viendo el reproductor de {watchingUser}
</h3>
<p className="text-sm text-purple-700">
{remoteStream ? '🟢 Conectado' : '🟡 Conectando...'}
</p>
</div>
</div>
<button
onClick={handleStopWatching}
className="bg-red-500 hover:bg-red-600 text-white font-medium px-4 py-2 rounded-lg transition-colors"
>
Cerrar
</button>
</div>
</div>
)}
{/* URL personalizada */}
{!watchingUser && (
<div className="bg-white rounded-lg shadow-lg p-4">
<h3 className="text-lg font-bold text-gray-800 mb-3">URL de Video Personalizada</h3>
<p className="text-xs text-gray-500 mb-2">
💡 Las URLs externas se procesan a través de nuestro proxy para evitar problemas de CORS
</p>
<div className="flex space-x-2">
<input
type="text"
value={customUrl}
onChange={(e) => 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"
/>
<button
onClick={loadCustomUrl}
className="bg-blue-500 hover:bg-blue-600 text-white font-medium px-6 py-2 rounded-lg transition-colors"
>
Cargar
</button>
</div>
{/* Videos de ejemplo */}
<div className="mt-4">
<p className="text-sm font-semibold text-gray-700 mb-2">Videos de ejemplo:</p>
<div className="flex flex-wrap gap-2">
{exampleVideos.map((video, index) => (
<button
key={index}
onClick={() => setVideoUrl(video.url)}
className="px-3 py-1 bg-gray-100 hover:bg-gray-200 text-gray-700 text-sm rounded-full transition-colors"
>
{video.name}
</button>
))}
</div>
</div>
</div>
)}
{/* Video Player */}
<div className="bg-white rounded-lg shadow-lg p-4" ref={videoPlayerRef}>
{watchingUser ? (
// Modo watching: mostrar stream remoto o mensaje de carga
remoteStream ? (
<VideoPlayer
isRemoteStream={true}
remoteStream={remoteStream}
socket={socket}
username={username}
peers={peers}
/>
) : (
<div className="flex flex-col items-center justify-center bg-gradient-to-br from-purple-100 to-blue-100 rounded-lg p-12 min-h-[400px]">
<div className="animate-spin rounded-full h-16 w-16 border-4 border-purple-500 border-t-transparent mb-4"></div>
<h3 className="text-2xl font-bold text-purple-900 mb-2">
Conectando con {watchingUser}...
</h3>
<p className="text-purple-700 text-center">
Estableciendo conexión P2P
</p>
<button
onClick={handleStopWatching}
className="mt-6 bg-red-500 hover:bg-red-600 text-white font-medium px-6 py-2 rounded-lg transition-colors"
>
Cancelar
</button>
</div>
)
) : (
// Modo normal: video propio
<VideoPlayer
key={videoUrl}
url={videoUrl}
onStats={handleVideoStats}
socket={socket}
username={username}
peers={peers}
/>
)}
</div>
</div>
{/* Columna Lateral - Chat */}
<div className="space-y-6">
{/* P2P Manager (oculto) */}
<div className="hidden">
<P2PManager
ref={p2pManagerRef}
socket={socket}
username={username}
onPeerStats={handlePeerStats}
onPeersUpdate={handlePeersUpdate}
localStream={localStream}
onRemoteStream={handleRemoteStream}
onNeedStream={captureLocalStream}
/>
</div>
{/* Chat */}
<div className="h-full">
<Chat
username={username}
onUsernameChange={setUsername}
onSocketReady={handleSocketReady}
onWatchUser={handleWatchUser}
/>
</div>
</div>
</div>
</main>
{/* Footer */}
<footer className="bg-white shadow-md mt-12">
<div className="container mx-auto px-4 py-6">
<div className="text-center text-gray-600 text-sm">
<p className="font-semibold mb-2">P2P Media Streaming Platform</p>
<p>Desarrollado con Next.js, Socket.IO, WebRTC y HLS.js</p>
<p className="mt-2 text-xs text-gray-500">
© 2025 - Streaming P2P sin WebTorrent | Servidor STUN: manalejandro.com:3478
</p>
</div>
</div>
</footer>
</div>
);
}

354
src/components/Chat.js Archivo normal
Ver fichero

@@ -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 (
<div className="bg-white rounded-lg shadow-lg p-6">
<h3 className="text-xl font-bold mb-4 text-gray-800">Únete al Chat</h3>
<form onSubmit={handleUsernameSubmit} className="space-y-4">
<div>
<label className="block text-sm font-medium text-gray-700 mb-2">
Nombre de usuario
</label>
<input
type="text"
value={tempUsername}
onChange={(e) => 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
/>
</div>
<button
type="submit"
className="w-full bg-blue-500 hover:bg-blue-600 text-white font-medium py-2 px-4 rounded-lg transition-colors"
>
Unirse
</button>
</form>
</div>
);
}
return (
<div className="bg-white rounded-lg shadow-lg overflow-hidden flex flex-col h-full">
{/* Header */}
<div className="bg-gradient-to-r from-blue-500 to-purple-600 p-4">
<div className="flex items-center justify-between">
<h3 className="text-white font-bold text-lg">Chat en Vivo</h3>
<div className="flex items-center space-x-2">
<div className={`w-2 h-2 rounded-full ${isConnected ? 'bg-green-400' : 'bg-red-400'}`}></div>
<span className="text-white text-sm">{isConnected ? 'Conectado' : 'Desconectado'}</span>
</div>
</div>
<p className="text-white text-sm mt-1">Como: <span className="font-semibold">{username}</span></p>
</div>
{/* Usuarios conectados */}
<div className="bg-gray-50 p-3 border-b">
<h4 className="text-xs font-semibold text-gray-600 mb-2">
USUARIOS CONECTADOS ({users.length})
</h4>
<div className="grid grid-cols-2 gap-2 max-h-40 overflow-y-auto">
{users.map((user, index) => {
const isCurrentUser = user === username;
const hasThumbnail = userThumbnails[user]?.thumbnail;
const isHovered = hoveredUser === user;
return (
<div
key={index}
className="relative group"
onMouseEnter={() => setHoveredUser(user)}
onMouseLeave={() => setHoveredUser(null)}
>
<button
onClick={() => !isCurrentUser && onWatchUser && onWatchUser(user)}
disabled={isCurrentUser}
className={`w-full px-2 py-1.5 rounded-lg text-xs font-medium transition-all ${
isCurrentUser
? 'bg-green-100 text-green-800 cursor-default'
: 'bg-blue-100 text-blue-800 hover:bg-blue-200 cursor-pointer'
}`}
>
<div className="flex items-center justify-between">
<span className="truncate">{user}</span>
{hasThumbnail && !isCurrentUser && (
<span className="ml-1 text-red-500">🔴</span>
)}
{isCurrentUser && (
<span className="ml-1">👤</span>
)}
</div>
</button>
{/* Thumbnail preview en hover - FLOTANTE */}
{isHovered && hasThumbnail && !isCurrentUser && (
<div className="fixed z-[9999] bg-white rounded-lg shadow-2xl border-4 border-blue-500 p-3"
style={{
left: '50%',
top: '50%',
transform: 'translate(-50%, -50%)',
width: '400px',
maxWidth: '90vw'
}}>
<div className="relative">
{/* eslint-disable-next-line @next/next/no-img-element */}
<img
src={userThumbnails[user].thumbnail}
alt={`Stream de ${user}`}
className="rounded w-full h-auto shadow-lg"
/>
<div className="absolute bottom-2 left-2 bg-black bg-opacity-80 rounded px-2 py-1">
<span className="text-white text-sm font-bold">
{userThumbnails[user].isPlaying ? '▶️ Reproduciendo' : '⏸️ Pausado'}
</span>
</div>
<div className="absolute top-2 left-2 bg-blue-600 bg-opacity-90 rounded px-2 py-1">
<span className="text-white text-sm font-bold">
{user}
</span>
</div>
</div>
<p className="text-sm text-center mt-3 font-bold text-blue-600 animate-pulse">
👆 Click para ver en vivo
</p>
</div>
)}
</div>
);
})}
</div>
</div>
{/* Mensajes */}
<div className="flex-1 overflow-y-auto p-4 space-y-2 bg-gray-50">
{messages.length === 0 ? (
<p className="text-gray-400 text-center text-sm">No hay mensajes aún...</p>
) : (
messages.map((msg, index) => (
<div
key={index}
className={`${
msg.type === 'system' || msg.type === 'error'
? 'text-center'
: msg.type === 'own'
? 'flex justify-end'
: 'flex justify-start'
}`}
>
{msg.type === 'system' && (
<span className="text-xs text-gray-500 italic">{msg.text}</span>
)}
{msg.type === 'error' && (
<span className="text-xs text-red-500 italic">{msg.text}</span>
)}
{(msg.type === 'user' || msg.type === 'own') && (
<div
className={`max-w-[75%] rounded-lg px-3 py-2 ${
msg.type === 'own'
? 'bg-blue-500 text-white'
: 'bg-white text-gray-800 shadow'
}`}
>
<p className={`text-xs font-semibold mb-1 ${
msg.type === 'own' ? 'text-blue-100' : 'text-blue-600'
}`}>
{msg.user}
</p>
<p className="text-sm break-words">{msg.text}</p>
</div>
)}
</div>
))
)}
<div ref={messagesEndRef} />
</div>
{/* Input de mensaje */}
<form onSubmit={sendMessage} className="p-4 bg-white border-t">
<div className="flex space-x-2">
<input
type="text"
value={inputMessage}
onChange={(e) => 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}
/>
<button
type="submit"
disabled={!isConnected || !inputMessage.trim()}
className="bg-blue-500 hover:bg-blue-600 disabled:bg-gray-300 disabled:cursor-not-allowed text-white font-medium px-6 py-2 rounded-lg transition-colors"
>
Enviar
</button>
</div>
</form>
</div>
);
}

431
src/components/P2PManager.js Archivo normal
Ver fichero

@@ -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 (
<div className="bg-white rounded-lg shadow-lg p-4">
<h3 className="text-lg font-bold text-gray-800 mb-4">Conexiones P2P</h3>
{/* Estadísticas */}
<div className="grid grid-cols-2 gap-4 mb-4">
<div className="bg-blue-50 rounded p-3">
<p className="text-xs text-gray-600">Peers Conectados</p>
<p className="text-2xl font-bold text-blue-600">{stats.connectedPeers}</p>
</div>
<div className="bg-green-50 rounded p-3">
<p className="text-xs text-gray-600">Subida</p>
<p className="text-lg font-bold text-green-600">
{(stats.uploadSpeed / 1024).toFixed(1)} KB/s
</p>
<p className="text-xs text-gray-500">
Total: {(stats.totalUploaded / 1024 / 1024).toFixed(2)} MB
</p>
</div>
<div className="bg-purple-50 rounded p-3">
<p className="text-xs text-gray-600">Descarga</p>
<p className="text-lg font-bold text-purple-600">
{(stats.downloadSpeed / 1024).toFixed(1)} KB/s
</p>
<p className="text-xs text-gray-500">
Total: {(stats.totalDownloaded / 1024 / 1024).toFixed(2)} MB
</p>
</div>
<div className="bg-yellow-50 rounded p-3">
<p className="text-xs text-gray-600">Configuración</p>
<p className="text-xs font-medium text-yellow-800">STUN Server</p>
<p className="text-xs text-gray-600">manalejandro.com:3478</p>
</div>
</div>
{/* Lista de peers */}
<div>
<h4 className="text-sm font-semibold text-gray-700 mb-2">Peers Conectados:</h4>
{peers.length === 0 ? (
<p className="text-sm text-gray-400 italic">Sin conexiones P2P activas</p>
) : (
<div className="space-y-1">
{peers.map((peer, index) => (
<div key={index} className="flex items-center space-x-2 text-sm">
<div className="w-2 h-2 bg-green-400 rounded-full animate-pulse"></div>
<span className="text-gray-700">{peer}</span>
</div>
))}
</div>
)}
</div>
</div>
);
});
P2PManager.displayName = 'P2PManager';
export default P2PManager;

197
src/components/VideoPlayer.js Archivo normal
Ver fichero

@@ -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 (
<div className="relative w-full">
{error ? (
<div className="bg-red-100 border border-red-400 text-red-700 px-4 py-3 rounded">
<p className="font-bold">Error</p>
<p className="text-sm">{error}</p>
</div>
) : (
<>
<video
ref={videoRef}
className="w-full rounded-lg shadow-xl"
controls
playsInline
autoPlay
muted={!isRemoteStream}
crossOrigin="anonymous"
/>
{isRemoteStream && (
<div className="absolute top-2 left-2 bg-purple-600 bg-opacity-90 rounded px-3 py-2">
<p className="text-white text-sm font-bold">
📡 Stream Remoto
</p>
</div>
)}
{!isRemoteStream && peers && peers.length > 0 && (
<div className="absolute top-2 left-2 bg-black bg-opacity-70 rounded px-2 py-1">
<p className="text-white text-xs font-medium">
<span className="text-green-400">🔗 {peers.length} peers</span>
</p>
</div>
)}
</>
)}
</div>
);
}