297 lines
9.8 KiB
TypeScript
297 lines
9.8 KiB
TypeScript
/* eslint-disable @typescript-eslint/member-ordering, @typescript-eslint/no-unused-vars */
|
|
import {
|
|
Component,
|
|
inject,
|
|
signal,
|
|
computed,
|
|
OnInit
|
|
} from '@angular/core';
|
|
import { CommonModule, NgOptimizedImage } from '@angular/common';
|
|
import { Store } from '@ngrx/store';
|
|
import { NgIcon, provideIcons } from '@ng-icons/core';
|
|
import {
|
|
lucideMic,
|
|
lucideMicOff,
|
|
lucideMonitor,
|
|
lucideMonitorOff,
|
|
lucidePhoneOff,
|
|
lucideHeadphones,
|
|
lucideArrowLeft
|
|
} from '@ng-icons/lucide';
|
|
|
|
import { VoiceSessionFacade } from '../../application/facades/voice-session.facade';
|
|
import { loadVoiceSettingsFromStorage, saveVoiceSettingsToStorage } from '../../infrastructure/util/voice-settings-storage.util';
|
|
import { VoiceConnectionFacade } from '../../../../domains/voice-connection';
|
|
import { VoicePlaybackService } from '../../../../domains/voice-connection';
|
|
import { ScreenShareFacade, ScreenShareQuality } from '../../../../domains/screen-share';
|
|
import { UsersActions } from '../../../../store/users/users.actions';
|
|
import { selectCurrentUser } from '../../../../store/users/users.selectors';
|
|
import { DebugConsoleComponent, ScreenShareQualityDialogComponent } from '../../../../shared';
|
|
import { ThemeNodeDirective } from '../../../../domains/theme';
|
|
|
|
@Component({
|
|
selector: 'app-floating-voice-controls',
|
|
standalone: true,
|
|
imports: [
|
|
CommonModule,
|
|
NgOptimizedImage,
|
|
NgIcon,
|
|
DebugConsoleComponent,
|
|
ScreenShareQualityDialogComponent,
|
|
ThemeNodeDirective
|
|
],
|
|
viewProviders: [
|
|
provideIcons({
|
|
lucideMic,
|
|
lucideMicOff,
|
|
lucideMonitor,
|
|
lucideMonitorOff,
|
|
lucidePhoneOff,
|
|
lucideHeadphones,
|
|
lucideArrowLeft
|
|
})
|
|
],
|
|
templateUrl: './floating-voice-controls.component.html'
|
|
})
|
|
/**
|
|
* Floating voice controls displayed when the user navigates away from the voice-connected server.
|
|
* Provides mute, deafen, screen-share, and disconnect actions in a compact overlay.
|
|
*/
|
|
export class FloatingVoiceControlsComponent implements OnInit {
|
|
private readonly webrtcService = inject(VoiceConnectionFacade);
|
|
private readonly screenShareService = inject(ScreenShareFacade);
|
|
private readonly voiceSessionService = inject(VoiceSessionFacade);
|
|
private readonly voicePlayback = inject(VoicePlaybackService);
|
|
private readonly store = inject(Store);
|
|
|
|
currentUser = this.store.selectSignal(selectCurrentUser);
|
|
|
|
// Voice state from services
|
|
showFloatingControls = this.voiceSessionService.showFloatingControls;
|
|
voiceSession = this.voiceSessionService.voiceSession;
|
|
|
|
isConnected = computed(() => this.webrtcService.isVoiceConnected());
|
|
isMuted = signal(false);
|
|
isDeafened = signal(false);
|
|
isScreenSharing = this.screenShareService.isScreenSharing;
|
|
includeSystemAudio = signal(false);
|
|
screenShareQuality = signal<ScreenShareQuality>('balanced');
|
|
askScreenShareQuality = signal(true);
|
|
showScreenShareQualityDialog = signal(false);
|
|
|
|
/** Sync local mute/deafen state from the WebRTC service on init. */
|
|
ngOnInit(): void {
|
|
// Sync mute/deafen state from webrtc service
|
|
this.isMuted.set(this.webrtcService.isMuted());
|
|
this.isDeafened.set(this.webrtcService.isDeafened());
|
|
this.syncScreenShareSettings();
|
|
|
|
const settings = loadVoiceSettingsFromStorage();
|
|
|
|
this.voicePlayback.updateOutputVolume(settings.outputVolume / 100);
|
|
this.voicePlayback.updateDeafened(this.isDeafened());
|
|
|
|
if (settings.outputDevice) {
|
|
this.voicePlayback.applyOutputDevice(settings.outputDevice);
|
|
}
|
|
}
|
|
|
|
/** Navigate back to the voice-connected server. */
|
|
navigateToServer(): void {
|
|
this.voiceSessionService.navigateToVoiceServer();
|
|
}
|
|
|
|
/** Toggle microphone mute and broadcast the updated voice state. */
|
|
toggleMute(): void {
|
|
this.isMuted.update((current) => !current);
|
|
this.webrtcService.toggleMute(this.isMuted());
|
|
|
|
// 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()
|
|
}
|
|
});
|
|
}
|
|
|
|
/** Toggle deafen state (muting audio output) and broadcast the updated voice state. */
|
|
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()
|
|
}
|
|
});
|
|
}
|
|
|
|
/** Toggle screen sharing on or off. */
|
|
async toggleScreenShare(): Promise<void> {
|
|
if (this.isScreenSharing()) {
|
|
this.screenShareService.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<void> {
|
|
this.showScreenShareQualityDialog.set(false);
|
|
this.screenShareQuality.set(quality);
|
|
saveVoiceSettingsToStorage({ screenShareQuality: quality });
|
|
await this.startScreenShareWithOptions(quality);
|
|
}
|
|
|
|
/** Disconnect from the voice session entirely, cleaning up all voice state. */
|
|
disconnect(): void {
|
|
// Stop voice heartbeat
|
|
this.webrtcService.stopVoiceHeartbeat();
|
|
|
|
// Broadcast voice disconnect
|
|
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
|
|
}
|
|
});
|
|
|
|
// Stop screen sharing if active
|
|
if (this.isScreenSharing()) {
|
|
this.screenShareService.stopScreenShare();
|
|
}
|
|
|
|
// Disable voice
|
|
this.webrtcService.disableVoice();
|
|
this.voicePlayback.teardownAll();
|
|
this.voicePlayback.updateDeafened(false);
|
|
|
|
// Update user voice state in store
|
|
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
|
|
this.voiceSessionService.endSession();
|
|
|
|
// Reset local state
|
|
this.isMuted.set(false);
|
|
this.isDeafened.set(false);
|
|
}
|
|
|
|
/** Return the CSS classes for the compact control button based on active state. */
|
|
getCompactButtonClass(isActive: boolean): string {
|
|
const base = 'inline-flex h-7 w-7 items-center justify-center rounded-md border transition-colors';
|
|
|
|
if (isActive) {
|
|
return base + ' border-destructive/30 bg-destructive/10 text-destructive hover:bg-destructive/15';
|
|
}
|
|
|
|
return base + ' border-border bg-card text-foreground hover:bg-secondary/70';
|
|
}
|
|
|
|
/** Return the CSS classes for the compact screen-share button. */
|
|
getCompactScreenShareClass(): string {
|
|
const base = 'inline-flex h-7 w-7 items-center justify-center rounded-md border transition-colors';
|
|
|
|
if (this.isScreenSharing()) {
|
|
return base + ' border-primary/20 bg-primary/10 text-primary hover:bg-primary/15';
|
|
}
|
|
|
|
return base + ' border-border bg-card text-foreground hover:bg-secondary/70';
|
|
}
|
|
|
|
/** Return the CSS classes for the mute toggle button. */
|
|
getMuteButtonClass(): string {
|
|
const base = 'inline-flex h-10 w-10 items-center justify-center rounded-md border transition-colors';
|
|
|
|
if (this.isMuted()) {
|
|
return base + ' border-destructive/30 bg-destructive/10 text-destructive hover:bg-destructive/15';
|
|
}
|
|
|
|
return base + ' border-border bg-card text-foreground hover:bg-secondary/70';
|
|
}
|
|
|
|
/** Return the CSS classes for the deafen toggle button. */
|
|
getDeafenButtonClass(): string {
|
|
const base = 'inline-flex h-10 w-10 items-center justify-center rounded-md border transition-colors';
|
|
|
|
if (this.isDeafened()) {
|
|
return base + ' border-destructive/30 bg-destructive/10 text-destructive hover:bg-destructive/15';
|
|
}
|
|
|
|
return base + ' border-border bg-card text-foreground hover:bg-secondary/70';
|
|
}
|
|
|
|
/** Return the CSS classes for the screen-share toggle button. */
|
|
getScreenShareButtonClass(): string {
|
|
const base = 'inline-flex h-10 w-10 items-center justify-center rounded-md border transition-colors';
|
|
|
|
if (this.isScreenSharing()) {
|
|
return base + ' border-primary/20 bg-primary/10 text-primary hover:bg-primary/15';
|
|
}
|
|
|
|
return base + ' border-border bg-card text-foreground hover:bg-secondary/70';
|
|
}
|
|
|
|
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<void> {
|
|
try {
|
|
await this.screenShareService.startScreenShare({
|
|
includeSystemAudio: this.includeSystemAudio(),
|
|
quality
|
|
});
|
|
} catch (_error) {
|
|
// Screen share request was denied or failed
|
|
}
|
|
}
|
|
}
|