12
.dockerignore
Archivo normal
12
.dockerignore
Archivo normal
@@ -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
41
.gitignore
vendido
Archivo normal
@@ -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
49
Dockerfile
Archivo normal
@@ -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
21
LICENSE
Archivo normal
@@ -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
599
README.md
Archivo normal
@@ -0,0 +1,599 @@
|
|||||||
|
# 📺 P2P Media Streaming Platform
|
||||||
|
|
||||||
|

|
||||||
|

|
||||||
|

|
||||||
|

|
||||||
|

|
||||||
|
|
||||||
|
> 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
18
docker-compose.yml
Archivo normal
@@ -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
25
eslint.config.mjs
Archivo normal
@@ -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
7
jsconfig.json
Archivo normal
@@ -0,0 +1,7 @@
|
|||||||
|
{
|
||||||
|
"compilerOptions": {
|
||||||
|
"paths": {
|
||||||
|
"@/*": ["./src/*"]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
4
next.config.mjs
Archivo normal
4
next.config.mjs
Archivo normal
@@ -0,0 +1,4 @@
|
|||||||
|
/** @type {import('next').NextConfig} */
|
||||||
|
const nextConfig = {};
|
||||||
|
|
||||||
|
export default nextConfig;
|
||||||
93
nginx.conf
Archivo normal
93
nginx.conf
Archivo normal
@@ -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
29
package.json
Archivo normal
@@ -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
5
postcss.config.mjs
Archivo normal
@@ -0,0 +1,5 @@
|
|||||||
|
const config = {
|
||||||
|
plugins: ["@tailwindcss/postcss"],
|
||||||
|
};
|
||||||
|
|
||||||
|
export default config;
|
||||||
1
public/file.svg
Archivo normal
1
public/file.svg
Archivo normal
@@ -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
1
public/globe.svg
Archivo normal
@@ -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
1
public/next.svg
Archivo normal
@@ -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
1
public/vercel.svg
Archivo normal
@@ -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
1
public/window.svg
Archivo normal
@@ -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
498
server.js
Archivo normal
@@ -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
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
26
src/app/globals.css
Archivo normal
@@ -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
43
src/app/layout.js
Archivo normal
@@ -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
362
src/app/page.js
Archivo normal
@@ -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
354
src/components/Chat.js
Archivo normal
@@ -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
431
src/components/P2PManager.js
Archivo normal
@@ -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
197
src/components/VideoPlayer.js
Archivo normal
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
Referencia en una nueva incidencia
Block a user