Files
Toju/src/app/core/services/webrtc.service.ts
2026-03-02 00:13:34 +01:00

465 lines
19 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* WebRTCService — thin Angular service that composes specialised managers.
*
* Each concern lives in its own file under `./webrtc/`:
* • SignalingManager WebSocket lifecycle & reconnection
* • PeerConnectionManager RTCPeerConnection, offers/answers, ICE, data channels
* • MediaManager mic voice, mute, deafen, bitrate
* • ScreenShareManager screen capture & mixed audio
* • WebRTCLogger debug / diagnostic logging
*
* This file wires them together and exposes a public API that is
* identical to the old monolithic service so consumers don't change.
*/
import { Injectable, signal, computed, inject, OnDestroy } 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';
import {
// Managers
SignalingManager,
PeerConnectionManager,
MediaManager,
ScreenShareManager,
WebRTCLogger,
// Types
IdentifyCredentials,
JoinedServerInfo,
VoiceStateSnapshot,
LatencyProfile,
// Constants
SIGNALING_TYPE_IDENTIFY,
SIGNALING_TYPE_JOIN_SERVER,
SIGNALING_TYPE_VIEW_SERVER,
SIGNALING_TYPE_LEAVE_SERVER,
SIGNALING_TYPE_OFFER,
SIGNALING_TYPE_ANSWER,
SIGNALING_TYPE_ICE_CANDIDATE,
SIGNALING_TYPE_CONNECTED,
SIGNALING_TYPE_SERVER_USERS,
SIGNALING_TYPE_USER_JOINED,
SIGNALING_TYPE_USER_LEFT,
DEFAULT_DISPLAY_NAME,
P2P_TYPE_VOICE_STATE,
P2P_TYPE_SCREEN_STATE,
} from './webrtc';
@Injectable({
providedIn: 'root',
})
export class WebRTCService implements OnDestroy {
private readonly timeSync = inject(TimeSyncService);
// ─── Logger ────────────────────────────────────────────────────────
private readonly logger = new WebRTCLogger(/* debugEnabled */ true);
// ─── Identity & server membership ──────────────────────────────────
private lastIdentifyCredentials: IdentifyCredentials | null = null;
private lastJoinedServer: JoinedServerInfo | null = null;
private readonly memberServerIds = new Set<string>();
private activeServerId: string | null = null;
private readonly serviceDestroyed$ = new Subject<void>();
// ─── Angular signals (reactive state) ──────────────────────────────
private readonly _localPeerId = signal<string>(uuidv4());
private readonly _isSignalingConnected = 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);
private readonly _hasConnectionError = signal(false);
private readonly _connectionErrorMessage = signal<string | null>(null);
// Public computed signals (unchanged external API)
readonly peerId = computed(() => this._localPeerId());
readonly isConnected = computed(() => this._isSignalingConnected());
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());
readonly hasConnectionError = computed(() => this._hasConnectionError());
readonly connectionErrorMessage = computed(() => this._connectionErrorMessage());
readonly shouldShowConnectionError = computed(() => {
if (!this._hasConnectionError()) return false;
if (this._isVoiceConnected() && this._connectedPeers().length > 0) return false;
return true;
});
// ─── Public observables (unchanged external API) ───────────────────
private readonly signalingMessage$ = new Subject<SignalingMessage>();
readonly onSignalingMessage = this.signalingMessage$.asObservable();
// Delegates to managers
get onMessageReceived(): Observable<ChatEvent> { return this.peerManager.messageReceived$.asObservable(); }
get onPeerConnected(): Observable<string> { return this.peerManager.peerConnected$.asObservable(); }
get onPeerDisconnected(): Observable<string> { return this.peerManager.peerDisconnected$.asObservable(); }
get onRemoteStream(): Observable<{ peerId: string; stream: MediaStream }> { return this.peerManager.remoteStream$.asObservable(); }
get onVoiceConnected(): Observable<void> { return this.mediaManager.voiceConnected$.asObservable(); }
// ─── Sub-managers ──────────────────────────────────────────────────
private readonly signalingManager: SignalingManager;
private readonly peerManager: PeerConnectionManager;
private readonly mediaManager: MediaManager;
private readonly screenShareManager: ScreenShareManager;
constructor() {
// Create managers with null callbacks first to break circular initialization
this.signalingManager = new SignalingManager(
this.logger,
() => this.lastIdentifyCredentials,
() => this.lastJoinedServer,
() => this.memberServerIds,
);
this.peerManager = new PeerConnectionManager(
this.logger,
null!,
);
this.mediaManager = new MediaManager(
this.logger,
null!,
);
this.screenShareManager = new ScreenShareManager(
this.logger,
null!,
);
// Now wire up cross-references (all managers are instantiated)
this.peerManager.setCallbacks({
sendRawMessage: (msg: Record<string, unknown>) => this.signalingManager.sendRawMessage(msg),
getLocalMediaStream: (): MediaStream | null => this.mediaManager.getLocalStream(),
isSignalingConnected: (): boolean => this._isSignalingConnected(),
getVoiceStateSnapshot: (): VoiceStateSnapshot => this.getCurrentVoiceState(),
getIdentifyCredentials: (): IdentifyCredentials | null => this.lastIdentifyCredentials,
getLocalPeerId: (): string => this._localPeerId(),
isScreenSharingActive: (): boolean => this._isScreenSharing(),
});
this.mediaManager.setCallbacks({
getActivePeers: (): Map<string, import('./webrtc').PeerData> => this.peerManager.activePeerConnections,
renegotiate: (peerId: string): Promise<void> => this.peerManager.renegotiate(peerId),
broadcastMessage: (event: any): void => this.peerManager.broadcastMessage(event),
getIdentifyOderId: (): string => this.lastIdentifyCredentials?.oderId || this._localPeerId(),
getIdentifyDisplayName: (): string => this.lastIdentifyCredentials?.displayName || DEFAULT_DISPLAY_NAME,
});
this.screenShareManager.setCallbacks({
getActivePeers: (): Map<string, import('./webrtc').PeerData> => this.peerManager.activePeerConnections,
getLocalMediaStream: (): MediaStream | null => this.mediaManager.getLocalStream(),
renegotiate: (peerId: string): Promise<void> => this.peerManager.renegotiate(peerId),
broadcastCurrentStates: (): void => this.peerManager.broadcastCurrentStates(),
});
this.wireManagerEvents();
}
// ─── Event wiring ──────────────────────────────────────────────────
private wireManagerEvents(): void {
// Signaling → connection status
this.signalingManager.connectionStatus$.subscribe(({ connected, errorMessage }) => {
this._isSignalingConnected.set(connected);
this._hasConnectionError.set(!connected);
this._connectionErrorMessage.set(connected ? null : (errorMessage ?? null));
});
// Signaling → message routing
this.signalingManager.messageReceived$.subscribe((msg) => this.handleSignalingMessage(msg));
// Signaling → heartbeat → broadcast states
this.signalingManager.heartbeatTick$.subscribe(() => this.peerManager.broadcastCurrentStates());
// Peer manager → connected peers signal
this.peerManager.connectedPeersChanged$.subscribe((peers: string[]) => this._connectedPeers.set(peers));
// Media manager → voice connected signal
this.mediaManager.voiceConnected$.subscribe(() => {
this._isVoiceConnected.set(true);
});
}
// ─── Signaling message routing ─────────────────────────────────────
private handleSignalingMessage(message: any): void {
this.signalingMessage$.next(message);
this.logger.info('Signaling message', { type: message.type });
switch (message.type) {
case SIGNALING_TYPE_CONNECTED:
this.logger.info('Server connected', { oderId: message.oderId });
if (typeof message.serverTime === 'number') {
this.timeSync.setFromServerTime(message.serverTime);
}
break;
case SIGNALING_TYPE_SERVER_USERS:
this.logger.info('Server users', { count: Array.isArray(message.users) ? message.users.length : 0 });
if (message.users && Array.isArray(message.users)) {
message.users.forEach((user: { oderId: string; displayName: string }) => {
if (user.oderId && !this.peerManager.activePeerConnections.has(user.oderId)) {
this.logger.info('Create peer connection to existing user', { oderId: user.oderId });
this.peerManager.createPeerConnection(user.oderId, true);
this.peerManager.createAndSendOffer(user.oderId);
}
});
}
break;
case SIGNALING_TYPE_USER_JOINED:
this.logger.info('User joined', { displayName: message.displayName, oderId: message.oderId });
break;
case SIGNALING_TYPE_USER_LEFT:
this.logger.info('User left', { displayName: message.displayName, oderId: message.oderId, serverId: message.serverId });
break;
case SIGNALING_TYPE_OFFER:
if (message.fromUserId && message.payload?.sdp) {
this.peerManager.handleOffer(message.fromUserId, message.payload.sdp);
}
break;
case SIGNALING_TYPE_ANSWER:
if (message.fromUserId && message.payload?.sdp) {
this.peerManager.handleAnswer(message.fromUserId, message.payload.sdp);
}
break;
case SIGNALING_TYPE_ICE_CANDIDATE:
if (message.fromUserId && message.payload?.candidate) {
this.peerManager.handleIceCandidate(message.fromUserId, message.payload.candidate);
}
break;
}
}
// ─── Voice state snapshot ──────────────────────────────────────────
private getCurrentVoiceState(): VoiceStateSnapshot {
return {
isConnected: this._isVoiceConnected(),
isMuted: this._isMuted(),
isDeafened: this._isDeafened(),
isScreenSharing: this._isScreenSharing(),
roomId: this.mediaManager.getCurrentVoiceRoomId(),
serverId: this.mediaManager.getCurrentVoiceServerId(),
};
}
// ═══════════════════════════════════════════════════════════════════
// PUBLIC API matches the old monolithic service's interface
// ═══════════════════════════════════════════════════════════════════
// ─── Signaling ─────────────────────────────────────────────────────
connectToSignalingServer(serverUrl: string): Observable<boolean> {
return this.signalingManager.connect(serverUrl);
}
async ensureSignalingConnected(timeoutMs?: number): Promise<boolean> {
return this.signalingManager.ensureConnected(timeoutMs);
}
sendSignalingMessage(message: Omit<SignalingMessage, 'from' | 'timestamp'>): void {
this.signalingManager.sendSignalingMessage(message, this._localPeerId());
}
sendRawMessage(message: Record<string, unknown>): void {
this.signalingManager.sendRawMessage(message);
}
// ─── Server membership ─────────────────────────────────────────────
setCurrentServer(serverId: string): void {
this.activeServerId = serverId;
}
identify(oderId: string, displayName: string): void {
this.lastIdentifyCredentials = { oderId, displayName };
this.sendRawMessage({ type: SIGNALING_TYPE_IDENTIFY, oderId, displayName });
}
joinRoom(roomId: string, userId: string): void {
this.lastJoinedServer = { serverId: roomId, userId };
this.memberServerIds.add(roomId);
this.sendRawMessage({ type: SIGNALING_TYPE_JOIN_SERVER, serverId: roomId });
}
switchServer(serverId: string, userId: string): void {
this.lastJoinedServer = { serverId, userId };
if (this.memberServerIds.has(serverId)) {
this.sendRawMessage({ type: SIGNALING_TYPE_VIEW_SERVER, serverId });
this.logger.info('Viewed server (already joined)', { serverId, userId, voiceConnected: this._isVoiceConnected() });
} else {
this.memberServerIds.add(serverId);
this.sendRawMessage({ type: SIGNALING_TYPE_JOIN_SERVER, serverId });
this.logger.info('Joined new server via switch', { serverId, userId, voiceConnected: this._isVoiceConnected() });
}
}
leaveRoom(serverId?: string): void {
if (serverId) {
this.memberServerIds.delete(serverId);
this.sendRawMessage({ type: SIGNALING_TYPE_LEAVE_SERVER, serverId });
this.logger.info('Left server', { serverId });
if (this.memberServerIds.size === 0) { this.fullCleanup(); }
return;
}
this.memberServerIds.forEach((sid) => {
this.sendRawMessage({ type: SIGNALING_TYPE_LEAVE_SERVER, serverId: sid });
});
this.memberServerIds.clear();
this.fullCleanup();
}
hasJoinedServer(serverId: string): boolean {
return this.memberServerIds.has(serverId);
}
getJoinedServerIds(): ReadonlySet<string> {
return this.memberServerIds;
}
// ─── Peer messaging ────────────────────────────────────────────────
broadcastMessage(event: ChatEvent): void {
this.peerManager.broadcastMessage(event);
}
sendToPeer(peerId: string, event: ChatEvent): void {
this.peerManager.sendToPeer(peerId, event);
}
async sendToPeerBuffered(peerId: string, event: ChatEvent): Promise<void> {
return this.peerManager.sendToPeerBuffered(peerId, event);
}
getConnectedPeers(): string[] {
return this.peerManager.getConnectedPeerIds();
}
getRemoteStream(peerId: string): MediaStream | null {
return this.peerManager.remotePeerStreams.get(peerId) ?? null;
}
// ─── Voice / Media ─────────────────────────────────────────────────
async enableVoice(): Promise<MediaStream> {
const stream = await this.mediaManager.enableVoice();
this.syncMediaSignals();
return stream;
}
disableVoice(): void {
this.mediaManager.disableVoice();
this._isVoiceConnected.set(false);
}
setLocalStream(stream: MediaStream): void {
this.mediaManager.setLocalStream(stream);
this.syncMediaSignals();
}
toggleMute(muted?: boolean): void {
this.mediaManager.toggleMute(muted);
this._isMuted.set(this.mediaManager.getIsMicMuted());
}
toggleDeafen(deafened?: boolean): void {
this.mediaManager.toggleDeafen(deafened);
this._isDeafened.set(this.mediaManager.getIsSelfDeafened());
}
setOutputVolume(volume: number): void {
this.mediaManager.setOutputVolume(volume);
}
async setAudioBitrate(kbps: number): Promise<void> {
return this.mediaManager.setAudioBitrate(kbps);
}
async setLatencyProfile(profile: LatencyProfile): Promise<void> {
return this.mediaManager.setLatencyProfile(profile);
}
startVoiceHeartbeat(roomId?: string, serverId?: string): void {
this.mediaManager.startVoiceHeartbeat(roomId, serverId);
}
stopVoiceHeartbeat(): void {
this.mediaManager.stopVoiceHeartbeat();
}
// ─── Screen share ──────────────────────────────────────────────────
async startScreenShare(includeAudio: boolean = false): Promise<MediaStream> {
const stream = await this.screenShareManager.startScreenShare(includeAudio);
this._isScreenSharing.set(true);
this._screenStreamSignal.set(stream);
return stream;
}
stopScreenShare(): void {
this.screenShareManager.stopScreenShare();
this._isScreenSharing.set(false);
this._screenStreamSignal.set(null);
}
// ─── Disconnect / cleanup ─────────────────────────────────────────
disconnect(): void {
this.leaveRoom();
this.mediaManager.stopVoiceHeartbeat();
this.signalingManager.close();
this._isSignalingConnected.set(false);
this._hasConnectionError.set(false);
this._connectionErrorMessage.set(null);
this.serviceDestroyed$.next();
}
disconnectAll(): void {
this.disconnect();
}
private fullCleanup(): void {
this.peerManager.closeAllPeers();
this._connectedPeers.set([]);
this.mediaManager.disableVoice();
this._isVoiceConnected.set(false);
this.screenShareManager.stopScreenShare();
this._isScreenSharing.set(false);
this._screenStreamSignal.set(null);
}
// ─── Helpers ───────────────────────────────────────────────────────
/** Synchronise Angular signals from the MediaManager's internal state. */
private syncMediaSignals(): void {
this._isVoiceConnected.set(this.mediaManager.getIsVoiceActive());
this._isMuted.set(this.mediaManager.getIsMicMuted());
this._isDeafened.set(this.mediaManager.getIsSelfDeafened());
}
// ─── Lifecycle ─────────────────────────────────────────────────────
ngOnDestroy(): void {
this.disconnect();
this.serviceDestroyed$.complete();
this.signalingManager.destroy();
this.peerManager.destroy();
this.mediaManager.destroy();
this.screenShareManager.destroy();
}
}