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