Files
Toju/src/app/core/services/webrtc.service.ts
2025-12-28 08:23:30 +01:00

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