/* eslint-disable @typescript-eslint/member-ordering, @typescript-eslint/no-unused-vars, complexity */ import { Component, inject, signal, OnInit, OnDestroy, computed } from '@angular/core'; import { CommonModule } from '@angular/common'; import { Store } from '@ngrx/store'; import { NgIcon, provideIcons } from '@ng-icons/core'; import { lucideMic, lucideMicOff, lucideVideo, lucideVideoOff, lucideMonitor, lucideMonitorOff, lucidePhoneOff, lucideSettings, lucideHeadphones } from '@ng-icons/lucide'; import { WebRTCService } from '../../../core/services/webrtc.service'; import { VoiceSessionService } from '../../../core/services/voice-session.service'; import { VoiceActivityService } from '../../../core/services/voice-activity.service'; import { UsersActions } from '../../../store/users/users.actions'; import { selectCurrentUser } from '../../../store/users/users.selectors'; import { selectCurrentRoom } from '../../../store/rooms/rooms.selectors'; import { SettingsModalService } from '../../../core/services/settings-modal.service'; import { loadVoiceSettingsFromStorage, saveVoiceSettingsToStorage } from '../../../core/services/voice-settings.storage'; import { ScreenShareQuality } from '../../../core/services/webrtc'; import { DebugConsoleComponent, ScreenShareQualityDialogComponent, UserAvatarComponent } from '../../../shared'; import { PlaybackOptions, VoicePlaybackService } from './services/voice-playback.service'; interface AudioDevice { deviceId: string; label: string; } @Component({ selector: 'app-voice-controls', standalone: true, imports: [ CommonModule, NgIcon, DebugConsoleComponent, ScreenShareQualityDialogComponent, UserAvatarComponent ], viewProviders: [ provideIcons({ lucideMic, lucideMicOff, lucideVideo, lucideVideoOff, lucideMonitor, lucideMonitorOff, lucidePhoneOff, lucideSettings, lucideHeadphones }) ], templateUrl: './voice-controls.component.html' }) export class VoiceControlsComponent implements OnInit, OnDestroy { private webrtcService = inject(WebRTCService); private voiceSessionService = inject(VoiceSessionService); private voiceActivity = inject(VoiceActivityService); private voicePlayback = inject(VoicePlaybackService); private store = inject(Store); private settingsModal = inject(SettingsModalService); currentUser = this.store.selectSignal(selectCurrentUser); currentRoom = this.store.selectSignal(selectCurrentRoom); isConnected = computed(() => this.webrtcService.isVoiceConnected()); showConnectionError = computed(() => this.webrtcService.shouldShowConnectionError()); connectionErrorMessage = computed(() => this.webrtcService.connectionErrorMessage()); isMuted = signal(false); isDeafened = signal(false); isScreenSharing = this.webrtcService.isScreenSharing; showSettings = signal(false); inputDevices = signal([]); outputDevices = signal([]); selectedInputDevice = signal(''); selectedOutputDevice = signal(''); inputVolume = signal(100); outputVolume = signal(100); audioBitrate = signal(96); latencyProfile = signal<'low' | 'balanced' | 'high'>('balanced'); includeSystemAudio = signal(false); noiseReduction = signal(true); screenShareQuality = signal('balanced'); askScreenShareQuality = signal(true); showScreenShareQualityDialog = signal(false); private playbackOptions(): PlaybackOptions { return { isConnected: this.isConnected(), outputVolume: this.outputVolume() / 100, isDeafened: this.isDeafened() }; } async ngOnInit(): Promise { await this.loadAudioDevices(); // Load persisted voice settings and apply this.loadSettings(); this.applySettingsToWebRTC(); } ngOnDestroy(): void { if (!this.webrtcService.isVoiceConnected()) { this.voicePlayback.teardownAll(); } } async loadAudioDevices(): Promise { try { if (!navigator.mediaDevices?.enumerateDevices) { return; } const devices = await navigator.mediaDevices.enumerateDevices(); this.inputDevices.set( devices .filter((device) => device.kind === 'audioinput') .map((device) => ({ deviceId: device.deviceId, label: device.label })) ); this.outputDevices.set( devices .filter((device) => device.kind === 'audiooutput') .map((device) => ({ deviceId: device.deviceId, label: device.label })) ); } catch (_error) {} } async connect(): Promise { try { // Require signaling connectivity first const ok = await this.webrtcService.ensureSignalingConnected(); if (!ok) { return; } if (!navigator.mediaDevices?.getUserMedia) { return; } const stream = await navigator.mediaDevices.getUserMedia({ audio: { deviceId: this.selectedInputDevice() || undefined, echoCancellation: true, noiseSuppression: !this.noiseReduction() } }); await this.webrtcService.setLocalStream(stream); // Track local mic for voice-activity visualisation // Use oderId||id to match the key used by the rooms-side-panel template. const userId = this.currentUser()?.oderId || this.currentUser()?.id; if (userId) { this.voiceActivity.trackLocalMic(userId, stream); } // Start voice heartbeat to broadcast presence every 5 seconds const room = this.currentRoom(); const roomId = this.currentUser()?.voiceState?.roomId || room?.id; const serverId = room?.id; this.webrtcService.startVoiceHeartbeat(roomId, serverId); // Update local user's voice state in the store so the side panel // shows us in the voice channel with a speaking indicator. const user = this.currentUser(); if (user?.id) { this.store.dispatch( UsersActions.updateVoiceState({ userId: user.id, voiceState: { isConnected: true, isMuted: this.isMuted(), isDeafened: this.isDeafened(), roomId, serverId } }) ); } // Broadcast voice state to other users this.webrtcService.broadcastMessage({ type: 'voice-state', oderId: this.currentUser()?.oderId || this.currentUser()?.id, displayName: this.currentUser()?.displayName || 'User', voiceState: { isConnected: true, isMuted: this.isMuted(), isDeafened: this.isDeafened(), roomId, serverId } }); // Play any pending remote streams now that we're connected this.voicePlayback.playPendingStreams(this.playbackOptions()); // Persist settings after successful connection this.saveSettings(); } catch (_error) {} } // Retry connection when there's a connection error async retryConnection(): Promise { try { await this.webrtcService.ensureSignalingConnected(10000); } catch (_error) {} } disconnect(): void { // Stop voice heartbeat this.webrtcService.stopVoiceHeartbeat(); // Broadcast voice disconnect to other users this.webrtcService.broadcastMessage({ type: 'voice-state', oderId: this.currentUser()?.oderId || this.currentUser()?.id, displayName: this.currentUser()?.displayName || 'User', voiceState: { isConnected: false, isMuted: false, isDeafened: false, serverId: this.currentRoom()?.id } }); // Stop screen sharing if active if (this.isScreenSharing()) { this.webrtcService.stopScreenShare(); } // Untrack local mic from voice-activity visualisation const userId = this.currentUser()?.oderId || this.currentUser()?.id; if (userId) { this.voiceActivity.untrackLocalMic(userId); } // Disable voice (stops audio tracks but keeps peer connections open for chat) this.webrtcService.disableVoice(); this.voicePlayback.teardownAll(); this.voicePlayback.updateDeafened(false); const user = this.currentUser(); if (user?.id) { this.store.dispatch( UsersActions.updateVoiceState({ userId: user.id, voiceState: { isConnected: false, isMuted: false, isDeafened: false, roomId: undefined, serverId: undefined } }) ); } // End voice session for floating controls this.voiceSessionService.endSession(); this.isMuted.set(false); this.isDeafened.set(false); } toggleMute(): void { this.isMuted.update((current) => !current); this.webrtcService.toggleMute(this.isMuted()); // Update local store so the side panel reflects the mute state const user = this.currentUser(); if (user?.id) { this.store.dispatch( UsersActions.updateVoiceState({ userId: user.id, voiceState: { isConnected: this.isConnected(), isMuted: this.isMuted(), isDeafened: this.isDeafened() } }) ); } // Broadcast mute state change this.webrtcService.broadcastMessage({ type: 'voice-state', oderId: this.currentUser()?.oderId || this.currentUser()?.id, displayName: this.currentUser()?.displayName || 'User', voiceState: { isConnected: this.isConnected(), isMuted: this.isMuted(), isDeafened: this.isDeafened() } }); } toggleDeafen(): void { this.isDeafened.update((current) => !current); this.webrtcService.toggleDeafen(this.isDeafened()); this.voicePlayback.updateDeafened(this.isDeafened()); // When deafening, also mute if (this.isDeafened() && !this.isMuted()) { this.isMuted.set(true); this.webrtcService.toggleMute(true); } // Broadcast deafen state change this.webrtcService.broadcastMessage({ type: 'voice-state', oderId: this.currentUser()?.oderId || this.currentUser()?.id, displayName: this.currentUser()?.displayName || 'User', voiceState: { isConnected: this.isConnected(), isMuted: this.isMuted(), isDeafened: this.isDeafened() } }); // Update local store so the side panel reflects the deafen/mute state const user = this.currentUser(); if (user?.id) { this.store.dispatch( UsersActions.updateVoiceState({ userId: user.id, voiceState: { isConnected: this.isConnected(), isMuted: this.isMuted(), isDeafened: this.isDeafened() } }) ); } } async toggleScreenShare(): Promise { if (this.isScreenSharing()) { this.webrtcService.stopScreenShare(); } else { 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); this.saveSettings(); await this.startScreenShareWithOptions(quality); } toggleSettings(): void { this.settingsModal.open('voice'); } closeSettings(): void { this.showSettings.set(false); } onInputDeviceChange(event: Event): void { const select = event.target as HTMLSelectElement; this.selectedInputDevice.set(select.value); // Reconnect with new device if connected if (this.isConnected()) { this.disconnect(); this.connect(); } this.saveSettings(); } onOutputDeviceChange(event: Event): void { const select = event.target as HTMLSelectElement; this.selectedOutputDevice.set(select.value); this.applyOutputDevice(); this.saveSettings(); } onInputVolumeChange(event: Event): void { const input = event.target as HTMLInputElement; this.inputVolume.set(parseInt(input.value, 10)); this.webrtcService.setInputVolume(this.inputVolume() / 100); this.saveSettings(); } onOutputVolumeChange(event: Event): void { const input = event.target as HTMLInputElement; this.outputVolume.set(parseInt(input.value, 10)); this.webrtcService.setOutputVolume(this.outputVolume() / 100); this.voicePlayback.updateOutputVolume(this.outputVolume() / 100); this.saveSettings(); } onLatencyProfileChange(event: Event): void { const select = event.target as HTMLSelectElement; const profile = select.value as 'low' | 'balanced' | 'high'; this.latencyProfile.set(profile); this.webrtcService.setLatencyProfile(profile); this.saveSettings(); } onAudioBitrateChange(event: Event): void { const input = event.target as HTMLInputElement; const kbps = parseInt(input.value, 10); this.audioBitrate.set(kbps); this.webrtcService.setAudioBitrate(kbps); this.saveSettings(); } onIncludeSystemAudioChange(event: Event): void { const input = event.target as HTMLInputElement; this.includeSystemAudio.set(!!input.checked); this.saveSettings(); } async onNoiseReductionChange(event: Event): Promise { const input = event.target as HTMLInputElement; this.noiseReduction.set(!!input.checked); await this.webrtcService.toggleNoiseReduction(this.noiseReduction()); this.saveSettings(); } private loadSettings(): void { const settings = loadVoiceSettingsFromStorage(); this.selectedInputDevice.set(settings.inputDevice); this.selectedOutputDevice.set(settings.outputDevice); this.inputVolume.set(settings.inputVolume); this.outputVolume.set(settings.outputVolume); this.audioBitrate.set(settings.audioBitrate); this.latencyProfile.set(settings.latencyProfile); this.includeSystemAudio.set(settings.includeSystemAudio); this.noiseReduction.set(settings.noiseReduction); this.screenShareQuality.set(settings.screenShareQuality); this.askScreenShareQuality.set(settings.askScreenShareQuality); } private saveSettings(): void { saveVoiceSettingsToStorage({ inputDevice: this.selectedInputDevice(), outputDevice: this.selectedOutputDevice(), inputVolume: this.inputVolume(), outputVolume: this.outputVolume(), audioBitrate: this.audioBitrate(), latencyProfile: this.latencyProfile(), includeSystemAudio: this.includeSystemAudio(), noiseReduction: this.noiseReduction(), screenShareQuality: this.screenShareQuality(), askScreenShareQuality: this.askScreenShareQuality() }); } private applySettingsToWebRTC(): void { try { this.webrtcService.setOutputVolume(this.outputVolume() / 100); this.voicePlayback.updateOutputVolume(this.outputVolume() / 100); this.webrtcService.setInputVolume(this.inputVolume() / 100); this.webrtcService.setAudioBitrate(this.audioBitrate()); this.webrtcService.setLatencyProfile(this.latencyProfile()); this.applyOutputDevice(); // Always sync the desired noise-reduction preference (even before // a mic stream exists - the flag will be honoured on connect). this.webrtcService.toggleNoiseReduction(this.noiseReduction()); } catch {} } private async applyOutputDevice(): Promise { const deviceId = this.selectedOutputDevice(); if (!deviceId) return; this.voicePlayback.applyOutputDevice(deviceId); } 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 { try { await this.webrtcService.startScreenShare({ includeSystemAudio: this.includeSystemAudio(), quality }); } catch (_error) {} } getMuteButtonClass(): string { const base = 'w-10 h-10 inline-flex items-center justify-center rounded-full transition-colors disabled:opacity-50 disabled:cursor-not-allowed'; if (this.isMuted()) { return `${base} bg-destructive/20 text-destructive hover:bg-destructive/30`; } return `${base} bg-secondary text-foreground hover:bg-secondary/80`; } getDeafenButtonClass(): string { const base = 'w-10 h-10 inline-flex items-center justify-center rounded-full transition-colors disabled:opacity-50 disabled:cursor-not-allowed'; if (this.isDeafened()) { return `${base} bg-destructive/20 text-destructive hover:bg-destructive/30`; } return `${base} bg-secondary text-foreground hover:bg-secondary/80`; } getScreenShareButtonClass(): string { const base = 'w-10 h-10 inline-flex items-center justify-center rounded-full transition-colors disabled:opacity-50 disabled:cursor-not-allowed'; if (this.isScreenSharing()) { return `${base} bg-primary/20 text-primary hover:bg-primary/30`; } return `${base} bg-secondary text-foreground hover:bg-secondary/80`; } }