814 lines
26 KiB
TypeScript
814 lines
26 KiB
TypeScript
import { Injectable, signal, computed, inject } from '@angular/core';
|
|
import { Observable, Subject } from 'rxjs';
|
|
import { v4 as uuidv4 } from 'uuid';
|
|
import { SignalingMessage, ChatEvent } from '../models';
|
|
import { TimeSyncService } from './time-sync.service';
|
|
|
|
// ICE server configuration for NAT traversal
|
|
const ICE_SERVERS: RTCIceServer[] = [
|
|
{ urls: 'stun:stun.l.google.com:19302' },
|
|
{ urls: 'stun:stun1.l.google.com:19302' },
|
|
{ urls: 'stun:stun2.l.google.com:19302' },
|
|
{ urls: 'stun:stun3.l.google.com:19302' },
|
|
{ urls: 'stun:stun4.l.google.com:19302' },
|
|
];
|
|
|
|
interface PeerData {
|
|
connection: RTCPeerConnection;
|
|
dataChannel: RTCDataChannel | null;
|
|
isInitiator: boolean;
|
|
pendingCandidates: RTCIceCandidateInit[];
|
|
audioSender?: RTCRtpSender;
|
|
videoSender?: RTCRtpSender;
|
|
}
|
|
|
|
@Injectable({
|
|
providedIn: 'root',
|
|
})
|
|
export class WebRTCService {
|
|
private timeSync = inject(TimeSyncService);
|
|
private peers = new Map<string, PeerData>();
|
|
private localStream: MediaStream | null = null;
|
|
private _screenStream: MediaStream | null = null;
|
|
private remoteStreams = new Map<string, MediaStream>();
|
|
private signalingSocket: WebSocket | null = null;
|
|
private lastWsUrl: string | null = null;
|
|
private reconnectAttempts = 0;
|
|
private reconnectTimer: any = null;
|
|
private destroy$ = new Subject<void>();
|
|
private outputVolume = 1;
|
|
private currentServerId: string | null = null;
|
|
private lastIdentify: { oderId: string; displayName: string } | null = null;
|
|
private lastJoin: { serverId: string; userId: string } | null = null;
|
|
|
|
// Signals for reactive state
|
|
private readonly _peerId = signal<string>(uuidv4());
|
|
private readonly _isConnected = signal(false);
|
|
private readonly _isVoiceConnected = signal(false);
|
|
private readonly _connectedPeers = signal<string[]>([]);
|
|
private readonly _isMuted = signal(false);
|
|
private readonly _isDeafened = signal(false);
|
|
private readonly _isScreenSharing = signal(false);
|
|
private readonly _screenStreamSignal = signal<MediaStream | null>(null);
|
|
|
|
// Public computed signals
|
|
readonly peerId = computed(() => this._peerId());
|
|
readonly isConnected = computed(() => this._isConnected());
|
|
readonly isVoiceConnected = computed(() => this._isVoiceConnected());
|
|
readonly connectedPeers = computed(() => this._connectedPeers());
|
|
readonly isMuted = computed(() => this._isMuted());
|
|
readonly isDeafened = computed(() => this._isDeafened());
|
|
readonly isScreenSharing = computed(() => this._isScreenSharing());
|
|
readonly screenStream = computed(() => this._screenStreamSignal());
|
|
|
|
// Subjects for events
|
|
private readonly messageReceived$ = new Subject<ChatEvent>();
|
|
private readonly peerConnected$ = new Subject<string>();
|
|
private readonly peerDisconnected$ = new Subject<string>();
|
|
private readonly signalingMessage$ = new Subject<SignalingMessage>();
|
|
private readonly remoteStream$ = new Subject<{ peerId: string; stream: MediaStream }>();
|
|
|
|
// Public observables
|
|
readonly onMessageReceived = this.messageReceived$.asObservable();
|
|
readonly onPeerConnected = this.peerConnected$.asObservable();
|
|
readonly onPeerDisconnected = this.peerDisconnected$.asObservable();
|
|
readonly onSignalingMessage = this.signalingMessage$.asObservable();
|
|
readonly onRemoteStream = this.remoteStream$.asObservable();
|
|
|
|
// Accessor for remote screen/media streams by peer ID
|
|
getRemoteStream(peerId: string): MediaStream | null {
|
|
return this.remoteStreams.get(peerId) ?? null;
|
|
}
|
|
|
|
// Connect to signaling server
|
|
connectToSignalingServer(serverUrl: string): Observable<boolean> {
|
|
return new Observable<boolean>((observer) => {
|
|
try {
|
|
// Close existing connection if any
|
|
if (this.signalingSocket) {
|
|
this.signalingSocket.close();
|
|
}
|
|
|
|
this.lastWsUrl = serverUrl;
|
|
this.signalingSocket = new WebSocket(serverUrl);
|
|
|
|
this.signalingSocket.onopen = () => {
|
|
console.log('Connected to signaling server');
|
|
this._isConnected.set(true);
|
|
this.clearReconnect();
|
|
// Re-identify and rejoin if we have prior context
|
|
if (this.lastIdentify) {
|
|
this.sendRawMessage({
|
|
type: 'identify',
|
|
oderId: this.lastIdentify.oderId,
|
|
displayName: this.lastIdentify.displayName,
|
|
});
|
|
}
|
|
if (this.lastJoin) {
|
|
this.sendRawMessage({
|
|
type: 'join_server',
|
|
serverId: this.lastJoin.serverId,
|
|
});
|
|
}
|
|
observer.next(true);
|
|
};
|
|
|
|
this.signalingSocket.onmessage = (event) => {
|
|
try {
|
|
const message = JSON.parse(event.data);
|
|
this.handleSignalingMessage(message);
|
|
} catch (error) {
|
|
console.error('Failed to parse signaling message:', error);
|
|
}
|
|
};
|
|
|
|
this.signalingSocket.onerror = (error) => {
|
|
console.error('Signaling socket error:', error);
|
|
observer.error(error);
|
|
};
|
|
|
|
this.signalingSocket.onclose = () => {
|
|
console.log('Disconnected from signaling server');
|
|
this._isConnected.set(false);
|
|
this.scheduleReconnect();
|
|
};
|
|
} catch (error) {
|
|
observer.error(error);
|
|
}
|
|
});
|
|
}
|
|
|
|
// Send signaling message
|
|
sendSignalingMessage(message: Omit<SignalingMessage, 'from' | 'timestamp'>): void {
|
|
if (!this.signalingSocket || this.signalingSocket.readyState !== WebSocket.OPEN) {
|
|
console.error('Signaling socket not connected');
|
|
return;
|
|
}
|
|
|
|
const fullMessage: SignalingMessage = {
|
|
...message,
|
|
from: this._peerId(),
|
|
timestamp: Date.now(),
|
|
};
|
|
|
|
this.signalingSocket.send(JSON.stringify(fullMessage));
|
|
}
|
|
|
|
// Send raw message to server (for identify, join_server, etc.)
|
|
sendRawMessage(message: Record<string, unknown>): void {
|
|
if (!this.signalingSocket || this.signalingSocket.readyState !== WebSocket.OPEN) {
|
|
console.error('Signaling socket not connected');
|
|
return;
|
|
}
|
|
this.signalingSocket.send(JSON.stringify(message));
|
|
}
|
|
|
|
// Create peer connection using native WebRTC
|
|
private createPeerConnection(remotePeerId: string, isInitiator: boolean): PeerData {
|
|
console.log(`Creating peer connection to ${remotePeerId}, initiator: ${isInitiator}`);
|
|
|
|
const connection = new RTCPeerConnection({ iceServers: ICE_SERVERS });
|
|
let dataChannel: RTCDataChannel | null = null;
|
|
|
|
// Handle ICE candidates
|
|
connection.onicecandidate = (event) => {
|
|
if (event.candidate) {
|
|
console.log('Sending ICE candidate to:', remotePeerId);
|
|
this.sendRawMessage({
|
|
type: 'ice_candidate',
|
|
targetUserId: remotePeerId,
|
|
payload: { candidate: event.candidate },
|
|
});
|
|
}
|
|
};
|
|
|
|
// Handle connection state changes
|
|
connection.onconnectionstatechange = () => {
|
|
console.log(`Connection state with ${remotePeerId}:`, connection.connectionState);
|
|
if (connection.connectionState === 'connected') {
|
|
this._connectedPeers.update((peers) =>
|
|
peers.includes(remotePeerId) ? peers : [...peers, remotePeerId]
|
|
);
|
|
this.peerConnected$.next(remotePeerId);
|
|
} else if (connection.connectionState === 'disconnected' ||
|
|
connection.connectionState === 'failed' ||
|
|
connection.connectionState === 'closed') {
|
|
this.removePeer(remotePeerId);
|
|
}
|
|
};
|
|
|
|
// Handle incoming tracks (audio/video)
|
|
connection.ontrack = (event) => {
|
|
console.log(`Received track from ${remotePeerId}:`, event.track.kind);
|
|
if (event.streams[0]) {
|
|
this.remoteStreams.set(remotePeerId, event.streams[0]);
|
|
this.remoteStream$.next({ peerId: remotePeerId, stream: event.streams[0] });
|
|
}
|
|
};
|
|
|
|
// If initiator, create data channel
|
|
if (isInitiator) {
|
|
dataChannel = connection.createDataChannel('chat', { ordered: true });
|
|
this.setupDataChannel(dataChannel, remotePeerId);
|
|
} else {
|
|
// If not initiator, wait for data channel from remote peer
|
|
connection.ondatachannel = (event) => {
|
|
console.log('Received data channel from:', remotePeerId);
|
|
dataChannel = event.channel;
|
|
this.setupDataChannel(dataChannel, remotePeerId);
|
|
// Update the peer data with the new channel
|
|
const existing = this.peers.get(remotePeerId);
|
|
if (existing) {
|
|
existing.dataChannel = dataChannel;
|
|
}
|
|
};
|
|
}
|
|
|
|
// Create and register peer data before adding local tracks
|
|
const peerData: PeerData = { connection, dataChannel, isInitiator, pendingCandidates: [] };
|
|
this.peers.set(remotePeerId, peerData);
|
|
|
|
// Add local stream if available
|
|
if (this.localStream) {
|
|
this.localStream.getTracks().forEach((track) => {
|
|
const sender = connection.addTrack(track, this.localStream!);
|
|
if (track.kind === 'audio') peerData.audioSender = sender;
|
|
if (track.kind === 'video') peerData.videoSender = sender;
|
|
});
|
|
}
|
|
|
|
return peerData;
|
|
}
|
|
|
|
// Setup data channel event handlers
|
|
private setupDataChannel(channel: RTCDataChannel, remotePeerId: string): void {
|
|
channel.onopen = () => {
|
|
console.log(`Data channel open with ${remotePeerId}`);
|
|
};
|
|
|
|
channel.onclose = () => {
|
|
console.log(`Data channel closed with ${remotePeerId}`);
|
|
};
|
|
|
|
channel.onerror = (error) => {
|
|
console.error(`Data channel error with ${remotePeerId}:`, error);
|
|
};
|
|
|
|
channel.onmessage = (event) => {
|
|
try {
|
|
const message = JSON.parse(event.data);
|
|
this.handlePeerMessage(remotePeerId, message);
|
|
} catch (error) {
|
|
console.error('Failed to parse peer message:', error);
|
|
}
|
|
};
|
|
}
|
|
|
|
// Create and send offer
|
|
private async createOffer(remotePeerId: string): Promise<void> {
|
|
const peerData = this.peers.get(remotePeerId);
|
|
if (!peerData) return;
|
|
|
|
try {
|
|
const offer = await peerData.connection.createOffer();
|
|
await peerData.connection.setLocalDescription(offer);
|
|
|
|
console.log('Sending offer to:', remotePeerId);
|
|
this.sendRawMessage({
|
|
type: 'offer',
|
|
targetUserId: remotePeerId,
|
|
payload: { sdp: offer },
|
|
});
|
|
} catch (error) {
|
|
console.error('Failed to create offer:', error);
|
|
}
|
|
}
|
|
|
|
// Handle incoming offer
|
|
private async handleOffer(fromUserId: string, sdp: RTCSessionDescriptionInit): Promise<void> {
|
|
console.log('Handling offer from:', fromUserId);
|
|
|
|
let peerData = this.peers.get(fromUserId);
|
|
if (!peerData) {
|
|
peerData = this.createPeerConnection(fromUserId, false);
|
|
}
|
|
|
|
try {
|
|
await peerData.connection.setRemoteDescription(new RTCSessionDescription(sdp));
|
|
|
|
// Process any pending ICE candidates
|
|
for (const candidate of peerData.pendingCandidates) {
|
|
await peerData.connection.addIceCandidate(new RTCIceCandidate(candidate));
|
|
}
|
|
peerData.pendingCandidates = [];
|
|
|
|
const answer = await peerData.connection.createAnswer();
|
|
await peerData.connection.setLocalDescription(answer);
|
|
|
|
console.log('Sending answer to:', fromUserId);
|
|
this.sendRawMessage({
|
|
type: 'answer',
|
|
targetUserId: fromUserId,
|
|
payload: { sdp: answer },
|
|
});
|
|
} catch (error) {
|
|
console.error('Failed to handle offer:', error);
|
|
}
|
|
}
|
|
|
|
// Handle incoming answer
|
|
private async handleAnswer(fromUserId: string, sdp: RTCSessionDescriptionInit): Promise<void> {
|
|
console.log('Handling answer from:', fromUserId);
|
|
|
|
const peerData = this.peers.get(fromUserId);
|
|
if (!peerData) {
|
|
console.error('No peer connection for answer from:', fromUserId);
|
|
return;
|
|
}
|
|
|
|
try {
|
|
// Only set remote description if we're in the right state
|
|
if (peerData.connection.signalingState === 'have-local-offer') {
|
|
await peerData.connection.setRemoteDescription(new RTCSessionDescription(sdp));
|
|
|
|
// Process any pending ICE candidates
|
|
for (const candidate of peerData.pendingCandidates) {
|
|
await peerData.connection.addIceCandidate(new RTCIceCandidate(candidate));
|
|
}
|
|
peerData.pendingCandidates = [];
|
|
} else {
|
|
console.warn('Ignoring answer - wrong signaling state:', peerData.connection.signalingState);
|
|
}
|
|
} catch (error) {
|
|
console.error('Failed to handle answer:', error);
|
|
}
|
|
}
|
|
|
|
// Handle incoming ICE candidate
|
|
private async handleIceCandidate(fromUserId: string, candidate: RTCIceCandidateInit): Promise<void> {
|
|
let peerData = this.peers.get(fromUserId);
|
|
if (!peerData) {
|
|
// Create peer connection if it doesn't exist yet (candidate arrived before offer)
|
|
console.log('Creating peer connection for early ICE candidate from:', fromUserId);
|
|
peerData = this.createPeerConnection(fromUserId, false);
|
|
}
|
|
|
|
try {
|
|
if (peerData.connection.remoteDescription) {
|
|
await peerData.connection.addIceCandidate(new RTCIceCandidate(candidate));
|
|
} else {
|
|
// Queue the candidate for later
|
|
console.log('Queuing ICE candidate from:', fromUserId);
|
|
peerData.pendingCandidates.push(candidate);
|
|
}
|
|
} catch (error) {
|
|
console.error('Failed to add ICE candidate:', error);
|
|
}
|
|
}
|
|
|
|
// Handle incoming signaling messages
|
|
private handleSignalingMessage(message: any): void {
|
|
this.signalingMessage$.next(message);
|
|
console.log('Received signaling message:', message.type, message);
|
|
|
|
switch (message.type) {
|
|
case 'connected':
|
|
console.log('Server connection acknowledged, oderId:', message.oderId);
|
|
if (typeof message.serverTime === 'number') {
|
|
this.timeSync.setFromServerTime(message.serverTime);
|
|
}
|
|
break;
|
|
|
|
case 'server_users':
|
|
console.log('Users in server:', message.users);
|
|
if (message.users && Array.isArray(message.users)) {
|
|
message.users.forEach((user: { oderId: string; displayName: string }) => {
|
|
if (user.oderId && !this.peers.has(user.oderId)) {
|
|
console.log('Creating peer connection to existing user:', user.oderId);
|
|
this.createPeerConnection(user.oderId, true);
|
|
// Create and send offer
|
|
this.createOffer(user.oderId);
|
|
}
|
|
});
|
|
}
|
|
break;
|
|
|
|
case 'user_joined':
|
|
console.log('User joined:', message.displayName, message.oderId);
|
|
// Don't create connection here - the new user will initiate to us
|
|
break;
|
|
|
|
case 'user_left':
|
|
console.log('User left:', message.displayName, message.oderId);
|
|
this.removePeer(message.oderId);
|
|
break;
|
|
|
|
case 'offer':
|
|
if (message.fromUserId && message.payload?.sdp) {
|
|
this.handleOffer(message.fromUserId, message.payload.sdp);
|
|
}
|
|
break;
|
|
|
|
case 'answer':
|
|
if (message.fromUserId && message.payload?.sdp) {
|
|
this.handleAnswer(message.fromUserId, message.payload.sdp);
|
|
}
|
|
break;
|
|
|
|
case 'ice_candidate':
|
|
if (message.fromUserId && message.payload?.candidate) {
|
|
this.handleIceCandidate(message.fromUserId, message.payload.candidate);
|
|
}
|
|
break;
|
|
}
|
|
}
|
|
|
|
// Set current server ID for message routing
|
|
setCurrentServer(serverId: string): void {
|
|
this.currentServerId = serverId;
|
|
}
|
|
|
|
// Get a snapshot of currently connected peer IDs
|
|
getConnectedPeers(): string[] {
|
|
return this._connectedPeers();
|
|
}
|
|
|
|
// Identify and remember credentials
|
|
identify(oderId: string, displayName: string): void {
|
|
this.lastIdentify = { oderId, displayName };
|
|
this.sendRawMessage({ type: 'identify', oderId, displayName });
|
|
}
|
|
|
|
// Handle messages from peers
|
|
private handlePeerMessage(peerId: string, message: any): void {
|
|
console.log('Received P2P message from', peerId, ':', message);
|
|
const enriched = { ...message, fromPeerId: peerId };
|
|
this.messageReceived$.next(enriched);
|
|
}
|
|
|
|
// Send message to all connected peers via P2P only
|
|
broadcastMessage(event: ChatEvent): void {
|
|
const data = JSON.stringify(event);
|
|
|
|
this.peers.forEach((peerData, peerId) => {
|
|
try {
|
|
if (peerData.dataChannel && peerData.dataChannel.readyState === 'open') {
|
|
peerData.dataChannel.send(data);
|
|
console.log('Sent message via P2P to:', peerId);
|
|
}
|
|
} catch (error) {
|
|
console.error(`Failed to send to peer ${peerId}:`, error);
|
|
}
|
|
});
|
|
}
|
|
|
|
// Send message to specific peer
|
|
sendToPeer(peerId: string, event: ChatEvent): void {
|
|
const peerData = this.peers.get(peerId);
|
|
if (!peerData?.dataChannel || peerData.dataChannel.readyState !== 'open') {
|
|
console.error(`Peer ${peerId} not connected`);
|
|
return;
|
|
}
|
|
|
|
try {
|
|
const data = JSON.stringify(event);
|
|
peerData.dataChannel.send(data);
|
|
} catch (error) {
|
|
console.error(`Failed to send to peer ${peerId}:`, error);
|
|
}
|
|
}
|
|
|
|
// Remove peer connection
|
|
private removePeer(peerId: string): void {
|
|
const peerData = this.peers.get(peerId);
|
|
if (peerData) {
|
|
if (peerData.dataChannel) {
|
|
peerData.dataChannel.close();
|
|
}
|
|
peerData.connection.close();
|
|
this.peers.delete(peerId);
|
|
this._connectedPeers.update((peers) => peers.filter((p) => p !== peerId));
|
|
this.peerDisconnected$.next(peerId);
|
|
}
|
|
}
|
|
|
|
// Voice chat - get user media
|
|
async enableVoice(): Promise<MediaStream> {
|
|
try {
|
|
const stream = await navigator.mediaDevices.getUserMedia({
|
|
audio: {
|
|
echoCancellation: true,
|
|
noiseSuppression: true,
|
|
autoGainControl: true,
|
|
},
|
|
video: false,
|
|
});
|
|
|
|
this.localStream = stream;
|
|
|
|
// Add stream to all existing peers and renegotiate
|
|
this.peers.forEach((peerData, peerId) => {
|
|
if (this.localStream) {
|
|
this.localStream.getTracks().forEach((track) => {
|
|
peerData.connection.addTrack(track, this.localStream!);
|
|
});
|
|
|
|
// Renegotiate to send the new tracks (both sides need to renegotiate)
|
|
this.renegotiate(peerId);
|
|
}
|
|
});
|
|
|
|
this._isVoiceConnected.set(true);
|
|
return this.localStream;
|
|
} catch (error) {
|
|
console.error('Failed to get user media:', error);
|
|
throw error;
|
|
}
|
|
}
|
|
|
|
// Disable voice (stop and remove audio tracks)
|
|
disableVoice(): void {
|
|
if (this.localStream) {
|
|
this.localStream.getTracks().forEach((track) => {
|
|
track.stop();
|
|
});
|
|
this.localStream = null;
|
|
}
|
|
|
|
// Remove audio senders from peer connections but keep connections open
|
|
this.peers.forEach((peerData) => {
|
|
const senders = peerData.connection.getSenders();
|
|
senders.forEach(sender => {
|
|
if (sender.track?.kind === 'audio') {
|
|
peerData.connection.removeTrack(sender);
|
|
}
|
|
});
|
|
});
|
|
|
|
// Update voice connection state
|
|
this._isVoiceConnected.set(false);
|
|
}
|
|
|
|
// Screen sharing
|
|
async startScreenShare(): Promise<MediaStream> {
|
|
try {
|
|
// Check if Electron API is available for desktop capturer
|
|
if (typeof window !== 'undefined' && (window as any).electronAPI?.getSources) {
|
|
const sources = await (window as any).electronAPI.getSources();
|
|
const screenSource = sources.find((s: any) => s.name === 'Entire Screen') || sources[0];
|
|
|
|
this._screenStream = await navigator.mediaDevices.getUserMedia({
|
|
audio: false,
|
|
video: {
|
|
mandatory: {
|
|
chromeMediaSource: 'desktop',
|
|
chromeMediaSourceId: screenSource.id,
|
|
},
|
|
} as any,
|
|
});
|
|
} else {
|
|
// Fallback to standard getDisplayMedia (no system audio to preserve mic)
|
|
this._screenStream = await navigator.mediaDevices.getDisplayMedia({
|
|
video: {
|
|
width: { ideal: 1920 },
|
|
height: { ideal: 1080 },
|
|
frameRate: { ideal: 30 },
|
|
},
|
|
audio: false,
|
|
});
|
|
}
|
|
|
|
// Add/replace screen video track to all peers and renegotiate
|
|
this.peers.forEach((peerData, peerId) => {
|
|
if (this._screenStream) {
|
|
const videoTrack = this._screenStream.getVideoTracks()[0];
|
|
if (!videoTrack) return;
|
|
|
|
const sender = peerData.connection.getSenders().find(s => s.track?.kind === 'video');
|
|
if (sender) {
|
|
sender.replaceTrack(videoTrack).catch((e) => console.error('replaceTrack failed:', e));
|
|
} else {
|
|
peerData.connection.addTrack(videoTrack, this._screenStream!);
|
|
}
|
|
|
|
// Renegotiate to ensure remote receives video
|
|
this.renegotiate(peerId);
|
|
}
|
|
});
|
|
|
|
this._isScreenSharing.set(true);
|
|
this._screenStreamSignal.set(this._screenStream);
|
|
|
|
// Handle when user stops sharing via browser UI
|
|
this._screenStream.getVideoTracks()[0].onended = () => {
|
|
this.stopScreenShare();
|
|
};
|
|
|
|
return this._screenStream;
|
|
} catch (error) {
|
|
console.error('Failed to start screen share:', error);
|
|
throw error;
|
|
}
|
|
}
|
|
|
|
// Stop screen sharing
|
|
stopScreenShare(): void {
|
|
if (this._screenStream) {
|
|
this._screenStream.getTracks().forEach((track) => {
|
|
track.stop();
|
|
});
|
|
this._screenStream = null;
|
|
this._screenStreamSignal.set(null);
|
|
this._isScreenSharing.set(false);
|
|
}
|
|
|
|
// Remove sent video tracks from peers and renegotiate back to audio-only
|
|
this.peers.forEach((peerData, peerId) => {
|
|
const senders = peerData.connection.getSenders();
|
|
senders.forEach(sender => {
|
|
if (sender.track?.kind === 'video') {
|
|
peerData.connection.removeTrack(sender);
|
|
}
|
|
});
|
|
this.renegotiate(peerId);
|
|
});
|
|
}
|
|
|
|
// Join a room
|
|
joinRoom(roomId: string, userId: string): void {
|
|
this.lastJoin = { serverId: roomId, userId };
|
|
this.sendRawMessage({
|
|
type: 'join_server',
|
|
serverId: roomId,
|
|
});
|
|
}
|
|
|
|
private scheduleReconnect(): void {
|
|
if (this.reconnectTimer || !this.lastWsUrl) return;
|
|
const delay = Math.min(30000, 1000 * Math.pow(2, this.reconnectAttempts)); // 1s,2s,4s.. up to 30s
|
|
this.reconnectTimer = setTimeout(() => {
|
|
this.reconnectTimer = null;
|
|
this.reconnectAttempts++;
|
|
console.log('Attempting to reconnect to signaling...');
|
|
this.connectToSignalingServer(this.lastWsUrl!).subscribe({
|
|
next: () => {
|
|
this.reconnectAttempts = 0;
|
|
},
|
|
error: () => {
|
|
// schedule next attempt
|
|
this.scheduleReconnect();
|
|
},
|
|
});
|
|
}, delay);
|
|
}
|
|
|
|
private clearReconnect(): void {
|
|
if (this.reconnectTimer) {
|
|
clearTimeout(this.reconnectTimer);
|
|
this.reconnectTimer = null;
|
|
}
|
|
this.reconnectAttempts = 0;
|
|
}
|
|
|
|
// Leave room
|
|
leaveRoom(): void {
|
|
this.sendRawMessage({
|
|
type: 'leave_server',
|
|
});
|
|
|
|
// Close all peer connections
|
|
this.peers.forEach((peerData, peerId) => {
|
|
if (peerData.dataChannel) {
|
|
peerData.dataChannel.close();
|
|
}
|
|
peerData.connection.close();
|
|
});
|
|
this.peers.clear();
|
|
this._connectedPeers.set([]);
|
|
|
|
// Stop all media
|
|
this.disableVoice();
|
|
this.stopScreenShare();
|
|
}
|
|
|
|
// Disconnect from signaling server
|
|
disconnect(): void {
|
|
this.leaveRoom();
|
|
|
|
if (this.signalingSocket) {
|
|
this.signalingSocket.close();
|
|
this.signalingSocket = null;
|
|
}
|
|
|
|
this._isConnected.set(false);
|
|
this.destroy$.next();
|
|
}
|
|
|
|
// Alias for disconnect - used by components
|
|
disconnectAll(): void {
|
|
this.disconnect();
|
|
}
|
|
|
|
// Set local media stream from external source
|
|
setLocalStream(stream: MediaStream): void {
|
|
this.localStream = stream;
|
|
|
|
// Add stream to all existing peers and renegotiate
|
|
this.peers.forEach((peerData, peerId) => {
|
|
if (this.localStream) {
|
|
// Remove existing audio tracks first
|
|
const senders = peerData.connection.getSenders();
|
|
senders.forEach(sender => {
|
|
if (sender.track?.kind === 'audio') {
|
|
peerData.connection.removeTrack(sender);
|
|
}
|
|
});
|
|
|
|
// Add new tracks
|
|
this.localStream.getTracks().forEach((track) => {
|
|
const sender = peerData.connection.addTrack(track, this.localStream!);
|
|
if (track.kind === 'audio') peerData.audioSender = sender;
|
|
if (track.kind === 'video') peerData.videoSender = sender;
|
|
});
|
|
|
|
// Renegotiate to send the new tracks (both sides need to renegotiate)
|
|
this.renegotiate(peerId);
|
|
}
|
|
});
|
|
|
|
this._isVoiceConnected.set(true);
|
|
}
|
|
|
|
// Renegotiate connection (for adding/removing tracks)
|
|
private async renegotiate(peerId: string): Promise<void> {
|
|
const peerData = this.peers.get(peerId);
|
|
if (!peerData) return;
|
|
|
|
try {
|
|
const offer = await peerData.connection.createOffer();
|
|
await peerData.connection.setLocalDescription(offer);
|
|
|
|
console.log('Sending renegotiation offer to:', peerId);
|
|
this.sendRawMessage({
|
|
type: 'offer',
|
|
targetUserId: peerId,
|
|
payload: { sdp: offer },
|
|
});
|
|
} catch (error) {
|
|
console.error('Failed to renegotiate:', error);
|
|
}
|
|
}
|
|
|
|
// Toggle mute with explicit state
|
|
toggleMute(muted?: boolean): void {
|
|
if (this.localStream) {
|
|
const audioTracks = this.localStream.getAudioTracks();
|
|
const newMutedState = muted !== undefined ? muted : !this._isMuted();
|
|
audioTracks.forEach((track) => {
|
|
track.enabled = !newMutedState;
|
|
});
|
|
this._isMuted.set(newMutedState);
|
|
}
|
|
}
|
|
|
|
// Toggle deafen state
|
|
toggleDeafen(deafened?: boolean): void {
|
|
const newDeafenedState = deafened !== undefined ? deafened : !this._isDeafened();
|
|
this._isDeafened.set(newDeafenedState);
|
|
}
|
|
|
|
// Set output volume for remote streams
|
|
setOutputVolume(volume: number): void {
|
|
this.outputVolume = Math.max(0, Math.min(1, volume));
|
|
}
|
|
|
|
// Latency/bitrate controls for audio
|
|
async setAudioBitrate(kbps: number): Promise<void> {
|
|
const bps = Math.max(16000, Math.min(256000, Math.floor(kbps * 1000)));
|
|
this.peers.forEach(async (peerData) => {
|
|
const sender = peerData.audioSender || peerData.connection.getSenders().find(s => s.track?.kind === 'audio');
|
|
if (!sender) return;
|
|
const params = sender.getParameters();
|
|
params.encodings = params.encodings || [{}];
|
|
params.encodings[0].maxBitrate = bps;
|
|
try {
|
|
await sender.setParameters(params);
|
|
console.log('Applied audio bitrate:', bps);
|
|
} catch (e) {
|
|
console.warn('Failed to set audio bitrate', e);
|
|
}
|
|
});
|
|
}
|
|
|
|
async setLatencyProfile(profile: 'low' | 'balanced' | 'high'): Promise<void> {
|
|
const map = { low: 64000, balanced: 96000, high: 128000 } as const;
|
|
await this.setAudioBitrate(map[profile]);
|
|
}
|
|
|
|
// Cleanup
|
|
ngOnDestroy(): void {
|
|
this.disconnect();
|
|
this.destroy$.complete();
|
|
}
|
|
}
|