/* eslint-disable @typescript-eslint/member-ordering */ import { CUSTOM_ELEMENTS_SCHEMA, Component, DestroyRef, HostListener, computed, effect, inject, input, signal, untracked } from '@angular/core'; import { CommonModule } from '@angular/common'; import { ActivatedRoute, Router } from '@angular/router'; import { takeUntilDestroyed, toSignal } from '@angular/core/rxjs-interop'; import { FormsModule } from '@angular/forms'; import { Store } from '@ngrx/store'; import { NgIcon, provideIcons } from '@ng-icons/core'; import { lucidePhone, lucideX, lucideUsers, lucideUserPlus } from '@ng-icons/lucide'; import { map } from 'rxjs'; import { DirectCallService, participantToUser, type DirectCallSession } from '../../domains/direct-call'; import { DmChatComponent } from '../../domains/direct-message/feature/dm-chat/dm-chat.component'; import { VoiceActivityService, VoiceConnectionFacade, VoicePlaybackService } from '../../domains/voice-connection'; import { ScreenShareFacade, ScreenShareQuality, ScreenShareStartOptions } from '../../domains/screen-share'; import { loadVoiceSettingsFromStorage, saveVoiceSettingsToStorage } from '../../domains/voice-session'; import { ScreenShareQualityDialogComponent } from '../../shared'; import { ViewportService } from '../../core/platform'; import { selectAllUsers, selectCurrentUser } from '../../store/users/users.selectors'; import { UsersActions } from '../../store/users/users.actions'; import { User } from '../../shared-kernel'; import { VoiceWorkspaceStreamItem } from '../room/voice-workspace/voice-workspace.models'; import { VoiceWorkspaceStreamTileComponent } from '../room/voice-workspace/voice-workspace-stream-tile/voice-workspace-stream-tile.component'; import { PrivateCallControlsComponent } from './private-call-controls.component'; import { PrivateCallParticipantCardComponent } from './private-call-participant-card.component'; @Component({ selector: 'app-private-call', standalone: true, imports: [ CommonModule, DmChatComponent, FormsModule, NgIcon, PrivateCallControlsComponent, PrivateCallParticipantCardComponent, ScreenShareQualityDialogComponent, VoiceWorkspaceStreamTileComponent ], schemas: [CUSTOM_ELEMENTS_SCHEMA], host: { class: 'block h-full w-full' }, viewProviders: [ provideIcons({ lucidePhone, lucideX, lucideUsers, lucideUserPlus }) ], templateUrl: './private-call.component.html' }) export class PrivateCallComponent { private readonly route = inject(ActivatedRoute); private readonly router = inject(Router); private readonly destroyRef = inject(DestroyRef); private readonly store = inject(Store); private readonly calls = inject(DirectCallService); private readonly voice = inject(VoiceConnectionFacade); private readonly voiceActivity = inject(VoiceActivityService); private readonly playback = inject(VoicePlaybackService); private readonly screenShare = inject(ScreenShareFacade); private readonly viewport = inject(ViewportService); private chatResizing = false; readonly allUsers = this.store.selectSignal(selectAllUsers); readonly currentUser = this.store.selectSignal(selectCurrentUser); readonly isMobile = this.viewport.isMobile; readonly callIdInput = input(null); readonly overlayMode = input(false); readonly routeCallId = toSignal(this.route.paramMap.pipe(map((params) => params.get('callId'))), { initialValue: this.route.snapshot.paramMap.get('callId') }); readonly callId = computed(() => this.callIdInput() ?? this.routeCallId()); readonly session = computed(() => this.calls.sessionById(this.callId())); readonly participantUsers = computed(() => { const session = this.session(); if (!session) { return [] as User[]; } return session.participantIds .map((participantId) => this.userForSessionParticipant(session, participantId)) .filter((user): user is User => !!user); }); readonly isConnected = computed(() => { const session = this.session(); const currentUserId = this.currentUserKey(); return !!session && !!currentUserId && !!session.participants[currentUserId]?.joined; }); readonly isMuted = this.voice.isMuted; readonly isDeafened = this.voice.isDeafened; readonly isCameraEnabled = this.voice.isCameraEnabled; readonly isScreenSharing = this.screenShare.isScreenSharing; readonly remoteStreamRevision = signal(0); readonly includeSystemAudio = signal(false); readonly screenShareQuality = signal('balanced'); readonly askScreenShareQuality = signal(true); readonly showScreenShareQualityDialog = signal(false); readonly inviteUserId = signal(''); readonly focusedStreamId = signal(null); readonly showAllStreamsMode = signal(false); readonly chatWidthPx = signal(384); readonly inviteCandidates = computed(() => { const participantIds = new Set(this.session()?.participantIds ?? []); const currentUserId = this.currentUserKey(); return this.allUsers().filter((user) => { const userId = this.userKey(user); return userId !== currentUserId && !participantIds.has(userId); }); }); readonly activeShares = computed(() => { this.remoteStreamRevision(); const shares: VoiceWorkspaceStreamItem[] = []; const localUser = this.currentUser(); const localPeerKey = localUser ? this.userKey(localUser) : null; const isJoinedToCurrentCall = this.isConnected(); const localScreenStream = isJoinedToCurrentCall ? this.screenShare.screenStream() : null; const localCameraStream = isJoinedToCurrentCall && this.voice.isCameraEnabled() ? this.voice.getLocalCameraStream() : null; if (localUser && localPeerKey && localScreenStream) { shares.push(this.buildShare(localPeerKey, localUser, localScreenStream, true, 'screen')); } if (localUser && localPeerKey && localCameraStream) { shares.push(this.buildShare(localPeerKey, localUser, localCameraStream, true, 'camera')); } for (const user of this.participantUsers()) { const peerKey = this.getPeerKeyCandidates(user).find( (candidate) => candidate !== localPeerKey && (!!this.screenShare.getRemoteScreenShareStream(candidate) || !!this.voice.getRemoteCameraStream(candidate)) ) ?? this.userKey(user); if (peerKey === localPeerKey) { continue; } const screenStream = this.screenShare.getRemoteScreenShareStream(peerKey); const cameraStream = this.voice.getRemoteCameraStream(peerKey); if (screenStream && this.hasActiveVideo(screenStream)) { shares.push(this.buildShare(peerKey, user, screenStream, false, 'screen')); } if (cameraStream && this.hasActiveVideo(cameraStream)) { shares.push(this.buildShare(peerKey, user, cameraStream, false, 'camera')); } } return shares; }); readonly featuredShare = computed(() => this.activeShares()[0] ?? null); readonly hasMultipleShares = computed(() => this.activeShares().length > 1); readonly focusedShareId = computed(() => { const requested = this.focusedStreamId(); const activeShares = this.activeShares(); if (this.showAllStreamsMode() && activeShares.length > 1) { return null; } if (requested && activeShares.some((share) => share.id === requested)) { return requested; } if (activeShares.length === 1) { return activeShares[0].id; } return null; }); readonly focusedShare = computed(() => this.activeShares().find((share) => share.id === this.focusedShareId()) ?? null); readonly thumbnailShares = computed(() => { const focusedShareId = this.focusedShareId(); if (!focusedShareId) { return [] as VoiceWorkspaceStreamItem[]; } return this.activeShares().filter((share) => share.id !== focusedShareId); }); constructor() { effect(() => { const callId = this.callId(); if (callId) { untracked(() => void this.calls.openCall(callId)); } }); effect(() => { const session = this.session(); if (session && !this.calls.hasOngoingActivity(session)) { if (this.overlayMode()) { untracked(() => this.calls.closeMobileCallOverlay()); return; } untracked(() => void this.router.navigate(['/dm', session.conversationId], { replaceUrl: true })); } }); effect(() => { const callId = this.callId(); const session = this.session(); if (callId && session?.conversationId && this.isMobile() && !this.overlayMode()) { untracked(() => { void this.calls.openMobileCallOverlay(callId); void this.router.navigate(['/pm', session.conversationId], { replaceUrl: true }); }); } }); effect(() => { const session = this.session(); const currentUserId = this.currentUserKey(); const peerIds = session ? this.remoteParticipantPeerIds(session, currentUserId) : []; this.screenShare.syncRemoteScreenShareRequests(peerIds, this.isConnected() && !!session && session.status === 'connected'); }); effect(() => { this.session(); if (this.isConnected()) { this.trackLocalMic(); return; } this.untrackLocalMic(); }); this.screenShare.onRemoteStream.pipe(takeUntilDestroyed(this.destroyRef)).subscribe(() => this.bumpRemoteStreamRevision()); this.screenShare.onPeerDisconnected.pipe(takeUntilDestroyed(this.destroyRef)).subscribe(() => this.bumpRemoteStreamRevision()); this.destroyRef.onDestroy(() => { this.screenShare.syncRemoteScreenShareRequests([], false); }); } @HostListener('window:mousemove', ['$event']) onWindowMouseMove(event: MouseEvent): void { if (!this.chatResizing) { return; } event.preventDefault(); this.chatWidthPx.set(this.clampChatWidth(window.innerWidth - event.clientX)); } @HostListener('window:mouseup') onWindowMouseUp(): void { this.chatResizing = false; } async join(): Promise { const session = this.session(); if (session) { await this.calls.joinCall(session.callId); } } leave(): void { const session = this.session(); if (!session) { return; } this.calls.leaveCall(session.callId); this.calls.closeMobileCallOverlay(); this.untrackLocalMic(); if (!this.overlayMode()) { void this.router.navigate(['/pm', session.conversationId]); } } minimizeCall(): void { const session = this.session(); if (!session) { return; } if (this.overlayMode()) { this.calls.closeMobileCallOverlay(); return; } void this.router.navigate(['/pm', session.conversationId]); } onMobileCallSlideChange(event: Event): void { const detail = (event as CustomEvent).detail; const swiper = Array.isArray(detail) ? detail[0] : detail; if (this.isMobile() && swiper?.activeIndex === 0) { this.minimizeCall(); } } toggleMute(): void { this.voice.toggleMute(!this.isMuted()); this.broadcastLocalVoiceState(); } toggleDeafen(): void { const nextDeafened = !this.isDeafened(); this.voice.toggleDeafen(nextDeafened); this.playback.updateDeafened(nextDeafened); if (nextDeafened && !this.isMuted()) { this.voice.toggleMute(true); } this.broadcastLocalVoiceState(); } async toggleCamera(): Promise { const user = this.currentUser(); if (!this.isConnected() || !user?.id) { return; } if (this.isCameraEnabled()) { this.voice.disableCamera(); this.store.dispatch(UsersActions.updateCameraState({ userId: user.id, cameraState: { isEnabled: false } })); this.bumpRemoteStreamRevision(); return; } await this.voice.enableCamera(); this.store.dispatch(UsersActions.updateCameraState({ userId: user.id, cameraState: { isEnabled: true } })); this.bumpRemoteStreamRevision(); } async toggleScreenShare(): Promise { if (this.isScreenSharing()) { this.screenShare.stopScreenShare(); this.bumpRemoteStreamRevision(); return; } this.syncScreenShareSettings(); if (this.askScreenShareQuality()) { this.showScreenShareQualityDialog.set(true); return; } await this.startScreenShareWithOptions(this.screenShareQuality()); } onScreenShareQualityCancelled(): void { this.showScreenShareQualityDialog.set(false); } async onScreenShareQualityConfirmed(quality: ScreenShareQuality): Promise { this.showScreenShareQualityDialog.set(false); this.screenShareQuality.set(quality); saveVoiceSettingsToStorage({ screenShareQuality: quality }); await this.startScreenShareWithOptions(quality); } inviteSelectedUser(): void { const callId = this.callId(); const userId = this.inviteUserId(); const user = this.allUsers().find((candidate) => this.userKey(candidate) === userId); if (!callId || !user) { return; } void this.calls.inviteUser(callId, user); this.inviteUserId.set(''); } isSpeaking(user: User): boolean { return this.voiceActivity.isSpeaking(this.userKey(user))(); } isParticipantConnected(user: User): boolean { const session = this.session(); const userId = this.userKey(user); if (!session) { return false; } return ( !!session.participants[userId]?.joined || !!(user.voiceState?.isConnected && user.voiceState.roomId === session.callId && user.voiceState.serverId === session.callId) ); } participantIssueLabel(user: User): string | null { return this.isParticipantConnected(user) ? null : 'Waiting'; } streamLabel(share: VoiceWorkspaceStreamItem): string { if (!share.isLocal) { return share.user.displayName; } return share.kind === 'camera' ? 'Your camera' : 'Your screen'; } focusShare(shareId: string): void { this.showAllStreamsMode.set(false); this.focusedStreamId.set(shareId); } showAllStreams(): void { this.showAllStreamsMode.set(true); this.focusedStreamId.set(null); } startChatResize(event: MouseEvent): void { if (event.button !== 0) { return; } event.preventDefault(); this.chatResizing = true; } userKey(user: User): string { return user.oderId || user.id; } readonly trackUserKey = (index: number, user: User): string => this.userKey(user); private currentUserKey(): string { const user = this.currentUser(); return user ? this.userKey(user) : ''; } private broadcastLocalVoiceState(): void { const session = this.session(); const user = this.currentUser(); if (!session || !user?.id) { return; } this.store.dispatch( UsersActions.updateVoiceState({ userId: user.id, voiceState: { isConnected: this.isConnected(), isMuted: this.isMuted(), isDeafened: this.isDeafened(), roomId: session.callId, serverId: session.callId } }) ); } private remoteParticipantPeerIds(session: DirectCallSession, currentUserId: string): string[] { const peerIds = new Set(); for (const participantId of session.participantIds) { if (participantId === currentUserId) { continue; } const user = this.userForSessionParticipant(session, participantId); for (const peerId of [participantId, ...this.getPeerKeyCandidates(user)]) { if (peerId && peerId !== currentUserId) { peerIds.add(peerId); } } } return Array.from(peerIds); } private clampChatWidth(width: number): number { const maxWidth = Math.min(640, Math.max(360, window.innerWidth - 560)); return Math.round(Math.max(320, Math.min(maxWidth, width))); } private getPeerKeyCandidates(user: User | null | undefined): string[] { if (!user) { return []; } return [ user.oderId, user.peerId, user.id ].filter((peerId, index, peerIds): peerId is string => !!peerId && peerIds.indexOf(peerId) === index); } private userForSessionParticipant(session: DirectCallSession, participantId: string): User | null { const knownUser = this.calls.userForParticipant(participantId); if (knownUser) { return knownUser; } const participant = session.participants[participantId]?.profile; return participant ? participantToUser(participant) : null; } private trackLocalMic(): void { const userId = this.currentUserKey(); const stream = this.voice.getRawMicStream() ?? this.voice.getLocalStream(); if (userId && stream) { this.voiceActivity.trackLocalMic(userId, stream); } } private untrackLocalMic(): void { const userId = this.currentUserKey(); if (userId) { this.voiceActivity.untrackLocalMic(userId); } } private syncScreenShareSettings(): void { const settings = loadVoiceSettingsFromStorage(); this.includeSystemAudio.set(settings.includeSystemAudio); this.screenShareQuality.set(settings.screenShareQuality); this.askScreenShareQuality.set(settings.askScreenShareQuality); } private async startScreenShareWithOptions(quality: ScreenShareQuality): Promise { const options: ScreenShareStartOptions = { includeSystemAudio: this.includeSystemAudio(), quality }; try { await this.screenShare.startScreenShare(options); this.bumpRemoteStreamRevision(); } catch {} } private buildShare( peerKey: string, user: User, stream: MediaStream, isLocal: boolean, kind: VoiceWorkspaceStreamItem['kind'] ): VoiceWorkspaceStreamItem { return { id: `${kind}:${peerKey}`, peerKey, user, stream, isLocal, kind, hasAudio: stream.getAudioTracks().some((track) => track.readyState === 'live') }; } private hasActiveVideo(stream: MediaStream): boolean { return stream.getVideoTracks().some((track) => track.readyState === 'live'); } private bumpRemoteStreamRevision(): void { this.remoteStreamRevision.update((value) => value + 1); } }