loop control and external links
Signed-off-by: ale <ale@manalejandro.com>
Este commit está contenido en:
16
README.md
16
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
|
||||
|
||||
22
server.js
22
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 {
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
|
||||
@@ -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();
|
||||
|
||||
Referencia en una nueva incidencia
Block a user