Share


You said:
WebSocketManager: sendSignalingMessage => fromUserId=89, toUserId=85, signalType=offer, payload= {offer: {…}, roomName: '85_89'}offer: {sdp: 'v=0\r\no=- 7359858871318176101 2 IN IP4 127.0.0.1\r\ns…93c112e63a c43ee53b-a7fb-4b09-af3d-e2c186f535e9\r\n', type: 'offer'}roomName: "85_89"[[Prototype]]: Object
WebSocketManager.js:1112 WebSocketManager: send => Attempting to send data: {type: 'offer', to: 85, roomName: '85_89', offer: {…}}offer: {sdp: 'v=0\r\no=- 7359858871318176101 2 IN IP4 127.0.0.1\r\ns…93c112e63a c43ee53b-a7fb-4b09-af3d-e2c186f535e9\r\n', type: 'offer'}roomName: "85_89"to: 85type: "offer"[[Prototype]]: Object
WebSocketManager.js:1116 WebSocketManager: Message sent successfully.
VideoCall.js:74 [VideoCall] Error creating offer: Failed to execute 'setLocalDescription' on 'RTCPeerConnection': Failed to set local offer sdp: The order of m-lines in subsequent offer doesn't match order from previous offer/answer.
(anonymous) @ VideoCall.js:74
O @ VideoCall.js:267
await in O
t.onnegotiationneeded @ VideoCall.js:205Understand this errorAI
WebSocketManager.js:963 WebSocketManager: getCurrentUserId => Checking store and localStorage.
WebSocketManager.js:570 WebSocketManager: sendSignalingMessage => fromUserId=89, toUserId=85, signalType=ice-candidate, payload= {candidate: {…}, roomName: '85_89'}candidate: {candidate: 'candidate:1143093608 1 udp 2122260223 192.168.0.2 …eration 0 ufrag WrjS network-id 1 network-cost 10', sdpMid: '0', sdpMLineIndex: 0}roomName: "85_89"[[Prototype]]: Object
WebSocketManager.js:1112 WebSocketManager: send => Attempting to send data: {type: 'ice-candidate', to: 85, roomName: '85_89', candidate: {…}}
WebSocketManager.js:1116 WebSocketManager: Message sent successfully.
WebSocketManager.js:963 WebSocketManager: getCurrentUserId => Checking store and localStorage.
WebSocketManager.js:570 WebSocketManager: sendSignalingMessage => fromUserId=89, toUserId=85, signalType=ice-candidate, payload= {candidate: {…}, roomName: '85_89'}
WebSocketManager.js:1112 WebSocketManager: send => Attempting to send data: {type: 'ice-candidate', to: 85, roomName: '85_89', candidate: {…}}
WebSocketManager.js:1116 WebSocketManager: Message sent successfully.
WebSocketManager.js:963 WebSocketManager: getCurrentUserId => Checking store and localStorage.
WebSocketManager.js:570 WebSocketManager: sendSignalingMessage => fromUserId=89, toUserId=85, signalType=ice-candidate, payload= {candidate: {…}, roomName: '85_89'}
WebSocketManager.js:1112 WebSocketManager: send => Attempting to send data: {type: 'ice-candidate', to: 85, roomName: '85_89', candidate: {…}}
WebSocketManager.js:1116 WebSocketManager: Message sent successfully.
WebSocketManager.js:963 WebSocketManager: getCurrentUserId => Checking store and localStorage.
WebSocketManager.js:570 WebSocketManager: sendSignalingMessage => fromUserId=89, toUserId=85, signalType=ice-candidate, payload= {candidate: {…}, roomName: '85_89'}// src/components/VideoCall.js

import React, { useState, useEffect, useRef, useCallback } from 'react';
import { Mic, MicOff, Camera, CameraOff, PhoneOff } from 'lucide-react';
import webSocketManager from '../services/WebSocketManager'; // Adjust the import path
import { useDispatch } from 'react-redux';
import { endCall } from '../slices/notificationSlice'; // Adjust import path

const VideoCall = ({
  selectedUser,
  currentUser,
  roomName,
  callType, // 'audio' or 'video'
  isInitiator,
  handleEndCall, // Callback to inform parent components
}) => {
  const dispatch = useDispatch();

  /**
   * Helper Function to Normalize selectedUser
   * Handles both nested (selectedUser.user) and flat (selectedUser).
   */
  const getUserInfo = () => {
    if (selectedUser) {
      // Handle Nested Structure
      if (selectedUser.user && selectedUser.user.id) {
        const { id, first_name, last_name, fullName } = selectedUser.user;
        return {
          id,
          fullName:
            fullName ||
            ${first_name || ''} ${last_name || ''}.trim() ||
            'Unknown User',
        };
      }
      // Handle Flat Structure
      else if (selectedUser.id) {
        const { id, first_name, last_name, fullName } = selectedUser;
        return {
          id,
          fullName:
            fullName ||
            ${first_name || ''} ${last_name || ''}.trim() ||
            'Unknown User',
        };
      }
    }

    console.error("VideoCall: selectedUser is missing 'id' and 'user' properties.");
    return { id: null, fullName: 'Unknown User' };
  };

  const { id: selectedUserId, fullName: selectedUserFullName } = getUserInfo();

  // Logging for debugging
  console.log('VideoCall: selectedUser:', selectedUser);
  console.log('VideoCall: selectedUserId:', selectedUserId);
  console.log('VideoCall: fullName:', selectedUserFullName);

  const [localStream, setLocalStream] = useState(null);
  const [remoteStream, setRemoteStream] = useState(null);
  const [isMuted, setIsMuted] = useState(false);
  const [isCameraOff, setIsCameraOff] = useState(callType === 'audio');

  const localVideoRef = useRef(null);
  const remoteVideoRef = useRef(null);
  const peerConnectionRef = useRef(null);
  
  // Track null ICE candidates to avoid multiple sends
  const hasSentNullCandidate = useRef(false);

  const log = useCallback((message, level = 'info') => {
    if (process.env.NODE_ENV !== 'production' || level === 'error') {
      console[level]([VideoCall] ${message});
    }
  }, []);

  useEffect(() => {
    if (!selectedUserId) {
      console.error('VideoCall: Cannot initiate call without selectedUserId.');
      handleEndCallInternal();
      return;
    }

    log(Initializing peer connection for room: ${roomName}, initiator: ${isInitiator});

    initializePeerConnection();
    initializeMedia();

    const messageHandler = (data) => {
      log(Received signaling message: ${JSON.stringify(data)});
      if (data.roomName !== roomName) {
        log(Message roomName (${data.roomName}) does not match current room (${roomName}). Ignoring.);
        return;
      }

      switch (data.type) {
        case 'offer':
          handleOfferMessage(data.offer);
          break;
        case 'answer':
          handleAnswerMessage(data.answer);
          break;
        case 'ice-candidate':
          handleICECandidateMessage(data.candidate);
          break;
        case 'end-call':
          log('Call ended by peer.');
          handleEndCallInternal();
          break;
        default:
          log(Unknown message type: ${data.type});
          break;
      }
    };

    webSocketManager.onMessage(messageHandler);
    log('WebSocket message handler attached.');

    return () => {
      webSocketManager.offMessage(messageHandler);
      log('WebSocket message handler detached.');
      cleanupCall();
    };
    // eslint-disable-next-line react-hooks/exhaustive-deps
  }, [roomName, log, isInitiator, selectedUserId]);

  const initializePeerConnection = () => {
    const configuration = {
      iceServers: [
        { urls: 'stun:stun.l.google.com:19302' },
        // Consider adding TURN servers for better connectivity
      ],
    };
    const peerConnection = new RTCPeerConnection(configuration);
    peerConnectionRef.current = peerConnection;

    log('Created RTCPeerConnection with configuration:', configuration);

    peerConnection.onicecandidate = (event) => {
      if (event.candidate) {
        // Send the ICE candidate to the server
        log(New ICE candidate found: ${JSON.stringify(event.candidate)});
        webSocketManager.sendSignalingMessage(selectedUserId, 'ice-candidate', {
          candidate: {
            candidate: event.candidate.candidate,
            sdpMid: event.candidate.sdpMid,
            sdpMLineIndex: event.candidate.sdpMLineIndex,
          },
          roomName,
        });
        log('Sent ICE candidate.');
      } else {
        // ICE gathering completed
        if (!hasSentNullCandidate.current) {
          log('ICE gathering completed. Sending null candidate.');
          webSocketManager.sendSignalingMessage(selectedUserId, 'ice-candidate', {
            candidate: null,
            roomName,
          });
          hasSentNullCandidate.current = true;
          log('Sent ICE gathering complete.');
        } else {
          log('ICE gathering complete already sent. Skipping.');
        }
      }
    };

    peerConnection.ontrack = (event) => {
      log('Received remote track.');
      if (!remoteStream) {
        log('Setting remote stream.');
        setRemoteStream(event.streams[0]);
        if (remoteVideoRef.current) {
          remoteVideoRef.current.srcObject = event.streams[0];
        }
      } else {
        log('Remote stream already set.');
      }
    };

    peerConnection.oniceconnectionstatechange = () => {
      const state = peerConnectionRef.current.iceConnectionState;
      log(ICE connection state changed to: ${state});
      if (state === 'disconnected' || state === 'failed' || state === 'closed') {
        log(ICE connection state is ${state}. Ending call.);
        handleEndCallInternal();
      }
    };

    peerConnection.onconnectionstatechange = () => {
      const state = peerConnectionRef.current.connectionState;
      log(Connection state changed to: ${state});
      if (state === 'connected') {
        log('Peers connected successfully.');
      } else if (state === 'disconnected' || state === 'failed' || state === 'closed') {
        log(Connection state is ${state}. Ending call.);
        handleEndCallInternal();
      }
    };

    peerConnection.onnegotiationneeded = async () => {
      log('Negotiation needed.');
      if (isInitiator) {
        await createOffer();
      }
    };

    peerConnection.onicegatheringstatechange = () => {
      const gatheringState = peerConnectionRef.current.iceGatheringState;
      log(ICE gathering state changed to: ${gatheringState});
    };
  };

  const initializeMedia = async () => {
    try {
      log('Requesting user media.');
      // Always request both audio and video
      const stream = await navigator.mediaDevices.getUserMedia({
        audio: true,
        video: true,
      });
      log('User media obtained successfully.');
      setLocalStream(stream);

      // If callType === 'audio', then disable video tracks initially
      if (callType === 'audio') {
        log('Call type is audio. Disabling video tracks.');
        stream.getVideoTracks().forEach((track) => (track.enabled = false));
      }

      if (localVideoRef.current) {
        localVideoRef.current.srcObject = stream;
        log('Local video element set with user media stream.');
      }

      // Add all tracks to the peer connection
      stream.getTracks().forEach((track) => {
        peerConnectionRef.current.addTrack(track, stream);
        log(Added local track to peer connection: ${track.kind});
      });

      if (isInitiator) {
        log('User is initiator. Creating offer.');
        await createOffer();
      }
    } catch (error) {
      log(Error accessing media devices: ${error.message}, 'error');
      alert(Error accessing camera/microphone: ${error.message});
      handleEndCallInternal();
    }
  };

  const createOffer = async () => {
    try {
      log('Creating SDP offer.');
      const offer = await peerConnectionRef.current.createOffer();
      log('SDP offer created:', offer);
      await peerConnectionRef.current.setLocalDescription(offer);
      log('Set local description with offer.');
      webSocketManager.sendSignalingMessage(selectedUserId, 'offer', {
        offer,
        roomName,
      });
      log('Sent SDP offer to signaling server.');
    } catch (error) {
      log(Error creating offer: ${error.message}, 'error');
    }
  };

  const handleOfferMessage = async (offer) => {
    try {
      log('Handling received SDP offer.');
      await peerConnectionRef.current.setRemoteDescription(new RTCSessionDescription(offer));
      log('Set remote description with received offer.');
      const answer = await peerConnectionRef.current.createAnswer();
      log('SDP answer created:', answer);
      await peerConnectionRef.current.setLocalDescription(answer);
      log('Set local description with answer.');
      webSocketManager.sendSignalingMessage(selectedUserId, 'answer', {
        answer,
        roomName,
      });
      log('Sent SDP answer to signaling server.');
    } catch (error) {
      log(Error handling offer: ${error.message}, 'error');
      alert(Failed to handle offer: ${error.message});
      handleEndCallInternal();
    }
  };

  const handleAnswerMessage = async (answer) => {
    try {
      log('Handling received SDP answer.');
      await peerConnectionRef.current.setRemoteDescription(new RTCSessionDescription(answer));
      log('Set remote description with received answer.');
    } catch (error) {
      log(Error setting remote description: ${error.message}, 'error');
      alert(Failed to set remote description: ${error.message});
      handleEndCallInternal();
    }
  };

  const handleICECandidateMessage = async (candidate) => {
    log(Handling received ICE candidate: ${JSON.stringify(candidate)});
    try {
      if (candidate && candidate.candidate) {
        await peerConnectionRef.current.addIceCandidate(new RTCIceCandidate(candidate));
        log('Added ICE candidate to peer connection.');
      } else {
        // ICE gathering complete
        log('Received null ICE candidate. ICE gathering completed.');
      }
    } catch (error) {
      log(Error adding ICE candidate: ${error.message}, 'error');
      alert(Failed to add ICE candidate: ${error.message});
      handleEndCallInternal();
    }
  };

  const toggleMute = () => {
    if (localStream) {
      const audioTracks = localStream.getAudioTracks();
      if (audioTracks.length > 0) {
        const currentState = audioTracks[0].enabled;
        audioTracks.forEach((track) => (track.enabled = !currentState));
        setIsMuted(!currentState);
        log(Audio ${!currentState ? 'muted' : 'unmuted'}.);
      }
    }
  };

  const toggleCamera = () => {
    if (localStream) {
      const videoTracks = localStream.getVideoTracks();
      if (videoTracks.length > 0) {
        const currentState = videoTracks[0].enabled;
        videoTracks.forEach((track) => (track.enabled = !currentState));
        setIsCameraOff(!currentState);
        log(Camera ${!currentState ? 'turned off' : 'turned on'}.);
      }
    }
  };

  const cleanupCall = () => {
    log('Cleaning up call resources.');
    if (localStream) {
      log('Stopping local media tracks.');
      localStream.getTracks().forEach((track) => track.stop());
    }
    if (remoteStream) {
      log('Stopping remote media tracks.');
      remoteStream.getTracks().forEach((track) => track.stop());
    }
    if (peerConnectionRef.current) {
      log('Closing peer connection.');
      peerConnectionRef.current.close();
    }
  };

  /**
   * Internal handler to end the call.
   */
  const handleEndCallInternal = () => {
    log('handleEndCallInternal: Ending call.');
    cleanupCall();
    // Notify the server to end the call
    webSocketManager.endCall(selectedUserId, roomName);
    log('Sent end-call message to signaling server.');
    // Dispatch Redux action to update state
    dispatch(endCall({ roomName }));
    log('Dispatched endCall action to Redux.');
    // Reset null-candidate tracking
    hasSentNullCandidate.current = false;
    // Notify parent components (e.g., GlobalCallNotification)
    if (handleEndCall) {
      log('Notifying parent component to handle end call.');
      handleEndCall();
    }
  };

  return (
    <div className="video-call-container">
      {/* Remote Video */}
      <div className="remote-video-wrapper">
        <video
          ref={remoteVideoRef}
          autoPlay
          playsInline
          className="remote-video"
          onPlay={() => log('Remote video started playing.')}
          onError={(e) => log(Error on remote video element: ${e.message}, 'error')}
        />
      </div>

      {/* Local Video / "audio only" indicator */}
      <div className={local-video-wrapper ${isCameraOff ? 'audio-call' : ''}}>
        <video
          ref={localVideoRef}
          autoPlay
          playsInline
          muted
          className="local-video"
          style={{ display: isCameraOff ? 'none' : 'block' }}
          onPlay={() => log('Local video started playing.')}
          onError={(e) => log(Error on local video element: ${e.message}, 'error')}
        />
        {isCameraOff && (
          <div className="audio-indicator">
            <Mic size={32} />
            {log('Displaying audio-only indicator.')}
          </div>
        )}
      </div>

      <div className="controls">
        <button onClick={toggleMute} className="control-button" aria-label="Toggle Mute">
          {isMuted ? <MicOff size={24} /> : <Mic size={24} />}
        </button>
        <button onClick={toggleCamera} className="control-button" aria-label="Toggle Camera">
          {isCameraOff ? <CameraOff size={24} /> : <Camera size={24} />}
        </button>
        <button onClick={handleEndCallInternal} className="control-button end-call" aria-label="End Call">
          <PhoneOff size={24} />
        </button>
      </div>

      {/* Inline CSS for demonstration; ideally, use external CSS files */}
      <style jsx>{
        .video-call-container {
          position: fixed;
          top: 0;
          left: 0;
          width: 100%;
          height: 100%;
          background-color: rgba(0, 0, 0, 0.8);
          display: flex;
          flex-direction: column;
          align-items: center;
          justify-content: center;
          z-index: 1000;
        }
        .remote-video-wrapper {
          position: absolute;
          top: 0;
          left: 0;
          width: 100%;
          height: 100%;
        }
        .remote-video {
          width: 100%;
          height: 100%;
          object-fit: cover;
        }
        .local-video-wrapper {
          position: absolute;
          bottom: 80px;
          right: 20px;
          width: 200px;
          height: 150px;
          border: 2px solid #fff;
          border-radius: 8px;
          overflow: hidden;
          background-color: #000;
          display: flex;
          align-items: center;
          justify-content: center;
        }
        .audio-call {
          width: 150px;
          height: 150px;
          border-radius: 50%;
          background-color: #333;
          display: flex;
          align-items: center;
          justify-content: center;
        }
        .audio-indicator {
          color: #fff;
        }
        .local-video {
          width: 100%;
          height: 100%;
          object-fit: cover;
        }
        .controls {
          position: absolute;
          bottom: 20px;
          display: flex;
          justify-content: center;
          width: 100%;
        }
        .control-button {
          margin: 0 10px;
          padding: 10px;
          background-color: rgba(255, 255, 255, 0.2);
          color: white;
          border: none;
          border-radius: 50%;
          cursor: pointer;
          display: flex;
          align-items: center;
          justify-content: center;
          transition: background-color 0.3s;
        }
        .control-button:hover {
          background-color: rgba(255, 255, 255, 0.3);
        }
        .end-call {
          background-color: rgba(255, 0, 0, 0.6);
        }
        .end-call:hover {
          background-color: rgba(255, 0, 0, 0.8);
        }
      }</style>
    </div>
  );
};

export default VideoCall;