Move toju-app into own its folder

This commit is contained in:
2026-03-29 23:30:37 +02:00
parent 0467a7b612
commit 8162e0444a
287 changed files with 42 additions and 34 deletions

View File

@@ -0,0 +1,104 @@
@if (showFloatingControls()) {
<!-- Centered relative to rooms-side-panel (w-80 = 320px, so right-40 = 160px from right edge = center) -->
<div class="fixed bottom-4 right-40 translate-x-1/2 z-50 bg-card border border-border rounded-xl shadow-lg">
<div class="p-2 flex items-center gap-2">
<!-- Back to server button -->
<button
(click)="navigateToServer()"
type="button"
class="flex items-center gap-1.5 px-2 py-1 bg-primary/10 hover:bg-primary/20 text-primary rounded-lg transition-colors"
title="Back to {{ voiceSession()?.serverName }}"
>
<ng-icon
name="lucideArrowLeft"
class="w-3.5 h-3.5"
/>
@if (voiceSession()?.serverIcon) {
<img
[src]="voiceSession()?.serverIcon"
class="w-5 h-5 rounded object-cover"
alt=""
/>
} @else {
<div class="w-5 h-5 rounded bg-primary/20 flex items-center justify-center text-[10px] font-semibold">
{{ voiceSession()?.serverName?.charAt(0)?.toUpperCase() || '?' }}
</div>
}
</button>
<!-- Voice status indicator -->
<div class="flex items-center gap-1 px-1">
<span class="w-1.5 h-1.5 rounded-full bg-green-500 animate-pulse"></span>
<span class="text-xs text-muted-foreground max-w-20 truncate">{{ voiceSession()?.roomName || 'Voice' }}</span>
</div>
<!-- Divider -->
<div class="w-px h-6 bg-border"></div>
<!-- Voice controls -->
<div class="flex items-center gap-1">
<button
(click)="toggleMute()"
type="button"
[class]="getCompactButtonClass(isMuted())"
title="Toggle Mute"
>
<ng-icon
[name]="isMuted() ? 'lucideMicOff' : 'lucideMic'"
class="w-4 h-4"
/>
</button>
<button
(click)="toggleDeafen()"
type="button"
[class]="getCompactButtonClass(isDeafened())"
title="Toggle Deafen"
>
<ng-icon
name="lucideHeadphones"
class="w-4 h-4"
/>
</button>
<button
(click)="toggleScreenShare()"
type="button"
[class]="getCompactScreenShareClass()"
title="Toggle Screen Share"
>
<ng-icon
[name]="isScreenSharing() ? 'lucideMonitorOff' : 'lucideMonitor'"
class="w-4 h-4"
/>
</button>
<app-debug-console
launcherVariant="compact"
[showPanel]="false"
/>
<button
(click)="disconnect()"
type="button"
class="w-7 h-7 inline-flex items-center justify-center bg-destructive text-destructive-foreground rounded-lg hover:bg-destructive/90 transition-colors"
title="Disconnect"
>
<ng-icon
name="lucidePhoneOff"
class="w-4 h-4"
/>
</button>
</div>
</div>
</div>
}
@if (showScreenShareQualityDialog()) {
<app-screen-share-quality-dialog
[selectedQuality]="screenShareQuality()"
[includeSystemAudio]="includeSystemAudio()"
(cancelled)="onScreenShareQualityCancelled()"
(confirmed)="onScreenShareQualityConfirmed($event)"
/>
}

View File

@@ -0,0 +1,293 @@
/* eslint-disable @typescript-eslint/member-ordering, @typescript-eslint/no-unused-vars */
import {
Component,
inject,
signal,
computed,
OnInit
} from '@angular/core';
import { CommonModule } 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/voice-session.facade';
import { loadVoiceSettingsFromStorage, saveVoiceSettingsToStorage } from '../../infrastructure/voice-settings.storage';
import { VoiceConnectionFacade } from '../../../../domains/voice-connection';
import { VoicePlaybackService } from '../../../../domains/voice-connection/application/voice-playback.service';
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';
@Component({
selector: 'app-floating-voice-controls',
standalone: true,
imports: [
CommonModule,
NgIcon,
DebugConsoleComponent,
ScreenShareQualityDialogComponent
],
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 = 'w-7 h-7 inline-flex items-center justify-center rounded-lg transition-colors';
if (isActive) {
return base + ' bg-destructive/20 text-destructive hover:bg-destructive/30';
}
return base + ' bg-secondary text-foreground hover:bg-secondary/80';
}
/** Return the CSS classes for the compact screen-share button. */
getCompactScreenShareClass(): string {
const base = 'w-7 h-7 inline-flex items-center justify-center rounded-lg transition-colors';
if (this.isScreenSharing()) {
return base + ' bg-primary/20 text-primary hover:bg-primary/30';
}
return base + ' bg-secondary text-foreground hover:bg-secondary/80';
}
/** Return the CSS classes for the mute toggle button. */
getMuteButtonClass(): string {
const base = 'w-10 h-10 inline-flex items-center justify-center rounded-full transition-colors';
if (this.isMuted()) {
return base + ' bg-destructive/20 text-destructive hover:bg-destructive/30';
}
return base + ' bg-secondary text-foreground hover:bg-secondary/80';
}
/** Return the CSS classes for the deafen toggle button. */
getDeafenButtonClass(): string {
const base = 'w-10 h-10 inline-flex items-center justify-center rounded-full transition-colors';
if (this.isDeafened()) {
return base + ' bg-destructive/20 text-destructive hover:bg-destructive/30';
}
return base + ' bg-secondary text-foreground hover:bg-secondary/80';
}
/** Return the CSS classes for the screen-share toggle button. */
getScreenShareButtonClass(): string {
const base = 'w-10 h-10 inline-flex items-center justify-center rounded-full transition-colors';
if (this.isScreenSharing()) {
return base + ' bg-primary/20 text-primary hover:bg-primary/30';
}
return base + ' bg-secondary text-foreground hover:bg-secondary/80';
}
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
}
}
}

View File

@@ -0,0 +1,130 @@
<div class="bg-card border-border p-4">
<!-- Connection Error Banner -->
@if (showConnectionError()) {
<div class="mb-3 p-2 bg-destructive/20 border border-destructive/30 rounded-lg flex items-center gap-2">
<span class="w-2 h-2 rounded-full bg-destructive animate-pulse"></span>
<span class="text-xs text-destructive">{{ connectionErrorMessage() || 'Connection error' }}</span>
<button
type="button"
(click)="retryConnection()"
class="ml-auto text-xs text-destructive hover:underline"
>
Retry
</button>
</div>
}
<!-- User Info -->
<div class="flex items-center gap-3 mb-4">
<app-user-avatar
[name]="currentUser()?.displayName || '?'"
size="sm"
/>
<div class="flex-1 min-w-0">
<p class="font-medium text-sm text-foreground truncate">
{{ currentUser()?.displayName || 'Unknown' }}
</p>
<p class="text-xs text-muted-foreground">
@if (showConnectionError()) {
<span class="text-destructive">● Connection Error</span>
} @else if (isConnected()) {
<span class="text-green-500">● Connected</span>
} @else {
<span class="text-muted-foreground">● Disconnected</span>
}
</p>
</div>
<div class="flex items-center gap-1">
<app-debug-console
launcherVariant="inline"
[showPanel]="false"
/>
<button
type="button"
(click)="toggleSettings()"
class="p-2 hover:bg-secondary rounded-lg transition-colors"
>
<ng-icon
name="lucideSettings"
class="w-4 h-4 text-muted-foreground"
/>
</button>
</div>
</div>
<!-- Voice Controls -->
<div class="flex items-center justify-center gap-2">
@if (isConnected()) {
<!-- Mute Toggle -->
<button
type="button"
(click)="toggleMute()"
[class]="getMuteButtonClass()"
>
@if (isMuted()) {
<ng-icon
name="lucideMicOff"
class="w-5 h-5"
/>
} @else {
<ng-icon
name="lucideMic"
class="w-5 h-5"
/>
}
</button>
<!-- Deafen Toggle -->
<button
type="button"
(click)="toggleDeafen()"
[class]="getDeafenButtonClass()"
>
<ng-icon
name="lucideHeadphones"
class="w-5 h-5"
/>
</button>
<!-- Screen Share Toggle -->
<button
type="button"
(click)="toggleScreenShare()"
[class]="getScreenShareButtonClass()"
>
@if (isScreenSharing()) {
<ng-icon
name="lucideMonitorOff"
class="w-5 h-5"
/>
} @else {
<ng-icon
name="lucideMonitor"
class="w-5 h-5"
/>
}
</button>
<!-- Disconnect -->
<button
type="button"
(click)="disconnect()"
class="w-10 h-10 inline-flex items-center justify-center bg-destructive text-destructive-foreground rounded-full hover:bg-destructive/90 transition-colors"
>
<ng-icon
name="lucidePhoneOff"
class="w-5 h-5"
/>
</button>
}
</div>
</div>
@if (showScreenShareQualityDialog()) {
<app-screen-share-quality-dialog
[selectedQuality]="screenShareQuality()"
[includeSystemAudio]="includeSystemAudio()"
(cancelled)="onScreenShareQualityCancelled()"
(confirmed)="onScreenShareQualityConfirmed($event)"
/>
}

View File

@@ -0,0 +1,575 @@
/* 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 { VoiceSessionFacade } from '../../application/voice-session.facade';
import { loadVoiceSettingsFromStorage, saveVoiceSettingsToStorage } from '../../infrastructure/voice-settings.storage';
import { VoiceActivityService, VoiceConnectionFacade } from '../../../../domains/voice-connection';
import { PlaybackOptions, VoicePlaybackService } from '../../../../domains/voice-connection/application/voice-playback.service';
import { ScreenShareFacade, ScreenShareQuality } from '../../../../domains/screen-share';
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 {
DebugConsoleComponent,
ScreenShareQualityDialogComponent,
UserAvatarComponent
} from '../../../../shared';
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 readonly webrtcService = inject(VoiceConnectionFacade);
private readonly screenShareService = inject(ScreenShareFacade);
private readonly voiceSessionService = inject(VoiceSessionFacade);
private readonly voiceActivity = inject(VoiceActivityService);
private readonly voicePlayback = inject(VoicePlaybackService);
private readonly store = inject(Store);
private readonly 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.screenShareService.isScreenSharing;
showSettings = signal(false);
inputDevices = signal<AudioDevice[]>([]);
outputDevices = signal<AudioDevice[]>([]);
selectedInputDevice = signal<string>('');
selectedOutputDevice = signal<string>('');
inputVolume = signal(100);
outputVolume = signal(100);
audioBitrate = signal(96);
latencyProfile = signal<'low' | 'balanced' | 'high'>('balanced');
includeSystemAudio = signal(false);
noiseReduction = signal(true);
screenShareQuality = signal<ScreenShareQuality>('balanced');
askScreenShareQuality = signal(true);
showScreenShareQualityDialog = signal(false);
private playbackOptions(): PlaybackOptions {
return {
isConnected: this.isConnected(),
outputVolume: this.outputVolume() / 100,
isDeafened: this.isDeafened()
};
}
async ngOnInit(): Promise<void> {
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<void> {
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<void> {
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<void> {
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.screenShareService.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<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);
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<void> {
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<void> {
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<void> {
try {
await this.screenShareService.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`;
}
}