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
- **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

@@ -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 {

Ver fichero

@@ -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 */}
<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">
@@ -516,6 +571,7 @@ export default function Home() {
localStream={localStream}
onRemoteStream={handleRemoteStream}
onNeedStream={captureLocalStream}
onLoopDetected={handleLoopDetected}
/>
</div>
@@ -526,6 +582,7 @@ export default function Home() {
onUsernameChange={setUsername}
onSocketReady={handleSocketReady}
onWatchUser={handleWatchUser}
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, 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
<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 */}
@@ -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 (
<div
@@ -235,12 +267,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>

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,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();