loop control and external links

Signed-off-by: ale <ale@manalejandro.com>
Este commit está contenido en:
ale
2025-11-27 22:03:01 +01:00
padre 686d418129
commit 10920e9f51
Se han modificado 5 ficheros con 180 adiciones y 11 borrados

Ver fichero

@@ -19,10 +19,13 @@
- **Miniaturas en Vivo**: Previsualizaciones de video actualizadas cada 2 segundos con hover preview - **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 - **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 - **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 ### 💬 Chat en Tiempo Real
- Sistema de chat multiusuario con Socket.IO - Sistema de chat multiusuario con Socket.IO
- Lista de usuarios conectados con indicadores visuales - Lista de usuarios conectados con indicadores visuales
- Indicador visual (👁️) para ver quién está viendo tu stream
- Notificaciones de entrada/salida de usuarios - Notificaciones de entrada/salida de usuarios
- Limitación de mensajes para prevenir spam - Limitación de mensajes para prevenir spam
@@ -31,6 +34,7 @@
- **Límite de Conexiones**: Máximo 5 conexiones simultáneas 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 - **Validación de Datos**: Sanitización automática de mensajes y nombres
- **CORS Configurado**: Seguridad en comunicaciones cross-origin - **CORS Configurado**: Seguridad en comunicaciones cross-origin
- **Protección contra Loops**: Prevención automática de bucles de video infinitos
### 🌐 Proxy de Streams ### 🌐 Proxy de Streams
- Endpoints integrados para streams RTVE (La 1, La 2, 24H) - 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 3. Haz click en el usuario para cargar su stream en tu reproductor
4. El video se transmitirá directamente vía WebRTC (P2P) 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 - Haz click en el botón "✕ Cerrar" en el banner morado
- Volverás a tu reproductor local - Volverás a tu reproductor local

Ver fichero

@@ -416,6 +416,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 // Solicitar ver stream de usuario
socket.on('request-watch', (data) => { socket.on('request-watch', (data) => {
try { try {

Ver fichero

@@ -18,6 +18,8 @@ export default function Home() {
const [remoteStream, setRemoteStream] = useState(null); const [remoteStream, setRemoteStream] = useState(null);
const [watchingUser, setWatchingUser] = useState(null); const [watchingUser, setWatchingUser] = useState(null);
const [isCapturingStream, setIsCapturingStream] = useState(false); const [isCapturingStream, setIsCapturingStream] = useState(false);
const [autoConnectUser, setAutoConnectUser] = useState(null);
const [showAutoConnectBanner, setShowAutoConnectBanner] = useState(false);
const videoPlayerRef = useRef(null); const videoPlayerRef = useRef(null);
const p2pManagerRef = useRef(null); const p2pManagerRef = useRef(null);
const [stats, setStats] = useState({ const [stats, setStats] = useState({
@@ -127,9 +129,33 @@ export default function Home() {
} else { } else {
setVideoUrl(exampleVideos[0].url); 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 // eslint-disable-next-line react-hooks/exhaustive-deps
}, [searchParams]); }, [searchParams]);
// Auto-conectar cuando el usuario ingresa al chat (después de registrarse)
useEffect(() => {
if (autoConnectUser && username && socket && socket.connected) {
console.log('🚀 Iniciando auto-conexión con:', autoConnectUser);
setShowAutoConnectBanner(true);
// Esperar 1 segundo para que el usuario vea el banner y el socket esté completamente listo
const timer = setTimeout(() => {
handleWatchUser(autoConnectUser);
setShowAutoConnectBanner(false);
setAutoConnectUser(null); // Limpiar para no reconectar
}, 1500);
return () => clearTimeout(timer);
}
}, [autoConnectUser, username, socket, handleWatchUser]);
const handleVideoStats = useCallback((data) => { const handleVideoStats = useCallback((data) => {
setStats(prev => ({ setStats(prev => ({
...prev, ...prev,
@@ -227,6 +253,13 @@ export default function Home() {
return; 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('👁️ Solicitando ver a:', targetUser);
console.log('Estado actual:', { console.log('Estado actual:', {
socket: !!socket, socket: !!socket,
@@ -260,6 +293,15 @@ export default function Home() {
setRemoteStream(null); setRemoteStream(null);
}, [watchingUser]); }, [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 = () => { const loadCustomUrl = () => {
if (customUrl.trim()) { if (customUrl.trim()) {
// Si la URL es externa, usar el proxy // Si la URL es externa, usar el proxy
@@ -325,6 +367,19 @@ export default function Home() {
{/* Columna Principal - Video */} {/* Columna Principal - Video */}
<div className="lg:col-span-2 space-y-6"> <div className="lg:col-span-2 space-y-6">
{/* Banner de stream remoto */} {/* 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 && ( {watchingUser && (
<div className="bg-purple-100 border-2 border-purple-500 rounded-lg p-4"> <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 justify-between">
@@ -516,6 +571,7 @@ export default function Home() {
localStream={localStream} localStream={localStream}
onRemoteStream={handleRemoteStream} onRemoteStream={handleRemoteStream}
onNeedStream={captureLocalStream} onNeedStream={captureLocalStream}
onLoopDetected={handleLoopDetected}
/> />
</div> </div>
@@ -526,6 +582,7 @@ export default function Home() {
onUsernameChange={setUsername} onUsernameChange={setUsername}
onSocketReady={handleSocketReady} onSocketReady={handleSocketReady}
onWatchUser={handleWatchUser} onWatchUser={handleWatchUser}
peers={peers}
/> />
</div> </div>
</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 * 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, peers = [] }) {
const [socket, setSocket] = useState(null); const [socket, setSocket] = useState(null);
const [messages, setMessages] = useState([]); const [messages, setMessages] = useState([]);
const [users, setUsers] = useState([]); const [users, setUsers] = useState([]);
@@ -15,6 +15,7 @@ export default function Chat({ username, onUsernameChange, onSocketReady, onWatc
const [isConnected, setIsConnected] = useState(false); const [isConnected, setIsConnected] = useState(false);
const [tempUsername, setTempUsername] = useState(username || ''); const [tempUsername, setTempUsername] = useState(username || '');
const [hoveredUser, setHoveredUser] = useState(null); const [hoveredUser, setHoveredUser] = useState(null);
const [showCopiedTooltip, setShowCopiedTooltip] = useState(false);
const messagesEndRef = useRef(null); const messagesEndRef = useRef(null);
// Auto-scroll al final de los mensajes // Auto-scroll al final de los mensajes
@@ -160,6 +161,19 @@ export default function Chat({ username, onUsernameChange, onSocketReady, onWatc
} }
}; };
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 // Formulario de username si no está conectado
if (!username) { if (!username) {
return ( return (
@@ -203,7 +217,24 @@ export default function Chat({ username, onUsernameChange, onSocketReady, onWatc
<span className="text-white text-sm">{isConnected ? 'Conectado' : 'Desconectado'}</span> <span className="text-white text-sm">{isConnected ? 'Conectado' : 'Desconectado'}</span>
</div> </div>
</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> </div>
{/* Usuarios conectados */} {/* Usuarios conectados */}
@@ -216,6 +247,7 @@ export default function Chat({ username, onUsernameChange, onSocketReady, onWatc
const isCurrentUser = user === username; const isCurrentUser = user === username;
const hasThumbnail = userThumbnails[user]?.thumbnail; const hasThumbnail = userThumbnails[user]?.thumbnail;
const isHovered = hoveredUser === user; const isHovered = hoveredUser === user;
const isWatchingMe = peers.includes(user); // Este usuario me está viendo
return ( return (
<div <div
@@ -235,12 +267,17 @@ export default function Chat({ username, onUsernameChange, onSocketReady, onWatc
> >
<div className="flex items-center justify-between"> <div className="flex items-center justify-between">
<span className="truncate">{user}</span> <span className="truncate">{user}</span>
{hasThumbnail && !isCurrentUser && ( <div className="flex items-center space-x-1">
<span className="ml-1 text-red-500">🔴</span> {isWatchingMe && !isCurrentUser && (
)} <span className="text-purple-600" title="Te está viendo">👁</span>
{isCurrentUser && ( )}
<span className="ml-1">👤</span> {hasThumbnail && !isCurrentUser && (
)} <span className="text-red-500">🔴</span>
)}
{isCurrentUser && (
<span>👤</span>
)}
</div>
</div> </div>
</button> </button>

Ver fichero

@@ -36,7 +36,8 @@ const P2PManager = forwardRef(({
onPeersUpdate, onPeersUpdate,
localStream = null, localStream = null,
onRemoteStream = null, onRemoteStream = null,
onNeedStream = null onNeedStream = null,
onLoopDetected = null
}, ref) => { }, ref) => {
const [peers, setPeers] = useState([]); const [peers, setPeers] = useState([]);
const [stats, setStats] = useState({ const [stats, setStats] = useState({
@@ -64,6 +65,7 @@ const P2PManager = forwardRef(({
const onPeersUpdateRef = useRef(onPeersUpdate); const onPeersUpdateRef = useRef(onPeersUpdate);
const onRemoteStreamRef = useRef(onRemoteStream); const onRemoteStreamRef = useRef(onRemoteStream);
const onNeedStreamRef = useRef(onNeedStream); const onNeedStreamRef = useRef(onNeedStream);
const onLoopDetectedRef = useRef(onLoopDetected);
// Actualizar refs cuando cambien las callbacks // Actualizar refs cuando cambien las callbacks
useEffect(() => { useEffect(() => {
@@ -71,7 +73,8 @@ const P2PManager = forwardRef(({
onPeersUpdateRef.current = onPeersUpdate; onPeersUpdateRef.current = onPeersUpdate;
onRemoteStreamRef.current = onRemoteStream; onRemoteStreamRef.current = onRemoteStream;
onNeedStreamRef.current = onNeedStream; onNeedStreamRef.current = onNeedStream;
}, [onPeerStats, onPeersUpdate, onRemoteStream, onNeedStream]); onLoopDetectedRef.current = onLoopDetected;
}, [onPeerStats, onPeersUpdate, onRemoteStream, onNeedStream, onLoopDetected]);
// Exponer métodos al componente padre // Exponer métodos al componente padre
useImperativeHandle(ref, () => ({ useImperativeHandle(ref, () => ({
@@ -130,6 +133,17 @@ const P2PManager = forwardRef(({
socket.on('peer-requested', (data) => { socket.on('peer-requested', (data) => {
if (!data || !data.from) return; 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 // Verificar si ya existe un peer
const existingPeer = peersRef.current[data.from]; const existingPeer = peersRef.current[data.from];
if (existingPeer) { if (existingPeer) {
@@ -194,6 +208,30 @@ const P2PManager = forwardRef(({
alert(`No se puede conectar: ${data.message}`); 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 // Intervalo para calcular estadísticas
statsInterval.current = setInterval(() => { statsInterval.current = setInterval(() => {
const uploadSpeed = uploadBytes.current / 5; const uploadSpeed = uploadBytes.current / 5;
@@ -226,6 +264,7 @@ const P2PManager = forwardRef(({
socket.off('peer-requested'); socket.off('peer-requested');
socket.off('signal'); socket.off('signal');
socket.off('peer-not-found'); socket.off('peer-not-found');
socket.off('peer-loop-detected');
Object.values(peersRef.current).forEach(peer => { Object.values(peersRef.current).forEach(peer => {
if (peer) peer.destroy(); if (peer) peer.destroy();