Comparar commits

...

9 Commits

Autor SHA1 Mensaje Fecha
ale
02c84bc6fe anonymous users
Signed-off-by: ale <ale@manalejandro.com>
2025-11-29 23:42:59 +01:00
ale
de0fbce466 fix player reload
Signed-off-by: ale <ale@manalejandro.com>
2025-11-29 22:58:02 +01:00
ale
54cab5e611 auto-connect online
Signed-off-by: ale <ale@manalejandro.com>
2025-11-27 22:43:47 +01:00
ale
46198eca5d loop control and external links
Signed-off-by: ale <ale@manalejandro.com>
2025-11-27 22:07:11 +01:00
ale
10920e9f51 loop control and external links
Signed-off-by: ale <ale@manalejandro.com>
2025-11-27 22:03:01 +01:00
ale
686d418129 fix comment
Signed-off-by: ale <ale@manalejandro.com>
2025-11-24 18:19:29 +01:00
ale
0d10ad7b97 clean no peers
Signed-off-by: ale <ale@manalejandro.com>
2025-11-24 18:11:41 +01:00
ale
75555b8454 stats panel
Signed-off-by: ale <ale@manalejandro.com>
2025-11-24 18:03:28 +01:00
ale
18d1eb88da chat scroll
Signed-off-by: ale <ale@manalejandro.com>
2025-11-24 18:00:09 +01:00
Se han modificado 6 ficheros con 487 adiciones y 58 borrados

Ver fichero

@@ -19,10 +19,13 @@
- **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
- **Enlaces Compartibles**: Genera enlaces para que usuarios externos se unan directamente a tu stream
- **Auto-conexión**: Los usuarios que reciben un link compartido se conectan automáticamente al stream
### 💬 Chat en Tiempo Real
- Sistema de chat multiusuario con Socket.IO
- Lista de usuarios conectados con indicadores visuales
- Indicador visual (👁️) para ver quién está viendo tu stream
- Notificaciones de entrada/salida de usuarios
- Limitación de mensajes para prevenir spam
@@ -31,6 +34,7 @@
- **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
- **Protección contra Loops**: Prevención automática de bucles de video infinitos
### 🌐 Proxy de Streams
- Endpoints integrados para streams RTVE (La 1, La 2, 24H)
@@ -128,7 +132,17 @@ docker-compose up -d
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
### 5. Compartir tu Stream con Enlaces
1. Haz click en el botón "🔗 Compartir" en el header del chat
2. El enlace se copiará automáticamente al portapapeles
3. Comparte el enlace con quien quieras
4. Cuando alguien abra el enlace, se le pedirá un nombre de usuario
5. Después de registrarse, se conectará automáticamente a tu stream
**Formato del enlace**: `https://tu-dominio.com?watch=tu_nombre_usuario`
### 6. Volver a tu Video
- Haz click en el botón "✕ Cerrar" en el banner morado
- Volverás a tu reproductor local

Ver fichero

@@ -300,6 +300,14 @@ app.prepare().then(() => {
return;
}
// Validar que usuarios normales no puedan usar el prefijo "anon"
// Solo permitir si viene exactamente con el formato anon#### (4 dígitos)
const anonPattern = /^anon\d{4}$/;
if (username.toLowerCase().startsWith('anon') && !anonPattern.test(username)) {
socket.emit('error', 'El prefijo "anon" está reservado para usuarios anónimos del sistema');
return;
}
// Verificar si el usuario ya existe
const existingUser = Array.from(connectedUsers.values()).find(u => u.username === username);
if (existingUser) {
@@ -416,6 +424,28 @@ app.prepare().then(() => {
}
});
// Notificar loop detectado
socket.on('peer-loop-detected', (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-loop-detected', {
from: socket.username,
message: data.message
});
console.log(`⚠️ Loop detectado entre ${socket.username} y ${data.to}`);
}
} catch (error) {
console.error('Error al notificar loop:', error);
}
});
// Solicitar ver stream de usuario
socket.on('request-watch', (data) => {
try {

Ver fichero

@@ -12,17 +12,23 @@ export default function Home() {
const [username, setUsername] = useState('');
const [socket, setSocket] = useState(null);
const [peers, setPeers] = useState([]);
const [connectedUsers, setConnectedUsers] = 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 [autoConnectUser, setAutoConnectUser] = useState(null);
const [showAutoConnectBanner, setShowAutoConnectBanner] = useState(false);
const videoPlayerRef = useRef(null);
const p2pManagerRef = useRef(null);
const [stats, setStats] = useState({
http: 0,
p2p: 0
p2p: 0,
uploadSpeed: 0,
downloadSpeed: 0,
peers: 0
});
// URLs de ejemplo - usando proxy del servidor
@@ -124,6 +130,13 @@ export default function Home() {
} else {
setVideoUrl(exampleVideos[0].url);
}
// Detectar parámetro ?watch=username para auto-conectar
const watchParam = searchParams.get('watch');
if (watchParam) {
setAutoConnectUser(watchParam);
console.log('🔗 Link compartido detectado. Auto-conectando con:', watchParam);
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [searchParams]);
@@ -135,10 +148,13 @@ export default function Home() {
}, []);
const handlePeerStats = useCallback((data) => {
// Actualizar estadísticas P2P - usar los deltas que envía el componente
// Actualizar estadísticas P2P completas
setStats(prev => ({
...prev,
p2p: prev.p2p + (data.downloadSpeed || 0) * 5 // velocidad * 5 segundos = bytes descargados en este intervalo
p2p: prev.p2p + (data.downloadSpeed || 0) * 5,
uploadSpeed: data.uploadSpeed || 0,
downloadSpeed: data.downloadSpeed || 0,
peers: data.peers || 0
}));
}, []);
@@ -147,8 +163,22 @@ export default function Home() {
setSocket(socketInstance);
}, []);
const handleUsersUpdate = useCallback((users) => {
console.log('👥 Usuarios conectados actualizados:', users);
setConnectedUsers(users);
}, []);
const handlePeersUpdate = useCallback((peersList) => {
setPeers(peersList);
// Si no hay peers, resetear las estadísticas de velocidad
if (peersList.length === 0) {
setStats(prev => ({
...prev,
uploadSpeed: 0,
downloadSpeed: 0,
peers: 0
}));
}
}, []);
const captureLocalStream = useCallback(() => {
@@ -211,6 +241,13 @@ export default function Home() {
alert('No puedes ver tu propio stream. Conéctate desde otro navegador o dispositivo.');
return;
}
// Protección contra loops: si el usuario objetivo ya me está viendo, no permitir
if (peers.includes(targetUser)) {
console.error('⚠️ Loop detectado: el usuario ya te está viendo');
alert(`⚠️ No se puede crear conexión: ${targetUser} ya está viendo tu stream.\n\nEsto crearía un bucle infinito de video. Espera a que ${targetUser} cierre tu stream primero.`);
return;
}
console.log('👁️ Solicitando ver a:', targetUser);
console.log('Estado actual:', {
@@ -234,12 +271,49 @@ export default function Home() {
} else {
console.error('❌ No hay P2PManager o requestPeer disponible');
}
}, [socket, watchingUser, remoteStream, username]);
}, [socket, watchingUser, remoteStream, username, peers]);
// Auto-conectar cuando el usuario ingresa al chat (después de registrarse)
useEffect(() => {
if (autoConnectUser && username && socket && socket.connected) {
console.log('🚀 Validando auto-conexión con:', autoConnectUser);
setShowAutoConnectBanner(true);
// Esperar 1.5 segundos para dar tiempo a recibir la lista de usuarios
const timer = setTimeout(() => {
// Verificar si el usuario objetivo está conectado
if (connectedUsers.includes(autoConnectUser)) {
console.log('✅ Usuario encontrado, conectando...');
handleWatchUser(autoConnectUser);
} else {
console.log('❌ Usuario no conectado:', autoConnectUser);
alert(`⚠️ El usuario "${autoConnectUser}" no está conectado en este momento.\n\nPor favor, pídele que se conecte o intenta más tarde.`);
}
setShowAutoConnectBanner(false);
setAutoConnectUser(null); // Limpiar para no reconectar
}, 1500);
return () => clearTimeout(timer);
}
}, [autoConnectUser, username, socket, connectedUsers, handleWatchUser]);
const handleStopWatching = useCallback(() => {
console.log('🛑 Dejando de ver stream remoto');
if (watchingUser && p2pManagerRef.current && p2pManagerRef.current.closePeer) {
p2pManagerRef.current.closePeer(watchingUser);
}
setWatchingUser(null);
setRemoteStream(null);
}, []);
}, [watchingUser]);
const handleLoopDetected = useCallback((fromUser) => {
console.log('⚠️ Loop detectado, limpiando estado de watchingUser');
// Si estamos viendo a ese usuario, cerrar
if (watchingUser === fromUser) {
setWatchingUser(null);
setRemoteStream(null);
}
}, [watchingUser]);
const loadCustomUrl = () => {
if (customUrl.trim()) {
@@ -267,9 +341,34 @@ export default function Home() {
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 className="flex items-center space-x-4">
{/* Estadísticas P2P */}
{stats.peers > 0 && (
<div className="flex items-center space-x-4 text-sm">
<div className="flex items-center space-x-1">
<span className="text-gray-600">👥</span>
<span className="font-semibold text-blue-600">{stats.peers}</span>
</div>
<div className="flex items-center space-x-1">
<span className="text-gray-600"></span>
<span className="font-semibold text-green-600">
{(stats.uploadSpeed / 1024).toFixed(1)} KB/s
</span>
</div>
<div className="flex items-center space-x-1">
<span className="text-gray-600"></span>
<span className="font-semibold text-purple-600">
{(stats.downloadSpeed / 1024).toFixed(1)} KB/s
</span>
</div>
</div>
)}
<div className="flex items-center space-x-2">
<div className={`w-3 h-3 rounded-full animate-pulse ${stats.peers > 0 ? 'bg-green-400' : 'bg-gray-400'}`}></div>
<span className="text-sm text-gray-600">
{stats.peers > 0 ? 'WebRTC Activo' : 'WebRTC Listo'}
</span>
</div>
</div>
</div>
</div>
@@ -281,6 +380,19 @@ export default function Home() {
{/* Columna Principal - Video */}
<div className="lg:col-span-2 space-y-6">
{/* Banner de stream remoto */}
{/* Banner de auto-conexión */}
{showAutoConnectBanner && (
<div className="bg-gradient-to-r from-green-400 to-blue-500 text-white rounded-lg p-4 shadow-lg animate-pulse">
<div className="flex items-center space-x-3">
<span className="text-3xl">🔗</span>
<div>
<h3 className="text-lg font-bold">Conectando automáticamente...</h3>
<p className="text-sm">Uniéndose al stream de {autoConnectUser}</p>
</div>
</div>
</div>
)}
{watchingUser && (
<div className="bg-purple-100 border-2 border-purple-500 rounded-lg p-4">
<div className="flex items-center justify-between">
@@ -408,7 +520,6 @@ export default function Home() {
) : (
// Modo normal: video propio
<VideoPlayer
key={videoUrl}
url={videoUrl}
onStats={handleVideoStats}
socket={socket}
@@ -418,7 +529,45 @@ export default function Home() {
)}
</div>
{/* Panel de Estadísticas P2P - Debajo del reproductor */}
{stats.peers > 0 && (
<div className="bg-gradient-to-br from-blue-50 to-purple-50 rounded-lg shadow-lg p-4 border-2 border-blue-200">
<h3 className="text-lg font-bold text-gray-800 mb-3 flex items-center">
<span className="mr-2">📊</span>
Estadísticas P2P en Tiempo Real
</h3>
<div className="grid grid-cols-2 gap-3">
<div className="bg-white rounded-lg p-3 shadow">
<p className="text-xs text-gray-600 mb-1">👥 Peers Conectados</p>
<p className="text-2xl font-bold text-blue-600">{stats.peers}</p>
</div>
<div className="bg-white rounded-lg p-3 shadow">
<p className="text-xs text-gray-600 mb-1"> Subida</p>
<p className="text-xl font-bold text-green-600">
{(stats.uploadSpeed / 1024).toFixed(1)}
</p>
<p className="text-xs text-gray-500">KB/s</p>
</div>
<div className="bg-white rounded-lg p-3 shadow">
<p className="text-xs text-gray-600 mb-1"> Descarga</p>
<p className="text-xl font-bold text-purple-600">
{(stats.downloadSpeed / 1024).toFixed(1)}
</p>
<p className="text-xs text-gray-500">KB/s</p>
</div>
<div className="bg-white rounded-lg p-3 shadow">
<p className="text-xs text-gray-600 mb-1">📦 Total P2P</p>
<p className="text-lg font-bold text-orange-600">
{(stats.p2p / 1024 / 1024).toFixed(2)}
</p>
<p className="text-xs text-gray-500">MB</p>
</div>
</div>
</div>
)}
</div>
{/* Columna Lateral - Chat */}
@@ -434,16 +583,19 @@ export default function Home() {
localStream={localStream}
onRemoteStream={handleRemoteStream}
onNeedStream={captureLocalStream}
onLoopDetected={handleLoopDetected}
/>
</div>
{/* Chat */}
<div className="h-full">
{/* Chat - altura fija con scroll interno */}
<div className="h-[calc(100vh-12rem)] min-h-[600px]">
<Chat
username={username}
onUsernameChange={setUsername}
onSocketReady={handleSocketReady}
onWatchUser={handleWatchUser}
onUsersUpdate={handleUsersUpdate}
peers={peers}
/>
</div>
</div>

Ver fichero

@@ -6,7 +6,7 @@ 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 }) {
export default function Chat({ username, onUsernameChange, onSocketReady, onWatchUser, onUsersUpdate, peers = [] }) {
const [socket, setSocket] = useState(null);
const [messages, setMessages] = useState([]);
const [users, setUsers] = useState([]);
@@ -15,6 +15,8 @@ export default function Chat({ username, onUsernameChange, onSocketReady, onWatc
const [isConnected, setIsConnected] = useState(false);
const [tempUsername, setTempUsername] = useState(username || '');
const [hoveredUser, setHoveredUser] = useState(null);
const [showCopiedTooltip, setShowCopiedTooltip] = useState(false);
const [showUsernameForm, setShowUsernameForm] = useState(false);
const messagesEndRef = useRef(null);
// Auto-scroll al final de los mensajes
@@ -50,11 +52,21 @@ export default function Chat({ username, onUsernameChange, onSocketReady, onWatc
});
newSocket.on('users', (data) => {
setUsers(data.users || []);
const userList = data.users || [];
setUsers(userList);
if (onUsersUpdate) {
onUsersUpdate(userList);
}
});
newSocket.on('adduser', (data) => {
setUsers(prevUsers => [...prevUsers, data.user]);
setUsers(prevUsers => {
const newUsers = [...prevUsers, data.user];
if (onUsersUpdate) {
onUsersUpdate(newUsers);
}
return newUsers;
});
});
newSocket.on('user-thumbnail', (data) => {
@@ -113,6 +125,16 @@ export default function Chat({ username, onUsernameChange, onSocketReady, onWatc
newSocket.on('error', (error) => {
console.error('Error del servidor:', error);
// Si el error es sobre el prefijo "anon", resetear el usuario para que vuelva al formulario
if (error.includes('prefijo "anon"') || error.includes('anon')) {
alert(error);
if (onUsernameChange) {
onUsernameChange('');
}
return;
}
setMessages(prev => [
...prev,
{
@@ -153,11 +175,47 @@ export default function Chat({ username, onUsernameChange, onSocketReady, onWatc
const handleUsernameSubmit = (e) => {
e.preventDefault();
if (tempUsername.trim().length >= 2) {
if (onUsernameChange) {
onUsernameChange(tempUsername.trim());
}
// Si no hay nombre, usar anónimo
if (!tempUsername.trim() || tempUsername.trim().length < 2) {
handleSkipUsername();
return;
}
const normalizedUsername = tempUsername.trim();
// Validar que no use el prefijo reservado "anon"
if (normalizedUsername.toLowerCase().startsWith('anon')) {
alert('⚠️ El prefijo "anon" está reservado para usuarios anónimos.\n\nPor favor, elige otro nombre de usuario.');
return;
}
if (onUsernameChange) {
onUsernameChange(normalizedUsername);
}
};
const handleSkipUsername = () => {
// Generar usuario anónimo: anon + 4 dígitos aleatorios
const randomNum = Math.floor(1000 + Math.random() * 9000);
const anonUsername = `anon${randomNum}`;
if (onUsernameChange) {
onUsernameChange(anonUsername);
}
};
const shareMyStream = () => {
const shareUrl = `${window.location.origin}?watch=${encodeURIComponent(username)}`;
navigator.clipboard.writeText(shareUrl).then(() => {
setShowCopiedTooltip(true);
setTimeout(() => setShowCopiedTooltip(false), 2000);
}).catch(err => {
console.error('Error al copiar:', err);
// Fallback: mostrar prompt
prompt('Copia este enlace:', shareUrl);
});
};
// Formulario de username si no está conectado
@@ -168,7 +226,7 @@ export default function Chat({ username, onUsernameChange, onSocketReady, onWatc
<form onSubmit={handleUsernameSubmit} className="space-y-4">
<div>
<label className="block text-sm font-medium text-gray-700 mb-2">
Nombre de usuario
Nombre de usuario (opcional)
</label>
<input
type="text"
@@ -178,15 +236,26 @@ export default function Chat({ username, onUsernameChange, onSocketReady, onWatc
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
/>
<p className="text-xs text-gray-500 mt-1">
🚫 El prefijo &quot;anon&quot; está reservado para usuarios anónimos
</p>
</div>
<div className="space-y-2">
<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 con este nombre
</button>
<button
type="button"
onClick={handleSkipUsername}
className="w-full bg-gray-400 hover:bg-gray-500 text-white font-medium py-2 px-4 rounded-lg transition-colors"
>
Continuar como anónimo
</button>
</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>
);
@@ -195,7 +264,7 @@ export default function Chat({ username, onUsernameChange, onSocketReady, onWatc
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="bg-gradient-to-r from-blue-500 to-purple-600 p-4 flex-shrink-0">
<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">
@@ -203,20 +272,37 @@ export default function Chat({ username, onUsernameChange, onSocketReady, onWatc
<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 className="flex items-center justify-between mt-2">
<p className="text-white text-sm">Como: <span className="font-semibold">{username}</span></p>
<div className="relative">
<button
onClick={shareMyStream}
className="bg-white/20 hover:bg-white/30 text-white text-xs font-medium px-3 py-1 rounded-full transition-colors flex items-center space-x-1"
title="Compartir enlace para que otros vean tu stream"
>
<span>🔗</span>
<span>Compartir</span>
</button>
{showCopiedTooltip && (
<div className="absolute top-full right-0 mt-1 bg-green-600 text-white text-xs px-2 py-1 rounded shadow-lg whitespace-nowrap z-50">
Enlace copiado
</div>
)}
</div>
</div>
</div>
{/* Usuarios conectados */}
<div className="bg-gray-50 p-3 border-b">
<div className="bg-gray-50 p-3 border-b flex-shrink-0">
<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) => {
<div className="grid grid-cols-2 gap-2 max-h-32 overflow-y-auto">{users.map((user, index) => {
const isCurrentUser = user === username;
const hasThumbnail = userThumbnails[user]?.thumbnail;
const isHovered = hoveredUser === user;
const isWatchingMe = peers.includes(user); // Este usuario me está viendo
return (
<div
@@ -236,12 +322,17 @@ export default function Chat({ username, onUsernameChange, onSocketReady, onWatc
>
<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 className="flex items-center space-x-1">
{isWatchingMe && !isCurrentUser && (
<span className="text-purple-600" title="Te está viendo">👁</span>
)}
{hasThumbnail && !isCurrentUser && (
<span className="text-red-500">🔴</span>
)}
{isCurrentUser && (
<span>👤</span>
)}
</div>
</div>
</button>
@@ -329,7 +420,7 @@ export default function Chat({ username, onUsernameChange, onSocketReady, onWatc
</div>
{/* Input de mensaje */}
<form onSubmit={sendMessage} className="p-4 bg-white border-t">
<form onSubmit={sendMessage} className="p-4 bg-white border-t flex-shrink-0">
<div className="flex space-x-2">
<input
type="text"

Ver fichero

@@ -36,7 +36,8 @@ const P2PManager = forwardRef(({
onPeersUpdate,
localStream = null,
onRemoteStream = null,
onNeedStream = null
onNeedStream = null,
onLoopDetected = null
}, ref) => {
const [peers, setPeers] = useState([]);
const [stats, setStats] = useState({
@@ -64,6 +65,7 @@ const P2PManager = forwardRef(({
const onPeersUpdateRef = useRef(onPeersUpdate);
const onRemoteStreamRef = useRef(onRemoteStream);
const onNeedStreamRef = useRef(onNeedStream);
const onLoopDetectedRef = useRef(onLoopDetected);
// Actualizar refs cuando cambien las callbacks
useEffect(() => {
@@ -71,12 +73,38 @@ const P2PManager = forwardRef(({
onPeersUpdateRef.current = onPeersUpdate;
onRemoteStreamRef.current = onRemoteStream;
onNeedStreamRef.current = onNeedStream;
}, [onPeerStats, onPeersUpdate, onRemoteStream, onNeedStream]);
onLoopDetectedRef.current = onLoopDetected;
}, [onPeerStats, onPeersUpdate, onRemoteStream, onNeedStream, onLoopDetected]);
// Exponer métodos al componente padre
useImperativeHandle(ref, () => ({
requestPeer: (targetUser) => {
return requestPeer(targetUser);
},
closePeer: (targetUser) => {
const peer = peersRef.current[targetUser];
if (peer) {
console.log(`🔌 Cerrando peer con ${targetUser}`);
peer.destroy();
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;
});
}
},
closeAllPeers: () => {
console.log('🔌 Cerrando todos los peers');
Object.keys(peersRef.current).forEach(targetUser => {
const peer = peersRef.current[targetUser];
if (peer) peer.destroy();
});
peersRef.current = {};
remoteStreamsRef.current = {};
setPeers([]);
if (onPeersUpdateRef.current) onPeersUpdateRef.current([]);
}
}));
@@ -105,6 +133,17 @@ const P2PManager = forwardRef(({
socket.on('peer-requested', (data) => {
if (!data || !data.from) return;
// PROTECCIÓN CONTRA LOOPS: Si YO estoy viendo a ese usuario, rechazar
const amWatchingThem = peers.includes(data.from);
if (amWatchingThem) {
console.error('⚠️ Loop detectado: estás viendo a', data.from, 'y ahora intenta verte');
socket.emit('peer-loop-detected', {
to: data.from,
message: `No se puede establecer conexión: ya estás viendo a ${username}. Esto crearía un bucle infinito.`
});
return;
}
// Verificar si ya existe un peer
const existingPeer = peersRef.current[data.from];
if (existingPeer) {
@@ -169,6 +208,30 @@ const P2PManager = forwardRef(({
alert(`No se puede conectar: ${data.message}`);
});
// Loop detectado por el otro usuario
socket.on('peer-loop-detected', (data) => {
console.error('⚠️ Loop detectado por el otro usuario:', data.message);
alert(`⚠️ Loop de video detectado\n\n${data.message}`);
// Si tenemos un peer con ese usuario, cerrarlo
if (data.from && peersRef.current[data.from]) {
const peer = peersRef.current[data.from];
peer.destroy();
delete peersRef.current[data.from];
delete remoteStreamsRef.current[data.from];
setPeers(prev => {
const newPeers = prev.filter(p => p !== data.from);
if (onPeersUpdateRef.current) onPeersUpdateRef.current(newPeers);
return newPeers;
});
}
// Notificar al componente padre
if (onLoopDetectedRef.current) {
onLoopDetectedRef.current(data.from);
}
});
// Intervalo para calcular estadísticas
statsInterval.current = setInterval(() => {
const uploadSpeed = uploadBytes.current / 5;
@@ -201,6 +264,7 @@ const P2PManager = forwardRef(({
socket.off('peer-requested');
socket.off('signal');
socket.off('peer-not-found');
socket.off('peer-loop-detected');
Object.values(peersRef.current).forEach(peer => {
if (peer) peer.destroy();
@@ -257,11 +321,56 @@ const P2PManager = forwardRef(({
});
peer.on('connect', () => {
console.log(`✅ Peer conectado con ${targetUser}`);
setPeers(prev => {
const newPeers = [...prev, targetUser];
if (onPeersUpdateRef.current) onPeersUpdateRef.current(newPeers);
return newPeers;
});
// Iniciar monitoreo de estadísticas WebRTC cada 1 segundo
const statsMonitor = setInterval(async () => {
if (!peer._pc || peer.destroyed) {
clearInterval(statsMonitor);
return;
}
try {
const stats = await peer._pc.getStats();
let bytesReceived = 0;
let bytesSent = 0;
stats.forEach(report => {
if (report.type === 'inbound-rtp' && report.mediaType === 'video') {
bytesReceived += report.bytesReceived || 0;
}
if (report.type === 'outbound-rtp' && report.mediaType === 'video') {
bytesSent += report.bytesSent || 0;
}
});
// Guardar stats anteriores para calcular delta
if (!peer._lastStats) {
peer._lastStats = { bytesReceived, bytesSent, timestamp: Date.now() };
} else {
const deltaTime = (Date.now() - peer._lastStats.timestamp) / 1000;
const deltaReceived = bytesReceived - peer._lastStats.bytesReceived;
const deltaSent = bytesSent - peer._lastStats.bytesSent;
if (deltaTime > 0) {
downloadBytes.current += deltaReceived;
uploadBytes.current += deltaSent;
}
peer._lastStats = { bytesReceived, bytesSent, timestamp: Date.now() };
}
} catch (err) {
// Ignorar errores de stats
}
}, 1000);
// Guardar el interval en el peer para limpiarlo después
peer._statsMonitor = statsMonitor;
});
peer.on('data', (data) => {
@@ -277,6 +386,10 @@ const P2PManager = forwardRef(({
});
peer.on('close', () => {
console.log(`🔌 Peer cerrado con ${targetUser}`);
if (peer._statsMonitor) {
clearInterval(peer._statsMonitor);
}
if (peersRef.current[targetUser] === peer) {
delete peersRef.current[targetUser];
delete remoteStreamsRef.current[targetUser];
@@ -290,6 +403,9 @@ const P2PManager = forwardRef(({
peer.on('error', (err) => {
console.error('❌ Error en peer', targetUser, ':', err.message || err);
if (peer._statsMonitor) {
clearInterval(peer._statsMonitor);
}
if (peersRef.current[targetUser] === peer) {
delete peersRef.current[targetUser];
delete remoteStreamsRef.current[targetUser];

Ver fichero

@@ -16,6 +16,7 @@ export default function VideoPlayer({
const hlsRef = useRef(null);
const [error, setError] = useState(null);
const isInitializedRef = useRef(false);
const currentUrlRef = useRef(null);
const segmentCache = useRef(new Map());
const canvasRef = useRef(null);
const thumbnailIntervalRef = useRef(null);
@@ -82,10 +83,25 @@ export default function VideoPlayer({
}, [socket, username, isRemoteStream]);
useEffect(() => {
if (isInitializedRef.current) return;
if (!url || !videoRef.current || isRemoteStream) return;
// Si la URL cambió, necesitamos reiniciar
const urlChanged = currentUrlRef.current && currentUrlRef.current !== url;
if (urlChanged) {
console.log('📺 URL cambió, recargando video...', url);
isInitializedRef.current = false;
if (hlsRef.current) {
hlsRef.current.destroy();
hlsRef.current = null;
}
segmentCache.current.clear();
}
if (isInitializedRef.current && !urlChanged) return;
isInitializedRef.current = true;
currentUrlRef.current = url;
const video = videoRef.current;
@@ -126,37 +142,47 @@ export default function VideoPlayer({
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;
currentUrlRef.current = null;
};
} else if (video.canPlayType('application/vnd.apple.mpegurl')) {
video.src = url;
return () => {
isInitializedRef.current = false;
currentUrlRef.current = null;
};
} else {
setError('Tu navegador no soporta HLS');
}
}, [url, socket]);
}, [url, isRemoteStream]);
// Efecto separado para manejar eventos del socket (no reinicia el player)
useEffect(() => {
if (!socket) return;
const handleRequestSegment = (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))
});
}
};
socket.on('request-segment', handleRequestSegment);
return () => {
socket.off('request-segment', handleRequestSegment);
};
}, [socket]);
return (
<div className="relative w-full">