feat(CallScreen): enhance video call functionality and UI updates
- Integrated video call support by adding local and remote video stream handling. - Implemented state management for video track detection in both local and peer streams. - Updated UI to conditionally render video components based on the presence of video tracks. - Added video toggle functionality to enable or disable local video during calls. - Enhanced incoming call modal to display call type (voice or video). - Refactored call store to manage call types and video state more effectively.
This commit is contained in:
@@ -5,6 +5,7 @@ import {
|
||||
mediaDevices,
|
||||
MediaStream,
|
||||
MediaStreamTrack,
|
||||
RTCRtpTransceiver,
|
||||
} from 'react-native-webrtc';
|
||||
|
||||
export interface ICEServer {
|
||||
@@ -15,10 +16,13 @@ export interface ICEServer {
|
||||
|
||||
export type ConnectionState = 'new' | 'connecting' | 'connected' | 'disconnected' | 'failed' | 'closed';
|
||||
|
||||
export type CallType = 'voice' | 'video';
|
||||
|
||||
export type WebRTCManagerEvent =
|
||||
| { type: 'icecandidate'; candidate: RTCIceCandidate | null }
|
||||
| { type: 'connectionstatechange'; state: ConnectionState }
|
||||
| { type: 'remotestream'; stream: MediaStream }
|
||||
| { type: 'negotiationneeded'; offer: RTCSessionDescriptionInit }
|
||||
| { type: 'error'; error: Error };
|
||||
|
||||
type EventHandler = (event: WebRTCManagerEvent) => void;
|
||||
@@ -32,6 +36,9 @@ class WebRTCManager {
|
||||
private eventHandlers: Set<EventHandler> = new Set();
|
||||
private disposed = false;
|
||||
private isInitiator = false;
|
||||
private callType: CallType = 'voice';
|
||||
private isNegotiating = false;
|
||||
private initialOfferCreated = false; // Flag to prevent duplicate offer creation
|
||||
|
||||
async initialize(iceServers: ICEServer[] = []): Promise<void> {
|
||||
if (this.peerConnection) {
|
||||
@@ -81,6 +88,7 @@ class WebRTCManager {
|
||||
|
||||
// @ts-ignore
|
||||
pc.ontrack = (event) => {
|
||||
console.log('[WebRTC] ontrack event, kind:', event.track.kind, 'streams:', event.streams.length);
|
||||
if (event.streams && event.streams[0]) {
|
||||
this.remoteStream = event.streams[0];
|
||||
this.emit({ type: 'remotestream', stream: event.streams[0] });
|
||||
@@ -89,12 +97,83 @@ class WebRTCManager {
|
||||
|
||||
// @ts-ignore
|
||||
pc.onsignalingstatechange = () => {
|
||||
// Could emit state change here if needed
|
||||
console.log('[WebRTC] Signaling state changed:', pc.signalingState);
|
||||
};
|
||||
|
||||
// @ts-ignore
|
||||
pc.onnegotiationneeded = async () => {
|
||||
console.log('[WebRTC] Negotiation needed, signalingState:', pc.signalingState, 'isNegotiating:', this.isNegotiating);
|
||||
|
||||
// Check if peer connection is still valid
|
||||
if (!this.peerConnection || this.peerConnection !== pc) {
|
||||
console.log('[WebRTC] PeerConnection changed or disposed, skipping negotiation');
|
||||
return;
|
||||
}
|
||||
|
||||
// Only start negotiation if:
|
||||
// 1. We're in stable state
|
||||
// 2. Not already negotiating
|
||||
// 3. We are the initiator
|
||||
// 4. Initial offer has NOT been created yet (prevent duplicate during startCall)
|
||||
if (pc.signalingState === 'stable' && !this.isNegotiating && this.isInitiator && !this.initialOfferCreated) {
|
||||
try {
|
||||
this.isNegotiating = true;
|
||||
const offer = await this.createOffer();
|
||||
// Check again after async operation
|
||||
if (this.peerConnection && !this.disposed) {
|
||||
this.emit({ type: 'negotiationneeded', offer });
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('[WebRTC] Failed to create offer for renegotiation:', err);
|
||||
} finally {
|
||||
// Reset after a short delay to allow the offer to be processed
|
||||
setTimeout(() => {
|
||||
this.isNegotiating = false;
|
||||
}, 500);
|
||||
}
|
||||
} else {
|
||||
console.log('[WebRTC] Skipping negotiation: state=', pc.signalingState, 'isNegotiating=', this.isNegotiating, 'isInitiator=', this.isInitiator, 'initialOfferCreated=', this.initialOfferCreated);
|
||||
}
|
||||
};
|
||||
|
||||
return pc;
|
||||
}
|
||||
|
||||
/**
|
||||
* Setup transceivers with predefined m-line order
|
||||
* This ensures m-line order is always: audio -> video
|
||||
* Even for voice calls, we pre-allocate video transceiver as 'inactive'
|
||||
*/
|
||||
private setupTransceivers(callType: CallType): void {
|
||||
if (!this.peerConnection) return;
|
||||
|
||||
console.log('[WebRTC] Setting up transceivers for callType:', callType);
|
||||
|
||||
// Always add audio transceiver first
|
||||
this.peerConnection.addTransceiver('audio', { direction: 'sendrecv' });
|
||||
|
||||
// Add video transceiver - for voice calls it's inactive, for video calls it's sendrecv
|
||||
const videoDirection = callType === 'video' ? 'sendrecv' : 'inactive';
|
||||
this.peerConnection.addTransceiver('video', { direction: videoDirection });
|
||||
|
||||
console.log('[WebRTC] Transceivers setup complete, video direction:', videoDirection);
|
||||
}
|
||||
|
||||
/**
|
||||
* Update transceiver directions based on current state
|
||||
*/
|
||||
private updateTransceiverDirections(videoDirection: 'sendrecv' | 'recvonly' | 'inactive'): void {
|
||||
if (!this.peerConnection) return;
|
||||
|
||||
const transceivers = this.peerConnection.getTransceivers();
|
||||
const videoTransceiver = transceivers.find(t => t.receiver.track?.kind === 'video');
|
||||
|
||||
if (videoTransceiver) {
|
||||
console.log('[WebRTC] Updating video transceiver direction to:', videoDirection);
|
||||
videoTransceiver.direction = videoDirection;
|
||||
}
|
||||
}
|
||||
|
||||
async createLocalStream(voiceOnly = true): Promise<MediaStream> {
|
||||
const constraints = voiceOnly
|
||||
? { audio: true, video: false }
|
||||
@@ -110,19 +189,43 @@ class WebRTCManager {
|
||||
}
|
||||
}
|
||||
|
||||
async startCall(isInitiator: boolean): Promise<RTCSessionDescriptionInit | null> {
|
||||
/**
|
||||
* Start a call with transceiver-based m-line allocation
|
||||
* This replaces the old addTrack approach
|
||||
*/
|
||||
async startCall(isInitiator: boolean, callType: CallType = 'voice'): Promise<RTCSessionDescriptionInit | null> {
|
||||
if (this.disposed) throw new Error('WebRTCManager has been disposed');
|
||||
if (!this.localStream) throw new Error('Local stream not initialized');
|
||||
|
||||
this.isInitiator = isInitiator;
|
||||
this.callType = callType;
|
||||
this.initialOfferCreated = true; // Set flag BEFORE creating connection to prevent onnegotiationneeded
|
||||
this.peerConnection = this.createPeerConnection();
|
||||
|
||||
// Add local tracks to peer connection
|
||||
for (const track of this.localStream.getTracks()) {
|
||||
this.peerConnection.addTrack(track, this.localStream);
|
||||
// Setup transceivers FIRST - this ensures consistent m-line order
|
||||
this.setupTransceivers(callType);
|
||||
|
||||
// Now add local tracks to transceivers
|
||||
const transceivers = this.peerConnection.getTransceivers();
|
||||
|
||||
// Add audio track to audio transceiver
|
||||
const audioTrack = this.localStream.getAudioTracks()[0];
|
||||
const audioTransceiver = transceivers.find(t => t.receiver.track?.kind === 'audio');
|
||||
if (audioTransceiver && audioTrack) {
|
||||
await audioTransceiver.sender.replaceTrack(audioTrack);
|
||||
}
|
||||
|
||||
// Add video track if this is a video call
|
||||
if (callType === 'video') {
|
||||
const videoTrack = this.localStream.getVideoTracks()[0];
|
||||
const videoTransceiver = transceivers.find(t => t.receiver.track?.kind === 'video');
|
||||
if (videoTransceiver && videoTrack) {
|
||||
await videoTransceiver.sender.replaceTrack(videoTrack);
|
||||
}
|
||||
}
|
||||
|
||||
if (isInitiator) {
|
||||
// For initiator, create offer directly here
|
||||
const offer = await this.createOffer();
|
||||
return offer;
|
||||
}
|
||||
@@ -132,21 +235,59 @@ class WebRTCManager {
|
||||
async createOffer(): Promise<RTCSessionDescriptionInit> {
|
||||
if (!this.peerConnection) throw new Error('PeerConnection not initialized');
|
||||
|
||||
console.log('[WebRTC] Creating offer...');
|
||||
|
||||
// Check if we have video tracks
|
||||
const hasLocalVideo = (this.localStream?.getVideoTracks().length ?? 0) > 0;
|
||||
const offerOptions = {
|
||||
offerToReceiveAudio: true,
|
||||
offerToReceiveVideo: false,
|
||||
offerToReceiveVideo: true, // Always offer to receive video (transceiver will handle direction)
|
||||
};
|
||||
|
||||
console.log('[WebRTC] Offer options:', offerOptions, 'hasLocalVideo:', hasLocalVideo);
|
||||
|
||||
const offer = await this.peerConnection.createOffer(offerOptions);
|
||||
|
||||
// Check again after async operation
|
||||
if (!this.peerConnection || this.disposed) {
|
||||
throw new Error('PeerConnection was disposed during offer creation');
|
||||
}
|
||||
|
||||
await this.peerConnection.setLocalDescription(offer);
|
||||
return offer;
|
||||
}
|
||||
|
||||
async createAnswer(): Promise<RTCSessionDescriptionInit> {
|
||||
if (!this.peerConnection) throw new Error('PeerConnection not initialized');
|
||||
console.log('[WebRTC] createAnswer called, peerConnection exists:', !!this.peerConnection, 'disposed:', this.disposed);
|
||||
|
||||
if (!this.peerConnection) {
|
||||
console.error('[WebRTC] createAnswer: PeerConnection is null');
|
||||
throw new Error('PeerConnection not initialized');
|
||||
}
|
||||
|
||||
const answer = await this.peerConnection.createAnswer();
|
||||
console.log('[WebRTC] Creating answer...');
|
||||
|
||||
const answerOptions = {
|
||||
offerToReceiveAudio: true,
|
||||
offerToReceiveVideo: true, // Always offer to receive video
|
||||
};
|
||||
|
||||
console.log('[WebRTC] Answer options:', answerOptions);
|
||||
|
||||
// @ts-ignore - react-native-webrtc types
|
||||
const answer = await this.peerConnection.createAnswer(answerOptions);
|
||||
|
||||
console.log('[WebRTC] Answer created, setting local description...');
|
||||
|
||||
// Check again after async operation
|
||||
if (!this.peerConnection || this.disposed) {
|
||||
console.error('[WebRTC] PeerConnection was disposed after createAnswer');
|
||||
throw new Error('PeerConnection was disposed during answer creation');
|
||||
}
|
||||
|
||||
await this.peerConnection.setLocalDescription(answer);
|
||||
console.log('[WebRTC] Local description set successfully');
|
||||
|
||||
// Process pending candidates after local description is set
|
||||
await this.processPendingCandidates();
|
||||
return answer;
|
||||
@@ -158,19 +299,40 @@ class WebRTCManager {
|
||||
}
|
||||
|
||||
async setRemoteDescription(description: RTCSessionDescriptionInit): Promise<void> {
|
||||
if (!this.peerConnection) throw new Error('PeerConnection not initialized');
|
||||
if (!this.peerConnection) {
|
||||
console.error('[WebRTC] setRemoteDescription: PeerConnection is null');
|
||||
throw new Error('PeerConnection not initialized');
|
||||
}
|
||||
|
||||
if (!description.sdp) {
|
||||
throw new Error('setRemoteDescription: sdp is required');
|
||||
}
|
||||
|
||||
console.log('[WebRTC] Setting remote description, type:', description.type);
|
||||
|
||||
const desc = new RTCSessionDescription({
|
||||
type: description.type,
|
||||
sdp: description.sdp,
|
||||
});
|
||||
await this.peerConnection.setRemoteDescription(desc);
|
||||
|
||||
console.log('[WebRTC] Remote description set successfully');
|
||||
|
||||
// Process pending candidates after remote description is set
|
||||
await this.processPendingCandidates();
|
||||
|
||||
console.log('[WebRTC] Pending candidates processed, connection state:', this.peerConnection?.signalingState);
|
||||
}
|
||||
|
||||
/**
|
||||
* Rollback to stable state (for Glare handling)
|
||||
*/
|
||||
async rollback(): Promise<void> {
|
||||
if (!this.peerConnection) return;
|
||||
|
||||
console.log('[WebRTC] Rolling back to stable state...');
|
||||
// @ts-ignore - react-native-webrtc supports rollback
|
||||
await this.peerConnection.setLocalDescription({ type: 'rollback', sdp: '' });
|
||||
}
|
||||
|
||||
async addIceCandidate(candidate: RTCIceCandidateInit): Promise<void> {
|
||||
@@ -202,6 +364,12 @@ class WebRTCManager {
|
||||
this.pendingCandidates = [];
|
||||
|
||||
for (const candidate of candidates) {
|
||||
// Check connection state before each candidate
|
||||
if (!this.peerConnection) {
|
||||
console.log('[WebRTC] PeerConnection lost during processing pending candidates');
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const iceCandidate = new RTCIceCandidate(candidate);
|
||||
await this.peerConnection.addIceCandidate(iceCandidate);
|
||||
@@ -225,6 +393,118 @@ class WebRTCManager {
|
||||
return audioTracks.some((track) => !track.enabled);
|
||||
}
|
||||
|
||||
/**
|
||||
* Enable video using transceiver direction
|
||||
* This preserves m-line order and triggers renegotiation
|
||||
*/
|
||||
async enableVideo(): Promise<MediaStream> {
|
||||
if (!this.peerConnection) throw new Error('PeerConnection not initialized');
|
||||
|
||||
console.log('[WebRTC] Enabling video...');
|
||||
|
||||
try {
|
||||
// Get video stream
|
||||
const videoStream = await mediaDevices.getUserMedia({
|
||||
video: { facingMode: 'user', frameRate: 30 },
|
||||
audio: false,
|
||||
});
|
||||
|
||||
const videoTrack = videoStream.getVideoTracks()[0];
|
||||
|
||||
// Find the video transceiver and update it
|
||||
const transceivers = this.peerConnection.getTransceivers();
|
||||
const videoTransceiver = transceivers.find(t => t.receiver.track?.kind === 'video');
|
||||
|
||||
if (videoTransceiver) {
|
||||
// Replace the track
|
||||
await videoTransceiver.sender.replaceTrack(videoTrack);
|
||||
// Update direction to sendrecv
|
||||
videoTransceiver.direction = 'sendrecv';
|
||||
console.log('[WebRTC] Video transceiver updated, direction:', videoTransceiver.direction);
|
||||
} else {
|
||||
console.error('[WebRTC] No video transceiver found!');
|
||||
}
|
||||
|
||||
// Update local stream
|
||||
const newStream = new MediaStream();
|
||||
|
||||
if (this.localStream) {
|
||||
// Add existing audio tracks
|
||||
this.localStream.getAudioTracks().forEach(track => {
|
||||
newStream.addTrack(track);
|
||||
});
|
||||
// Stop old video tracks
|
||||
this.localStream.getVideoTracks().forEach(track => {
|
||||
track.stop();
|
||||
});
|
||||
}
|
||||
|
||||
// Add new video track
|
||||
newStream.addTrack(videoTrack);
|
||||
this.localStream = newStream;
|
||||
|
||||
console.log('[WebRTC] Video enabled successfully');
|
||||
|
||||
// onnegotiationneeded will be triggered automatically
|
||||
return newStream;
|
||||
} catch (error) {
|
||||
console.error('[WebRTC] Failed to enable video:', error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Disable video using transceiver direction
|
||||
*/
|
||||
async disableVideo(): Promise<MediaStream | null> {
|
||||
if (!this.peerConnection) {
|
||||
console.log('[WebRTC] disableVideo: No peer connection');
|
||||
return null;
|
||||
}
|
||||
if (!this.localStream) {
|
||||
console.log('[WebRTC] disableVideo: No local stream');
|
||||
return null;
|
||||
}
|
||||
|
||||
console.log('[WebRTC] Disabling video...');
|
||||
|
||||
// Stop video tracks
|
||||
const videoTracks = this.localStream.getVideoTracks();
|
||||
videoTracks.forEach((track) => {
|
||||
track.stop();
|
||||
});
|
||||
|
||||
// Find video transceiver and update direction
|
||||
const transceivers = this.peerConnection.getTransceivers();
|
||||
const videoTransceiver = transceivers.find(t => t.receiver.track?.kind === 'video');
|
||||
|
||||
if (videoTransceiver) {
|
||||
// Remove the track
|
||||
await videoTransceiver.sender.replaceTrack(null);
|
||||
// Set direction to inactive
|
||||
videoTransceiver.direction = 'inactive';
|
||||
console.log('[WebRTC] Video transceiver direction set to inactive');
|
||||
}
|
||||
|
||||
// Create new stream with only audio
|
||||
const newStream = new MediaStream();
|
||||
this.localStream.getAudioTracks().forEach(track => {
|
||||
newStream.addTrack(track);
|
||||
});
|
||||
|
||||
this.localStream = newStream;
|
||||
console.log('[WebRTC] Video disabled successfully');
|
||||
|
||||
// onnegotiationneeded will be triggered automatically
|
||||
return newStream;
|
||||
}
|
||||
|
||||
isVideoEnabled(): boolean {
|
||||
if (!this.localStream) return false;
|
||||
const videoTracks = this.localStream.getVideoTracks();
|
||||
return videoTracks.length > 0 && videoTracks.some((track) => track.enabled);
|
||||
}
|
||||
|
||||
getRemoteStream(): MediaStream | null {
|
||||
return this.remoteStream;
|
||||
}
|
||||
@@ -237,6 +517,14 @@ class WebRTCManager {
|
||||
return this.peerConnection;
|
||||
}
|
||||
|
||||
getSignalingState(): RTCSignalingState | null {
|
||||
return this.peerConnection?.signalingState || null;
|
||||
}
|
||||
|
||||
getIsInitiator(): boolean {
|
||||
return this.isInitiator;
|
||||
}
|
||||
|
||||
onEvent(handler: EventHandler): () => void {
|
||||
this.eventHandlers.add(handler);
|
||||
return () => {
|
||||
@@ -258,6 +546,8 @@ class WebRTCManager {
|
||||
this.disposed = true;
|
||||
this.eventHandlers.clear();
|
||||
this.pendingCandidates = [];
|
||||
this.isNegotiating = false;
|
||||
this.initialOfferCreated = false;
|
||||
|
||||
if (this.localStream) {
|
||||
this.localStream.getTracks().forEach((track) => track.stop());
|
||||
|
||||
@@ -718,10 +718,11 @@ class WebSocketService {
|
||||
}
|
||||
|
||||
// Call signaling send methods
|
||||
sendCallInvite(conversationId: string, calleeId: string): void {
|
||||
sendCallInvite(conversationId: string, calleeId: string, callType: 'voice' | 'video' = 'voice'): void {
|
||||
this.sendFireAndForget('call_invite', {
|
||||
conversation_id: conversationId,
|
||||
callee_id: calleeId,
|
||||
call_type: callType,
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user