feat(call): migrate from WebRTC to LiveKit
Some checks failed
Frontend CI / ota-android (push) Has been cancelled
Frontend CI / ota-ios (push) Has been cancelled
Frontend CI / build-and-push-web (push) Has been cancelled
Frontend CI / build-android-apk (push) Has been cancelled

Replace the custom WebRTC implementation with LiveKit for improved
stability and feature support.

- Remove `react-native-webrtc` and custom `WebRTCManager`
- Implement `LiveKitService` for room and track management
- Update `callStore` to handle LiveKit events and connection states
- Refactor `CallScreen` (mobile and web) to use `@livekit/react-native`
  and `VideoView`
- Update WebSocket protocol to use `call_ready` instead of manual SDP/ICE
  exchanges
- Add necessary camera and microphone permissions to `app.json`
- Update Metro and Babel configurations for LiveKit compatibility
This commit is contained in:
2026-06-01 13:45:45 +08:00
parent 72c30ed156
commit 70ab00795a
17 changed files with 2500 additions and 1420 deletions

View File

@@ -24,14 +24,11 @@ export type {
WSCallRejectedMessage,
WSCallBusyMessage,
WSCallEndedMessage,
WSCallSDPMessage,
WSCallICEMessage,
WSCallPeerMutedMessage,
WSCallInvitedMessage,
WSCallAnsweredElsewhereMessage,
WSErrorMessage,
WSServerMessage,
ICEServerConfig,
} from './wsService';
export { handleError, createErrorHandler, safeAsync, createAppError } from './errorHandler';

View File

@@ -39,8 +39,7 @@ export type WSMessageType =
| 'call_accepted'
| 'call_rejected'
| 'call_busy'
| 'call_sdp'
| 'call_ice'
| 'call_ready'
| 'call_ended'
| 'call_peer_muted'
| 'call_invited'
@@ -56,20 +55,12 @@ export interface WSCallIncomingMessage {
media_type?: string;
created_at: number;
lifetime?: number;
ice_servers?: ICEServerConfig[];
}
export interface ICEServerConfig {
urls: string[];
username?: string;
credential?: string;
}
export interface WSCallAcceptedMessage {
type: 'call_accepted';
call_id: string;
started_at: number;
ice_servers?: ICEServerConfig[];
}
export interface WSCallRejectedMessage {
@@ -92,25 +83,6 @@ export interface WSCallEndedMessage {
ended_at?: number;
}
export interface WSCallSDPMessage {
type: 'call_sdp';
call_id: string;
from_id: string;
payload: {
sdp_type: string;
sdp: string;
};
}
export interface WSCallICEMessage {
type: 'call_ice';
call_id: string;
from_id: string;
payload: {
candidate: string;
};
}
export interface WSCallPeerMutedMessage {
type: 'call_peer_muted';
call_id: string;
@@ -282,8 +254,6 @@ export type WSMessage =
| WSCallRejectedMessage
| WSCallBusyMessage
| WSCallEndedMessage
| WSCallSDPMessage
| WSCallICEMessage
| WSCallPeerMutedMessage
| WSCallInvitedMessage
| WSCallAnsweredElsewhereMessage
@@ -496,12 +466,6 @@ class WebSocketService {
case 'call_busy':
this.handleCallBusy(payload);
break;
case 'call_sdp':
this.handleCallSDP(payload);
break;
case 'call_ice':
this.handleCallICE(payload);
break;
case 'call_ended':
this.handleCallEnded(payload);
break;
@@ -664,7 +628,6 @@ class WebSocketService {
call_type: payload.media_type || payload.call_type,
created_at: payload.created_at,
lifetime: payload.lifetime,
ice_servers: payload.ice_servers,
};
this.emit('call_incoming', m);
}
@@ -674,7 +637,6 @@ class WebSocketService {
type: 'call_accepted',
call_id: payload.call_id,
started_at: payload.started_at,
ice_servers: payload.ice_servers,
};
this.emit('call_accepted', m);
}
@@ -696,26 +658,6 @@ class WebSocketService {
this.emit('call_busy', m);
}
private handleCallSDP(payload: any): void {
const m: WSCallSDPMessage = {
type: 'call_sdp',
call_id: payload.call_id,
from_id: payload.from_id,
payload: payload.payload,
};
this.emit('call_sdp', m);
}
private handleCallICE(payload: any): void {
const m: WSCallICEMessage = {
type: 'call_ice',
call_id: payload.call_id,
from_id: payload.from_id,
payload: payload.payload,
};
this.emit('call_ice', m);
}
private handleCallEnded(payload: any): void {
const m: WSCallEndedMessage = {
type: 'call_ended',
@@ -793,21 +735,6 @@ class WebSocketService {
this.sendFireAndForget('call_busy', { call_id: callId });
}
sendCallSDP(callId: string, sdpType: string, sdp: string): void {
this.sendFireAndForget('call_sdp', {
call_id: callId,
sdp_type: sdpType,
sdp,
});
}
sendCallICE(callId: string, candidate: string): void {
this.sendFireAndForget('call_ice', {
call_id: callId,
candidate,
});
}
sendCallEnd(callId: string, reason?: string): void {
this.sendFireAndForget('call_end', { call_id: callId, reason });
}
@@ -816,6 +743,10 @@ class WebSocketService {
this.sendFireAndForget('call_mute', { call_id: callId, muted });
}
sendCallReady(callId: string): void {
this.sendFireAndForget('call_ready', { call_id: callId });
}
private handleMessageSent(payload: any): void {
// 找到对应的发送请求并resolve
const pendingMsg = this.pendingMessages.find(p =>

View File

@@ -0,0 +1,220 @@
import { Room, RoomEvent, Track, ConnectionState, LocalParticipant, RemoteParticipant, RemoteTrackPublication, TrackPublication, Participant as LKParticipant } from 'livekit-client';
export interface LiveKitServiceConfig {
url: string;
token: string;
}
export type ConnectionStatus = 'disconnected' | 'connecting' | 'connected' | 'reconnecting' | 'failed';
export interface LiveKitEventMap {
connected: undefined;
disconnected: undefined;
reconnecting: undefined;
reconnected: undefined;
trackSubscribed: { participant: RemoteParticipant; publication: RemoteTrackPublication; track: Track };
trackUnsubscribed: { participant: RemoteParticipant; publication: RemoteTrackPublication; track: Track };
trackMuted: { participant: LKParticipant; publication: TrackPublication };
trackUnmuted: { participant: LKParticipant; publication: TrackPublication };
connectionStatusChanged: ConnectionStatus;
error: Error;
}
type EventHandler<K extends keyof LiveKitEventMap> = (data: LiveKitEventMap[K]) => void;
class LiveKitServiceImpl {
private room: Room | null = null;
private handlers: Map<string, Set<EventHandler<any>>> = new Map();
private _connectionStatus: ConnectionStatus = 'disconnected';
get connectionStatus(): ConnectionStatus {
return this._connectionStatus;
}
get connected(): boolean {
return this._connectionStatus === 'connected';
}
get localParticipant(): LocalParticipant | null {
return this.room?.localParticipant ?? null;
}
get remoteParticipants(): Map<string, RemoteParticipant> {
return this.room?.remoteParticipants ?? new Map();
}
async connect(url: string, token: string): Promise<void> {
if (this.room) {
await this.disconnect();
}
this.room = new Room({
adaptiveStream: true,
dynacast: true,
});
this.setupRoomListeners();
this._connectionStatus = 'connecting';
this.emit('connectionStatusChanged', 'connecting');
try {
await this.room.connect(url, token);
this._connectionStatus = 'connected';
this.emit('connectionStatusChanged', 'connected');
this.emit('connected', undefined);
} catch (err) {
this._connectionStatus = 'failed';
this.emit('connectionStatusChanged', 'failed');
this.emit('error', err instanceof Error ? err : new Error(String(err)));
throw err;
}
}
async disconnect(): Promise<void> {
if (!this.room) return;
this.room.off(RoomEvent.TrackSubscribed, this.onTrackSubscribed);
this.room.off(RoomEvent.TrackUnsubscribed, this.onTrackUnsubscribed);
this.room.off(RoomEvent.TrackMuted, this.onTrackMuted);
this.room.off(RoomEvent.TrackUnmuted, this.onTrackUnmuted);
this.room.off(RoomEvent.Disconnected, this.onDisconnected);
this.room.off(RoomEvent.Reconnecting, this.onReconnecting);
this.room.off(RoomEvent.Reconnected, this.onReconnected);
this.room.off(RoomEvent.ConnectionStateChanged, this.onConnectionStateChanged);
await this.room.disconnect();
this.room = null;
this._connectionStatus = 'disconnected';
this.emit('connectionStatusChanged', 'disconnected');
this.emit('disconnected', undefined);
}
async setMuted(muted: boolean): Promise<void> {
if (!this.room?.localParticipant) return;
await this.room.localParticipant.setMicrophoneEnabled(!muted);
}
async setVideoEnabled(enabled: boolean): Promise<void> {
if (!this.room?.localParticipant) return;
await this.room.localParticipant.setCameraEnabled(enabled);
}
isMuted(): boolean {
if (!this.room?.localParticipant) return true;
const audioTrack = this.room.localParticipant.getTrackPublication(Track.Source.Microphone);
return audioTrack?.isMuted ?? true;
}
isVideoEnabled(): boolean {
if (!this.room?.localParticipant) return false;
const videoTrack = this.room.localParticipant.getTrackPublication(Track.Source.Camera);
return videoTrack?.isSubscribed ?? false;
}
getRemoteAudioTrack(): Track | null {
for (const [, participant] of this.remoteParticipants) {
const audioPub = participant.getTrackPublication(Track.Source.Microphone);
if (audioPub?.track) return audioPub.track;
}
return null;
}
getRemoteVideoTrack(): Track | null {
for (const [, participant] of this.remoteParticipants) {
const videoPub = participant.getTrackPublication(Track.Source.Camera);
if (videoPub?.track) return videoPub.track;
}
return null;
}
on<K extends keyof LiveKitEventMap>(event: K, handler: EventHandler<K>): () => void {
if (!this.handlers.has(event)) {
this.handlers.set(event, new Set());
}
this.handlers.get(event)!.add(handler);
return () => {
this.handlers.get(event)?.delete(handler);
};
}
dispose(): void {
this.disconnect();
this.handlers.clear();
}
private setupRoomListeners(): void {
if (!this.room) return;
this.room.on(RoomEvent.TrackSubscribed, this.onTrackSubscribed);
this.room.on(RoomEvent.TrackUnsubscribed, this.onTrackUnsubscribed);
this.room.on(RoomEvent.TrackMuted, this.onTrackMuted);
this.room.on(RoomEvent.TrackUnmuted, this.onTrackUnmuted);
this.room.on(RoomEvent.Disconnected, this.onDisconnected);
this.room.on(RoomEvent.Reconnecting, this.onReconnecting);
this.room.on(RoomEvent.Reconnected, this.onReconnected);
this.room.on(RoomEvent.ConnectionStateChanged, this.onConnectionStateChanged);
}
private onTrackSubscribed = (track: Track, publication: RemoteTrackPublication, participant: RemoteParticipant): void => {
this.emit('trackSubscribed', { participant, publication, track });
};
private onTrackUnsubscribed = (track: Track, publication: RemoteTrackPublication, participant: RemoteParticipant): void => {
this.emit('trackUnsubscribed', { participant, publication, track });
};
private onTrackMuted = (publication: TrackPublication, participant: LKParticipant): void => {
this.emit('trackMuted', { participant, publication });
};
private onTrackUnmuted = (publication: TrackPublication, participant: LKParticipant): void => {
this.emit('trackUnmuted', { participant, publication });
};
private onDisconnected = (): void => {
this._connectionStatus = 'disconnected';
this.emit('connectionStatusChanged', 'disconnected');
this.emit('disconnected', undefined);
};
private onReconnecting = (): void => {
this._connectionStatus = 'reconnecting';
this.emit('connectionStatusChanged', 'reconnecting');
this.emit('reconnecting', undefined);
};
private onReconnected = (): void => {
this._connectionStatus = 'connected';
this.emit('connectionStatusChanged', 'connected');
this.emit('reconnected', undefined);
};
private onConnectionStateChanged = (state: ConnectionState): void => {
const status: ConnectionStatus = state === ConnectionState.Connected
? 'connected'
: state === ConnectionState.Connecting
? 'connecting'
: state === ConnectionState.Reconnecting
? 'reconnecting'
: 'disconnected';
this._connectionStatus = status;
this.emit('connectionStatusChanged', status);
};
private emit<K extends keyof LiveKitEventMap>(event: K, data: LiveKitEventMap[K]): void {
const handlers = this.handlers.get(event);
if (handlers) {
for (const handler of handlers) {
try {
handler(data);
} catch (err) {
console.error(`LiveKit event handler error for ${event}:`, err);
}
}
}
}
}
export const liveKitService = new LiveKitServiceImpl();
export default liveKitService;

View File

@@ -0,0 +1,2 @@
export { liveKitService, default } from './LiveKitService';
export type { LiveKitServiceConfig, LiveKitEventMap, ConnectionStatus } from './LiveKitService';

View File

@@ -1,672 +0,0 @@
import {
RTCPeerConnection,
RTCSessionDescription,
RTCIceCandidate,
mediaDevices,
MediaStream,
} from 'react-native-webrtc';
export interface ICEServer {
urls: string[];
username?: string;
credential?: string;
}
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;
class WebRTCManager {
private peerConnection: RTCPeerConnection | null = null;
private localStream: MediaStream | null = null;
private remoteStream: MediaStream | null = null;
private pendingCandidates: RTCIceCandidateInit[] = [];
private iceServers: ICEServer[] = [];
private eventHandlers: Set<EventHandler> = new Set();
private disposed = false;
private isInitiator = false;
// ICE restart 相关状态
private reconnectAttempts = 0;
private readonly MAX_RECONNECT_ATTEMPTS = 3;
private reconnectTimer: ReturnType<typeof setTimeout> | null = null;
private disconnectTimer: ReturnType<typeof setTimeout> | null = null;
async initialize(iceServers: ICEServer[] = []): Promise<void> {
if (this.peerConnection) {
this.dispose();
}
this.disposed = false;
this.iceServers = iceServers.length > 0
? iceServers
: [{ urls: ['stun:stun.l.google.com:19302'] }];
}
private buildPeerConnectionConfig(): RTCConfiguration {
return {
iceServers: this.iceServers.map((server) => ({
urls: server.urls,
...(server.username ? { username: server.username } : {}),
...(server.credential ? { credential: server.credential } : {}),
})),
iceCandidatePoolSize: 10,
};
}
private createPeerConnection(): RTCPeerConnection {
const config = this.buildPeerConnectionConfig();
const pc = new RTCPeerConnection(config);
// @ts-ignore
pc.onicecandidate = (event) => {
if (event.candidate) {
this.emit({ type: 'icecandidate', candidate: event.candidate });
} else {
this.emit({ type: 'icecandidate', candidate: null });
}
};
// @ts-ignore
pc.oniceconnectionstatechange = () => {
const state = pc.iceConnectionState as ConnectionState;
this.emit({ type: 'connectionstatechange', state });
this.handleIceConnectionStateChange(state);
};
// @ts-ignore
pc.onconnectionstatechange = () => {
const state = pc.connectionState as ConnectionState;
this.emit({ type: 'connectionstatechange', state });
this.handleConnectionStateChange(state);
};
// @ts-ignore
pc.ontrack = (event) => {
console.log('[WebRTC] ontrack event, kind:', event.track?.kind, 'streams:', event.streams?.length ?? 0);
if (!event.track) {
console.log('[WebRTC] ontrack: no track in event');
return;
}
// Always manually construct/append to remoteStream to ensure all tracks are collected
// react-native-webrtc may fire ontrack per-track and event.streams[0] might not contain all tracks
if (!this.remoteStream) {
this.remoteStream = new MediaStream();
}
// Check if track already exists to avoid duplicates
const existingTracks = this.remoteStream.getTracks();
const trackExists = existingTracks.some(t => t.id === event.track.id);
if (!trackExists) {
this.remoteStream.addTrack(event.track);
console.log('[WebRTC] Added remote track:', event.track.kind, 'total tracks:', this.remoteStream.getTracks().length);
} else {
console.log('[WebRTC] Remote track already exists:', event.track.kind);
}
this.emit({ type: 'remotestream', stream: this.remoteStream });
};
// @ts-ignore
pc.onsignalingstatechange = () => {
console.log('[WebRTC] Signaling state changed:', pc.signalingState);
};
// @ts-ignore
pc.onnegotiationneeded = async () => {
console.log('[WebRTC] Negotiation needed, signalingState:', pc.signalingState);
if (!this.peerConnection || this.peerConnection !== pc) {
console.log('[WebRTC] PeerConnection changed or disposed, skipping');
return;
}
if (pc.signalingState !== 'stable') {
console.log('[WebRTC] Skipping negotiation, not in stable state:', pc.signalingState);
return;
}
try {
const offer = await this.createOffer();
if (this.peerConnection && !this.disposed) {
this.emit({ type: 'negotiationneeded', offer });
}
} catch (err) {
console.error('[WebRTC] Negotiation needed failed:', err);
}
};
return pc;
}
async createLocalStream(voiceOnly = true): Promise<MediaStream> {
const constraints = voiceOnly
? { audio: true, video: false }
: { audio: true, video: { facingMode: 'user', frameRate: 30 } };
try {
// @ts-ignore
this.localStream = await mediaDevices.getUserMedia(constraints);
return this.localStream;
} catch (error) {
console.error('[WebRTC] Failed to get local media stream:', error);
throw error;
}
}
/**
* Start a call using official addTrack approach
* Follows react-native-webrtc CallGuide.md
*/
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.peerConnection = this.createPeerConnection();
// Official approach: add all tracks using addTrack
// addTrack automatically creates senders with proper directions
this.localStream.getTracks().forEach((track) => {
console.log('[WebRTC] Adding track:', track.kind, track.enabled);
this.peerConnection!.addTrack(track, this.localStream!);
});
if (isInitiator) {
// For initiator, create offer directly
const offer = await this.createOffer();
return offer;
}
return null;
}
async createOffer(): Promise<RTCSessionDescriptionInit> {
if (!this.peerConnection) throw new Error('PeerConnection not initialized');
console.log('[WebRTC] Creating offer...');
const offerOptions = {
offerToReceiveAudio: true,
offerToReceiveVideo: true,
};
const offer = await this.peerConnection.createOffer(offerOptions);
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] Creating answer...');
const answerOptions = {
offerToReceiveAudio: true,
offerToReceiveVideo: true,
};
// @ts-ignore
const answer = await this.peerConnection.createAnswer(answerOptions);
if (!this.peerConnection || this.disposed) {
throw new Error('PeerConnection was disposed during answer creation');
}
await this.peerConnection.setLocalDescription(answer);
// Process pending candidates
await this.processPendingCandidates();
return answer;
}
async createAnswerFromOffer(offer: RTCSessionDescriptionInit): Promise<RTCSessionDescriptionInit> {
await this.setRemoteDescription(offer);
return this.createAnswer();
}
/**
* Rollback to stable state (for glare handling)
* Used when both peers try to negotiate simultaneously
*/
async rollback(): Promise<void> {
if (!this.peerConnection) {
throw new Error('PeerConnection not initialized');
}
const pc = this.peerConnection;
const signalingState = pc.signalingState;
console.log('[WebRTC] Attempting rollback, current state:', signalingState);
// Only rollback if we're not in stable state
if (signalingState === 'stable') {
console.log('[WebRTC] Already in stable state, no rollback needed');
return;
}
try {
// For react-native-webrtc, we may need to recreate the peer connection
// as rollback is not fully supported
if (signalingState === 'have-local-offer') {
// Rollback local offer by setting local description to null/undefined
// @ts-ignore - react-native-webrtc specific
if (pc.setLocalDescription) {
// @ts-ignore
await pc.setLocalDescription({ type: 'rollback' });
}
} else if (signalingState === 'have-remote-offer') {
// Rollback remote offer
// @ts-ignore
if (pc.setRemoteDescription) {
// @ts-ignore
await pc.setRemoteDescription({ type: 'rollback' });
}
}
console.log('[WebRTC] Rollback successful');
} catch (err) {
console.error('[WebRTC] Rollback failed:', err);
throw err;
}
}
async setRemoteDescription(description: RTCSessionDescriptionInit): Promise<void> {
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();
}
async addIceCandidate(candidate: RTCIceCandidateInit): Promise<void> {
if (!this.peerConnection) {
this.pendingCandidates.push(candidate);
return;
}
if (!this.peerConnection.remoteDescription) {
this.pendingCandidates.push(candidate);
return;
}
try {
const iceCandidate = new RTCIceCandidate(candidate);
await this.peerConnection.addIceCandidate(iceCandidate);
} catch (error) {
console.error('[WebRTC] Failed to add ICE candidate:', error);
this.pendingCandidates.push(candidate);
}
}
private async processPendingCandidates(): Promise<void> {
if (this.pendingCandidates.length === 0) return;
if (!this.peerConnection) return;
const candidates = [...this.pendingCandidates];
this.pendingCandidates = [];
for (const candidate of candidates) {
if (!this.peerConnection) return;
try {
const iceCandidate = new RTCIceCandidate(candidate);
await this.peerConnection.addIceCandidate(iceCandidate);
} catch (error) {
console.error('[WebRTC] Failed to add pending ICE candidate:', error);
}
}
}
setMuted(muted: boolean): void {
if (!this.localStream) return;
const audioTracks = this.localStream.getAudioTracks();
audioTracks.forEach((track) => {
track.enabled = !muted;
});
}
isMuted(): boolean {
if (!this.localStream) return false;
const audioTracks = this.localStream.getAudioTracks();
return audioTracks.some((track) => !track.enabled);
}
/**
* Enable video - official replaceTrack approach
*/
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 sender for video and replace the track
const senders = this.peerConnection.getSenders();
const videoSender = senders.find(s => s.track?.kind === 'video');
if (videoSender) {
await videoSender.replaceTrack(videoTrack);
console.log('[WebRTC] Video track replaced on existing sender');
} else {
// No existing video sender, add track
this.peerConnection.addTrack(videoTrack, this.localStream!);
console.log('[WebRTC] Video track added via addTrack');
}
// Update local stream
const newStream = new MediaStream();
if (this.localStream) {
this.localStream.getAudioTracks().forEach((track) => {
newStream.addTrack(track);
});
// Stop old video tracks
this.localStream.getVideoTracks().forEach((track) => {
track.stop();
});
}
newStream.addTrack(videoTrack);
this.localStream = newStream;
console.log('[WebRTC] Video enabled successfully');
return newStream;
} catch (error) {
console.error('[WebRTC] Failed to enable video:', error);
throw error;
}
}
/**
* Disable video - official replaceTrack approach
*/
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...');
// Find the sender for video and replace with null
const senders = this.peerConnection.getSenders();
const videoSender = senders.find(s => s.track?.kind === 'video');
if (videoSender) {
await videoSender.replaceTrack(null);
console.log('[WebRTC] Video track removed from sender');
}
// Stop video tracks
const videoTracks = this.localStream.getVideoTracks();
videoTracks.forEach((track) => {
track.stop();
});
// 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');
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;
}
getLocalStream(): MediaStream | null {
return this.localStream;
}
getPeerConnection(): RTCPeerConnection | null {
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 () => {
this.eventHandlers.delete(handler);
};
}
private emit(event: WebRTCManagerEvent): void {
this.eventHandlers.forEach((handler) => {
try {
handler(event);
} catch (error) {
console.error('[WebRTC] Event handler error:', error);
}
});
}
// ========== ICE Restart 支持 ==========
/**
* 处理 ICE 连接状态变化
* 根据 W3C 规范: disconnected 状态可能间歇性触发并自发解决
* failed 状态表示需要 ICE restart
*/
private handleIceConnectionStateChange(state: ConnectionState): void {
console.log('[WebRTC] ICE connection state:', state);
switch (state) {
case 'connected': // 连接成功,重置重连状态
this.resetReconnectState();
break;
case 'disconnected':
// 临时断开,等待一段时间看是否自动恢复
this.scheduleDisconnectCheck();
break;
case 'failed':
// ICE 失败,尝试 ICE restart
this.attemptIceRestart();
break;
case 'closed':
this.clearReconnectTimers();
break;
}
}
/**
* 处理 PeerConnection 连接状态变化
*/
private handleConnectionStateChange(state: ConnectionState): void {
console.log('[WebRTC] PeerConnection state:', state);
switch (state) {
case 'connected':
this.resetReconnectState();
break;
case 'disconnected':
// 等待短暂时间看是否自动恢复
this.scheduleDisconnectCheck();
break;
case 'failed':
// 连接完全失败
this.emit({ type: 'error', error: new Error('Connection failed') });
break;
}
}
/**
* 安排断开检查
* 给 disconnected 状态一个恢复窗口5秒
*/
private scheduleDisconnectCheck(): void {
if (this.disconnectTimer) {
clearTimeout(this.disconnectTimer);
}
this.disconnectTimer = setTimeout(() => {
const pc = this.peerConnection;
if (!pc || this.disposed) return;
// 如果 5 秒后仍然是 disconnected尝试 ICE restart
if (pc.iceConnectionState === 'disconnected' || pc.iceConnectionState === 'failed') {
console.log('[WebRTC] Connection still disconnected after 5s, attempting ICE restart');
this.attemptIceRestart();
}
}, 5000);
}
/**
* 尝试 ICE restart
* 参考: https://developer.mozilla.org/en-US/docs/Web/API/WebRTC_API/Session_lifetime#ice_restart
*/
private async attemptIceRestart(): Promise<void> {
if (this.reconnectAttempts >= this.MAX_RECONNECT_ATTEMPTS) {
console.error('[WebRTC] Max reconnection attempts reached');
this.emit({ type: 'error', error: new Error('Max reconnection attempts reached') });
return;
}
const pc = this.peerConnection;
if (!pc || this.disposed) {
console.log('[WebRTC] Cannot restart ICE: PeerConnection not available');
return;
}
// 检查信令状态
if (pc.signalingState !== 'stable') {
console.log('[WebRTC] Cannot restart ICE: signaling state not stable:', pc.signalingState);
return;
}
this.reconnectAttempts++;
console.log(`[WebRTC] Attempting ICE restart (${this.reconnectAttempts}/${this.MAX_RECONNECT_ATTEMPTS})`);
try {
// 尝试使用 restartIce() API (现代浏览器支持)
// @ts-ignore
if (pc.restartIce) {
// @ts-ignore
pc.restartIce();
console.log('[WebRTC] restartIce() called');
}
// 创建新的 offer触发 ICE restart
const offer = await pc.createOffer({ iceRestart: true });
await pc.setLocalDescription(offer);
console.log('[WebRTC] ICE restart offer created');
// 发送新的 offer 给对方
this.emit({ type: 'negotiationneeded', offer });
} catch (error) {
console.error('[WebRTC] ICE restart failed:', error);
this.emit({ type: 'error', error: error as Error });
}
}
/**
* 重置重连状态
*/
private resetReconnectState(): void {
this.reconnectAttempts = 0;
this.clearReconnectTimers();
}
/**
* 清除重连定时器
*/
private clearReconnectTimers(): void {
if (this.disconnectTimer) {
clearTimeout(this.disconnectTimer);
this.disconnectTimer = null;
}
if (this.reconnectTimer) {
clearTimeout(this.reconnectTimer);
this.reconnectTimer = null;
}
}
dispose(): void {
this.disposed = true;
this.eventHandlers.clear();
this.pendingCandidates = [];
this.clearReconnectTimers();
if (this.localStream) {
this.localStream.getTracks().forEach((track) => track.stop());
this.localStream = null;
}
if (this.remoteStream) {
// Do not stop remote tracks; they are managed by the peer connection
// and will be ended automatically when the connection closes
this.remoteStream = null;
}
if (this.peerConnection) {
this.peerConnection.close();
this.peerConnection = null;
}
}
}
export const webrtcManager = new WebRTCManager();

View File

@@ -1,89 +0,0 @@
// WebRTCManager for Web platform (stub implementation)
import type {
ICEServer,
ConnectionState,
WebRTCManagerEvent,
} from './WebRTCManager';
type EventHandler = (event: WebRTCManagerEvent) => void;
class WebRTCManagerWeb {
private eventHandlers: Set<EventHandler> = new Set();
private disposed = false;
async initialize(iceServers: ICEServer[] = []): Promise<void> {
console.log('[WebRTC] WebRTC not supported on web platform');
}
async createLocalStream(voiceOnly = true): Promise<MediaStream | null> {
console.warn('[WebRTC] WebRTC not supported on web platform');
return null;
}
async startCall(isInitiator: boolean): Promise<RTCSessionDescriptionInit | null> {
console.warn('[WebRTC] WebRTC not supported on web platform');
return null;
}
async createOffer(): Promise<RTCSessionDescriptionInit | null> {
console.warn('[WebRTC] WebRTC not supported on web platform');
return null;
}
async createAnswer(): Promise<RTCSessionDescriptionInit | null> {
console.warn('[WebRTC] WebRTC not supported on web platform');
return null;
}
async createAnswerFromOffer(offer: RTCSessionDescriptionInit): Promise<RTCSessionDescriptionInit | null> {
console.warn('[WebRTC] WebRTC not supported on web platform');
return null;
}
async setRemoteDescription(description: RTCSessionDescriptionInit): Promise<void> {
console.warn('[WebRTC] WebRTC not supported on web platform');
}
async addIceCandidate(candidate: RTCIceCandidateInit): Promise<void> {
console.warn('[WebRTC] WebRTC not supported on web platform');
}
setMuted(muted: boolean): void {
console.warn('[WebRTC] WebRTC not supported on web platform');
}
isMuted(): boolean {
console.warn('[WebRTC] WebRTC not supported on web platform');
return false;
}
getRemoteStream(): MediaStream | null {
console.warn('[WebRTC] WebRTC not supported on web platform');
return null;
}
getLocalStream(): MediaStream | null {
console.warn('[WebRTC] WebRTC not supported on web platform');
return null;
}
getPeerConnection(): RTCPeerConnection | null {
console.warn('[WebRTC] WebRTC not supported on web platform');
return null;
}
onEvent(handler: EventHandler): () => void {
console.warn('[WebRTC] WebRTC not supported on web platform');
return () => {
this.eventHandlers.delete(handler);
};
}
dispose(): void {
this.disposed = true;
this.eventHandlers.clear();
console.log('[WebRTC] WebRTC disposed on web platform');
}
}
export const webrtcManager = new WebRTCManagerWeb();

View File

@@ -1,2 +0,0 @@
export { webrtcManager } from './WebRTCManager';
export type { ICEServer, ConnectionState, WebRTCManagerEvent } from './WebRTCManager';