364 lines
15 KiB
TypeScript
364 lines
15 KiB
TypeScript
/* eslint-disable */
|
|
// [TSM.ID].[11031972] -- All Rights Reserved. Proprietary & Confidential.
|
|
import React, { useEffect, useRef, useState, useCallback } from 'react';
|
|
import { Socket } from '../lib/zero-socket';
|
|
|
|
interface QuantumCallProps {
|
|
room: string;
|
|
socket: Socket | null;
|
|
username: string;
|
|
onClose: () => void;
|
|
isAudioOnly?: boolean;
|
|
}
|
|
|
|
export function QuantumP2PCall({ room, socket, username, onClose, isAudioOnly = false }: QuantumCallProps) {
|
|
const localVideoRef = useRef<HTMLVideoElement>(null);
|
|
const remoteVideoRef = useRef<HTMLVideoElement>(null);
|
|
const pcRef = useRef<RTCPeerConnection | null>(null);
|
|
const localStreamRef = useRef<MediaStream | null>(null);
|
|
const remoteStreamRef = useRef<MediaStream | null>(null);
|
|
|
|
// Audio Analyser Refs
|
|
const audioContextRef = useRef<AudioContext | null>(null);
|
|
const analyserRef = useRef<AnalyserNode | null>(null);
|
|
const dataArrayRef = useRef<Uint8Array | null>(null);
|
|
const animationFrameRef = useRef<number>(0);
|
|
|
|
const [isMicOn, setIsMicOn] = useState(true);
|
|
const [isCameraOn, setIsCameraOn] = useState(!isAudioOnly);
|
|
const [connectionState, setConnectionState] = useState<string>('Membuka Terowongan Kuantum...');
|
|
|
|
// Audio Aura Level (0 to 100)
|
|
const [remoteAudioLevel, setRemoteAudioLevel] = useState<number>(0);
|
|
|
|
// Zero-UI Cinematic Mode
|
|
const [showControls, setShowControls] = useState(true);
|
|
const idleTimerRef = useRef<NodeJS.Timeout | null>(null);
|
|
|
|
// Draggable PiP (Picture in Picture)
|
|
const [pipPos, setPipPos] = useState({ x: window.innerWidth - 220, y: window.innerHeight - 320 });
|
|
const [isDragging, setIsDragging] = useState(false);
|
|
const dragOffset = useRef({ x: 0, y: 0 });
|
|
|
|
const resetIdleTimer = useCallback(() => {
|
|
setShowControls(true);
|
|
if (idleTimerRef.current) clearTimeout(idleTimerRef.current);
|
|
idleTimerRef.current = setTimeout(() => setShowControls(false), 3000);
|
|
}, []);
|
|
|
|
useEffect(() => {
|
|
window.addEventListener('mousemove', resetIdleTimer);
|
|
resetIdleTimer();
|
|
return () => {
|
|
window.removeEventListener('mousemove', resetIdleTimer);
|
|
if (idleTimerRef.current) clearTimeout(idleTimerRef.current);
|
|
};
|
|
}, [resetIdleTimer]);
|
|
|
|
useEffect(() => {
|
|
if (!socket) return;
|
|
|
|
// Phase 83: Omni-Relay Inject
|
|
const configuration = {
|
|
iceServers: [
|
|
{ urls: 'stun:stun.l.google.com:19302' },
|
|
{ urls: 'stun:stun1.l.google.com:19302' },
|
|
{
|
|
urls: 'turn:160.187.143.172:3478',
|
|
username: 'xcu ULTRA',
|
|
credential: 'quantum_mesh'
|
|
}
|
|
]
|
|
};
|
|
|
|
const pc = new RTCPeerConnection(configuration);
|
|
pcRef.current = pc;
|
|
|
|
pc.onicecandidate = (event) => {
|
|
if (event.candidate) {
|
|
socket.emit('quantum_candidate', {
|
|
target: getTargetFromRoom(room),
|
|
sender: username,
|
|
candidate: event.candidate,
|
|
room
|
|
});
|
|
}
|
|
};
|
|
|
|
pc.onconnectionstatechange = () => {
|
|
setConnectionState(pc.connectionState);
|
|
};
|
|
|
|
pc.ontrack = (event) => {
|
|
if (remoteVideoRef.current && event.streams[0]) {
|
|
remoteStreamRef.current = event.streams[0];
|
|
remoteVideoRef.current.srcObject = event.streams[0];
|
|
setupAudioAnalyser(event.streams[0]);
|
|
}
|
|
};
|
|
|
|
navigator.mediaDevices.getUserMedia({
|
|
video: !isAudioOnly ? { width: { ideal: 1280 }, height: { ideal: 720 } } : false,
|
|
audio: true
|
|
}).then((stream) => {
|
|
localStreamRef.current = stream;
|
|
if (localVideoRef.current && !isAudioOnly) {
|
|
localVideoRef.current.srcObject = stream;
|
|
}
|
|
stream.getTracks().forEach((track) => pc.addTrack(track, stream));
|
|
createOffer();
|
|
}).catch(e => {
|
|
console.error("Gagal mengakses media:", e);
|
|
setConnectionState("Akses Kamera/Mic Ditolak!");
|
|
});
|
|
|
|
socket.on('quantum_offer_received', async (data: any) => {
|
|
if (data.caller !== username && pcRef.current) {
|
|
await pcRef.current.setRemoteDescription(new RTCSessionDescription(data.sdp));
|
|
const answer = await pcRef.current.createAnswer();
|
|
await pcRef.current.setLocalDescription(answer);
|
|
socket.emit('quantum_answer', {
|
|
target: data.caller,
|
|
responder: username,
|
|
sdp: answer,
|
|
room
|
|
});
|
|
}
|
|
});
|
|
|
|
socket.on('quantum_answer_received', async (data: any) => {
|
|
if (data.responder !== username && pcRef.current) {
|
|
await pcRef.current.setRemoteDescription(new RTCSessionDescription(data.sdp));
|
|
}
|
|
});
|
|
|
|
socket.on('quantum_candidate_received', async (data: any) => {
|
|
if (data.sender !== username && pcRef.current) {
|
|
try {
|
|
await pcRef.current.addIceCandidate(new RTCIceCandidate(data.candidate));
|
|
} catch(e) { console.error(e); }
|
|
}
|
|
});
|
|
|
|
socket.on('quantum_call_ended_broadcast', (data: any) => {
|
|
if (data.sender !== username) {
|
|
handleEndCall();
|
|
}
|
|
});
|
|
|
|
return () => {
|
|
socket.off('quantum_offer_received');
|
|
socket.off('quantum_answer_received');
|
|
socket.off('quantum_candidate_received');
|
|
socket.off('quantum_call_ended_broadcast');
|
|
cleanup();
|
|
};
|
|
}, [socket, room]);
|
|
|
|
const setupAudioAnalyser = (stream: MediaStream) => {
|
|
try {
|
|
const audioCtx = new (window.AudioContext || (window as any).webkitAudioContext)();
|
|
const analyser = audioCtx.createAnalyser();
|
|
analyser.fftSize = 256;
|
|
const source = audioCtx.createMediaStreamSource(stream);
|
|
source.connect(analyser);
|
|
|
|
audioContextRef.current = audioCtx;
|
|
analyserRef.current = analyser;
|
|
dataArrayRef.current = new Uint8Array(analyser.frequencyBinCount);
|
|
|
|
const updateAura = () => {
|
|
if (!analyserRef.current || !dataArrayRef.current) return;
|
|
analyserRef.current.getByteFrequencyData(dataArrayRef.current as any);
|
|
|
|
let sum = 0;
|
|
for (let i = 0; i < dataArrayRef.current.length; i++) {
|
|
sum += dataArrayRef.current[i];
|
|
}
|
|
const average = sum / dataArrayRef.current.length;
|
|
// Normalize 0-100
|
|
const level = Math.min(100, Math.round((average / 255) * 100 * 2));
|
|
setRemoteAudioLevel(level);
|
|
|
|
animationFrameRef.current = requestAnimationFrame(updateAura);
|
|
};
|
|
|
|
updateAura();
|
|
} catch (e) {
|
|
console.error("Audio Context Error", e);
|
|
}
|
|
};
|
|
|
|
const cleanup = () => {
|
|
if (animationFrameRef.current) cancelAnimationFrame(animationFrameRef.current);
|
|
if (audioContextRef.current) audioContextRef.current.close();
|
|
if (localStreamRef.current) localStreamRef.current.getTracks().forEach(track => track.stop());
|
|
if (pcRef.current) pcRef.current.close();
|
|
};
|
|
|
|
const createOffer = async () => {
|
|
if (!pcRef.current || !socket) return;
|
|
try {
|
|
const offer = await pcRef.current.createOffer();
|
|
await pcRef.current.setLocalDescription(offer);
|
|
socket.emit('quantum_offer', {
|
|
target: getTargetFromRoom(room),
|
|
caller: username,
|
|
sdp: offer,
|
|
room
|
|
});
|
|
} catch (e) {
|
|
console.error("Offer creation failed", e);
|
|
}
|
|
};
|
|
|
|
const getTargetFromRoom = (roomId: string) => {
|
|
if (roomId.startsWith('DM_')) {
|
|
const users = roomId.replace('DM_', '').split('_');
|
|
return users.find(u => u !== username) || '';
|
|
}
|
|
return '';
|
|
};
|
|
|
|
const toggleMic = () => {
|
|
if (localStreamRef.current) {
|
|
localStreamRef.current.getAudioTracks().forEach(track => track.enabled = !isMicOn);
|
|
setIsMicOn(!isMicOn);
|
|
}
|
|
};
|
|
|
|
const toggleCamera = () => {
|
|
if (localStreamRef.current) {
|
|
localStreamRef.current.getVideoTracks().forEach(track => track.enabled = !isCameraOn);
|
|
setIsCameraOn(!isCameraOn);
|
|
}
|
|
};
|
|
|
|
const handleEndCall = () => {
|
|
if (socket) {
|
|
socket.emit('quantum_call_ended', { sender: username, room, target: getTargetFromRoom(room) });
|
|
}
|
|
cleanup();
|
|
onClose();
|
|
};
|
|
|
|
// Drag Logic
|
|
const handleMouseDown = (e: React.MouseEvent) => {
|
|
setIsDragging(true);
|
|
dragOffset.current = {
|
|
x: e.clientX - pipPos.x,
|
|
y: e.clientY - pipPos.y
|
|
};
|
|
};
|
|
|
|
const handleMouseMove = (e: React.MouseEvent) => {
|
|
if (!isDragging) return;
|
|
setPipPos({
|
|
x: e.clientX - dragOffset.current.x,
|
|
y: e.clientY - dragOffset.current.y
|
|
});
|
|
};
|
|
|
|
const handleMouseUp = () => setIsDragging(false);
|
|
|
|
// Deep Sapphire Blue Aura Calculation
|
|
const auraBlur = 20 + (remoteAudioLevel * 0.8);
|
|
const auraSpread = remoteAudioLevel * 0.5;
|
|
const boxShadowStyle = `0 0 ${auraBlur}px ${auraSpread}px rgba(15, 82, 186, ${remoteAudioLevel > 10 ? 0.6 : 0.1})`;
|
|
|
|
return (
|
|
<div
|
|
className="absolute inset-0 z-[100] flex items-center justify-center pointer-events-auto bg-black/70 backdrop-blur-xl overflow-hidden"
|
|
onMouseMove={handleMouseMove}
|
|
onMouseUp={handleMouseUp}
|
|
onMouseLeave={handleMouseUp}
|
|
>
|
|
{/* Status Bar */}
|
|
<div className={`absolute top-6 left-6 text-blue-300 text-xs font-mono bg-blue-900/30 px-4 py-2 rounded-full border border-blue-500/20 shadow-[0_0_15px_rgba(15,82,186,0.5)] transition-opacity duration-700 ${showControls ? 'opacity-100' : 'opacity-0'}`}>
|
|
<span className="animate-pulse mr-2">●</span> {connectionState}
|
|
</div>
|
|
|
|
<div className="w-full h-full relative flex items-center justify-center p-8">
|
|
|
|
{/* Main Remote Video (Deep Sapphire Blue Aura) */}
|
|
<div
|
|
className="relative w-full max-w-5xl aspect-video rounded-[2rem] overflow-hidden transition-all duration-300"
|
|
style={{ boxShadow: boxShadowStyle }}
|
|
>
|
|
{/* Glassmorphism Border */}
|
|
<div className="absolute inset-0 rounded-[2rem] border-[1px] border-white/10 z-10 pointer-events-none"></div>
|
|
<video
|
|
ref={remoteVideoRef}
|
|
autoPlay
|
|
playsInline
|
|
className="w-full h-full object-cover"
|
|
style={{ filter: 'contrast(1.05) saturate(1.1)' }}
|
|
/>
|
|
|
|
{/* Fallback Audio Only Avatar */}
|
|
{isAudioOnly && (
|
|
<div className="absolute inset-0 flex items-center justify-center bg-gray-900">
|
|
<div className="w-32 h-32 rounded-full bg-blue-600/20 flex items-center justify-center" style={{ transform: `scale(${1 + remoteAudioLevel/100})`, transition: 'transform 0.1s ease-out' }}>
|
|
<span className="text-5xl text-blue-400 font-bold">{getTargetFromRoom(room).charAt(0).toUpperCase()}</span>
|
|
</div>
|
|
</div>
|
|
)}
|
|
</div>
|
|
|
|
{/* Draggable PiP (Local Video) */}
|
|
<div
|
|
className="absolute w-48 h-72 rounded-2xl overflow-hidden cursor-move border-[1px] border-white/20 shadow-[0_20px_50px_rgba(0,0,0,0.5)] z-30 transition-shadow hover:shadow-[0_0_20px_rgba(255,255,255,0.2)]"
|
|
style={{
|
|
left: pipPos.x, top: pipPos.y,
|
|
backdropFilter: 'blur(20px)',
|
|
backgroundColor: 'rgba(15, 23, 42, 0.4)'
|
|
}}
|
|
onMouseDown={handleMouseDown}
|
|
>
|
|
{isCameraOn ? (
|
|
<video
|
|
ref={localVideoRef}
|
|
autoPlay
|
|
playsInline
|
|
muted
|
|
className="w-full h-full object-cover transform -scale-x-100"
|
|
/>
|
|
) : (
|
|
<div className="w-full h-full flex items-center justify-center">
|
|
<svg className="w-10 h-10 text-white/30" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" d="M15 10l4.553-2.276A1 1 0 0121 8.618v6.764a1 1 0 01-1.447.894L15 14M5 18h8a2 2 0 002-2V8a2 2 0 00-2-2H5a2 2 0 00-2 2v8a2 2 0 002 2zM3 3l18 18"></path></svg>
|
|
</div>
|
|
)}
|
|
</div>
|
|
|
|
{/* Cinematic Controls (Zero-UI) */}
|
|
<div className={`absolute bottom-10 left-1/2 -translate-x-1/2 flex items-center gap-6 bg-slate-900/60 backdrop-blur-2xl px-10 py-5 rounded-full border border-white/5 shadow-2xl z-40 transition-all duration-700 transform ${showControls ? 'translate-y-0 opacity-100' : 'translate-y-10 opacity-0 pointer-events-none'}`}>
|
|
|
|
{/* Mic Toggle */}
|
|
<button onClick={toggleMic} className={`relative group w-14 h-14 rounded-full flex items-center justify-center transition-all duration-300 ${isMicOn ? 'bg-white/10 hover:bg-white/20 text-white' : 'bg-red-500/80 text-white shadow-[0_0_20px_rgba(239,68,68,0.4)]'}`}>
|
|
{isMicOn ? (
|
|
<svg className="w-6 h-6" fill="currentColor" viewBox="0 0 24 24"><path d="M12 14c1.66 0 3-1.34 3-3V5c0-1.66-1.34-3-3-3S9 3.34 9 5v6c0 1.66 1.34 3 3 3zm5-3c0 2.76-2.24 5-5 5s-5-2.24-5-5H5c0 3.53 2.61 6.43 6 6.92V21h2v-3.08c3.39-.49 6-3.39 6-6.92h-1.7z"/></svg>
|
|
) : (
|
|
<svg className="w-6 h-6" fill="currentColor" viewBox="0 0 24 24"><path d="M19 11h-1.7c0 .74-.16 1.43-.43 2.05l1.23 1.23c.56-.98.9-2.09.9-3.28zm-4.02.17c0-.06.02-.11.02-.17V5c0-1.66-1.34-3-3-3S9 3.34 9 5v.18l5.98 5.99zM4.27 3L3 4.27l6.01 6.01V11c0 1.66 1.33 3 2.99 3 .22 0 .44-.03.65-.08l1.66 1.66c-.71.33-1.5.52-2.31.52-2.76 0-5-2.24-5-5H5c0 3.53 2.61 6.43 6 6.92V21h2v-3.08c-.37-.05-.74-.12-1.1-.22l2.83 2.83 1.27-1.27L4.27 3z"/></svg>
|
|
)}
|
|
</button>
|
|
|
|
{/* Camera Toggle */}
|
|
<button onClick={toggleCamera} className={`relative group w-14 h-14 rounded-full flex items-center justify-center transition-all duration-300 ${isCameraOn ? 'bg-white/10 hover:bg-white/20 text-white' : 'bg-red-500/80 text-white shadow-[0_0_20px_rgba(239,68,68,0.4)]'}`}>
|
|
{isCameraOn ? (
|
|
<svg className="w-6 h-6" fill="currentColor" viewBox="0 0 24 24"><path d="M17 10.5V7c0-.55-.45-1-1-1H4c-.55 0-1 .45-1 1v10c0 .55.45 1 1 1h12c.55 0 1-.45 1-1v-3.5l4 4v-11l-4 4z"/></svg>
|
|
) : (
|
|
<svg className="w-6 h-6" fill="currentColor" viewBox="0 0 24 24"><path d="M21 6.5l-4 4V7c0-.55-.45-1-1-1H9.82L21 17.18V6.5zM3.27 2L2 3.27 4.73 6H4c-.55 0-1 .45-1 1v10c0 .55.45 1 1 1h12c.55 0 .64-.15 1.14-.39L20.73 22 22 20.73 3.27 2z"/></svg>
|
|
)}
|
|
</button>
|
|
|
|
{/* End Call */}
|
|
<button onClick={handleEndCall} className="w-16 h-16 rounded-full flex items-center justify-center bg-gradient-to-r from-red-600 to-rose-600 hover:from-red-500 hover:to-rose-500 text-white shadow-[0_0_30px_rgba(225,29,72,0.6)] transition-all duration-300 transform hover:scale-110">
|
|
<svg className="w-8 h-8" fill="currentColor" viewBox="0 0 24 24"><path d="M12 9c-1.6 0-3.15.25-4.6.72v3.1c0 .39-.23.74-.56.9-.98.49-1.87 1.12-2.66 1.85-.18.18-.43.28-.7.28-.28 0-.53-.11-.71-.29L.29 13.08c-.18-.17-.29-.42-.29-.7 0-.28.11-.53.29-.71C3.34 8.78 7.46 7 12 7s8.66 1.78 11.71 4.67c.18.18.29.43.29.71 0 .28-.11.53-.29.71l-2.48 2.48c-.18.18-.43.29-.71.29-.27 0-.52-.11-.7-.28-.79-.74-1.69-1.36-2.67-1.85-.33-.16-.56-.5-.56-.9v-3.1C15.15 9.25 13.6 9 12 9z"/></svg>
|
|
</button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|
|
|