Comparar commits
9 Commits
01347d5153
...
master
| Autor | SHA1 | Fecha | |
|---|---|---|---|
|
02c84bc6fe
|
|||
|
de0fbce466
|
|||
|
54cab5e611
|
|||
|
46198eca5d
|
|||
|
10920e9f51
|
|||
|
686d418129
|
|||
|
0d10ad7b97
|
|||
|
75555b8454
|
|||
|
18d1eb88da
|
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
|
||||
|
||||
30
server.js
30
server.js
@@ -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 {
|
||||
|
||||
176
src/app/page.js
176
src/app/page.js
@@ -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>
|
||||
|
||||
@@ -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 "anon" 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"
|
||||
|
||||
@@ -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];
|
||||
|
||||
@@ -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">
|
||||
|
||||
Referencia en una nueva incidencia
Block a user