From 10920e9f51e32a499800ccbb739540b2d3b82697 Mon Sep 17 00:00:00 2001 From: ale Date: Thu, 27 Nov 2025 22:03:01 +0100 Subject: [PATCH] loop control and external links Signed-off-by: ale --- README.md | 16 +++++++++- server.js | 22 ++++++++++++++ src/app/page.js | 57 ++++++++++++++++++++++++++++++++++++ src/components/Chat.js | 53 ++++++++++++++++++++++++++++----- src/components/P2PManager.js | 43 +++++++++++++++++++++++++-- 5 files changed, 180 insertions(+), 11 deletions(-) diff --git a/README.md b/README.md index 9b988c1..69c6dc1 100644 --- a/README.md +++ b/README.md @@ -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 diff --git a/server.js b/server.js index 04285a0..640e83f 100644 --- a/server.js +++ b/server.js @@ -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 socket.on('request-watch', (data) => { try { diff --git a/src/app/page.js b/src/app/page.js index 5bbf632..95f3a0b 100644 --- a/src/app/page.js +++ b/src/app/page.js @@ -18,6 +18,8 @@ export default function Home() { 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({ @@ -127,9 +129,33 @@ 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]); + // 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) => { setStats(prev => ({ ...prev, @@ -226,6 +252,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:', { @@ -260,6 +293,15 @@ export default function Home() { 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()) { // Si la URL es externa, usar el proxy @@ -325,6 +367,19 @@ export default function Home() { {/* Columna Principal - Video */}
{/* Banner de stream remoto */} + {/* Banner de auto-conexión */} + {showAutoConnectBanner && ( +
+
+ 🔗 +
+

Conectando automáticamente...

+

Uniéndose al stream de {autoConnectUser}

+
+
+
+ )} + {watchingUser && (
@@ -516,6 +571,7 @@ export default function Home() { localStream={localStream} onRemoteStream={handleRemoteStream} onNeedStream={captureLocalStream} + onLoopDetected={handleLoopDetected} />
@@ -526,6 +582,7 @@ export default function Home() { onUsernameChange={setUsername} onSocketReady={handleSocketReady} onWatchUser={handleWatchUser} + peers={peers} />
diff --git a/src/components/Chat.js b/src/components/Chat.js index 28731f9..09bb6ad 100644 --- a/src/components/Chat.js +++ b/src/components/Chat.js @@ -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, peers = [] }) { const [socket, setSocket] = useState(null); const [messages, setMessages] = useState([]); const [users, setUsers] = useState([]); @@ -15,6 +15,7 @@ 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 messagesEndRef = useRef(null); // 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 if (!username) { return ( @@ -203,7 +217,24 @@ export default function Chat({ username, onUsernameChange, onSocketReady, onWatc {isConnected ? 'Conectado' : 'Desconectado'} -

Como: {username}

+
+

Como: {username}

+
+ + {showCopiedTooltip && ( +
+ ✓ Enlace copiado +
+ )} +
+
{/* Usuarios conectados */} @@ -216,6 +247,7 @@ export default function Chat({ username, onUsernameChange, onSocketReady, onWatc const isCurrentUser = user === username; const hasThumbnail = userThumbnails[user]?.thumbnail; const isHovered = hoveredUser === user; + const isWatchingMe = peers.includes(user); // Este usuario me está viendo return (
{user} - {hasThumbnail && !isCurrentUser && ( - 🔴 - )} - {isCurrentUser && ( - 👤 - )} +
+ {isWatchingMe && !isCurrentUser && ( + 👁️ + )} + {hasThumbnail && !isCurrentUser && ( + 🔴 + )} + {isCurrentUser && ( + 👤 + )} +
diff --git a/src/components/P2PManager.js b/src/components/P2PManager.js index 35e12ad..245ee8c 100644 --- a/src/components/P2PManager.js +++ b/src/components/P2PManager.js @@ -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,7 +73,8 @@ 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, () => ({ @@ -130,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) { @@ -194,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; @@ -226,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();