feat(CallScreen): enhance video call functionality and UI updates
Some checks failed
Frontend CI / build-and-push-web (push) Successful in 3m13s
Frontend CI / build-android-apk (push) Has been cancelled
Frontend CI / ota-android (push) Has been cancelled

- 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:
lafay
2026-03-28 00:56:52 +08:00
parent b19a2ced6f
commit f6176c945b
11 changed files with 790 additions and 268 deletions

View File

@@ -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());

View File

@@ -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,
});
}